import AppKit import UniformTypeIdentifiers import EdmundCore /// NSDocument subclass that provides standard macOS document lifecycle: /// file open/save, dirty-dot indicator, click-to-rename in the titlebar, /// recent documents, and more — all for free. /// /// The actual editing is delegated entirely to `EditorTextView`. class Document: NSDocument, HeadingNavigable { var editor: EditorTextView! private var statusBar: StatusBarView! private var viewModeButton: NSButton? private static let viewModeItemID = NSToolbarItem.Identifier("viewMode") /// Session-only zoom scale (View ▸ Actual Size/Zoom In/Zoom Out), applied on /// top of the persisted font size and content width. Not saved — each new /// window starts back at 100%. private var zoomFactor: CGFloat = 1.0 private static let zoomStep: CGFloat = 0.1 private static let zoomRange: ClosedRange = 0.5...3.0 /// Editor scroll view and its container, held so Read mode can swap the /// editor out for a `ReadModeWebView` (created lazily on first read). private var scrollView: NSScrollView! private var containerView: NSView! private var readView: ReadModeWebView? /// Content loaded from disk before the editor window exists. /// `nonisolated(unsafe)` because `read(from:ofType:)` may be called /// off the main actor, but the value is only consumed on main via `showWindows`. nonisolated(unsafe) var pendingContent: String? // MARK: - Type Registration // // Without an Info.plist (SPM executable), NSDocument's default readableTypes // and writableTypes are empty, which causes NSDocumentController to disable // Open/Save entirely. We override them here. override class var readableTypes: [String] { ["public.plain-text", "net.daringfireball.markdown"] } override class var writableTypes: [String] { ["net.daringfireball.markdown", "public.plain-text"] } override class var autosavesInPlace: Bool { AppSettings.autoSaveWithVersions } override class func isNativeType(_ name: String) -> Bool { return readableTypes.contains(name) } // A single writable type keeps the save panel from showing a file-format // popup. Everything we write is markdown, so there's nothing to choose. override func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] { ["net.daringfireball.markdown"] } // The `net.daringfireball.markdown` UTI prefers the ".markdown" extension; // force ".md" instead, which is what people actually expect. override func fileNameExtension(forType typeName: String, saveOperation: NSDocument.SaveOperationType) -> String? { "md" } // `fileNameExtension(forType:…)` alone isn't enough: for an untitled save // the panel still seeds its name field from the markdown UTI's preferred // extension (".markdown"). Force the default name to end in ".md" and let // the user type any other extension if they really want one. override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool { savePanel.allowedContentTypes = [] savePanel.allowsOtherFileTypes = true let base = (savePanel.nameFieldStringValue as NSString).deletingPathExtension savePanel.nameFieldStringValue = (base.isEmpty ? "Untitled" : base) + ".md" return true } // MARK: - Window Setup override func makeWindowControllers() { // Default content size for first launch. Any saved size is applied as a // full window frame at the end of setup (below), once the toolbar is in // place — so the frame round-trips exactly and doesn't drift by the // title bar + toolbar height each time. let windowWidth: CGFloat = 800 let windowHeight: CGFloat = 560 let window = DocumentWindow( contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false ) window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true // Don't persist/restore document windows: macOS state restoration // otherwise reopens the last-edited file on the next launch, so a fresh // start (or File ▸ New) shows that document instead of a blank Untitled. window.isRestorable = AppSettings.reopenWindows window.minSize = NSSize(width: 320, height: 400) window.backgroundColor = NSColor.textBackgroundColor // Build the TextKit 2 text system chain (viewport-based layout). editor = EditorTextView.makeTextKit2( frame: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), containerSize: NSSize(width: windowWidth, height: CGFloat.greatestFiniteMagnitude) ) editor.minSize = NSSize(width: 0, height: 0) editor.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) editor.isVerticallyResizable = true editor.isHorizontallyResizable = false editor.autoresizingMask = [.width] editor.textContainerInset = NSSize(width: 24, height: 18) // Centered reading column (see EditorTextView+ContentWidth). Convert the // persisted cm value to points using the main screen PPI at window-creation // time; recomputed on resize (setFrameSize) and when the window moves to a // different display (windowDidChangeScreen). let initScreen = NSScreen.main editor.maxContentWidthPoints = initScreen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000 editor.updateContentInset() editor.allowRemoteImages = !AppSettings.blockExternalImages editor.typewriterModeEnabled = AppDelegate.typewriterModeEnabled() editor.document = self // Toolbar holds the right-aligned view-mode toggle (and gives the // titlebar extra height for roomy traffic lights). Set it only after // `editor` exists — assigning the toolbar synchronously vends its items. let toolbar = NSToolbar(identifier: "MainToolbar") toolbar.delegate = self toolbar.displayMode = .iconOnly toolbar.allowsUserCustomization = true toolbar.autosavesConfiguration = true // persists layout per "MainToolbar" window.toolbar = toolbar window.toolbarStyle = .unified window.titlebarSeparatorStyle = .line // Wire the window's secondary-click interception now that the toolbar has // synchronously vended the view-mode button (see DocumentWindow). window.viewModeButton = viewModeButton window.makeViewModeMenu = { [weak self] in self?.viewModeMenu() ?? NSMenu() } let statusBarHeight: CGFloat = 22 let contentBounds = window.contentView!.bounds // The text view fills the whole window; the status bar floats over its // bottom edge, revealed on hover. scrollView = NSScrollView(frame: contentBounds) scrollView.autoresizingMask = [.width, .height] scrollView.hasVerticalScroller = true scrollView.scrollerStyle = .overlay scrollView.drawsBackground = false scrollView.documentView = editor // Floating status bar: hidden by default, fades in when the pointer // enters its strip. Counts on the left, line ending on the right. statusBar = StatusBarView(frame: NSRect( x: 0, y: 0, width: contentBounds.width, height: statusBarHeight )) statusBar.autoresizingMask = [.width] containerView = NSView(frame: contentBounds) containerView.autoresizesSubviews = true containerView.addSubview(scrollView) containerView.addSubview(statusBar) // overlay, on top of the text window.contentView = containerView NotificationCenter.default.addObserver( self, selector: #selector(editorDidChange(_:)), name: NSText.didChangeNotification, object: editor ) NotificationCenter.default.addObserver( self, selector: #selector(editorSelectionDidChange(_:)), name: NSTextView.didChangeSelectionNotification, object: editor ) NotificationCenter.default.addObserver( self, selector: #selector(windowDidResize(_:)), name: NSWindow.didResizeNotification, object: window ) NotificationCenter.default.addObserver( self, selector: #selector(windowDidChangeScreen(_:)), name: NSWindow.didChangeScreenNotification, object: window ) // Restore the last window's frame size (the toolbar is now installed, so // the frame is final). Applied as a frame, not a contentRect, so it // round-trips exactly with what windowDidResize saves. Then center. if let savedSize = AppSettings.lastWindowSize { window.setFrame(NSRect(origin: window.frame.origin, size: savedSize), display: false) } window.center() let wc = NSWindowController(window: window) addWindowController(wc) window.makeFirstResponder(editor) // Honor the persisted source-mode preference for the editing view. if AppSettings.sourceMode { setViewMode(.source) } updateStatusBar() } @objc private func windowDidResize(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } // Save the full frame size; it's restored verbatim via setFrame on the // next window, so the size round-trips exactly (no title-bar/toolbar drift). AppSettings.lastWindowSize = window.frame.size } /// Reapply the content-width cap in points when the window moves to a /// display with a different physical PPI (e.g. external monitor). @objc private func windowDidChangeScreen(_ notification: Notification) { guard let window = notification.object as? NSWindow, let screen = window.screen else { return } editor?.maxContentWidthPoints = screen.cmToPoints(AppSettings.maxContentWidthCm) * zoomFactor } // MARK: - Zoom (View ▸ Actual Size / Zoom In / Zoom Out) @objc func zoomIn(_ sender: Any?) { setZoom(zoomFactor + Self.zoomStep) } @objc func zoomOut(_ sender: Any?) { setZoom(zoomFactor - Self.zoomStep) } @objc func actualSize(_ sender: Any?) { setZoom(1.0) } /// Scales font size (standard + code) and max content width together by /// `factor`, off the persisted base values — never off the currently /// applied (possibly already-zoomed) theme, so repeated zooming doesn't /// compound rounding error and Actual Size always returns to the true base. private func setZoom(_ factor: CGFloat) { guard let editor else { return } zoomFactor = min(Self.zoomRange.upperBound, max(Self.zoomRange.lowerBound, factor)) let base = EditorTheme.load(from: editor.themeDefaults) var zoomed = base zoomed.fontSize = base.fontSize * zoomFactor zoomed.monospaceFontSize = base.monospaceFontSize * zoomFactor editor.applyTheme(zoomed, persist: false) let screen = editor.window?.screen ?? NSScreen.main editor.maxContentWidthPoints = (screen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000) * zoomFactor refreshReadView() } @objc private func editorDidChange(_ notification: Notification) { updateStatusBar() // Keep an open Read view in sync with edits (it renders a snapshot). refreshReadView() } @objc private func editorSelectionDidChange(_ notification: Notification) { updateStatusBar() } private func updateStatusBar() { guard let editor = editor, let statusBar = statusBar else { return } let text = editor.rawSource let nsText = text as NSString let wordCount = text.split { $0.isWhitespace || $0.isNewline }.count let charCount = text.count // Cursor position: 0-based character location and 1-based line number. let cursorOffset = editor.selectedRange().location let location = min(cursorOffset, nsText.length) let upToCursor = nsText.substring(to: location) let line = upToCursor.isEmpty ? 1 : upToCursor.components(separatedBy: "\n").count // The buffer is always LF; show the file's remembered original ending. statusBar.setMetrics(words: wordCount, characters: charCount, location: location, line: line, lineEnding: editor.originalLineEnding.displayName) } // MARK: - Reading override nonisolated func read(from data: Data, ofType typeName: String) throws { guard let contents = String(data: data, encoding: .utf8) else { Log.error("Read failed: \(data.count) bytes not valid UTF-8", category: .io) throw NSError(domain: NSOSStatusErrorDomain, code: -1, userInfo: [NSLocalizedDescriptionKey: "Could not read file as UTF-8"]) } Log.info("Read \(data.count) bytes from disk", category: .io) pendingContent = contents } /// Called after makeWindowControllers when opening an existing file. override func showWindows() { super.showWindows() if let content = pendingContent { editor?.loadContent(content) pendingContent = nil warnIfInconsistentLineEndings(in: content) } updateStatusBar() } /// Warn (once, suppressibly) when an opened file mixed line-ending styles. /// The buffer has already been normalized to a single style for editing. private func warnIfInconsistentLineEndings(in content: String) { guard LineEnding.isInconsistent(in: content), !AppSettings.suppressInconsistentLineEndingWarning, let window = windowControllers.first?.window else { return } let alert = NSAlert() alert.messageText = "Inconsistent Line Endings" alert.informativeText = "This document mixes different line endings. " + "It will be saved using \(editor?.originalLineEnding.displayName ?? "LF") throughout." alert.addButton(withTitle: "OK") alert.showsSuppressionButton = true alert.suppressionButton?.title = "Do not warn about inconsistent line endings" alert.beginSheetModal(for: window) { _ in if alert.suppressionButton?.state == .on { AppSettings.suppressInconsistentLineEndingWarning = true } } } /// Cross-file link following: scroll this document's editor to a heading /// once it's on screen (the content has already loaded in showWindows). func navigateToHeading(_ heading: String) { editor?.scrollToHeading(heading) } // MARK: - Rename & Move (manual — NSDocument's built-in versions // are disabled without Info.plist / .app bundle) override func rename(_ sender: Any?) { guard let url = fileURL, let window = windowControllers.first?.window else { return } let panel = NSSavePanel() panel.directoryURL = url.deletingLastPathComponent() panel.nameFieldStringValue = url.lastPathComponent panel.prompt = "Rename" panel.beginSheetModal(for: window) { response in guard response == .OK, let newURL = panel.url else { return } do { try FileManager.default.moveItem(at: url, to: newURL) self.fileURL = newURL } catch { NSAlert(error: error).runModal() } } } override func move(_ sender: Any?) { guard let url = fileURL, let window = windowControllers.first?.window else { return } let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.canCreateDirectories = true panel.prompt = "Move" panel.message = "Choose a new location for \"\(url.lastPathComponent)\"" panel.beginSheetModal(for: window) { response in guard response == .OK, let destDir = panel.url else { return } let newURL = destDir.appendingPathComponent(url.lastPathComponent) do { try FileManager.default.moveItem(at: url, to: newURL) self.fileURL = newURL } catch { NSAlert(error: error).runModal() } } } // MARK: - View Mode (edit / reading / source) private func icon(for mode: EditorTextView.ViewMode) -> NSImage? { let name: String switch mode { // Source is a raw-text view of the same editing mode as Edit, so it // shares the pencil icon rather than getting a distinct glyph. case .edit, .source: name = "pencil" case .reading: name = "book" } return NSImage(systemSymbolName: name, accessibilityDescription: label(for: mode)) } private func label(for mode: EditorTextView.ViewMode) -> String { switch mode { case .edit: return "Edit" case .reading: return "Read" case .source: return "Source" } } /// Shows the active mode's icon on the button and keeps the tooltip in sync. private func refreshViewModeButton() { guard let editor else { return } viewModeButton?.image = icon(for: editor.viewMode)? .withSymbolConfiguration(.init(pointSize: 13, weight: .regular)) viewModeButton?.toolTip = "View mode: \(label(for: editor.viewMode))" } private func setViewMode(_ mode: EditorTextView.ViewMode) { editor.viewMode = mode applyViewMode(mode) refreshViewModeButton() } /// Swaps the on-screen view for the mode: Read mode shows the rendered-HTML /// `ReadModeWebView`; Edit and Source stay on the editor's scroll view. private func applyViewMode(_ mode: EditorTextView.ViewMode) { guard let containerView else { return } if mode == .reading { let read = readView ?? { let v = ReadModeWebView() v.frame = scrollView.frame v.autoresizingMask = [.width, .height] // Below the floating status bar so counts stay visible. containerView.addSubview(v, positioned: .below, relativeTo: statusBar) // Route internal navigation through the editor's link resolver // (which resolves against this document's directory and opens via // NSDocumentController) instead of navigating the webview. v.onOpenWikiLink = { [weak self] in self?.editor.followWikiLink($0) } v.onOpenInternalLink = { [weak self] in self?.editor.followLinkDestination($0) } readView = v return v }() read.render(markdown: editor.rawSource, theme: editor.theme, callouts: mergedCallouts, baseURL: documentDirectory, options: renderOptions) read.isHidden = false scrollView.isHidden = true editor.window?.makeFirstResponder(read) } else { readView?.isHidden = true scrollView.isHidden = false editor.window?.makeFirstResponder(editor) } } /// Re-renders an open Read view from the editor's current source + theme. /// No-op unless Read mode is the active, visible view — so settings/edit /// broadcasts stay cheap when the user is in Edit or Source mode. func refreshReadView() { guard let read = readView, !read.isHidden, editor?.viewMode == .reading else { return } read.render(markdown: editor.rawSource, theme: editor.theme, callouts: mergedCallouts, baseURL: documentDirectory, options: renderOptions) } /// The opened file's directory, used to resolve relative image paths for /// inlining (nil for an unsaved document). private var documentDirectory: URL? { fileURL?.deletingLastPathComponent() } /// Built-in callout styles merged with the editor's user overrides, so Read /// mode and the PDF match exactly what the editor draws. private var mergedCallouts: [String: CalloutStyle] { var m = Callout.defaultStyles for (k, v) in editor.calloutStyleOverrides { m[k] = v } return m } /// Read-mode/export render options derived from user settings. Reuses the /// editor's own `maxContentWidthPoints` (already the cm setting converted via /// the window's screen PPI) so Read mode's column matches Edit mode's. private var renderOptions: ReadRenderOptions { ReadRenderOptions(preserveBlankLines: AppSettings.renderBlankLinesAsBreaks, allowRemoteImages: !AppSettings.blockExternalImages, maxContentWidthPoints: Double(editor.maxContentWidthPoints)) } // MARK: - Export / Print @objc func exportToPDF(_ sender: Any?) { let name = (displayName as NSString).deletingPathExtension MarkdownPrinter.exportPDF(markdown: editor.rawSource, theme: editor.theme, callouts: mergedCallouts, baseURL: documentDirectory, options: renderOptions, suggestedName: name.isEmpty ? "Untitled" : name, window: windowControllers.first?.window) } @objc override func printDocument(_ sender: Any?) { MarkdownPrinter.print(markdown: editor.rawSource, theme: editor.theme, callouts: mergedCallouts, baseURL: documentDirectory, options: renderOptions, window: windowControllers.first?.window) } /// The editing-side view: Source when source mode is on, otherwise Edit. /// Read is the other half of the toggle. private var editingMode: EditorTextView.ViewMode { AppSettings.sourceMode ? .source : .edit } @objc private func selectEditMode(_ sender: Any?) { setViewMode(editingMode) } @objc private func selectReadingMode(_ sender: Any?) { setViewMode(.reading) } /// The "Show source in editor" checkbox (button menu and View menu). /// Persists the setting and, if we're in the editing view, swaps it to /// the new editing mode right away. @objc func toggleSourceMode(_ sender: Any?) { AppSettings.sourceMode.toggle() if editor.viewMode != .reading { setViewMode(editingMode) } } /// Keeps the View-menu "Show Source in Editor" checkmark in sync with the setting. override func validateMenuItem(_ item: NSMenuItem) -> Bool { if item.action == #selector(toggleSourceMode(_:)) { item.state = AppSettings.sourceMode ? .on : .off } return super.validateMenuItem(item) } /// Toggle the editing view ↔ Read (the View-menu ⌘E item and the toolbar /// button). With source mode on the editing view is Source, so this flips /// Source ↔ Read; otherwise Edit ↔ Read. @objc func toggleViewMode(_ sender: Any?) { setViewMode(editor.viewMode == .reading ? editingMode : .reading) } /// One mode menu item: icon + title, checked when `on`. private func menuItem(_ title: String, _ image: NSImage?, _ action: Selector, on: Bool) -> NSMenuItem { let item = NSMenuItem(title: title, action: action, keyEquivalent: "") item.target = self item.image = image item.state = on ? .on : .off return item } /// The right-click menu: Edit / Read selection, a divider, then the /// "Show source in editor" checkbox. Built fresh each time so state stays current. fileprivate func viewModeMenu() -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false // actions always fire on selection let inEditing = editor?.viewMode != .reading menu.addItem(menuItem("Edit", icon(for: .edit), #selector(selectEditMode(_:)), on: inEditing)) menu.addItem(menuItem("Read", icon(for: .reading), #selector(selectReadingMode(_:)), on: !inEditing)) menu.addItem(.separator()) menu.addItem(menuItem("Show source in editor", nil, #selector(toggleSourceMode(_:)), on: AppSettings.sourceMode)) return menu } // MARK: - Writing override func data(ofType typeName: String) throws -> Data { // The buffer is always LF; restore the file's original line ending on // write so opening, then saving, doesn't silently rewrite every line. let normalized = editor?.rawSource ?? "" let ending = editor?.originalLineEnding ?? .lf let text = ending == .lf ? normalized : normalized.replacingOccurrences(of: "\n", with: ending.string) guard let data = text.data(using: .utf8) else { Log.error("Save failed: could not encode \(text.count) chars as UTF-8", category: .io) throw NSError(domain: NSOSStatusErrorDomain, code: -1, userInfo: [NSLocalizedDescriptionKey: "Could not encode text as UTF-8"]) } Log.info("Saving \(data.count) bytes (\(ending.displayName))", category: .io) return data } } // MARK: - Toolbar (view-mode toggle) extension Document: NSToolbarDelegate { func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [.flexibleSpace, Self.viewModeItemID] } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [.flexibleSpace, .space, Self.viewModeItemID] } func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { guard itemIdentifier == Self.viewModeItemID else { return nil } let item = NSToolbarItem(itemIdentifier: itemIdentifier) item.label = "View Mode" item.visibilityPriority = .high // Left-click toggles the editing view ↔ Read. The right-click mode menu // is handled upstream in DocumentWindow.sendEvent — every view-level // approach (the view's `menu`, rightMouseDown, a gesture recognizer) // loses the secondary click to the toolbar's "Customize Toolbar…" menu. let button = NSButton(image: NSImage(), target: self, action: #selector(toggleViewMode(_:))) button.bezelStyle = .texturedRounded button.imagePosition = .imageOnly viewModeButton = button item.view = button refreshViewModeButton() return item } } /// Document window that intercepts a secondary (right / control) click on the /// view-mode toolbar button and shows the mode menu itself. `sendEvent` is the /// single funnel all window events pass through *before* the toolbar/titlebar /// can turn the click into its own "Customize Toolbar…" context menu, so this is /// the one place the interception reliably wins. final class DocumentWindow: NSWindow { weak var viewModeButton: NSView? var makeViewModeMenu: (() -> NSMenu)? override func sendEvent(_ event: NSEvent) { if isSecondaryClick(event), let button = viewModeButton, button.bounds.contains(button.convert(event.locationInWindow, from: nil)), let menu = makeViewModeMenu?() { menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.maxY + 4), in: button) return } super.sendEvent(event) } private func isSecondaryClick(_ event: NSEvent) -> Bool { switch event.type { case .rightMouseDown: return true case .leftMouseDown: return event.modifierFlags.contains(.control) default: return false } } }