diff --git a/README.md b/README.md index 7b40d2c..0c91882 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The app is not notarized yet. If macOS blocks the first launch, try opening it o - Open a Markdown or HTML file with **File > Open** (`⌘O`), or drag a file into the app window. - Use the **Aa** button in the toolbar to choose Native Reader or Book Reader and set light or dark appearance. The same options are in the **View** menu. - In a Markdown task list, click a checkbox to update its marker in the file. Other document text is read-only. +- Right-click a tab to put it in a group. A group has a colored chip with an optional name: click the chip to collapse the group, right-click it to rename or recolor it, and drag it to move the whole group. Groups reopen with the session. - Each file opens in its own tab. **File > Open Files In** switches to new windows instead. `⌘T` and `⌘N` add an empty tab or window, and an empty tab lists your recent files. - `⌘F` finds text in the page. The outline button in the toolbar (`⌥⌘O`) jumps to a heading. - The page updates when the file changes on disk, so you can keep it open next to your editor. The reload button in the toolbar (`⌘R`) reloads it by hand. diff --git a/Sources/ZenMarky/App.swift b/Sources/ZenMarky/App.swift index acc37c0..9a08111 100644 --- a/Sources/ZenMarky/App.swift +++ b/Sources/ZenMarky/App.swift @@ -15,7 +15,7 @@ enum ZenMarky { // Owns the open windows, the shared renderer, and the preferences every window follows. @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemValidation { +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemValidation, ReaderWindowOwner { private var controllers: [ReaderWindowController] = [] private var renderer: DocumentRenderer? private var preferences = ReaderPreferences() { @@ -27,14 +27,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu } private var quitPending = false + private var tabs: [ReaderTab] { controllers.flatMap(\.tabs) } + // Runs before Finder hands over files, so those open as tabs next to the restored ones. func applicationWillFinishLaunching(_ notification: Notification) { + // Tabs live in the app's own strip, so the system's window tabs and their menu items are off. + NSWindow.allowsAutomaticWindowTabbing = false NSApp.appearance = preferences.appearance restoreSession() } func applicationDidFinishLaunching(_ notification: Notification) { buildMenu() + // ⌃⇥ and ⌃⇧⇥ switch tabs, as they did with the system's tabs. The window uses them + // to move keyboard focus before menu shortcuts see them, so they are caught here. + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard event.keyCode == 48, event.modifierFlags.contains(.control), let front = self?.frontController else { return event } + if event.modifierFlags.contains(.shift) { front.previousTab(nil) } else { front.nextTab(nil) } + return nil + } for path in CommandLine.arguments.dropFirst() where !path.hasPrefix("-") { open(URL(fileURLWithPath: path)) } @@ -53,7 +64,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { quitPending = true Task { - for controller in controllers { await controller.storeScroll() } + for tab in tabs { await tab.storeScroll() } finishQuit() } // The common modes include the one AppKit runs while it waits for the reply. @@ -69,23 +80,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu NSApp.reply(toApplicationShouldTerminate: true) } - // Records each window's files in tab order, front window first. + // Records each window's files and groups in tab order, front window first. private func saveSession() { let front = NSApp.orderedWindows - var saved: [(depth: Int, window: ReaderSession.Window)] = [] + let depth = { (controller: ReaderWindowController) in controller.window.flatMap(front.firstIndex(of:)) ?? front.count } var session = ReaderSession() - var seen: Set = [] - for window in controllers.compactMap(\.window) where !seen.contains(window) { - let tabs = window.tabGroup?.windows ?? [window] - seen.formUnion(tabs) - let path = { (tab: NSWindow) in (tab.windowController as? ReaderWindowController)?.readerDocument?.url.path } - let files = tabs.compactMap(path) - guard !files.isEmpty else { continue } - let selected = window.tabGroup?.selectedWindow ?? window - saved.append((front.firstIndex(of: selected) ?? front.count, ReaderSession.Window(files: files, selected: path(selected), frame: NSStringFromRect(selected.frame)))) - for file in files { session.scrollOffsets[file] = ReaderWindowController.scrollPositions[URL(fileURLWithPath: file)] } + session.windows = controllers.sorted { depth($0) < depth($1) }.compactMap(\.savedWindow) + for file in session.windows.flatMap(\.files) { + session.scrollOffsets[file] = ReaderTab.scrollPositions[URL(fileURLWithPath: file)] } - session.windows = saved.sorted { $0.depth < $1.depth }.map(\.window) session.save() } @@ -94,19 +97,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu private func restoreSession() { guard let session = ReaderSession.load()?.existing() else { return } for (path, offset) in session.scrollOffsets { - ReaderWindowController.scrollPositions[URL(fileURLWithPath: path)] = offset + ReaderTab.scrollPositions[URL(fileURLWithPath: path)] = offset } for saved in session.windows.reversed() { - var last: ReaderWindowController? - var selected: ReaderWindowController? - for path in saved.files { - let controller = makeWindow(tabbedWith: last?.window) - if last == nil { controller.window?.setFrame(NSRectFromString(saved.frame), display: false) } - controller.open(URL(fileURLWithPath: path)) - if path == saved.selected { selected = controller } - last = controller + let groups = saved.groups.map { TabGroup(name: $0.name, color: $0.color, collapsed: $0.collapsed) } + let tabs = saved.files.map { path in + let tab = makeTab() + tab.group = saved.groups.firstIndex { $0.files.contains(path) }.map { groups[$0] } + return tab } - (selected ?? last)?.window?.makeKeyAndOrderFront(nil) + let controller = makeWindow() + controller.window?.setFrame(NSRectFromString(saved.frame), display: false) + controller.insert(tabs, at: 0) + for (tab, path) in zip(tabs, saved.files) { tab.open(URL(fileURLWithPath: path)) } + if let index = saved.files.firstIndex(where: { $0 == saved.selected }) { controller.select(tabs[index]) } + controller.window?.makeKeyAndOrderFront(nil) } } @@ -119,55 +124,85 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu (NSApp.keyWindow ?? NSApp.mainWindow)?.windowController as? ReaderWindowController } - // Opens the file in the window that asked, a welcome window that is free, or a - // new tab or window according to the placement. A file that is already open - // is brought to the front instead of being opened twice. - func open(_ url: URL, from source: ReaderWindowController? = nil, placement: OpenPlacement = .preferred) { + // Opens the file in the tab that asked, an empty tab that is free, or a new tab or + // window according to the placement. A file that is already open is brought to the + // front instead of being opened twice. + func open(_ url: URL, from source: ReaderTab? = nil, placement: OpenPlacement = .preferred) { let resolved = url.resolvingSymlinksInPath() - if let existing = controllers.first(where: { $0.documentURL == resolved }) { - existing.window?.makeKeyAndOrderFront(nil) + if let existing = tabs.first(where: { $0.documentURL == resolved }) { + show(existing) return } // At launch the app is not active yet, so there is no key window; fall back to the last window made. - let source = source ?? frontController ?? controllers.last - let target: ReaderWindowController + let source = source ?? frontController?.selectedTab ?? controllers.last?.selectedTab + let target: ReaderTab if placement == .current, let source { target = source } else if let source, source.documentURL == nil { target = source + } else if let window = source?.windowController, placement == .tab || (placement == .preferred && preferences.opensInTabs) { + target = makeTab() + // A link opened in a new tab goes next to its page, in the same group. + window.add(target, nextTo: placement == .tab ? source : nil) } else { - let asTab = placement == .tab || (placement == .preferred && preferences.opensInTabs) - target = makeWindow(tabbedWith: asTab ? source?.window : nil) + target = makeTab() + openWindow(with: [target], topLeft: nil) } target.open(resolved) - target.window?.makeKeyAndOrderFront(nil) + show(target) + } + + private func show(_ tab: ReaderTab) { + tab.windowController?.select(tab) + tab.windowController?.window?.makeKeyAndOrderFront(nil) } - @discardableResult - private func makeWindow(tabbedWith anchor: NSWindow?) -> ReaderWindowController { + func makeTab() -> ReaderTab { do { if renderer == nil { renderer = try DocumentRenderer() } } catch { NSAlert(error: error).runModal() } guard let renderer else { fatalError("The document renderer could not load.") } - let controller = ReaderWindowController(renderer: renderer, preferences: preferences) - controller.openRequest = { [weak self, weak controller] url, placement in self?.open(url, from: controller, placement: placement) } - controller.closed = { [weak self, weak controller] in self?.controllers.removeAll { $0 === controller } } - controllers.append(controller) - if let anchor, let window = controller.window { - anchor.addTabbedWindow(window, ordered: .above) - } else if let front = frontController?.window, let window = controller.window { + let tab = ReaderTab(renderer: renderer, preferences: preferences) + tab.openRequest = { [weak self, weak tab] url, placement in self?.open(url, from: tab, placement: placement) } + return tab + } + + private func makeWindow() -> ReaderWindowController { + let controller = ReaderWindowController(preferences: preferences, app: self) + if let front = frontController?.window, let window = controller.window { window.cascadeTopLeft(from: front.cascadeTopLeft(from: .zero)) } - controller.showWindow(nil) + controllers.append(controller) return controller } - @objc func newWindow(_ sender: Any?) { makeWindow(tabbedWith: nil) } - @objc func newTab(_ sender: Any?) { makeWindow(tabbedWith: frontController?.window) } - // The tab bar's plus button sends this through the responder chain. - @objc func newWindowForTab(_ sender: Any?) { newTab(sender) } + func openWindow(with tabs: [ReaderTab], topLeft: NSPoint?) { + let controller = makeWindow() + if let topLeft { controller.window?.setFrameTopLeftPoint(topLeft) } + controller.insert(tabs, at: 0) + controller.showWindow(nil) + } + + func windowClosed(_ controller: ReaderWindowController) { + controllers.removeAll { $0 === controller } + } + + @objc func newWindow(_ sender: Any?) { openWindow(with: [makeTab()], topLeft: nil) } + + @objc func newTab(_ sender: Any?) { + if let front = frontController { front.addNewTab() } else { newWindow(sender) } + } + + // Reader windows close their shown tab first; this closes panels such as About. + @objc func closeTab(_ sender: Any?) { NSApp.keyWindow?.performClose(sender) } + + // Moves every tab into the front window, groups included. + @objc func mergeWindows(_ sender: Any?) { + guard let front = frontController else { return } + for other in controllers where other !== front { front.insert(other.tabs, at: front.tabs.count) } + } @objc func openDocument(_ sender: Any?) { let panel = NSOpenPanel() @@ -178,26 +213,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu let source = frontController let finish: (NSApplication.ModalResponse) -> Void = { [weak self] response in guard response == .OK else { return } - for url in panel.urls { self?.open(url, from: source) } + for url in panel.urls { self?.open(url, from: source?.selectedTab) } } if let window = source?.window { panel.beginSheetModal(for: window, completionHandler: finish) } else { finish(panel.runModal()) } } @objc func selectPlacement(_ sender: NSMenuItem) { preferences.opensInTabs = sender.tag == 1 } - // ⌘1 through ⌘9 select a tab of the front window by position. - @objc func selectTab(_ sender: NSMenuItem) { - guard let windows = frontController?.window?.tabGroup?.windows, windows.indices.contains(sender.tag) else { return } - windows[sender.tag].makeKeyAndOrderFront(nil) - } - @objc func openRecent(_ sender: NSMenuItem) { if let url = sender.representedObject as? URL { open(url) } } @objc func clearRecentDocuments(_ sender: Any?) { RecentDocuments.clear() - for controller in controllers where controller.readerDocument == nil { controller.refreshWelcome() } + for tab in tabs { tab.refreshWelcome() } } // The shortcut list is a bundled Markdown page shown in the reader itself. @@ -225,6 +254,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu @objc func selectReaderStyle(_ sender: NSMenuItem) { preferences.bookStyle = sender.tag == 1 } @objc func toggleFrontMatter(_ sender: NSMenuItem) { preferences.showsFrontMatter.toggle() } + @objc func toggleShowsTabBar(_ sender: NSMenuItem) { preferences.showsTabBar.toggle() } @objc func selectAppearance(_ sender: NSMenuItem) { guard let mode = sender.representedObject as? String else { return } @@ -238,6 +268,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu case #selector(selectAppearance(_:)): item.state = item.representedObject as? String == preferences.appearanceMode ? .on : .off case #selector(selectPlacement(_:)): item.state = (item.tag == 1) == preferences.opensInTabs ? .on : .off case #selector(toggleFrontMatter(_:)): item.state = preferences.showsFrontMatter ? .on : .off + case #selector(toggleShowsTabBar(_:)): item.state = preferences.showsTabBar ? .on : .off + case #selector(mergeWindows(_:)): return controllers.count > 1 default: break } return true @@ -276,26 +308,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu add(placement, "New Tab", #selector(selectPlacement(_:))).tag = 1 add(placement, "New Window", #selector(selectPlacement(_:))) file.addItem(.separator()) - add(file, "Reload", #selector(ReaderWindowController.reloadDocument(_:)), "r") - add(file, "Show in Finder", #selector(ReaderWindowController.revealDocument(_:))) + add(file, "Reload", #selector(ReaderTab.reloadDocument(_:)), "r") + add(file, "Show in Finder", #selector(ReaderTab.revealDocument(_:))) file.addItem(.separator()) - add(file, "Export as PDF…", #selector(ReaderWindowController.exportPDF(_:))) - add(file, "Print…", #selector(ReaderWindowController.printDocument(_:)), "p") + add(file, "Export as PDF…", #selector(ReaderTab.exportPDF(_:))) + add(file, "Print…", #selector(ReaderTab.printDocument(_:)), "p") file.addItem(.separator()) - add(file, "Close", #selector(NSWindow.performClose(_:)), "w") + add(file, "Close Tab", #selector(closeTab(_:)), "w") + add(file, "Close Window", #selector(NSWindow.performClose(_:)), "w", modifiers: [.command, .shift]) let edit = menu("Edit") add(edit, "Copy", #selector(NSText.copy(_:)), "c") add(edit, "Select All", #selector(NSText.selectAll(_:)), "a") edit.addItem(.separator()) - add(edit, "Find…", #selector(ReaderWindowController.showFind(_:)), "f") - add(edit, "Find Next", #selector(ReaderWindowController.findNext(_:)), "g") - add(edit, "Find Previous", #selector(ReaderWindowController.findPrevious(_:)), "g", modifiers: [.command, .shift]) + add(edit, "Find…", #selector(ReaderTab.showFind(_:)), "f") + add(edit, "Find Next", #selector(ReaderTab.findNext(_:)), "g") + add(edit, "Find Previous", #selector(ReaderTab.findPrevious(_:)), "g", modifiers: [.command, .shift]) let view = menu("View") - add(view, "Zoom In", #selector(ReaderWindowController.zoomIn(_:)), "=") - add(view, "Zoom Out", #selector(ReaderWindowController.zoomOut(_:)), "-") - add(view, "Actual Size", #selector(ReaderWindowController.actualSize(_:)), "0") + add(view, "Zoom In", #selector(ReaderTab.zoomIn(_:)), "=") + add(view, "Zoom Out", #selector(ReaderTab.zoomOut(_:)), "-") + add(view, "Actual Size", #selector(ReaderTab.actualSize(_:)), "0") view.addItem(.separator()) - add(view, "Show Outline", #selector(ReaderWindowController.showOutline(_:)), "o", modifiers: [.command, .option]) + add(view, "Show Outline", #selector(ReaderTab.showOutline(_:)), "o", modifiers: [.command, .option]) view.addItem(.separator()) let style = menu("Reader Style", in: view) add(style, "Native", #selector(selectReaderStyle(_:))) @@ -306,21 +339,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu } add(view, "Show Front Matter", #selector(toggleFrontMatter(_:))) view.addItem(.separator()) - add(view, "Show Tab Bar", #selector(NSWindow.toggleTabBar(_:)), "t", modifiers: [.command, .shift]) - add(view, "Show All Tabs", #selector(NSWindow.toggleTabOverview(_:)), "\\", modifiers: [.command, .shift]) + add(view, "Always Show Tab Bar", #selector(toggleShowsTabBar(_:)), "t", modifiers: [.command, .shift]) let windows = menu("Window") add(windows, "Minimize", #selector(NSWindow.performMiniaturize(_:)), "m") add(windows, "Zoom", #selector(NSWindow.performZoom(_:))) windows.addItem(.separator()) - add(windows, "Show Previous Tab", #selector(NSWindow.selectPreviousTab(_:)), "[", modifiers: [.command, .shift]) - add(windows, "Show Next Tab", #selector(NSWindow.selectNextTab(_:)), "]", modifiers: [.command, .shift]) + // NSWindow has its own actions for these names, so the strip's use different ones. + add(windows, "Show Previous Tab", #selector(ReaderWindowController.previousTab(_:)), "[", modifiers: [.command, .shift]) + add(windows, "Show Next Tab", #selector(ReaderWindowController.nextTab(_:)), "]", modifiers: [.command, .shift]) for index in 0..<9 { - let item = add(windows, index == 8 ? "Show Last Tab" : "Show Tab \(index + 1)", #selector(selectTab(_:)), "\(index + 1)") + let item = add(windows, index == 8 ? "Show Last Tab" : "Show Tab \(index + 1)", #selector(ReaderWindowController.selectTab(_:)), "\(index + 1)") item.tag = index item.isHidden = index > 0 && index < 8 // ⌘2–⌘8 work without cluttering the menu } - add(windows, "Move Tab to New Window", #selector(NSWindow.moveTabToNewWindow(_:))) - add(windows, "Merge All Windows", #selector(NSWindow.mergeAllWindows(_:))) + add(windows, "Move Tab to New Window", #selector(ReaderWindowController.detachTab(_:))) + add(windows, "Merge All Windows", #selector(mergeWindows(_:))) windows.addItem(.separator()) add(windows, "Bring All to Front", #selector(NSApplication.arrangeInFront(_:))) NSApp.windowsMenu = windows diff --git a/Sources/ZenMarky/ReaderDocument.swift b/Sources/ZenMarky/ReaderDocument.swift index 0ac827b..e7edc46 100644 --- a/Sources/ZenMarky/ReaderDocument.swift +++ b/Sources/ZenMarky/ReaderDocument.swift @@ -156,6 +156,15 @@ struct ReaderSession: Codable, Equatable { var files: [String] var selected: String? var frame: String + var groups: [Group] = [] + } + + // A tab group, with its tabs by file. A file is open in one tab at most, so the path names the tab. + struct Group: Codable, Equatable { + var name: String + var color: GroupColor + var collapsed: Bool + var files: [String] } static let key = "session" @@ -171,14 +180,30 @@ struct ReaderSession: Codable, Equatable { UserDefaults.standard.set(try? JSONEncoder().encode(self), forKey: Self.key) } - // Drops files that no longer exist and the windows left with none. + // Drops files that no longer exist and the groups and windows left with none. func existing(_ exists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) -> ReaderSession { var kept = self kept.windows = windows.compactMap { window in var window = window window.files = window.files.filter(exists) + window.groups = window.groups.compactMap { group in + var group = group + group.files = group.files.filter(exists) + return group.files.isEmpty ? nil : group + } return window.files.isEmpty ? nil : window } return kept } } + +// Sessions saved before tab groups have no groups key, so a missing one reads as none. +extension ReaderSession.Window { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + files = try container.decode([String].self, forKey: .files) + selected = try container.decodeIfPresent(String.self, forKey: .selected) + frame = try container.decode(String.self, forKey: .frame) + groups = try container.decodeIfPresent([ReaderSession.Group].self, forKey: .groups) ?? [] + } +} diff --git a/Sources/ZenMarky/ReaderTab.swift b/Sources/ZenMarky/ReaderTab.swift new file mode 100644 index 0000000..397f97f --- /dev/null +++ b/Sources/ZenMarky/ReaderTab.swift @@ -0,0 +1,664 @@ +import AppKit +import WebKit + +struct Heading { + let level: Int + let text: String +} + +// One document in a window. The window shows one tab at a time; the others stay laid +// out but hidden, so their pages keep their size and scroll offsets. Menu and toolbar +// commands reach the shown tab through the window controller. +@MainActor +final class ReaderTab: NSViewController, WKNavigationDelegate, NSUserInterfaceValidations { + private(set) var readerDocument: ReaderDocument? + // The file shown or being loaded. It is set before the page's scroll offset is read, + // so the app never opens the same file twice or reuses a tab that is switching. + private(set) var documentURL: URL? + var preferences: ReaderPreferences { didSet { applyAppearance() } } + var openRequest: ((URL, OpenPlacement) -> Void)? + // Called when the title, status, outline, or format changes, so the window can follow. + var changed: (() -> Void)? + var group: TabGroup? + // Shown under the window title while this tab is selected. + private(set) var status = "" + + private let renderer: DocumentRenderer + private let localResources = LocalResourceHandler() + private var webView: ReaderWebView? + private let readerContainer = NSView() + private let findBar = FindBar() + private var findCount = 0 + private var findIndex = 0 + private var headings: [Heading] = [] + private var zoom: CGFloat = 1 + private var watcher: FileWatcher? + private var pendingChange: Task? + private var isClosed = false + + // Scroll offsets by file, so reloads, reopened files, and the next launch + // return to where they were. + static var scrollPositions: [URL: Double] = [:] + + private static let headingSelector = "h1,h2,h3,h4,h5,h6" + + // Matches the Book Reader page colors in reader.css so the unified title bar + // and the page read as one surface. + private static let paperColor = NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? NSColor(srgbRed: 0.141, green: 0.137, blue: 0.125, alpha: 1) + : NSColor(srgbRed: 0.988, green: 0.980, blue: 0.965, alpha: 1) + } + + init(renderer: DocumentRenderer, preferences: ReaderPreferences) { + self.renderer = renderer + self.preferences = preferences + super.init(nibName: nil, bundle: nil) + title = "New Tab" + findBar.onSearch = { [weak self] text, forward in self?.find(text, forward: forward) } + findBar.onClose = { [weak self] in self?.closeFind() } + showWelcome() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func loadView() { view = NSView() } + + var windowController: ReaderWindowController? { view.window?.windowController as? ReaderWindowController } + var hasOutline: Bool { headings.count >= 2 } + // Where typing goes when the tab is shown: the page, so the arrow keys scroll it. + var focusView: NSView? { readerDocument == nil ? nil : webView } + + // The window color behind this tab. Reader pages are transparent over it; HTML + // files get the white canvas a browser would give them. + var pageColor: NSColor { + preferences.bookStyle && readerDocument?.format != .html ? Self.paperColor : .textBackgroundColor + } + + // Stops watching the file and keeps the scroll offset for the next time it opens. + func close() { + isClosed = true + pendingChange?.cancel() + watcher = nil + Task { await storeScroll() } + } + + func refreshWelcome() { + if readerDocument == nil { showWelcome() } + } + + private func show(_ content: NSView) { + guard content.superview !== view else { return } + view.subviews.forEach { $0.removeFromSuperview() } + content.frame = view.bounds + content.autoresizingMask = [.width, .height] + view.addSubview(content) + } + + private func showWelcome() { + let root = DropView() + root.openFile = { [weak self] in self?.openRequest?($0, .preferred) } + let icon = NSImageView(image: NSApp.applicationIconImage) + icon.imageScaling = .scaleProportionallyUpOrDown + icon.translatesAutoresizingMaskIntoConstraints = false + icon.widthAnchor.constraint(equalToConstant: 96).isActive = true + icon.heightAnchor.constraint(equalToConstant: 96).isActive = true + let tip = NSTextField(labelWithString: "Drop a .md or .html file here, or press ⌘O.") + tip.font = .systemFont(ofSize: 14) + tip.textColor = .secondaryLabelColor + let stack = NSStackView(views: [icon, tip]) + stack.orientation = .vertical + stack.spacing = 14 + let recents = RecentDocuments.urls.prefix(5) + if !recents.isEmpty { + let heading = NSTextField(labelWithString: "Recent".uppercased()) + heading.font = .systemFont(ofSize: 11, weight: .medium) + heading.textColor = .tertiaryLabelColor + let list = NSStackView(views: [heading] + recents.map { url in + let row = RecentRow(url: url) + row.onOpen = { [weak self] in self?.openRequest?(url, .current) } + return row + }) + list.orientation = .vertical + list.alignment = .leading + list.spacing = 2 + list.setCustomSpacing(6, after: heading) + list.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 0) + stack.addArrangedSubview(list) + stack.setCustomSpacing(30, after: tip) + list.widthAnchor.constraint(equalToConstant: 340).isActive = true + } + stack.translatesAutoresizingMaskIntoConstraints = false + root.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: root.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: root.centerYAnchor, constant: -24) + ]) + show(root) + } + + private func makeReader() -> ReaderWebView { + if let webView { return webView } + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.defaultWebpagePreferences.allowsContentJavaScript = false + configuration.setURLSchemeHandler(localResources, forURLScheme: "marky-local") + let reader = ReaderWebView(frame: .zero, configuration: configuration) + reader.navigationDelegate = self + reader.allowsMagnification = true + reader.openFile = { [weak self] in self?.openRequest?($0, .preferred) } + // Diagrams take their colors when drawn, so they are drawn again in the new appearance. + reader.appearanceChanged = { [weak reader] in + guard let reader else { return } + Task { await Diagrams.render(in: reader, dark: reader.isDark) } + } + reader.registerForDraggedTypes([.fileURL]) + reader.translatesAutoresizingMaskIntoConstraints = false + findBar.translatesAutoresizingMaskIntoConstraints = false + findBar.isHidden = true + readerContainer.addSubview(reader) + readerContainer.addSubview(findBar) + NSLayoutConstraint.activate([ + reader.topAnchor.constraint(equalTo: readerContainer.topAnchor), + reader.bottomAnchor.constraint(equalTo: readerContainer.bottomAnchor), + reader.leadingAnchor.constraint(equalTo: readerContainer.leadingAnchor), + reader.trailingAnchor.constraint(equalTo: readerContainer.trailingAnchor), + findBar.topAnchor.constraint(equalTo: readerContainer.topAnchor, constant: 10), + findBar.trailingAnchor.constraint(equalTo: readerContainer.trailingAnchor, constant: -14) + ]) + webView = reader + return reader + } + + // Loads the file into this tab, replacing whatever it showed before. The + // current scroll offset is stored first so a reload or a later return keeps it. + // An empty tab loads at once, so the app sees it as taken straight away. + func open(_ url: URL) { + documentURL = url.resolvingSymlinksInPath() + guard readerDocument != nil else { load(url); return } + Task { await storeScroll(); load(url) } + } + + private func load(_ url: URL) { + guard !isClosed else { return } + do { + let next = try ReaderDocument(url: url) + let html = try renderer.render(next.text, format: next.format, bodyClass: preferences.bodyClass) + let reader = makeReader() + localResources.directory = next.url.deletingLastPathComponent() + reader.setValue(next.format == .html, forKey: "drawsBackground") + reader.pageZoom = zoom + show(readerContainer) + // A welcome tab has nothing to focus, so the page takes the keyboard once it opens. + if !view.isHidden, view.window?.firstResponder === view.window { view.window?.makeFirstResponder(reader) } + readerDocument = next + documentURL = next.url + headings = [] + closeFind() + title = next.url.lastPathComponent + status = "" + watch(next.url) + reader.loadHTMLString(html, baseURL: URL(string: "marky-local://document/")) + RecentDocuments.note(next.url) + changed?() + } catch { + // The tab keeps showing its file. After a failed live reload the old watcher + // may point at a replaced file, so the current path is watched again. + documentURL = readerDocument?.url + if let shown = readerDocument { watch(shown.url) } + presentError(error, title: "Couldn't open \(url.lastPathComponent)") + } + } + + func storeScroll() async { + guard let url = readerDocument?.url, let webView else { return } + if let offset = try? await webView.evaluateJavaScript("window.scrollY") as? Double { + Self.scrollPositions[url] = offset + } + } + + // MARK: Live reload + + private func watch(_ url: URL) { + watcher = FileWatcher(url: url) { [weak self] in self?.fileChanged() } + } + + // Editors often save in several steps, so the file is checked once events stop. + private func fileChanged() { + pendingChange?.cancel() + pendingChange = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + self?.reloadIfChanged() + } + } + + // Re-renders when the text differs from what is shown, which also skips the app's + // own checkbox writes. A file that is gone keeps its last render with a notice. + private func reloadIfChanged() { + guard let shown = readerDocument else { return } + guard FileManager.default.fileExists(atPath: shown.url.path) else { + watcher = nil + status = "Moved or deleted" + changed?() + return + } + if let current = try? ReaderDocument(url: shown.url), current.text == shown.text { + watch(shown.url) + } else { + open(shown.url) + } + } + + // Draws any diagrams first, since they change the page height, then collects the + // outline, adds link destinations as tooltips, and restores the scroll offset. + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let url = readerDocument?.url, let reader = webView as? ReaderWebView else { return } + Task { await Diagrams.render(in: reader, dark: reader.isDark); finishLoad(url) } + } + + private func finishLoad(_ url: URL) { + guard let webView, readerDocument?.url == url else { return } + let offset = Self.scrollPositions[url] ?? 0 + webView.evaluateJavaScript(""" + (() => { + for (const a of document.links) { + if (!a.title && !a.classList.contains('task-toggle')) a.title = a.getAttribute('href'); + } + if (\(offset) > 0) window.scrollTo(0, \(offset)); + return [...document.querySelectorAll('\(Self.headingSelector)')].map(h => ({ level: +h.tagName[1], text: h.textContent.trim().slice(0, 80) })); + })() + """) { [weak self] result, _ in + let items = result as? [[String: Any]] ?? [] + self?.headings = items.compactMap { item in + guard let level = item["level"] as? Int, let text = item["text"] as? String else { return nil } + return Heading(level: level, text: text) + } + self?.changed?() + } + } + + private func presentError(_ error: any Error, title: String) { + guard let window = view.window else { return } + let alert = NSAlert(error: error) + alert.messageText = title + alert.informativeText = error.localizedDescription + alert.beginSheetModal(for: window) + } + + // Writes the flipped task marker to the file, then updates the rendered + // checkbox in place so the scroll position and zoom are kept. The file is + // re-read first so edits made elsewhere since it was opened are not overwritten. + private func toggleTask(line: Int) { + guard let rendered = readerDocument, rendered.format == .markdown else { return } + do { + var current = try ReaderDocument(url: rendered.url) + guard TaskList.sameLine(line, in: rendered.text, and: current.text) else { + open(rendered.url) + throw DocumentError.documentChanged + } + guard let text = TaskList.toggling(line: line, in: current.text) else { throw DocumentError.taskNotFound } + // Edits made elsewhere may have moved other tasks, so the page is rendered again + // instead of updating one box; otherwise later clicks would use stale line numbers. + let changedElsewhere = current.text != rendered.text + current.text = text + try current.write() + readerDocument = current + if changedElsewhere { open(current.url); return } + webView?.evaluateJavaScript(TaskList.checkboxUpdateScript(line: line)) { [weak self] updated, error in + // The file is already written; a failed in-place update falls back to a full render. + if error != nil || updated as? Bool != true { self?.open(current.url) } + } + } catch { + presentError(error, title: "Couldn't update \(rendered.url.lastPathComponent)") + } + } + + // Copies a code block's text as shown, without the final line break, and marks + // the button as done for a moment. + private func copyCode(block: Int) { + let target = "document.querySelectorAll('.code-block')[\(block)]" + webView?.evaluateJavaScript("(() => { const block = \(target); if (!block) return null; block.classList.add('copied'); return block.querySelector('pre').textContent; })()") { [weak self] text, _ in + guard let text = text as? String else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text.hasSuffix("\n") ? String(text.dropLast()) : text, forType: .string) + Task { [weak self] in + try? await Task.sleep(for: .seconds(1.5)) + _ = try? await self?.webView?.evaluateJavaScript("\(target)?.classList.remove('copied'); 0") + } + } + } + + // MARK: Outline + + // Asks the page which heading is on screen, then shows the outline as a menu + // under the toolbar button or, from the keyboard, at the top of the page. + @objc func showOutline(_ sender: Any?) { + guard let webView, hasOutline else { return } + webView.evaluateJavaScript(""" + (() => { let current = -1; document.querySelectorAll('\(Self.headingSelector)').forEach((h, i) => { if (h.getBoundingClientRect().top <= 8) current = i; }); return current; })() + """) { [weak self] result, _ in + guard let self else { return } + let current = result as? Int ?? -1 + let menu = NSMenu() + menu.font = .menuFont(ofSize: 13) + let top = headings.map(\.level).min() ?? 1 + for (index, heading) in headings.enumerated() { + let item = NSMenuItem(title: heading.text, action: #selector(selectHeading(_:)), keyEquivalent: "") + item.target = self + item.tag = index + item.indentationLevel = heading.level - top + item.state = index == current ? .on : .off + menu.addItem(item) + } + // Drops down from the toolbar button; with the toolbar hidden, from the page's top-left corner. + let toolbar = view.window?.toolbar + let button = toolbar?.isVisible == true ? toolbar?.items.first(where: { $0.itemIdentifier == .outline })?.view : nil + let anchor: NSView = button.flatMap { $0.window == nil ? nil : $0 } ?? webView + let below = anchor === webView ? -12.0 : 4.0 + let x = anchor === webView ? 16.0 : 0.0 + menu.popUp(positioning: nil, at: NSPoint(x: x, y: anchor.isFlipped ? anchor.bounds.maxY + below : -below), in: anchor) + } + } + + @objc private func selectHeading(_ sender: NSMenuItem) { + webView?.evaluateJavaScript("document.querySelectorAll('\(Self.headingSelector)')[\(sender.tag)].scrollIntoView({ block: 'start' })") + } + + // MARK: Find + + @objc func showFind(_ sender: Any?) { + guard readerDocument != nil else { return } + findBar.isHidden = false + view.window?.makeFirstResponder(findBar.field) + findBar.field.selectText(nil) + } + + @objc func findNext(_ sender: Any?) { find(findBar.field.stringValue, forward: true) } + @objc func findPrevious(_ sender: Any?) { find(findBar.field.stringValue, forward: false) } + + private func closeFind() { + guard !findBar.isHidden else { return } + findBar.isHidden = true + findBar.field.stringValue = "" + findBar.count.stringValue = "" + findCount = 0 + findIndex = 0 + webView?.evaluateJavaScript("window.getSelection().removeAllRanges()") + // A hidden tab that reloads must not take the keyboard from the tab shown. + if let webView, !view.isHidden { view.window?.makeFirstResponder(webView) } + } + + // WebKit selects and scrolls to the next match; the total comes from the page text. + private func find(_ text: String, forward: Bool) { + guard let webView, !text.isEmpty else { + findBar.count.stringValue = "" + findCount = 0 + findIndex = 0 + webView?.evaluateJavaScript("window.getSelection().removeAllRanges()") + return + } + let query = String(data: try! JSONSerialization.data(withJSONObject: [text]), encoding: .utf8)!.dropFirst().dropLast() + webView.evaluateJavaScript(""" + (() => { const t = document.body.innerText.toLowerCase(), q = \(query).toLowerCase(); let n = 0, i = 0; while ((i = t.indexOf(q, i)) !== -1) { n++; i += q.length; } return n; })() + """) { [weak self] result, _ in + guard let self else { return } + let total = result as? Int ?? 0 + if total != findCount { findCount = total; findIndex = 0 } + guard total > 0 else { findBar.count.stringValue = "None"; return } + let configuration = WKFindConfiguration() + configuration.backwards = !forward + configuration.caseSensitive = false + configuration.wraps = true + webView.find(text, configuration: configuration) { [weak self] found in + guard let self, found.matchFound else { return } + findIndex = forward ? findIndex % total + 1 : (findIndex + total - 2) % total + 1 + findBar.count.stringValue = "\(findIndex) of \(total)" + } + } + } + + // MARK: Actions + + @objc func reloadDocument(_ sender: Any?) { + if let url = readerDocument?.url { open(url) } + } + + @objc func printDocument(_ sender: Any?) { runPrint(savingTo: nil) } + + @objc func exportPDF(_ sender: Any?) { + guard let window = view.window, let url = readerDocument?.url else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.pdf] + panel.nameFieldStringValue = url.deletingPathExtension().lastPathComponent + ".pdf" + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let destination = panel.url else { return } + // Starts after the save sheet has closed, so the print run can use the window. + DispatchQueue.main.async { self?.runPrint(savingTo: destination) } + } + } + + // Printing and PDF export share one path, so the PDF has the same pages as a + // printout. The print stylesheet in reader.css sets light colors and page breaks. + private func runPrint(savingTo destination: URL?) { + guard let webView, let window = view.window, let document = readerDocument else { return } + let info = NSPrintInfo.shared.copy() as! NSPrintInfo + info.horizontalPagination = .fit + info.verticalPagination = .automatic + info.isVerticallyCentered = false + info.topMargin = 48 + info.bottomMargin = 48 + info.leftMargin = 54 + info.rightMargin = 54 + if let destination { + info.jobDisposition = .save + info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = destination + } + let operation = webView.printOperation(with: info) + operation.jobTitle = document.url.deletingPathExtension().lastPathComponent + operation.showsPrintPanel = destination == nil + operation.showsProgressPanel = destination == nil + // WebKit's print view starts with a zero frame, which prints blank pages. + operation.view?.frame = webView.bounds + // Pages print light, so diagrams are drawn light first and redrawn for the screen afterwards. + Task { + await Diagrams.render(in: webView, dark: false, fitWidth: true) + operation.runModal(for: window, delegate: self, didRun: #selector(printFinished(_:success:contextInfo:)), contextInfo: nil) + } + } + + @objc private func printFinished(_ operation: NSPrintOperation, success: Bool, contextInfo: UnsafeMutableRawPointer?) { + guard let webView else { return } + Task { await Diagrams.render(in: webView, dark: webView.isDark) } + } + + @objc func revealDocument(_ sender: Any?) { + if let url = readerDocument?.url { NSWorkspace.shared.activateFileViewerSelecting([url]) } + } + + @objc func zoomIn(_ sender: Any?) { zoom = min(zoom + 0.1, 2); webView?.pageZoom = zoom } + @objc func zoomOut(_ sender: Any?) { zoom = max(zoom - 0.1, 0.7); webView?.pageZoom = zoom } + @objc func actualSize(_ sender: Any?) { zoom = 1; webView?.pageZoom = zoom } + + // Document commands stay disabled on the welcome screen. + func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { + guard let action = item.action else { return true } + if action == #selector(showOutline(_:)) { return hasOutline } + let needsDocument: [Selector] = [#selector(reloadDocument(_:)), #selector(revealDocument(_:)), #selector(printDocument(_:)), #selector(exportPDF(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)), #selector(actualSize(_:)), #selector(showFind(_:)), #selector(findNext(_:)), #selector(findPrevious(_:))] + return needsDocument.contains(action) ? readerDocument != nil : true + } + + private func applyAppearance() { + if readerDocument?.format == .markdown { + webView?.evaluateJavaScript("document.body.className = '\(preferences.bodyClass)'") + } + } + + // MARK: Navigation policy + + // Only the document itself may load in the web view. Links open outside or + // switch documents; anything else a page could trigger (meta refresh, forms) is dropped. + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) { + guard let url = action.request.url else { decisionHandler(.cancel); return } + let scheme = url.scheme?.lowercased() ?? "" + if action.navigationType != .linkActivated { + decisionHandler(scheme == "marky-local" || scheme == "about" ? .allow : .cancel) + return + } + if scheme == "marky-local", url.path == "/", url.fragment != nil { + decisionHandler(.allow) + return + } + decisionHandler(.cancel) + if let pageAction = PageAction(url) { + switch pageAction { + case .toggleTask(let line): toggleTask(line: line) + case .copyCode(let block): copyCode(block: block) + } + } else if ["https", "http", "mailto"].contains(scheme) { + NSWorkspace.shared.open(url) + } else if let directory = readerDocument?.url.deletingLastPathComponent(), let local = LocalResource.resolve(url, within: directory), DocumentFormat.of(local) != nil { + // A plain click follows the link here; ⌘-click opens it in a new tab. + openRequest?(local, action.modifierFlags.contains(.command) ? .tab : .current) + } + } +} + +@MainActor +private final class DropView: NSView { + var openFile: ((URL) -> Void)? + override init(frame: NSRect) { + super.init(frame: frame) + registerForDraggedTypes([.fileURL]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { droppedDocument(sender) == nil ? [] : .copy } + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard let url = droppedDocument(sender) else { return false } + openFile?(url) + return true + } +} + +// One recent file on the welcome screen: name on the left, folder on the right. +@MainActor +private final class RecentRow: NSView { + var onOpen: (() -> Void)? + private var hovered = false { didSet { needsDisplay = true } } + + init(url: URL) { + super.init(frame: .zero) + let name = NSTextField(labelWithString: url.lastPathComponent) + name.font = .systemFont(ofSize: 13) + name.lineBreakMode = .byTruncatingMiddle + let folder = NSTextField(labelWithString: (url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath) + folder.font = .systemFont(ofSize: 11) + folder.textColor = .tertiaryLabelColor + folder.lineBreakMode = .byTruncatingHead + folder.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let stack = NSStackView(views: [name, folder]) + stack.distribution = .fill + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 28), + widthAnchor.constraint(equalToConstant: 330), + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + stack.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + setAccessibilityElement(true) + setAccessibilityRole(.button) + setAccessibilityLabel(url.lastPathComponent) + toolTip = url.path + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInKeyWindow], owner: self)) + } + + override func mouseEntered(with event: NSEvent) { hovered = true } + override func mouseExited(with event: NSEvent) { hovered = false } + override func mouseUp(with event: NSEvent) { + if bounds.contains(convert(event.locationInWindow, from: nil)) { onOpen?() } + } + + override func draw(_ dirtyRect: NSRect) { + guard hovered else { return } + NSColor.quaternaryLabelColor.setFill() + NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() + } +} + +// Search field with the match position and arrows, floating over the page. +@MainActor +private final class FindBar: NSView, NSSearchFieldDelegate { + let field = NSSearchField() + let count = NSTextField(labelWithString: "") + var onSearch: ((String, Bool) -> Void)? + var onClose: (() -> Void)? + + init() { + super.init(frame: .zero) + field.placeholderString = "Find" + field.delegate = self + field.sendsSearchStringImmediately = true + field.widthAnchor.constraint(equalToConstant: 190).isActive = true + count.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + count.textColor = .secondaryLabelColor + let previous = NSButton(image: NSImage(systemSymbolName: "chevron.left", accessibilityDescription: "Previous match")!, target: self, action: #selector(previousMatch)) + let next = NSButton(image: NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Next match")!, target: self, action: #selector(nextMatch)) + let done = NSButton(title: "Done", target: self, action: #selector(close)) + for button in [previous, next, done] { + button.bezelStyle = .accessoryBarAction + button.controlSize = .small + } + let stack = NSStackView(views: [field, count, previous, next, done]) + stack.spacing = 6 + stack.setCustomSpacing(10, after: count) + stack.edgeInsets = NSEdgeInsets(top: 6, left: 8, bottom: 6, right: 8) + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), stack.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + wantsLayer = true + let shadow = NSShadow() + shadow.shadowBlurRadius = 12 + shadow.shadowOffset = NSSize(width: 0, height: -4) + shadow.shadowColor = NSColor.black.withAlphaComponent(0.18) + self.shadow = shadow + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func draw(_ dirtyRect: NSRect) { + let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 9, yRadius: 9) + NSColor.controlBackgroundColor.setFill() + path.fill() + NSColor.separatorColor.setStroke() + path.stroke() + } + + func controlTextDidChange(_ notification: Notification) { onSearch?(field.stringValue, true) } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.cancelOperation(_:)): onClose?() + case #selector(NSResponder.insertNewline(_:)): onSearch?(field.stringValue, !(NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)) + default: return false + } + return true + } + + @objc private func previousMatch() { onSearch?(field.stringValue, false) } + @objc private func nextMatch() { onSearch?(field.stringValue, true) } + @objc private func close() { onClose?() } +} diff --git a/Sources/ZenMarky/ReaderWindowController.swift b/Sources/ZenMarky/ReaderWindowController.swift index fc509e4..58c0e2d 100644 --- a/Sources/ZenMarky/ReaderWindowController.swift +++ b/Sources/ZenMarky/ReaderWindowController.swift @@ -1,12 +1,12 @@ import AppKit -import UniformTypeIdentifiers -import WebKit struct ReaderPreferences { var bookStyle = UserDefaults.standard.bool(forKey: "bookStyle") var appearanceMode = UserDefaults.standard.string(forKey: "appearanceMode") ?? "system" var opensInTabs = UserDefaults.standard.object(forKey: "opensInTabs") as? Bool ?? true var showsFrontMatter = UserDefaults.standard.bool(forKey: "showsFrontMatter") + // Without it, the tab strip shows only when a window has two tabs or a group. + var showsTabBar = UserDefaults.standard.bool(forKey: "showsTabBar") static let appearanceModes = [("system", "System"), ("light", "Light"), ("dark", "Dark")] @@ -27,501 +27,389 @@ struct ReaderPreferences { UserDefaults.standard.set(appearanceMode, forKey: "appearanceMode") UserDefaults.standard.set(opensInTabs, forKey: "opensInTabs") UserDefaults.standard.set(showsFrontMatter, forKey: "showsFrontMatter") + UserDefaults.standard.set(showsTabBar, forKey: "showsTabBar") } } -// Where a newly opened file goes relative to the window the request came from. +// Where a newly opened file goes relative to the tab the request came from. enum OpenPlacement { case current, preferred, tab, window } -struct Heading { - let level: Int - let text: String +// What a window needs from the app delegate, which makes tabs and windows and keeps the window list. +@MainActor +protocol ReaderWindowOwner: AnyObject { + func makeTab() -> ReaderTab + func openWindow(with tabs: [ReaderTab], topLeft: NSPoint?) + func windowClosed(_ controller: ReaderWindowController) +} + +extension NSToolbarItem.Identifier { + static let outline = Self("outline") + static let reload = Self("reload") + static let open = Self("open") + static let appearance = Self("appearance") } -// One window per document. Menu and toolbar actions reach it through the -// responder chain, so the app delegate only coordinates windows and preferences. +// One window: the toolbar, the tab strip, and the tabs, of which one is shown. +// Document commands from the menu and toolbar pass on to the shown tab, so the app +// delegate only coordinates windows and preferences. @MainActor -final class ReaderWindowController: NSWindowController, NSToolbarDelegate, NSWindowDelegate, WKNavigationDelegate, NSUserInterfaceValidations { - private(set) var readerDocument: ReaderDocument? - // The file shown or being loaded. It is set before the page's scroll offset is read, - // so the app never opens the same file twice or reuses a window that is switching. - private(set) var documentURL: URL? - var preferences: ReaderPreferences { didSet { applyAppearance() } } - var openRequest: ((URL, OpenPlacement) -> Void)? - var closed: (() -> Void)? - - private let renderer: DocumentRenderer - private let localResources = LocalResourceHandler() - private var webView: ReaderWebView? - private let readerContainer = NSView() - private let findBar = FindBar() - private var findCount = 0 - private var findIndex = 0 - private var headings: [Heading] = [] { didSet { outlineButton.isEnabled = headings.count >= 2 } } +final class ReaderWindowController: NSWindowController, NSWindowDelegate, NSToolbarDelegate, NSMenuItemValidation { + private(set) var tabs: [ReaderTab] = [] + private(set) var selectedTab: ReaderTab? + var preferences: ReaderPreferences { + didSet { + for tab in tabs { tab.preferences = preferences } + updateTabBar() + showSelection() + } + } + + private weak var app: ReaderWindowOwner? + private let strip = TabStripView() + // Holds the tabs' views, below the strip when it shows. + private let tabArea = NSView() private let outlineButton = NSButton() - private var zoom: CGFloat = 1 - private var watcher: FileWatcher? - private var pendingChange: Task? - private var isClosed = false - private let openID = NSToolbarItem.Identifier("open") - private let reloadID = NSToolbarItem.Identifier("reload") - private let outlineID = NSToolbarItem.Identifier("outline") - private let appearanceID = NSToolbarItem.Identifier("appearance") - - // Scroll offsets by file, so reloads, reopened files, and the next launch - // return to where they were. - static var scrollPositions: [URL: Double] = [:] - - private static let headingSelector = "h1,h2,h3,h4,h5,h6" - - // Matches the Book Reader page colors in reader.css so the unified title bar - // and the page read as one surface. - private static let paperColor = NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - ? NSColor(srgbRed: 0.141, green: 0.137, blue: 0.125, alpha: 1) - : NSColor(srgbRed: 0.988, green: 0.980, blue: 0.965, alpha: 1) - } - - init(renderer: DocumentRenderer, preferences: ReaderPreferences) { - self.renderer = renderer + + init(preferences: ReaderPreferences, app: ReaderWindowOwner) { self.preferences = preferences + self.app = app let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 820, height: 720), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) super.init(window: window) - window.title = "New Tab" window.minSize = NSSize(width: 440, height: 360) window.isReleasedWhenClosed = false window.toolbarStyle = .unified // The title bar shows the window color, so the page and the bar are one surface. window.titlebarAppearsTransparent = true - window.tabbingIdentifier = "reader" + // The strip below the toolbar replaces the system's window tabs. + window.tabbingMode = .disallowed window.delegate = self + // The strip sits at the top of the content rather than in the title bar, where + // AppKit draws a separator line over accessories that only its own tab bar can hide. + let content = NSView(frame: NSRect(x: 0, y: 0, width: 820, height: 720)) + window.contentView = content + tabArea.frame = content.bounds + tabArea.autoresizingMask = [.width, .height] + strip.frame = NSRect(x: 0, y: content.bounds.height - TabStripView.height, width: content.bounds.width, height: TabStripView.height) + strip.autoresizingMask = [.width, .minYMargin] + content.addSubview(tabArea) + content.addSubview(strip) let toolbar = NSToolbar(identifier: "ReaderToolbar") toolbar.delegate = self toolbar.displayMode = .iconOnly window.toolbar = toolbar + strip.controller = self window.center() // Restores the saved frame when there is one, so it must come after center(). window.setFrameAutosaveName("ReaderWindow") - findBar.onSearch = { [weak self] text, forward in self?.find(text, forward: forward) } - findBar.onClose = { [weak self] in self?.closeFind() } - applyAppearance() - showWelcome() + updateTabBar() } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } func windowWillClose(_ notification: Notification) { - isClosed = true - pendingChange?.cancel() - watcher = nil - Task { await storeScroll() } - closed?() + for tab in tabs { tab.close() } + app?.windowClosed(self) } // The recent list may have changed while another window was in front. - func windowDidBecomeKey(_ notification: Notification) { refreshWelcome() } - - func refreshWelcome() { - if readerDocument == nil { showWelcome() } - } - - private func showWelcome() { - let root = DropView() - root.openFile = { [weak self] in self?.openRequest?($0, .preferred) } - let icon = NSImageView(image: NSApp.applicationIconImage) - icon.imageScaling = .scaleProportionallyUpOrDown - icon.translatesAutoresizingMaskIntoConstraints = false - icon.widthAnchor.constraint(equalToConstant: 96).isActive = true - icon.heightAnchor.constraint(equalToConstant: 96).isActive = true - let tip = NSTextField(labelWithString: "Drop a .md or .html file here, or press ⌘O.") - tip.font = .systemFont(ofSize: 14) - tip.textColor = .secondaryLabelColor - let stack = NSStackView(views: [icon, tip]) - stack.orientation = .vertical - stack.spacing = 14 - let recents = RecentDocuments.urls.prefix(5) - if !recents.isEmpty { - let heading = NSTextField(labelWithString: "Recent".uppercased()) - heading.font = .systemFont(ofSize: 11, weight: .medium) - heading.textColor = .tertiaryLabelColor - let list = NSStackView(views: [heading] + recents.map { url in - let row = RecentRow(url: url) - row.onOpen = { [weak self] in self?.openRequest?(url, .current) } - return row - }) - list.orientation = .vertical - list.alignment = .leading - list.spacing = 2 - list.setCustomSpacing(6, after: heading) - list.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 0) - stack.addArrangedSubview(list) - stack.setCustomSpacing(30, after: tip) - list.widthAnchor.constraint(equalToConstant: 340).isActive = true - } - stack.translatesAutoresizingMaskIntoConstraints = false - root.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerXAnchor.constraint(equalTo: root.centerXAnchor), - stack.centerYAnchor.constraint(equalTo: root.centerYAnchor, constant: -24) - ]) - window?.contentView = root - } - - private func makeReader() -> ReaderWebView { - if let webView { return webView } - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .nonPersistent() - configuration.defaultWebpagePreferences.allowsContentJavaScript = false - configuration.setURLSchemeHandler(localResources, forURLScheme: "marky-local") - let reader = ReaderWebView(frame: .zero, configuration: configuration) - reader.navigationDelegate = self - reader.allowsMagnification = true - reader.openFile = { [weak self] in self?.openRequest?($0, .preferred) } - // Diagrams take their colors when drawn, so they are drawn again in the new appearance. - reader.appearanceChanged = { [weak reader] in - guard let reader else { return } - Task { await Diagrams.render(in: reader, dark: reader.isDark) } - } - reader.registerForDraggedTypes([.fileURL]) - reader.translatesAutoresizingMaskIntoConstraints = false - findBar.translatesAutoresizingMaskIntoConstraints = false - findBar.isHidden = true - readerContainer.addSubview(reader) - readerContainer.addSubview(findBar) - NSLayoutConstraint.activate([ - reader.topAnchor.constraint(equalTo: readerContainer.topAnchor), - reader.bottomAnchor.constraint(equalTo: readerContainer.bottomAnchor), - reader.leadingAnchor.constraint(equalTo: readerContainer.leadingAnchor), - reader.trailingAnchor.constraint(equalTo: readerContainer.trailingAnchor), - findBar.topAnchor.constraint(equalTo: readerContainer.topAnchor, constant: 10), - findBar.trailingAnchor.constraint(equalTo: readerContainer.trailingAnchor, constant: -14) - ]) - webView = reader - return reader - } - - // Loads the file into this window, replacing whatever it showed before. The - // current scroll offset is stored first so a reload or a later return keeps it. - // An empty window loads at once, so the app sees it as taken straight away. - func open(_ url: URL) { - documentURL = url.resolvingSymlinksInPath() - guard readerDocument != nil else { load(url); return } - Task { await storeScroll(); load(url) } - } - - private func load(_ url: URL) { - guard let window, !isClosed else { return } - do { - let next = try ReaderDocument(url: url) - let html = try renderer.render(next.text, format: next.format, bodyClass: preferences.bodyClass) - let reader = makeReader() - localResources.directory = next.url.deletingLastPathComponent() - // Reader pages are transparent over the window color. HTML files get the - // white canvas a browser would give them, so unstyled pages stay readable in dark mode. - reader.setValue(next.format == .html, forKey: "drawsBackground") - reader.pageZoom = zoom - if window.contentView !== readerContainer { window.contentView = readerContainer } - readerDocument = next - documentURL = next.url - headings = [] - closeFind() - window.title = next.url.lastPathComponent - window.subtitle = "" - window.representedURL = next.url - watch(next.url) - applyAppearance() - reader.loadHTMLString(html, baseURL: URL(string: "marky-local://document/")) - RecentDocuments.note(next.url) - } catch { - // The window keeps showing its file. After a failed live reload the old watcher - // may point at a replaced file, so the current path is watched again. - documentURL = readerDocument?.url - if let shown = readerDocument { watch(shown.url) } - presentError(error, title: "Couldn't open \(url.lastPathComponent)") - } - } + func windowDidBecomeKey(_ notification: Notification) { selectedTab?.refreshWelcome() } - func storeScroll() async { - guard let url = readerDocument?.url, let webView else { return } - if let offset = try? await webView.evaluateJavaScript("window.scrollY") as? Double { - Self.scrollPositions[url] = offset - } + // Tab titles dim while another window is in front. + func windowDidBecomeMain(_ notification: Notification) { strip.reload() } + func windowDidResignMain(_ notification: Notification) { strip.reload() } + + // Menu and toolbar commands this controller does not handle go to the shown tab. + override func supplementalTarget(forAction action: Selector, sender: Any?) -> Any? { + if let selectedTab, selectedTab.responds(to: action) { return selectedTab } + return super.supplementalTarget(forAction: action, sender: sender) } - // MARK: Live reload + // MARK: Tabs - private func watch(_ url: URL) { - watcher = FileWatcher(url: url) { [weak self] in self?.fileChanged() } + // The window's groups, left to right. + var groups: [TabGroup] { + var groups: [TabGroup] = [] + for case let group? in tabs.map(\.group) where !groups.contains(group) { groups.append(group) } + return groups } - // Editors often save in several steps, so the file is checked once events stop. - private func fileChanged() { - pendingChange?.cancel() - pendingChange = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(250)) - guard !Task.isCancelled else { return } - self?.reloadIfChanged() + func tabs(in group: TabGroup) -> [ReaderTab] { tabs.filter { $0.group === group } } + + private var visibleTabs: [ReaderTab] { tabs.filter { $0.group?.collapsed != true } } + + // Moves the tabs here from wherever they are, at an index of this window's list + // without them. A tab moved within this window stays shown. A window the tabs + // leave shows another tab in place of a shown one that left, or closes when empty. + func insert(_ moving: [ReaderTab], at index: Int) { + let shown = selectedTab + let sources = Set(moving.compactMap(\.windowController)).map { ($0, $0.remove(moving)) } + tabs.insert(contentsOf: moving, at: min(index, tabs.count)) + for tab in moving { attach(tab) } + tabs = StripLayout.keepingGroupsTogether(tabs) { $0.group } + if let shown, tabs.contains(shown) { select(shown) } + for (source, shownIndex) in sources where source !== self { + if source.tabs.isEmpty { source.close() } else if let shownIndex { source.showTab(near: shownIndex) } } + if selectedTab == nil { showTab(near: index) } + updateTabBar() } - // Re-renders when the text differs from what is shown, which also skips the app's - // own checkbox writes. A file that is gone keeps its last render with a notice. - private func reloadIfChanged() { - guard let shown = readerDocument else { return } - guard FileManager.default.fileExists(atPath: shown.url.path) else { - watcher = nil - window?.subtitle = "Moved or deleted" - return - } - if let current = try? ReaderDocument(url: shown.url), current.text == shown.text { - watch(shown.url) - } else { - open(shown.url) - } + // Adds a tab at the end, or right after the tab it was opened from and in that tab's group. + func add(_ tab: ReaderTab, nextTo opener: ReaderTab? = nil) { + let index = opener.flatMap { tabs.firstIndex(of: $0) } + tab.group = index == nil ? nil : opener?.group + insert([tab], at: index.map { $0 + 1 } ?? tabs.count) } - // Draws any diagrams first, since they change the page height, then collects the - // outline, adds link destinations as tooltips, and restores the scroll offset. - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard let url = readerDocument?.url, let reader = webView as? ReaderWebView else { return } - Task { await Diagrams.render(in: reader, dark: reader.isDark); finishLoad(url) } - } - - private func finishLoad(_ url: URL) { - guard let webView, readerDocument?.url == url else { return } - let offset = Self.scrollPositions[url] ?? 0 - webView.evaluateJavaScript(""" - (() => { - for (const a of document.links) { - if (!a.title && !a.classList.contains('task-toggle')) a.title = a.getAttribute('href'); - } - if (\(offset) > 0) window.scrollTo(0, \(offset)); - return [...document.querySelectorAll('\(Self.headingSelector)')].map(h => ({ level: +h.tagName[1], text: h.textContent.trim().slice(0, 80) })); - })() - """) { [weak self] result, _ in - let items = result as? [[String: Any]] ?? [] - self?.headings = items.compactMap { item in - guard let level = item["level"] as? Int, let text = item["text"] as? String else { return nil } - return Heading(level: level, text: text) - } - self?.window?.toolbar?.validateVisibleItems() - } + func addNewTab(in group: TabGroup? = nil) { + guard let tab = app?.makeTab() else { return } + add(tab, nextTo: group.flatMap { tabs(in: $0).last }) + select(tab) } - private func presentError(_ error: any Error, title: String) { - guard let window else { return } - let alert = NSAlert(error: error) - alert.messageText = title - alert.informativeText = error.localizedDescription - alert.beginSheetModal(for: window) - } - - // Writes the flipped task marker to the file, then updates the rendered - // checkbox in place so the scroll position and zoom are kept. The file is - // re-read first so edits made elsewhere since it was opened are not overwritten. - private func toggleTask(line: Int) { - guard let rendered = readerDocument, rendered.format == .markdown else { return } - do { - var current = try ReaderDocument(url: rendered.url) - guard TaskList.sameLine(line, in: rendered.text, and: current.text) else { - open(rendered.url) - throw DocumentError.documentChanged - } - guard let text = TaskList.toggling(line: line, in: current.text) else { throw DocumentError.taskNotFound } - // Edits made elsewhere may have moved other tasks, so the page is rendered again - // instead of updating one box; otherwise later clicks would use stale line numbers. - let changedElsewhere = current.text != rendered.text - current.text = text - try current.write() - readerDocument = current - if changedElsewhere { open(current.url); return } - webView?.evaluateJavaScript(TaskList.checkboxUpdateScript(line: line)) { [weak self] updated, error in - // The file is already written; a failed in-place update falls back to a full render. - if error != nil || updated as? Bool != true { self?.open(current.url) } - } - } catch { - presentError(error, title: "Couldn't update \(rendered.url.lastPathComponent)") + // Shows the tab, opening its group if it was collapsed. + func select(_ tab: ReaderTab) { + guard tabs.contains(tab) else { return } + tab.group?.collapsed = false + if selectedTab !== tab { + selectedTab?.view.isHidden = true + selectedTab = tab + tab.view.isHidden = false + window?.makeFirstResponder(tab.focusView) + tab.refreshWelcome() + } + showSelection() + updateTabBar() + } + + // Closes the tabs, and the window when none are left. + func close(_ closing: [ReaderTab]) { + for tab in closing where tabs.contains(tab) { tab.close() } + let shownIndex = remove(closing) + if tabs.isEmpty { close(); return } + if let shownIndex { showTab(near: shownIndex) } + updateTabBar() + } + + private func attach(_ tab: ReaderTab) { + tab.view.isHidden = true + tab.view.frame = tabArea.bounds + tab.view.autoresizingMask = [.width, .height] + tabArea.addSubview(tab.view) + tab.changed = { [weak self, weak tab] in + guard let self, let tab else { return } + updateTabBar() + if tab === selectedTab { showSelection() } } } - // Copies a code block's text as shown, without the final line break, and marks - // the button as done for a moment. - private func copyCode(block: Int) { - let target = "document.querySelectorAll('.code-block')[\(block)]" - webView?.evaluateJavaScript("(() => { const block = \(target); if (!block) return null; block.classList.add('copied'); return block.querySelector('pre').textContent; })()") { [weak self] text, _ in - guard let text = text as? String else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text.hasSuffix("\n") ? String(text.dropLast()) : text, forType: .string) - Task { [weak self] in - try? await Task.sleep(for: .seconds(1.5)) - _ = try? await self?.webView?.evaluateJavaScript("\(target)?.classList.remove('copied'); 0") - } + // Takes those of the tabs that are here out of this window without closing them. + // Returns where the shown tab was among the tabs left, if it was one of them. + private func remove(_ leaving: [ReaderTab]) -> Int? { + let shownIndex = selectedTab.flatMap { shown in + leaving.contains(shown) ? tabs.prefix(while: { $0 !== shown }).filter { !leaving.contains($0) }.count : nil } + for tab in leaving where tabs.contains(tab) { + tabs.removeAll { $0 === tab } + tab.view.removeFromSuperview() + tab.changed = nil + } + if shownIndex != nil { selectedTab = nil } + return shownIndex + } + + // Shows the visible tab nearest the position, preferring the right side as Chrome + // does, or a new tab when every tab left is in a collapsed group. + private func showTab(near index: Int) { + let visible = { (tab: ReaderTab) in tab.group?.collapsed != true } + let bound = min(index, tabs.count) + if let next = tabs[bound...].first(where: visible) ?? tabs[.. 1 || tabs.contains { $0.group != nil } || TabStripView.isDragging) + if let content = window?.contentView { + tabArea.frame = NSRect(x: 0, y: 0, width: content.bounds.width, height: content.bounds.height - (strip.isHidden ? 0 : TabStripView.height)) + } + strip.reload() } - // MARK: Outline + // MARK: Groups - // Asks the page which heading is on screen, then shows the outline as a menu - // under the toolbar button or, from the keyboard, at the top of the page. - @objc func showOutline(_ sender: Any?) { - guard let webView, headings.count >= 2 else { return } - webView.evaluateJavaScript(""" - (() => { let current = -1; document.querySelectorAll('\(Self.headingSelector)').forEach((h, i) => { if (h.getBoundingClientRect().top <= 8) current = i; }); return current; })() - """) { [weak self] result, _ in - guard let self else { return } - let current = result as? Int ?? -1 - let menu = NSMenu() - menu.font = .menuFont(ofSize: 13) - let top = headings.map(\.level).min() ?? 1 - for (index, heading) in headings.enumerated() { - let item = NSMenuItem(title: heading.text, action: #selector(selectHeading(_:)), keyEquivalent: "") - item.target = self - item.tag = index - item.indentationLevel = heading.level - top - item.state = index == current ? .on : .off - menu.addItem(item) - } - // Drops down from the toolbar button; with the toolbar hidden, from the page's top-left corner. - let anchor: NSView = outlineButton.window != nil && window?.toolbar?.isVisible == true ? outlineButton : webView - let below = anchor === outlineButton ? 4.0 : -12.0 - let x = anchor === outlineButton ? 0.0 : 16.0 - menu.popUp(positioning: nil, at: NSPoint(x: x, y: anchor.isFlipped ? anchor.bounds.maxY + below : -below), in: anchor) - } + // A group's name, or its tabs' titles when it has none, as Chrome labels unnamed groups. + func label(for group: TabGroup) -> String { + guard group.name.isEmpty else { return group.name } + let members = tabs(in: group) + let first = members.first?.title ?? "Group" + return members.count > 1 ? "\(first) and \(members.count - 1) more" : first } - @objc private func selectHeading(_ sender: NSMenuItem) { - webView?.evaluateJavaScript("document.querySelectorAll('\(Self.headingSelector)')[\(sender.tag)].scrollIntoView({ block: 'start' })") + // Starts a group with the tab and opens its editor, as Chrome does. + func newGroup(with tab: ReaderTab) { + let used = Set(groups.map(\.color)) + let group = TabGroup(color: GroupColor.allCases.first { !used.contains($0) } ?? .blue) + tab.group = group + regroup() + strip.edit(group) } - // MARK: Find - - @objc func showFind(_ sender: Any?) { - guard readerDocument != nil else { return } - findBar.isHidden = false - window?.makeFirstResponder(findBar.field) - findBar.field.selectText(nil) + // Puts the tab at the end of the group. + func add(_ tab: ReaderTab, to group: TabGroup) { + tabs.removeAll { $0 === tab } + let end = tabs.lastIndex { $0.group === group }.map { $0 + 1 } ?? tabs.count + tabs.insert(tab, at: end) + tab.group = group + if tab === selectedTab { group.collapsed = false } + regroup() } - @objc func findNext(_ sender: Any?) { find(findBar.field.stringValue, forward: true) } - @objc func findPrevious(_ sender: Any?) { find(findBar.field.stringValue, forward: false) } + func removeFromGroup(_ tab: ReaderTab) { + tab.group = nil + regroup() + } - private func closeFind() { - guard !findBar.isHidden else { return } - findBar.isHidden = true - findBar.field.stringValue = "" - findBar.count.stringValue = "" - findCount = 0 - findIndex = 0 - webView?.evaluateJavaScript("window.getSelection().removeAllRanges()") - if let webView { window?.makeFirstResponder(webView) } + func ungroup(_ group: TabGroup) { + for tab in tabs(in: group) { tab.group = nil } + regroup() } - // WebKit selects and scrolls to the next match; the total comes from the page text. - private func find(_ text: String, forward: Bool) { - guard let webView, !text.isEmpty else { - findBar.count.stringValue = "" - findCount = 0 - findIndex = 0 - webView?.evaluateJavaScript("window.getSelection().removeAllRanges()") - return - } - let query = String(data: try! JSONSerialization.data(withJSONObject: [text]), encoding: .utf8)!.dropFirst().dropLast() - webView.evaluateJavaScript(""" - (() => { const t = document.body.innerText.toLowerCase(), q = \(query).toLowerCase(); let n = 0, i = 0; while ((i = t.indexOf(q, i)) !== -1) { n++; i += q.length; } return n; })() - """) { [weak self] result, _ in - guard let self else { return } - let total = result as? Int ?? 0 - if total != findCount { findCount = total; findIndex = 0 } - guard total > 0 else { findBar.count.stringValue = "None"; return } - let configuration = WKFindConfiguration() - configuration.backwards = !forward - configuration.caseSensitive = false - configuration.wraps = true - webView.find(text, configuration: configuration) { [weak self] found in - guard let self, found.matchFound else { return } - findIndex = forward ? findIndex % total + 1 : (findIndex + total - 2) % total + 1 - findBar.count.stringValue = "\(findIndex) of \(total)" - } + func closeGroup(_ group: TabGroup) { close(tabs(in: group)) } + + // Collapsing the group that holds the shown tab shows the nearest tab outside it, + // or a new tab when there is none. + func toggle(_ group: TabGroup) { + group.collapsed.toggle() + if group.collapsed, let shown = selectedTab, shown.group === group, let index = tabs.firstIndex(of: shown) { + showTab(near: index) } + updateTabBar() } - // MARK: Actions - - @objc func reloadDocument(_ sender: Any?) { - if let url = readerDocument?.url { open(url) } + // A tab that leaves its group's middle moves to just after the group. + private func regroup() { + tabs = StripLayout.keepingGroupsTogether(tabs) { $0.group } + updateTabBar() } - @objc func printDocument(_ sender: Any?) { runPrint(savingTo: nil) } + // MARK: Moving - @objc func exportPDF(_ sender: Any?) { - guard let window, let url = readerDocument?.url else { return } - let panel = NSSavePanel() - panel.allowedContentTypes = [.pdf] - panel.nameFieldStringValue = url.deletingPathExtension().lastPathComponent + ".pdf" - panel.beginSheetModal(for: window) { [weak self] response in - guard response == .OK, let destination = panel.url else { return } - // Starts after the save sheet has closed, so the print run can use the window. - DispatchQueue.main.async { self?.runPrint(savingTo: destination) } + // Tabs dropped on this window's strip. A dropped tab joins the group it lands in. + func drop(_ moving: MovingTabs, at index: Int, in group: TabGroup?) { + switch moving { + case .tab(let tab): + tab.group = group + insert([tab], at: index) + select(tab) + case .group(_, let members): + // The group's shown tab stays shown when the group moves to another window. + let shown = members.first { $0 === $0.windowController?.selectedTab } + insert(members, at: index) + if let shown { select(shown) } } + window?.makeKeyAndOrderFront(nil) } - // Printing and PDF export share one path, so the PDF has the same pages as a - // printout. The print stylesheet in reader.css sets light colors and page breaks. - private func runPrint(savingTo destination: URL?) { - guard let webView, let window, let document = readerDocument else { return } - let info = NSPrintInfo.shared.copy() as! NSPrintInfo - info.horizontalPagination = .fit - info.verticalPagination = .automatic - info.isVerticallyCentered = false - info.topMargin = 48 - info.bottomMargin = 48 - info.leftMargin = 54 - info.rightMargin = 54 - if let destination { - info.jobDisposition = .save - info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = destination + // Opens the tabs in a new window, with its top-left corner at the point if given. + // Moving every tab moves this window instead. A tab moved on its own leaves its group. + func moveToNewWindow(_ moving: MovingTabs, at point: NSPoint? = nil) { + guard moving.tabs.count < tabs.count else { + if let point { window?.setFrameTopLeftPoint(point) } + return } - let operation = webView.printOperation(with: info) - operation.jobTitle = document.url.deletingPathExtension().lastPathComponent - operation.showsPrintPanel = destination == nil - operation.showsProgressPanel = destination == nil - // WebKit's print view starts with a zero frame, which prints blank pages. - operation.view?.frame = webView.bounds - // Pages print light, so diagrams are drawn light first and redrawn for the screen afterwards. - Task { - await Diagrams.render(in: webView, dark: false, fitWidth: true) - operation.runModal(for: window, delegate: self, didRun: #selector(printFinished(_:success:contextInfo:)), contextInfo: nil) + if case .tab(let tab) = moving { tab.group = nil } + app?.openWindow(with: moving.tabs, topLeft: point) + } + + // The tab's right-click menu. + func menu(for tab: ReaderTab) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menu.addItem(ActionMenuItem("Add Tab to New Group") { [weak self] in self?.newGroup(with: tab) }) + let others = groups.filter { $0 !== tab.group } + if !others.isEmpty { + let item = NSMenuItem(title: "Add Tab to Group", action: nil, keyEquivalent: "") + let submenu = NSMenu() + for group in others { + let choice = ActionMenuItem(label(for: group)) { [weak self] in self?.add(tab, to: group) } + choice.image = group.color.swatch(size: 12) + submenu.addItem(choice) + } + item.submenu = submenu + menu.addItem(item) + } + if tab.group != nil { + menu.addItem(ActionMenuItem("Remove from Group") { [weak self] in self?.removeFromGroup(tab) }) + } + menu.addItem(.separator()) + let move = ActionMenuItem("Move Tab to New Window") { [weak self] in self?.moveToNewWindow(.tab(tab)) } + move.isEnabled = tabs.count > 1 + menu.addItem(move) + menu.addItem(.separator()) + menu.addItem(ActionMenuItem("Close Tab") { [weak self] in self?.close([tab]) }) + let closeOthers = ActionMenuItem("Close Other Tabs") { [weak self] in self?.close(self?.tabs.filter { $0 !== tab } ?? []) } + closeOthers.isEnabled = tabs.count > 1 + menu.addItem(closeOthers) + return menu + } + + // This window's files in tab order with their groups, for the next launch. Empty tabs are left out. + var savedWindow: ReaderSession.Window? { + let path = { (tab: ReaderTab) in tab.readerDocument?.url.path } + let files = tabs.compactMap(path) + guard let window, !files.isEmpty else { return nil } + let saved = groups.map { group in + ReaderSession.Group(name: group.name, color: group.color, collapsed: group.collapsed, files: tabs(in: group).compactMap(path)) } + return ReaderSession.Window(files: files, selected: selectedTab.flatMap(path), frame: NSStringFromRect(window.frame), groups: saved.filter { !$0.files.isEmpty }) } - @objc private func printFinished(_ operation: NSPrintOperation, success: Bool, contextInfo: UnsafeMutableRawPointer?) { - guard let webView else { return } - Task { await Diagrams.render(in: webView, dark: webView.isDark) } + // MARK: Menu actions + + @objc func closeTab(_ sender: Any?) { + if let selectedTab { close([selectedTab]) } } - @objc func revealDocument(_ sender: Any?) { - if let url = readerDocument?.url { NSWorkspace.shared.activateFileViewerSelecting([url]) } + @objc func nextTab(_ sender: Any?) { cycleTabs(by: 1) } + @objc func previousTab(_ sender: Any?) { cycleTabs(by: -1) } + + // Tabs hidden in a collapsed group are skipped, as in Chrome. + private func cycleTabs(by step: Int) { + let visible = visibleTabs + guard let selectedTab, let index = visible.firstIndex(of: selectedTab) else { return } + select(visible[(index + step + visible.count) % visible.count]) } - @objc func zoomIn(_ sender: Any?) { zoom = min(zoom + 0.1, 2); webView?.pageZoom = zoom } - @objc func zoomOut(_ sender: Any?) { zoom = max(zoom - 0.1, 0.7); webView?.pageZoom = zoom } - @objc func actualSize(_ sender: Any?) { zoom = 1; webView?.pageZoom = zoom } + // ⌘1 through ⌘8 select a visible tab by position; ⌘9 selects the last. + @objc func selectTab(_ sender: NSMenuItem) { + let visible = visibleTabs + let index = sender.tag == 8 ? visible.count - 1 : sender.tag + if visible.indices.contains(index) { select(visible[index]) } + } - // Document commands stay disabled on the welcome screen. - func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { - guard let action = item.action else { return true } - if action == #selector(showOutline(_:)) { return headings.count >= 2 } - let needsDocument: [Selector] = [#selector(reloadDocument(_:)), #selector(revealDocument(_:)), #selector(printDocument(_:)), #selector(exportPDF(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)), #selector(actualSize(_:)), #selector(showFind(_:)), #selector(findNext(_:)), #selector(findPrevious(_:))] - return needsDocument.contains(action) ? readerDocument != nil : true + @objc func detachTab(_ sender: Any?) { + if let selectedTab { moveToNewWindow(.tab(selectedTab)) } } - private func applyAppearance() { - guard let window else { return } - window.backgroundColor = preferences.bookStyle && readerDocument?.format != .html ? Self.paperColor : .textBackgroundColor - if readerDocument?.format == .markdown { - webView?.evaluateJavaScript("document.body.className = '\(preferences.bodyClass)'") + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(nextTab(_:)), #selector(previousTab(_:)): visibleTabs.count > 1 + case #selector(detachTab(_:)): tabs.count > 1 + default: true } } @@ -529,12 +417,12 @@ final class ReaderWindowController: NSWindowController, NSToolbarDelegate, NSWin // The outline is navigation, so it sits at the leading edge before the title, as // Finder and Preview place theirs. Reload, Open, and the appearance settings sit at the trailing edge. - func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [outlineID, .flexibleSpace, reloadID, openID, appearanceID] } - func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [outlineID, reloadID, openID, appearanceID, .flexibleSpace] } + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [.outline, .flexibleSpace, .reload, .open, .appearance] } + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [.outline, .reload, .open, .appearance, .flexibleSpace] } func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier identifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { let item = NSToolbarItem(itemIdentifier: identifier) switch identifier { - case appearanceID: + case .appearance: let button = NSPopUpButton(frame: .zero, pullsDown: true) button.bezelStyle = .texturedRounded button.imagePosition = .imageOnly @@ -558,22 +446,22 @@ final class ReaderWindowController: NSWindowController, NSToolbarDelegate, NSWin button.setAccessibilityLabel("Reader style and appearance") item.view = button item.label = "Appearance" - case outlineID: + case .outline: item.label = "Outline" item.isNavigational = true outlineButton.image = NSImage(systemSymbolName: "list.bullet", accessibilityDescription: "Outline") outlineButton.bezelStyle = .texturedRounded - outlineButton.target = self - outlineButton.action = #selector(showOutline(_:)) + // No target, so the click travels the responder chain to the shown tab. + outlineButton.action = #selector(ReaderTab.showOutline(_:)) outlineButton.toolTip = "Jump to a heading (⌥⌘O)" - outlineButton.isEnabled = headings.count >= 2 + outlineButton.isEnabled = selectedTab?.hasOutline == true item.view = outlineButton - case reloadID: + case .reload: item.label = "Reload" item.toolTip = "Reload the file (⌘R)" item.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload the file") - item.action = #selector(reloadDocument(_:)) - case openID: + item.action = #selector(ReaderTab.reloadDocument(_:)) + case .open: item.label = "Open" item.toolTip = "Open a Markdown or HTML file (⌘O)" item.image = NSImage(systemSymbolName: "folder", accessibilityDescription: "Open a Markdown or HTML file") @@ -583,172 +471,4 @@ final class ReaderWindowController: NSWindowController, NSToolbarDelegate, NSWin } return item } - - // MARK: Navigation policy - - // Only the document itself may load in the web view. Links open outside or - // switch documents; anything else a page could trigger (meta refresh, forms) is dropped. - func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) { - guard let url = action.request.url else { decisionHandler(.cancel); return } - let scheme = url.scheme?.lowercased() ?? "" - if action.navigationType != .linkActivated { - decisionHandler(scheme == "marky-local" || scheme == "about" ? .allow : .cancel) - return - } - if scheme == "marky-local", url.path == "/", url.fragment != nil { - decisionHandler(.allow) - return - } - decisionHandler(.cancel) - if let pageAction = PageAction(url) { - switch pageAction { - case .toggleTask(let line): toggleTask(line: line) - case .copyCode(let block): copyCode(block: block) - } - } else if ["https", "http", "mailto"].contains(scheme) { - NSWorkspace.shared.open(url) - } else if let directory = readerDocument?.url.deletingLastPathComponent(), let local = LocalResource.resolve(url, within: directory), DocumentFormat.of(local) != nil { - // A plain click follows the link here; ⌘-click opens it in a new tab. - openRequest?(local, action.modifierFlags.contains(.command) ? .tab : .current) - } - } -} - -@MainActor -private final class DropView: NSView { - var openFile: ((URL) -> Void)? - override init(frame: NSRect) { - super.init(frame: frame) - registerForDraggedTypes([.fileURL]) - } - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { droppedDocument(sender) == nil ? [] : .copy } - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - guard let url = droppedDocument(sender) else { return false } - openFile?(url) - return true - } -} - -// One recent file on the welcome screen: name on the left, folder on the right. -@MainActor -private final class RecentRow: NSView { - var onOpen: (() -> Void)? - private var hovered = false { didSet { needsDisplay = true } } - - init(url: URL) { - super.init(frame: .zero) - let name = NSTextField(labelWithString: url.lastPathComponent) - name.font = .systemFont(ofSize: 13) - name.lineBreakMode = .byTruncatingMiddle - let folder = NSTextField(labelWithString: (url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath) - folder.font = .systemFont(ofSize: 11) - folder.textColor = .tertiaryLabelColor - folder.lineBreakMode = .byTruncatingHead - folder.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let stack = NSStackView(views: [name, folder]) - stack.distribution = .fill - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: 28), - widthAnchor.constraint(equalToConstant: 330), - stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), - stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - stack.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - setAccessibilityElement(true) - setAccessibilityRole(.button) - setAccessibilityLabel(url.lastPathComponent) - toolTip = url.path - } - - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - trackingAreas.forEach(removeTrackingArea) - addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInKeyWindow], owner: self)) - } - - override func mouseEntered(with event: NSEvent) { hovered = true } - override func mouseExited(with event: NSEvent) { hovered = false } - override func mouseUp(with event: NSEvent) { - if bounds.contains(convert(event.locationInWindow, from: nil)) { onOpen?() } - } - - override func draw(_ dirtyRect: NSRect) { - guard hovered else { return } - NSColor.quaternaryLabelColor.setFill() - NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() - } -} - -// Search field with the match position and arrows, floating over the page. -@MainActor -private final class FindBar: NSView, NSSearchFieldDelegate { - let field = NSSearchField() - let count = NSTextField(labelWithString: "") - var onSearch: ((String, Bool) -> Void)? - var onClose: (() -> Void)? - - init() { - super.init(frame: .zero) - field.placeholderString = "Find" - field.delegate = self - field.sendsSearchStringImmediately = true - field.widthAnchor.constraint(equalToConstant: 190).isActive = true - count.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) - count.textColor = .secondaryLabelColor - let previous = NSButton(image: NSImage(systemSymbolName: "chevron.left", accessibilityDescription: "Previous match")!, target: self, action: #selector(previousMatch)) - let next = NSButton(image: NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Next match")!, target: self, action: #selector(nextMatch)) - let done = NSButton(title: "Done", target: self, action: #selector(close)) - for button in [previous, next, done] { - button.bezelStyle = .accessoryBarAction - button.controlSize = .small - } - let stack = NSStackView(views: [field, count, previous, next, done]) - stack.spacing = 6 - stack.setCustomSpacing(10, after: count) - stack.edgeInsets = NSEdgeInsets(top: 6, left: 8, bottom: 6, right: 8) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), - stack.leadingAnchor.constraint(equalTo: leadingAnchor), stack.trailingAnchor.constraint(equalTo: trailingAnchor) - ]) - wantsLayer = true - let shadow = NSShadow() - shadow.shadowBlurRadius = 12 - shadow.shadowOffset = NSSize(width: 0, height: -4) - shadow.shadowColor = NSColor.black.withAlphaComponent(0.18) - self.shadow = shadow - } - - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - - override func draw(_ dirtyRect: NSRect) { - let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 9, yRadius: 9) - NSColor.controlBackgroundColor.setFill() - path.fill() - NSColor.separatorColor.setStroke() - path.stroke() - } - - func controlTextDidChange(_ notification: Notification) { onSearch?(field.stringValue, true) } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { - switch selector { - case #selector(NSResponder.cancelOperation(_:)): onClose?() - case #selector(NSResponder.insertNewline(_:)): onSearch?(field.stringValue, !(NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)) - default: return false - } - return true - } - - @objc private func previousMatch() { onSearch?(field.stringValue, false) } - @objc private func nextMatch() { onSearch?(field.stringValue, true) } - @objc private func close() { onClose?() } } diff --git a/Sources/ZenMarky/Resources/Shortcuts.md b/Sources/ZenMarky/Resources/Shortcuts.md index 836d84d..b600401 100644 --- a/Sources/ZenMarky/Resources/Shortcuts.md +++ b/Sources/ZenMarky/Resources/Shortcuts.md @@ -5,7 +5,8 @@ | Open a file | ⌘O | | New tab | ⌘T | | New window | ⌘N | -| Close tab or window | ⌘W | +| Close tab | ⌘W | +| Close window | ⇧⌘W | | Reload the file | ⌘R | | Print | ⌘P | | Copy / select all | ⌘C / ⌘A | @@ -13,9 +14,9 @@ | Find next / previous | ⌘G / ⇧⌘G | | Show outline | ⌥⌘O | | Zoom in / out / actual size | ⌘= / ⌘− / ⌘0 | -| Previous / next tab | ⇧⌘[ / ⇧⌘] | +| Previous / next tab | ⇧⌘[ / ⇧⌘], or ⌃⇧⇥ / ⌃⇥ | | Go to tab 1–8, last tab | ⌘1–⌘8, ⌘9 | -| Show tab bar / all tabs | ⇧⌘T / ⇧⌘\\ | +| Always show the tab bar | ⇧⌘T | | Cycle windows | ⌘` | | Minimize | ⌘M | | Hide / quit the app | ⌘H / ⌘Q | @@ -27,6 +28,11 @@ | --- | --- | | Open a file | Drop it on any window | | Open a linked file in a new tab | ⌘-click the link | +| Group a tab | Right-click the tab, then Add Tab to New Group | +| Collapse or expand a group | Click its chip | +| Rename, recolor, or close a group | Right-click its chip | +| Move a tab or a whole group | Drag the tab or the chip along the tab bar; pull it away from the bar to drop it on another window's tab bar, or outside every window for a new window | +| Close a tab | Middle-click it | | Check or uncheck a task | Click the box | | Copy a code block | Hover over it, then click the copy button | | Zoom | Pinch on the trackpad | @@ -38,4 +44,5 @@ | Reader style: Native or Book | Aa button, or View > Reader Style | | Appearance: System, Light, or Dark | Aa button, or View > Appearance | | Open files in a new tab or window | File > Open Files In | +| Tab bar with a single tab | View > Always Show Tab Bar | | Front matter (title, date, tags) | View > Show Front Matter | diff --git a/Sources/ZenMarky/TabStrip.swift b/Sources/ZenMarky/TabStrip.swift new file mode 100644 index 0000000..f5a1195 --- /dev/null +++ b/Sources/ZenMarky/TabStrip.swift @@ -0,0 +1,1017 @@ +import AppKit + +// A Chrome-style tab group: a named, colored run of neighboring tabs in one window. +// Its tabs share the object, so a new name or color reaches all of them at once. +final class TabGroup: Equatable { + var name: String + var color: GroupColor + var collapsed: Bool + + init(name: String = "", color: GroupColor, collapsed: Bool = false) { + self.name = name + self.color = color + self.collapsed = collapsed + } + + static func == (lhs: TabGroup, rhs: TabGroup) -> Bool { lhs === rhs } +} + +// Muted tones that keep chip text readable on the white and paper pages, in light and dark. +enum GroupColor: String, Codable, CaseIterable { + // New groups take the first color not in use, so grey comes last. + case blue, teal, green, yellow, orange, red, purple, grey + + var title: String { rawValue.capitalized } + + var color: NSColor { + let (light, dark): (UInt32, UInt32) = switch self { + case .grey: (0x6B7280, 0xA8AFBA) + case .blue: (0x3B6FB6, 0x8AB4F0) + case .teal: (0x237F86, 0x6FCBCF) + case .green: (0x3F7F4F, 0x8FCB98) + case .yellow: (0x946F10, 0xE3C265) + case .orange: (0xB25A26, 0xF0A36E) + case .red: (0xB23A48, 0xF08C96) + case .purple: (0x7652B0, 0xBBA2EE) + } + let srgb = { (hex: UInt32) in + NSColor(srgbRed: CGFloat(hex >> 16 & 0xFF) / 255, green: CGFloat(hex >> 8 & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1) + } + return .adaptive(light: srgb(light), dark: srgb(dark)) + } + + // A filled circle for menus and the group editor. The chosen color is a dot inside a ring. + func swatch(size: CGFloat, chosen: Bool = false) -> NSImage { + let color = color + return NSImage(size: NSSize(width: size, height: size), flipped: false) { rect in + color.set() + if chosen { + let ring = NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)) + ring.lineWidth = 1.5 + ring.stroke() + NSBezierPath(ovalIn: rect.insetBy(dx: 4.5, dy: 4.5)).fill() + } else { + NSBezierPath(ovalIn: rect.insetBy(dx: 2, dy: 2)).fill() + } + return true + } + } +} + +extension NSColor { + // A color that follows the light or dark appearance it is drawn in. + static func adaptive(light: NSColor, dark: NSColor) -> NSColor { + NSColor(name: nil) { $0.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light } + } +} + +// One piece of the strip, left to right: a group's chip, or the tab at an index of the window's list. +enum StripItem: Equatable { + case chip(TabGroup) + case tab(Int) +} + +// A place a moving tab or group can land: before the tab at an index of the window's +// list without the moving tabs, and in a group or none. +struct StripSlot: Equatable { + var index: Int + var group: TabGroup? +} + +// The strip's order and drop rules, kept apart from the views so they can be tested. +// Lists hold each tab's group, or nil for a tab outside any group. +enum StripLayout { + // Moves each group's tabs next to the group's first tab, keeping their order. + static func keepingGroupsTogether(_ tabs: [Tab], group: (Tab) -> TabGroup?) -> [Tab] { + var result: [Tab] = [] + var placed: [TabGroup] = [] + for tab in tabs { + guard let owner = group(tab) else { result.append(tab); continue } + guard !placed.contains(owner) else { continue } + placed.append(owner) + result += tabs.filter { group($0) === owner } + } + return result + } + + // A chip before each group's tabs. A collapsed group shows only its chip. + static func items(_ groups: [TabGroup?]) -> [StripItem] { + var items: [StripItem] = [] + for (index, group) in groups.enumerated() { + if let group, index == 0 || groups[index - 1] !== group { items.append(.chip(group)) } + if group?.collapsed != true { items.append(.tab(index)) } + } + return items + } + + // Where a moving tab can land, left to right. After a group's last tab it can stay in + // the group or leave it; both slots open the gap in the same place. Before a group + // it lands outside, and after the chip it is the group's first tab. Collapsed groups + // take no tabs. + static func tabSlots(_ groups: [TabGroup?]) -> [StripSlot] { + var slots: [StripSlot] = [] + for index in 0...groups.count { + let left = index > 0 ? groups[index - 1] : nil + let right = index < groups.count ? groups[index] : nil + if let left, left === right { + if !left.collapsed { slots.append(StripSlot(index: index, group: left)) } + continue + } + if let left, !left.collapsed { slots.append(StripSlot(index: index, group: left)) } + slots.append(StripSlot(index: index)) + if let right, !right.collapsed { slots.append(StripSlot(index: index, group: right)) } + } + return slots + } + + // A moving group lands only between groups and ungrouped tabs. + static func groupSlots(_ groups: [TabGroup?]) -> [StripSlot] { + (0...groups.count).filter { index in + index == 0 || index == groups.count || groups[index - 1] == nil || groups[index - 1] !== groups[index] + }.map { StripSlot(index: $0) } + } + + // The slot whose gap would open nearest x, the moving block's left edge; positions are + // each slot's gap edge. Where a group ends, the block leaves the group once it is more + // than `margin` points past the gap and rejoins once it is that far before it; in + // between it keeps the slot it had. + static func pick(_ slots: [StripSlot], at positions: [CGFloat], x: CGFloat, current: StripSlot?, margin: CGFloat = 8) -> StripSlot { + let best = positions.indices.min { abs(positions[$0] - x) < abs(positions[$1] - x) }! + guard best + 1 < slots.count, positions[best + 1] == positions[best] else { return slots[best] } + let (inside, outside, place) = (slots[best], slots[best + 1], positions[best]) + if x < place - margin { return inside } + if x > place + margin { return outside } + if let current, current == inside || current == outside { return current } + return x < place ? inside : outside + } +} + +// What a drag or a Move to New Window command carries: one tab, or a whole group with its tabs. +enum MovingTabs { + case tab(ReaderTab) + case group(TabGroup, [ReaderTab]) + + var tabs: [ReaderTab] { + switch self { + case .tab(let tab): [tab] + case .group(_, let tabs): tabs + } + } +} + +// The system tab bar's measures and colors, read from the bar AppKit draws for window +// tabs, so the strip matches it in light and dark. +@MainActor +private enum StripStyle { + // Tabs outside the shown one sit on a band that darkens the window color. + static let band = NSColor.adaptive(light: NSColor(genericGamma22White: 0.949, alpha: 1), dark: NSColor(genericGamma22White: 0, alpha: 0.45)) + static let hover = NSColor.adaptive(light: NSColor(genericGamma22White: 0.898, alpha: 1), dark: NSColor(genericGamma22White: 0, alpha: 0.15)) + static let divider = NSColor.adaptive(light: NSColor(genericGamma22White: 0.878, alpha: 1), dark: NSColor(genericGamma22White: 0.067, alpha: 1)) + static let buttonHover = NSColor.adaptive(light: NSColor(white: 0, alpha: 0.08), dark: NSColor(white: 1, alpha: 0.12)) + + // Shades a piece of the strip over the window's background, with the edge where the + // band meets the toolbar. The shown tab has no shade, so it joins the page; the window + // may tint its background from the desktop, which only the window itself draws. + static func fill(_ rect: NSRect, in view: NSView, shade: NSColor?) { + guard let shade else { return } + shade.setFill() + rect.fill(using: .sourceOver) + // A soft shadow in light mode and a dark line in dark mode, in half-point rows. + let dark = view.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let rows: [CGFloat] = dark ? [1, 0.25] : [0.14, 0.085, 0.055, 0.03, 0.015, 0.01, 0.005] + for (row, alpha) in rows.enumerated() { + NSColor(white: 0, alpha: alpha).setFill() + NSRect(x: rect.minX, y: rect.maxY - CGFloat(row + 1) / 2, width: rect.width, height: 0.5).fill(using: .sourceOver) + } + } + + static func divider(at x: CGFloat, in view: NSView) { + divider.setFill() + NSRect(x: x, y: 0, width: 1, height: view.bounds.height).fill() + } + + // Titles dim, as the system's do, when the window is not the main one. + static func titleColor(selected: Bool, in view: NSView) -> NSColor { + view.window?.isMainWindow == false ? .tertiaryLabelColor : selected ? .labelColor : .secondaryLabelColor + } +} + +// The row under the toolbar, drawn like the system tab bar: tabs share the width equally +// on a darker band, the shown tab takes the window color, and a new tab button sits at +// the right end. Group chips sit before their tabs. A tab or chip dragged along the +// strip slides the others aside; pulled away from it, it becomes a drag that other +// windows' strips take, and released outside every window it opens a new window there. +@MainActor +final class TabStripView: NSView, NSDraggingSource { + static let height: CGFloat = 28 + private static let pasteboardType = NSPasteboard.PasteboardType("com.zedyas.zenmarky.tab") + private static let addWidth: CGFloat = 28 + private static let minTabWidth: CGFloat = 48 + // How far the pointer can stray from the strip while carrying tabs before they tear off. + private static let tearOff = NSSize(width: 24, height: 56) + // The drag in progress and the strip it started from, which it keeps alive until + // the drag ends even if the strip's window closes. + private static var drag: (source: TabStripView, moving: MovingTabs)? { + didSet { + for window in NSApp.windows { (window.windowController as? ReaderWindowController)?.updateTabBar() } + } + } + static var isDragging: Bool { drag != nil } + + weak var controller: ReaderWindowController? + // Tabs carried along this strip by the pointer. + private var lift: Lift? + // What this strip is moving, left out of its layout meanwhile. + private var moving: MovingTabs? { lift?.moving ?? (Self.drag?.source === self ? Self.drag?.moving : nil) } + // Where the moving tabs would land; the layout opens a gap there. + private var drop: Drop? + // Each tab's width in the last layout, which carried tabs keep. + private var tabWidth: CGFloat = 0 + private var buttons: [ObjectIdentifier: TabButton] = [:] + private var chips: [ObjectIdentifier: GroupChip] = [:] + private let addButton = StripButton(kind: .add, label: "New Tab") + // The band where moving tabs would land. + private let gapView = BandView() + // The window's background behind carried tabs, so the tabs they pass do not show through. + private var backdrop: NSView? { didSet { oldValue?.removeFromSuperview() } } + private var editor: NSPopover? + + // The width moving tabs take: some tabs' share of the strip, plus a chip's width. + private struct Room: Equatable { + var tabs: Int + var chip: CGFloat + } + + private struct Drop: Equatable { + var slot: StripSlot + var room: Room + } + + private struct Lift { + let moving: MovingTabs + let views: [NSView] + // The pointer's distance from the left edge of the carried views. + let grab: CGFloat + var x: CGFloat + } + + init() { + super.init(frame: NSRect(x: 0, y: 0, width: 800, height: Self.height)) + wantsLayer = true + addButton.toolTip = "New Tab (⌘T)" + addButton.run = { [weak self] in self?.controller?.addNewTab() } + addSubview(gapView) + addSubview(addButton) + // Above tabs that overflow a narrow window, and above carried tabs. + addButton.wantsLayer = true + addButton.layer?.zPosition = 2 + registerForDraggedTypes([Self.pasteboardType]) + setAccessibilityElement(true) + setAccessibilityRole(.tabGroup) + setAccessibilityLabel("Tabs") + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + // The page's text cursor would otherwise linger over the strip. + override func resetCursorRects() { addCursorRect(bounds, cursor: .arrow) } + + // Matches the views to the window's tabs and groups, then lays them out. + func reload() { + guard let controller else { return } + let tabs = controller.tabs + let groups = controller.groups + for (tab, button) in zip(tabs, sync(&buttons, with: tabs) { TabButton(tab: $0, strip: self) }) { + button.show(selected: tab === controller.selectedTab) + } + for (group, chip) in zip(groups, sync(&chips, with: groups) { GroupChip(group: $0, strip: self) }) { + chip.show(label: controller.label(for: group), hiddenTabs: group.collapsed ? controller.tabs(in: group).count : 0) + } + // A carried tab closed mid-drag ends the lift; other changes move its gap. + if let lift { + if lift.moving.tabs.allSatisfy(tabs.contains) { + drop = nearestDrop(for: lift.moving, room: room(for: lift.moving), x: lift.x, centered: false) + } else { + self.lift = nil + drop = nil + backdrop = nil + } + } + addButton.needsDisplay = true + arrange(animated: window?.isVisible == true) + } + + // Reuses the view made for each object, makes views for new ones, and removes the rest. + private func sync(_ views: inout [ObjectIdentifier: View], with objects: [Object], make: (Object) -> View) -> [View] { + let kept = Set(objects.map(ObjectIdentifier.init)) + for (id, view) in views where !kept.contains(id) { + view.removeFromSuperview() + views[id] = nil + } + return objects.map { object in + if let view = views[ObjectIdentifier(object)] { return view } + let view = make(object) + view.isHidden = true + // Clicks follow view order, not drawing order, so the new tab button stays on top. + addSubview(view, positioned: .below, relativeTo: addButton) + views[ObjectIdentifier(object)] = view + return view + } + } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + arrange(animated: false) + } + + // Places chips and tabs left to right, with a gap where moving tabs would land, and + // the carried tabs under the pointer. + private func arrange(animated: Bool) { + guard let controller else { return } + let tabs: [ReaderTab?] = controller.tabs.filter { moving?.tabs.contains($0) != true } + if let drop, drop.slot.index > tabs.count { self.drop = nil } + var shown = tabs + var groups = tabs.map { $0?.group } + if let drop { + shown.insert(nil, at: drop.slot.index) + groups.insert(drop.slot.group, at: drop.slot.index) + } + let items = StripLayout.items(groups) + let (frames, tabWidth) = layout(items, gap: drop.map { ($0.slot.index, $0.room) }) + self.tabWidth = tabWidth + var placed: Set = [] + NSAnimationContext.runAnimationGroup { context in + context.duration = animated ? 0.15 : 0 + for (item, frame) in zip(items, frames) { + let view: NSView? = switch item { + case .chip(let group): chips[ObjectIdentifier(group)] + case .tab(let index): shown[index].map { buttons[ObjectIdentifier($0)] } ?? gapView + } + guard let view else { continue } + if view === gapView { gapView.line = drop?.slot.group?.color } + placed.insert(ObjectIdentifier(view)) + (view.isHidden ? view : view.animator()).frame = frame + view.isHidden = false + } + } + addButton.frame = NSRect(x: bounds.width - Self.addWidth, y: 0, width: Self.addWidth, height: bounds.height) + for view in lift?.views ?? [] { placed.insert(ObjectIdentifier(view)) } + placeLifted() + let views: [NSView] = Array(buttons.values) + Array(chips.values) + [gapView] + for view in views where !placed.contains(ObjectIdentifier(view)) { view.isHidden = true } + } + + // Chips fit their label, and tabs share the rest of the width equally. A gap takes the + // room of the tabs and chip it stands for. + private func layout(_ items: [StripItem], gap: (index: Int, room: Room)?) -> (frames: [NSRect], tabWidth: CGFloat) { + let chipWidths = items.map { item -> CGFloat? in + guard case .chip(let group) = item else { return nil } + return chips[ObjectIdentifier(group)]?.fittingWidth ?? 0 + } + let fixed = chipWidths.compactMap { $0 } + let tabCount = items.count - fixed.count + (gap.map { $0.room.tabs - 1 } ?? 0) + let free = bounds.width - Self.addWidth - fixed.reduce(0, +) - (gap?.room.chip ?? 0) + let tabWidth = max(free / CGFloat(max(tabCount, 1)), Self.minTabWidth) + var x: CGFloat = 0 + let frames = zip(items, chipWidths).map { item, chipWidth in + var width = chipWidth ?? tabWidth + if case .tab(let index) = item, let gap, index == gap.index { width = CGFloat(gap.room.tabs) * tabWidth + gap.room.chip } + defer { x += width } + // Whole points keep titles and dividers sharp. + return NSRect(x: x.rounded(), y: 0, width: (x + width).rounded() - x.rounded(), height: bounds.height) + } + return (frames, tabWidth) + } + + // MARK: Carrying tabs along the strip + + var isLifting: Bool { lift != nil } + + // Starts carrying the tabs from where the mouse went down on them. + func startLift(_ moving: MovingTabs, pressedAt event: NSEvent) { + guard let controller, lift == nil, let index = controller.tabs.firstIndex(of: moving.tabs[0]) else { return } + let views: [NSView] = switch moving { + case .tab(let tab): [buttons[ObjectIdentifier(tab)]].compactMap { $0 } + case .group(let group, let tabs): + [chips[ObjectIdentifier(group)]].compactMap { $0 } + (group.collapsed ? [] : tabs.compactMap { buttons[ObjectIdentifier($0)] }) + } + guard let first = views.first else { return } + for view in subviews where view !== addButton { view.layer?.zPosition = 0 } + for view in views { + view.wantsLayer = true + view.layer?.zPosition = 1 + } + backdrop = makeBackdrop() + let x = first.frame.minX + lift = Lift(moving: moving, views: views, grab: convert(event.locationInWindow, from: nil).x - x, x: x) + let group: TabGroup? = if case .tab(let tab) = moving { tab.group } else { nil } + drop = Drop(slot: StripSlot(index: index, group: group), room: room(for: moving)) + arrange(animated: true) + } + + // Moves the carried tabs with the pointer, or hands them to a drag once the pointer + // strays too far from the strip. + func moveLift(with event: NSEvent) { + guard var lift else { return } + let point = convert(event.locationInWindow, from: nil) + guard bounds.insetBy(dx: -Self.tearOff.width, dy: -Self.tearOff.height).contains(point) else { + self.lift = nil + drop = nil + backdrop = nil + beginDrag(lift.moving, showing: lift.views, with: event) + return + } + let width = lift.views.reduce(0) { $0 + $1.frame.width } + lift.x = min(max(point.x - lift.grab, 0), max(bounds.width - width, 0)) + self.lift = lift + let next = nearestDrop(for: lift.moving, room: room(for: lift.moving), x: lift.x, centered: false) + if next != drop { + drop = next + arrange(animated: true) + } else { + placeLifted() + } + } + + // Puts the carried tabs down where the gap is. + func endLift() { + guard let lift else { return } + let drop = drop + self.lift = nil + self.drop = nil + backdrop = nil + if let drop { controller?.drop(lift.moving, at: drop.slot.index, in: drop.slot.group) } else { reload() } + } + + // Lays the carried chip and tabs side by side from the lift's left edge. A carried tab + // shows the group it would land in. + private func placeLifted() { + guard let lift, let drop else { return } + var x = lift.x + for view in lift.views { + let width = (view as? GroupChip)?.fittingWidth ?? tabWidth + view.frame = NSRect(x: x.rounded(), y: 0, width: (x + width).rounded() - x.rounded(), height: bounds.height) + view.isHidden = false + x += width + } + backdrop?.frame = NSRect(x: lift.x.rounded(), y: 0, width: x.rounded() - lift.x.rounded(), height: bounds.height) + if case .tab = lift.moving, let button = lift.views.first as? TabButton { button.line = drop.slot.group?.color } + } + + // A copy of the effect view the window draws its background with, or the plain color + // when it has none. + private func makeBackdrop() -> NSView { + let view: NSView + if let effect = window?.contentView?.superview?.subviews.first(where: { $0 is NSVisualEffectView }) as? NSVisualEffectView { + let copy = NSVisualEffectView() + copy.material = effect.material + copy.blendingMode = effect.blendingMode + copy.state = effect.state + view = copy + } else { + view = BandView(shade: nil) + } + addSubview(view, positioned: .below, relativeTo: addButton) + view.wantsLayer = true + view.layer?.zPosition = 0.5 + return view + } + + private func room(for moving: MovingTabs) -> Room { + guard case .group(let group, let tabs) = moving else { return Room(tabs: 1, chip: 0) } + return Room(tabs: group.collapsed ? 0 : tabs.count, chip: chips[ObjectIdentifier(group)]?.fittingWidth ?? 0) + } + + // The drop nearest a moving block with its left edge at x, or its middle when centered. + private func nearestDrop(for moving: MovingTabs, room: Room, x: CGFloat, centered: Bool) -> Drop { + let groups = (controller?.tabs ?? []).filter { !moving.tabs.contains($0) }.map(\.group) + let slots = if case .group = moving { StripLayout.groupSlots(groups) } else { StripLayout.tabSlots(groups) } + let gaps = slots.map { slot in + var withGap = groups + withGap.insert(slot.group, at: slot.index) + let items = StripLayout.items(withGap) + return layout(items, gap: (slot.index, room)).frames[items.firstIndex(of: .tab(slot.index))!] + } + let left = centered ? x - (gaps.first?.width ?? 0) / 2 : x + return Drop(slot: StripLayout.pick(slots, at: gaps.map(\.minX), x: left, current: drop?.slot), room: room) + } + + // MARK: Group editor + + // Chrome's group editor under the chip: name, color, and the group's commands. + func edit(_ group: TabGroup) { + guard let controller, let chip = chips[ObjectIdentifier(group)] else { return } + window?.layoutIfNeeded() + editor?.close() + var actions: [(title: String, run: () -> Void)] = [ + ("New Tab in Group", { [weak controller] in controller?.addNewTab(in: group) }), + ("Ungroup", { [weak controller] in controller?.ungroup(group) }), + ("Close Group", { [weak controller] in controller?.closeGroup(group) }) + ] + if controller.tabs(in: group).count < controller.tabs.count { + actions.append(("Move Group to New Window", { [weak controller] in + guard let controller else { return } + controller.moveToNewWindow(.group(group, controller.tabs(in: group))) + })) + } + let content = GroupEditor(group: group, actions: actions) { [weak self] in self?.reload() } + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = content + content.popover = popover + popover.show(relativeTo: chip.bounds, of: chip, preferredEdge: .minY) + editor = popover + } + + // MARK: Dragging out + + // The drag shows the carried views as they were, on the window color, since the + // shown tab has no fill of its own. The image is centered on the pointer, where + // strips open their gap. + private func beginDrag(_ moving: MovingTabs, showing views: [NSView], with event: NSEvent) { + var frame = views.dropFirst().reduce(views[0].frame) { $0.union($1.frame) } + let shots = views.map { ($0.frame.offsetBy(dx: -frame.minX, dy: -frame.minY), $0.snapshot) } + let point = convert(event.locationInWindow, from: nil) + frame.origin = NSPoint(x: point.x - frame.width / 2, y: point.y - frame.height / 2) + let background = window?.backgroundColor ?? .windowBackgroundColor + let image = NSImage(size: frame.size, flipped: false) { rect in + background.setFill() + rect.fill() + for (place, shot) in shots { shot.draw(in: place) } + return true + } + let item = NSPasteboardItem() + item.setString("tab", forType: Self.pasteboardType) + let dragging = NSDraggingItem(pasteboardWriter: item) + dragging.setDraggingFrame(frame, contents: image) + Self.drag = (self, moving) + beginDraggingSession(with: [dragging], event: event, source: self).animatesToStartingPositionsOnCancelOrFail = false + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + context == .withinApplication ? .move : [] + } + + // Released outside every window, the tabs open in a new window there. Released + // anywhere else but a strip, nothing moves. + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + // The local copy keeps this strip alive to the end of this method. + guard let drag = Self.drag, drag.source === self else { return } + Self.drag = nil + let moving = drag.moving + let overWindow = NSApp.windows.contains { $0.windowController is ReaderWindowController && $0.isVisible && $0.frame.contains(screenPoint) } + if operation.isEmpty, !overWindow { + // Puts the new window's strip under the pointer. + controller?.moveToNewWindow(moving, at: NSPoint(x: screenPoint.x - 80, y: screenPoint.y + 64)) + } + } + + // MARK: Dropping in + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard let (source, moving) = Self.drag else { return [] } + let x = convert(sender.draggingLocation, from: nil).x + let next = nearestDrop(for: moving, room: source.room(for: moving), x: x, centered: true) + if next != drop { + drop = next + arrange(animated: true) + } + return .move + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + drop = nil + arrange(animated: true) + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard let moving = Self.drag?.moving, let drop, let controller else { return false } + self.drop = nil + controller.drop(moving, at: drop.slot.index, in: drop.slot.group) + return true + } +} + +private func movedEnough(from start: NSEvent, to event: NSEvent) -> Bool { + hypot(event.locationInWindow.x - start.locationInWindow.x, event.locationInWindow.y - start.locationInWindow.y) > 4 +} + +private extension NSView { + var snapshot: NSImage { + let image = NSImage(size: bounds.size) + if let rep = bitmapImageRepForCachingDisplay(in: bounds) { + cacheDisplay(in: bounds, to: rep) + image.addRepresentation(rep) + } + return image + } +} + +// A plain piece of the strip: the band, or the window's own background color when unshaded. +// In a group's run of tabs it carries the group's line. +@MainActor +private final class BandView: NSView { + var line: GroupColor? { didSet { if line != oldValue { needsDisplay = true } } } + private let shade: NSColor? + + init(shade: NSColor? = StripStyle.band) { + self.shade = shade + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func draw(_ dirtyRect: NSRect) { + if shade == nil { + window?.backgroundColor.setFill() + bounds.fill() + } + StripStyle.fill(bounds, in: self, shade: shade) + if let line { + line.color.setFill() + NSRect(x: 0, y: 0, width: bounds.width, height: 2).fill() + } + } +} + +// One tab: a centered title, and a close button at the left under the pointer. It is +// selected on mouse down, as the system's tabs are, so a drag carries the shown tab. +@MainActor +private final class TabButton: NSView { + let tab: ReaderTab + // The color of the group it is in or would land in, drawn as a line along its bottom. + var line: GroupColor? { didSet { if line != oldValue { needsDisplay = true } } } + private weak var strip: TabStripView? + private let label = NSTextField(labelWithString: "") + private let closeButton = StripButton(kind: .close, label: "Close Tab") + private var selected = false + private var hovered = false { didSet { refresh() } } + private var pressed: NSEvent? + + init(tab: ReaderTab, strip: TabStripView) { + self.tab = tab + self.strip = strip + super.init(frame: NSRect(x: 0, y: 0, width: 200, height: TabStripView.height)) + label.font = .systemFont(ofSize: 13) + label.alignment = .center + label.lineBreakMode = .byTruncatingTail + closeButton.frame = NSRect(x: 4, y: 6, width: 16, height: 16) + closeButton.toolTip = "Close Tab (⌘W)" + closeButton.run = { [weak self] in + guard let self else { return } + self.strip?.controller?.close([self.tab]) + } + addSubview(label) + addSubview(closeButton) + addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], owner: self)) + setAccessibilityElement(true) + setAccessibilityRole(.radioButton) + setAccessibilitySubrole(NSAccessibility.Subrole(rawValue: "AXTabButton")) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func show(selected: Bool) { + self.selected = selected + line = tab.group?.color + label.stringValue = tab.title ?? "" + toolTip = tab.readerDocument.map { ($0.url.path as NSString).abbreviatingWithTildeInPath } ?? tab.title + setAccessibilityLabel(tab.title) + setAccessibilityValue(selected ? 1 : 0) + refresh() + } + + private func refresh() { + label.textColor = StripStyle.titleColor(selected: selected, in: self) + closeButton.isHidden = !hovered || strip?.isLifting == true + placeLabel() + needsDisplay = true + } + + // A wide tab keeps its title centered, with room for the close button on both sides. + // A narrow one gives the title its whole width until the close button shows. + private func placeLabel() { + let (left, right): (CGFloat, CGFloat) = bounds.width >= 96 ? (24, 24) : (closeButton.isHidden ? 6 : 22, 6) + label.frame = NSRect(x: left, y: 6, width: max(bounds.width - left - right, 0), height: 16) + } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + placeLabel() + } + + override func draw(_ dirtyRect: NSRect) { + StripStyle.fill(bounds, in: self, shade: selected ? nil : hovered ? StripStyle.hover : StripStyle.band) + StripStyle.divider(at: bounds.maxX - 1, in: self) + // A group's tabs sit on a line in its color that continues from the chip. + if let line { + line.color.setFill() + NSRect(x: 0, y: 0, width: bounds.width, height: 2).fill() + } + } + + override var mouseDownCanMoveWindow: Bool { false } + override func mouseEntered(with event: NSEvent) { hovered = true } + override func mouseExited(with event: NSEvent) { hovered = false } + + override func mouseDown(with event: NSEvent) { + pressed = event + strip?.controller?.select(tab) + } + + // The group's only tab carries its group along. + override func mouseDragged(with event: NSEvent) { + guard let strip, let controller = strip.controller else { return } + if strip.isLifting { strip.moveLift(with: event); return } + guard let pressed, movedEnough(from: pressed, to: event) else { return } + self.pressed = nil + let moving: MovingTabs = if let group = tab.group, controller.tabs(in: group).count == 1 { .group(group, [tab]) } else { .tab(tab) } + strip.startLift(moving, pressedAt: pressed) + strip.moveLift(with: event) + refresh() + } + + override func mouseUp(with event: NSEvent) { + pressed = nil + strip?.endLift() + } + + // A middle click closes the tab. + override func otherMouseUp(with event: NSEvent) { + if event.buttonNumber == 2 { strip?.controller?.close([tab]) } + } + + override func menu(for event: NSEvent) -> NSMenu? { strip?.controller?.menu(for: tab) } + + override func accessibilityPerformPress() -> Bool { + strip?.controller?.select(tab) + return true + } +} + +// A group's chip: a segment tinted with its color, holding its name, or a dot when it +// has no name. A click collapses or expands the group, a drag moves the whole group, +// and a right-click edits it. A collapsed chip also shows how many tabs it holds. +@MainActor +private final class GroupChip: NSView { + let group: TabGroup + private weak var strip: TabStripView? + private var hiddenTabs = 0 + private var pressed: NSEvent? + private var hovered = false { didSet { needsDisplay = true } } + + init(group: TabGroup, strip: TabStripView) { + self.group = group + self.strip = strip + super.init(frame: NSRect(x: 0, y: 0, width: 40, height: TabStripView.height)) + addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], owner: self)) + setAccessibilityElement(true) + setAccessibilityRole(.button) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func show(label: String, hiddenTabs: Int) { + self.hiddenTabs = hiddenTabs + let state = group.collapsed ? "collapsed" : "expanded" + toolTip = "\(label). Click to \(group.collapsed ? "expand" : "collapse"); right-click to rename or recolor." + setAccessibilityLabel("\(label) group, \(state)") + needsDisplay = true + } + + private var text: NSAttributedString { + let color = group.color.color + let text = NSMutableAttributedString(string: group.name, attributes: [.font: NSFont.systemFont(ofSize: 12, weight: .semibold), .foregroundColor: color]) + if hiddenTabs > 0 { + let count = (group.name.isEmpty ? "" : " ") + "\(hiddenTabs)" + text.append(NSAttributedString(string: count, attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .medium), .foregroundColor: color.withAlphaComponent(0.75)])) + } + return text + } + + private var dotWidth: CGFloat { group.name.isEmpty ? (hiddenTabs > 0 ? 13 : 8) : 0 } + + var fittingWidth: CGFloat { max(ceil(text.size().width + dotWidth) + 24, 28) } + + override func draw(_ dirtyRect: NSRect) { + let color = group.color.color + StripStyle.fill(bounds, in: self, shade: hovered ? StripStyle.hover : StripStyle.band) + color.withAlphaComponent(0.2).setFill() + bounds.fill(using: .sourceOver) + StripStyle.divider(at: bounds.maxX - 1, in: self) + let text = text + let size = text.size() + var x = ((bounds.width - 1 - size.width - dotWidth) / 2).rounded() + if group.name.isEmpty { + color.setFill() + NSBezierPath(ovalIn: NSRect(x: x, y: bounds.midY - 4, width: 8, height: 8)).fill() + x += dotWidth + } + text.draw(at: NSPoint(x: x, y: bounds.midY - size.height / 2)) + if !group.collapsed { + color.setFill() + NSRect(x: 0, y: 0, width: bounds.width, height: 2).fill() + } + } + + override var mouseDownCanMoveWindow: Bool { false } + override func mouseEntered(with event: NSEvent) { hovered = true } + override func mouseExited(with event: NSEvent) { hovered = false } + + override func mouseDown(with event: NSEvent) { + if event.modifierFlags.contains(.control) { strip?.edit(group); return } + pressed = event + } + + override func mouseDragged(with event: NSEvent) { + guard let strip, let controller = strip.controller else { return } + if strip.isLifting { strip.moveLift(with: event); return } + guard let pressed, movedEnough(from: pressed, to: event) else { return } + self.pressed = nil + strip.startLift(.group(group, controller.tabs(in: group)), pressedAt: pressed) + strip.moveLift(with: event) + } + + override func mouseUp(with event: NSEvent) { + if pressed != nil { strip?.controller?.toggle(group) } + pressed = nil + strip?.endLift() + } + + override func rightMouseDown(with event: NSEvent) { strip?.edit(group) } + + override func accessibilityPerformPress() -> Bool { + strip?.controller?.toggle(group) + return true + } +} + +// The strip's small buttons, drawn as the system tab bar's: a tab's close button, which +// shades a rounded square under the pointer, and the new tab button, a band segment +// that lightens under the pointer. +@MainActor +private final class StripButton: NSView { + enum Kind { case close, add } + + var run: (() -> Void)? + private let kind: Kind + private let image: NSImage? + private var hovered = false { didSet { needsDisplay = true } } + + init(kind: Kind, label: String) { + self.kind = kind + image = switch kind { + case .close: NSImage(systemSymbolName: "xmark", accessibilityDescription: label)?.withSymbolConfiguration(.init(pointSize: 9, weight: .medium)) + case .add: NSImage(named: NSImage.addTemplateName) + } + super.init(frame: .zero) + addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], owner: self)) + setAccessibilityElement(true) + setAccessibilityRole(.button) + setAccessibilityLabel(label) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override var isHidden: Bool { didSet { if isHidden { hovered = false } } } + + override func draw(_ dirtyRect: NSRect) { + switch kind { + case .close: + if hovered { + StripStyle.buttonHover.setFill() + NSBezierPath(roundedRect: bounds, xRadius: 2, yRadius: 2).fill() + } + case .add: + StripStyle.fill(bounds, in: self, shade: hovered ? StripStyle.hover : StripStyle.band) + StripStyle.divider(at: 0, in: self) + } + guard let image, let context = NSGraphicsContext.current?.cgContext else { return } + let size = kind == .add ? NSSize(width: 14, height: 13) : image.size + let rect = NSRect(x: ((bounds.width - size.width) / 2).rounded(), y: ((bounds.height - size.height) / 2).rounded(), width: size.width, height: size.height) + // Recolors the template image inside its own layer, so the tint stays off the background. + context.beginTransparencyLayer(in: rect, auxiliaryInfo: nil) + image.draw(in: rect) + (kind == .add ? StripStyle.titleColor(selected: false, in: self) : NSColor.secondaryLabelColor).setFill() + rect.fill(using: .sourceIn) + context.endTransparencyLayer() + } + + override var mouseDownCanMoveWindow: Bool { false } + override func mouseEntered(with event: NSEvent) { hovered = true } + override func mouseExited(with event: NSEvent) { hovered = false } + override func mouseDown(with event: NSEvent) {} + + override func mouseUp(with event: NSEvent) { + if bounds.contains(convert(event.locationInWindow, from: nil)) { run?() } + } + + override func accessibilityPerformPress() -> Bool { + run?() + return true + } +} + +// The chip's editor: a name field, the colors, and the group's commands. Name and +// color changes apply as they are made. +@MainActor +private final class GroupEditor: NSViewController, NSTextFieldDelegate { + weak var popover: NSPopover? + private let group: TabGroup + private let actions: [(title: String, run: () -> Void)] + private let changed: () -> Void + private let field = NSTextField() + private var swatches: [NSButton] = [] + + init(group: TabGroup, actions: [(title: String, run: () -> Void)], changed: @escaping () -> Void) { + self.group = group + self.actions = actions + self.changed = changed + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func loadView() { + field.placeholderString = "Name this group" + field.stringValue = group.name + field.delegate = self + swatches = GroupColor.allCases.enumerated().map { index, color in + let button = NSButton(image: color.swatch(size: 20, chosen: color == group.color), target: self, action: #selector(pickColor(_:))) + button.isBordered = false + button.tag = index + button.toolTip = color.title + button.setAccessibilityLabel(color.title) + return button + } + let colors = NSStackView(views: swatches) + colors.spacing = 4 + let separator = NSBox() + separator.boxType = .separator + let commands = actions.enumerated().map { index, action in + let button = NSButton(title: action.title, target: self, action: #selector(runAction(_:))) + button.bezelStyle = .accessoryBarAction + button.showsBorderOnlyWhileMouseInside = true + button.alignment = .left + button.tag = index + return button + } + let stack = NSStackView(views: [field, colors, separator] + commands) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + stack.setCustomSpacing(10, after: field) + stack.setCustomSpacing(10, after: colors) + stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 10, right: 12) + field.widthAnchor.constraint(equalToConstant: 212).isActive = true + for view in [separator] + commands { view.widthAnchor.constraint(equalTo: field.widthAnchor).isActive = true } + view = stack + // The popover takes its size from here; without it, it squeezes the stack's margins away. + preferredContentSize = stack.fittingSize + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.makeFirstResponder(field) + } + + func controlTextDidChange(_ notification: Notification) { + group.name = field.stringValue.trimmingCharacters(in: .whitespaces) + changed() + } + + // Return closes the editor, keeping the name. + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + guard selector == #selector(NSResponder.insertNewline(_:)) else { return false } + popover?.performClose(nil) + return true + } + + @objc private func pickColor(_ sender: NSButton) { + group.color = GroupColor.allCases[sender.tag] + for (button, color) in zip(swatches, GroupColor.allCases) { + button.image = color.swatch(size: 20, chosen: color == group.color) + } + changed() + } + + @objc private func runAction(_ sender: NSButton) { + popover?.close() + actions[sender.tag].run() + } +} + +// A menu item that runs a closure, for menus built on each right-click. +final class ActionMenuItem: NSMenuItem { + private var handler: (() -> Void)? + + convenience init(_ title: String, handler: @escaping () -> Void) { + self.init(title: title, action: #selector(run), keyEquivalent: "") + self.handler = handler + target = self + } + + @objc private func run() { handler?() } +} diff --git a/Tests/ZenMarkyTests/DocumentTests.swift b/Tests/ZenMarkyTests/DocumentTests.swift index 5e916a4..0e6ee5d 100644 --- a/Tests/ZenMarkyTests/DocumentTests.swift +++ b/Tests/ZenMarkyTests/DocumentTests.swift @@ -199,12 +199,24 @@ struct DocumentTests { #expect(PageAction(URL(string: "marky-copy:1")!) == .copyCode(block: 1)) } - @Test func testSessionSkipsMissingFilesAndEmptyWindows() { + @Test func testSessionsSavedBeforeTabGroupsStillLoad() throws { + let saved = #"{"windows":[{"files":["/a.md"],"selected":"/a.md","frame":"{{0, 0}, {800, 600}}"}],"scrollOffsets":{"/a.md":12}}"# + let session = try JSONDecoder().decode(ReaderSession.self, from: Data(saved.utf8)) + #expect(session.windows == [.init(files: ["/a.md"], selected: "/a.md", frame: "{{0, 0}, {800, 600}}")]) + #expect(session.scrollOffsets == ["/a.md": 12]) + } + + @Test func testSessionSkipsMissingFilesAndEmptyGroupsAndWindows() { let session = ReaderSession(windows: [ - .init(files: ["/a.md", "/gone.md", "/b.md"], selected: "/b.md", frame: ""), + .init(files: ["/a.md", "/gone.md", "/b.md"], selected: "/b.md", frame: "", groups: [ + .init(name: "Docs", color: .blue, collapsed: false, files: ["/a.md", "/gone.md"]), + .init(name: "", color: .red, collapsed: true, files: ["/gone.md"]) + ]), .init(files: ["/gone.md"], selected: "/gone.md", frame: "") ]) let kept = session.existing { $0 != "/gone.md" } - #expect(kept.windows == [.init(files: ["/a.md", "/b.md"], selected: "/b.md", frame: "")]) + #expect(kept.windows == [.init(files: ["/a.md", "/b.md"], selected: "/b.md", frame: "", groups: [ + .init(name: "Docs", color: .blue, collapsed: false, files: ["/a.md"]) + ])]) } } diff --git a/Tests/ZenMarkyTests/TabStripTests.swift b/Tests/ZenMarkyTests/TabStripTests.swift new file mode 100644 index 0000000..d70816f --- /dev/null +++ b/Tests/ZenMarkyTests/TabStripTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import ZenMarky + +struct TabStripTests { + // Group a holds tabs 0 and 1, tab 2 has no group, and collapsed group b holds tabs 3 and 4. + // At rest the strip reads: chip a, tab 0, tab 1, tab 2, chip b, each item 100 points wide. + let a = TabGroup(color: .blue) + let b = TabGroup(color: .red, collapsed: true) + var groups: [TabGroup?] { [a, a, nil, b, b] } + var items: [StripItem] { StripLayout.items(groups) } + var frames: [NSRect] { items.indices.map { NSRect(x: CGFloat($0) * 100, y: 0, width: 100, height: 34) } } + + @Test func testChipsLeadGroupsAndCollapsedGroupsShowOnlyTheirChip() { + #expect(items == [.chip(a), .tab(0), .tab(1), .tab(2), .chip(b)]) + } + + @Test func testATabLeavingItsGroupsMiddleMovesAfterTheGroup() { + let tabs: [(name: String, group: TabGroup?)] = [("first", a), ("left", nil), ("last", a)] + #expect(StripLayout.keepingGroupsTogether(tabs) { $0.group }.map(\.name) == ["first", "last", "left"]) + } + + @Test func testATabLandsBetweenAnyTabsButNotInACollapsedGroup() { + let slots = StripLayout.tabSlots(groups).map { "\($0.index)\($0.group === a ? "a" : $0.group == nil ? "" : "?")" } + // Before chip a or after it; inside a; at a's end in it or after it; then around the collapsed group b. + #expect(slots == ["0", "0a", "1a", "2a", "2", "3", "5"]) + } + + @Test func testAGroupLandsOnlyBetweenGroups() { + #expect(StripLayout.groupSlots(groups).map(\.index) == [0, 2, 3, 5]) + } + + @Test func testATabAtAGroupsEndLeavesItOnlyWhenMovedPastTheMargin() { + let slots = StripLayout.tabSlots(groups) + let positions: [CGFloat] = [0, 40, 140, 240, 240, 340, 380] + func pick(_ x: CGFloat, from current: StripSlot?) -> StripSlot { + StripLayout.pick(slots, at: positions, x: x, current: current) + } + let (inside, outside) = (StripSlot(index: 2, group: a), StripSlot(index: 2)) + // The nearest gap wins elsewhere, so chip a's middle divides before it from inside it. + #expect(pick(15, from: nil) == StripSlot(index: 0)) + #expect(pick(25, from: nil) == StripSlot(index: 0, group: a)) + // At a's end the slot held is kept within 8 points either side. + #expect(pick(245, from: inside) == inside) + #expect(pick(235, from: outside) == outside) + #expect(pick(250, from: inside) == outside) + #expect(pick(230, from: outside) == inside) + } +} diff --git a/Tests/ZenMarkyTests/WindowTabsTests.swift b/Tests/ZenMarkyTests/WindowTabsTests.swift new file mode 100644 index 0000000..f8b2325 --- /dev/null +++ b/Tests/ZenMarkyTests/WindowTabsTests.swift @@ -0,0 +1,40 @@ +import AppKit +import Testing +@testable import ZenMarky + +@MainActor +private final class Owner: ReaderWindowOwner { + let renderer = try! DocumentRenderer() + func makeTab() -> ReaderTab { ReaderTab(renderer: renderer, preferences: ReaderPreferences()) } + func openWindow(with tabs: [ReaderTab], topLeft: NSPoint?) {} + func windowClosed(_ controller: ReaderWindowController) {} +} + +@MainActor +struct WindowTabsTests { + @Test func testMovesAndClosesKeepTheShownTabAndCollapsedGroups() { + _ = NSApplication.shared + let owner = Owner() + let window = ReaderWindowController(preferences: ReaderPreferences(), app: owner) + let group = TabGroup(color: .blue, collapsed: true) + let (shown, first, second) = (owner.makeTab(), owner.makeTab(), owner.makeTab()) + first.group = group + second.group = group + window.insert([shown, first, second], at: 0) + #expect(window.selectedTab === shown) + + // Moving the shown tab past the collapsed group, then the group back past it, changes neither. + window.drop(.tab(shown), at: 2, in: nil) + window.drop(.group(group, [first, second]), at: 1, in: nil) + #expect(window.tabs == [shown, first, second]) + #expect(window.selectedTab === shown) + #expect(group.collapsed) + + // Closing the only visible tab opens a new one instead of the collapsed group. + window.close([shown]) + #expect(window.tabs.count == 3) + #expect(group.collapsed) + #expect(window.selectedTab.map { $0.group == nil } == true) + window.close() + } +}