import AppKit /// Owns a document window's chrome: the toolbar, the editor / split / canvas /// mode switching, the Markdown preview, and the bottom status bar. One per /// document window; created in Document.makeWindowControllers. final class WorkspaceController: NSObject, NSToolbarDelegate, NSTextViewDelegate { enum Mode: Int { case editor, split, board } weak var document: Document? let containerView = NSView() let editorScrollView: NSScrollView let textView: NSTextView // the document's main page (== the .txt) private var editorTV: NSTextView // the text view currently shown in the editor private weak var activeEditorCard: CardView? // which board page the editor shows private let ruler: LineNumberRulerView private let splitView = NSSplitView() private let previewScroll = NSScrollView() private let previewTextView = NSTextView() let boardContainer = BoardContainerView(frame: .zero) private let statusBar = NSView() private let leftStatus = NSTextField(labelWithString: "") private let centerStatus = NSTextField(labelWithString: "") private let rightStatus = NSTextField(labelWithString: "") private var modeSegment: NSSegmentedControl? private(set) var mode: Mode = .editor init(document: Document, editorScrollView: NSScrollView, textView: NSTextView) { self.document = document self.editorScrollView = editorScrollView self.textView = textView self.editorTV = textView self.ruler = LineNumberRulerView(textView: textView) super.init() buildLayout() hookBoard() NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged), name: AppSettings.didChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(textChanged), name: NSText.didChangeNotification, object: textView) NotificationCenter.default.addObserver(self, selector: #selector(selectionChanged), name: NSTextView.didChangeSelectionNotification, object: textView) applyAppearance() recomputeMetrics() refreshStatus() } deinit { NotificationCenter.default.removeObserver(self) } // MARK: - Layout private func buildLayout() { editorScrollView.hasVerticalRuler = true editorScrollView.rulersVisible = AppSettings.lineNumbers editorScrollView.verticalRulerView = ruler // Preview pane previewScroll.hasVerticalScroller = true previewScroll.borderType = .noBorder previewScroll.documentView = previewTextView previewTextView.isEditable = false previewTextView.isSelectable = true previewTextView.textContainerInset = NSSize(width: 16, height: 14) previewTextView.autoresizingMask = [.width] previewTextView.minSize = NSSize(width: 0, height: 0) previewTextView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) previewTextView.isVerticallyResizable = true previewTextView.isHorizontallyResizable = false previewTextView.textContainer?.widthTracksTextView = true splitView.isVertical = true splitView.dividerStyle = .thin splitView.translatesAutoresizingMaskIntoConstraints = false splitView.addArrangedSubview(editorScrollView) boardContainer.translatesAutoresizingMaskIntoConstraints = false boardContainer.isHidden = true statusBar.translatesAutoresizingMaskIntoConstraints = false statusBar.wantsLayer = true buildStatusBar() containerView.addSubview(splitView) containerView.addSubview(boardContainer) containerView.addSubview(statusBar) NSLayoutConstraint.activate([ statusBar.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), statusBar.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), statusBar.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), statusBar.heightAnchor.constraint(equalToConstant: 24), splitView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), splitView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), splitView.topAnchor.constraint(equalTo: containerView.topAnchor), splitView.bottomAnchor.constraint(equalTo: statusBar.topAnchor), boardContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), boardContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), boardContainer.topAnchor.constraint(equalTo: containerView.topAnchor), boardContainer.bottomAnchor.constraint(equalTo: statusBar.topAnchor), ]) } private func buildStatusBar() { for label in [leftStatus, centerStatus, rightStatus] { label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) label.translatesAutoresizingMaskIntoConstraints = false statusBar.addSubview(label) } centerStatus.alignment = .center rightStatus.alignment = .right NSLayoutConstraint.activate([ leftStatus.leadingAnchor.constraint(equalTo: statusBar.leadingAnchor, constant: 12), leftStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor), centerStatus.centerXAnchor.constraint(equalTo: statusBar.centerXAnchor), centerStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor), rightStatus.trailingAnchor.constraint(equalTo: statusBar.trailingAnchor, constant: -12), rightStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor), ]) } private func hookBoard() { boardContainer.document = document boardContainer.onStatus = { [weak self] msg in self?.centerStatus.stringValue = msg } boardContainer.onSendToTailnet = { [weak self] data in self?.document?.sendSketchToTailnet(pngData: data) } boardContainer.boardView.onChange = { [weak self] in self?.document?.updateChangeCount(.changeDone); self?.refreshStatus() } boardContainer.onStatusChange = { [weak self] in self?.refreshStatus() } } // MARK: - Mode switching func setMode(_ newMode: Mode) { let leavingBoard = (mode == .board && newMode != .board) mode = newMode modeSegment?.selectedSegment = newMode.rawValue switch newMode { case .editor: if leavingBoard { showPageInEditor() } boardContainer.isHidden = true splitView.isHidden = false if previewScroll.superview != nil { previewScroll.removeFromSuperview() } applyWrap(AppSettings.softWrap) containerView.window?.makeFirstResponder(editorTV) case .split: if leavingBoard { showPageInEditor() } boardContainer.isHidden = true splitView.isHidden = false if previewScroll.superview == nil { // Editor stays at index 0; append preview. (Editor was never removed.) splitView.addArrangedSubview(previewScroll) } applyWrap(AppSettings.softWrap) refreshPreview() DispatchQueue.main.async { let w = self.splitView.bounds.width if w > 0 { self.splitView.setPosition(w * 0.52, ofDividerAt: 0) } } containerView.window?.makeFirstResponder(editorTV) case .board: returnPageToBoard() splitView.isHidden = true boardContainer.isHidden = false DispatchQueue.main.async { self.boardContainer.boardView.centerOnMain() } containerView.window?.makeFirstResponder(boardContainer.boardView) } refreshStatus() } // MARK: - Editor page binding (the editor follows the selected board page) /// Called once after the board loads: the document text view (in the editor) /// belongs to the main page card. func bindInitialEditorPage() { boardContainer.boardView.assignMainTextView(textView) activeEditorCard = boardContainer.boardView.mainCardView } /// Leaving the board: drop the active page's text view back into its card. private func returnPageToBoard() { boardContainer.boardView.unfocusCard(commit: true) activeEditorCard?.mountTextView() editorScrollView.rulersVisible = false } /// Entering editor/split from the board: host the selected page's text view /// (or the main page) in the editor and re-point all editor chrome to it. private func showPageInEditor() { let bv = boardContainer.boardView bv.unfocusCard(commit: true) guard let card = bv.editorPageCandidate(), let tv = card.unmountTextView() else { return } activeEditorCard = card tv.isEditable = true // idle board cards become editable in the editor tv.isSelectable = true editorScrollView.documentView = tv editorScrollView.rulersVisible = AppSettings.lineNumbers retargetEditor(to: tv) } /// Move the editor's observers, ruler, wrap, theme and metrics onto `tv`. private func retargetEditor(to tv: NSTextView) { if editorTV !== tv { let c = NotificationCenter.default c.removeObserver(self, name: NSText.didChangeNotification, object: editorTV) c.removeObserver(self, name: NSTextView.didChangeSelectionNotification, object: editorTV) editorTV = tv c.addObserver(self, selector: #selector(textChanged), name: NSText.didChangeNotification, object: tv) c.addObserver(self, selector: #selector(selectionChanged), name: NSTextView.didChangeSelectionNotification, object: tv) } themeEditorTextView() ruler.retarget(to: tv) ruler.updateThickness() applyWrap(AppSettings.softWrap) recomputeMetrics() refreshPreview() } @objc private func cycleMode(_ sender: NSSegmentedControl) { setMode(Mode(rawValue: sender.selectedSegment) ?? .editor) } // MARK: - Appearance @objc private func appearanceChanged() { applyAppearance() } /// Apply the current theme/font to whichever text view the editor is showing. private func themeEditorTextView() { let theme = AppSettings.theme let font = AppSettings.editorFont let tv = editorTV tv.font = font tv.backgroundColor = theme.background tv.drawsBackground = false // the card / editor draws the background tv.textColor = theme.foreground tv.insertionPointColor = theme.caret tv.selectedTextAttributes = [.backgroundColor: theme.selection] tv.typingAttributes = [.font: font, .foregroundColor: theme.foreground] let full = NSRange(location: 0, length: (tv.string as NSString).length) tv.textStorage?.addAttributes([.font: font, .foregroundColor: theme.foreground], range: full) } func applyAppearance() { let theme = AppSettings.theme let font = AppSettings.editorFont containerView.window?.appearance = theme.appearance themeEditorTextView() // Keep on-board (idle) text cards themed too, so they read correctly // whether shown on the board or lifted into the editor. boardContainer.boardView.applyTextTheme(font: font, fg: theme.foreground, caret: theme.caret) editorScrollView.backgroundColor = theme.background editorScrollView.drawsBackground = true ruler.apply(theme: theme) // Editor-only affordances: skip while the text view is reparented into // the board (rulersVisible / applyWrap both read editorScrollView state). if mode != .board { editorScrollView.rulersVisible = AppSettings.lineNumbers ruler.updateThickness() applyWrap(AppSettings.softWrap) } previewScroll.backgroundColor = theme.background previewTextView.backgroundColor = theme.background previewTextView.drawsBackground = true refreshPreview() boardContainer.apply(theme: theme) statusBar.layer?.backgroundColor = theme.gutterBackground.cgColor for label in [leftStatus, centerStatus, rightStatus] { label.textColor = theme.secondaryText } } private func applyWrap(_ wrap: Bool) { let tv = editorTV guard let container = tv.textContainer else { return } if wrap { container.widthTracksTextView = true container.size = NSSize(width: editorScrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) tv.isHorizontallyResizable = false tv.autoresizingMask = [.width] editorScrollView.hasHorizontalScroller = false } else { container.widthTracksTextView = false container.size = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) tv.isHorizontallyResizable = true tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) tv.autoresizingMask = [.width, .height] editorScrollView.hasHorizontalScroller = true } tv.needsDisplay = true } // MARK: - Preview + status @objc func textChanged() { if mode == .split { refreshPreview() } ruler.updateThickness() recomputeMetrics() refreshStatus() } @objc func selectionChanged() { refreshStatus() } // Word/char/line counts are cached so the board's pan/zoom status updates // don't re-scan a large pasted document on every notification. private var metrics: (words: Int, chars: Int, lines: Int) = (0, 0, 1) private func recomputeMetrics() { let s = editorTV.string metrics.chars = (s as NSString).length metrics.words = s.split { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }.count metrics.lines = s.isEmpty ? 1 : s.reduce(into: 1) { c, ch in if ch == "\n" { c += 1 } } } /// Called after the document sets text programmatically (load / openResults). func refreshTextMetrics() { recomputeMetrics(); refreshStatus() } private func refreshPreview() { guard mode == .split else { return } let theme = AppSettings.theme let attributed = MarkdownRenderer.render(editorTV.string, theme: theme, baseSize: AppSettings.fontSize + 1) previewTextView.textStorage?.setAttributedString(attributed) previewTextView.textColor = theme.foreground } private func refreshStatus() { let words = metrics.words, chars = metrics.chars, lines = metrics.lines let theme = AppSettings.theme if mode == .board { let bv = boardContainer.boardView if let card = bv.selectedCardView { let f = card.frame leftStatus.stringValue = "\(cardKindLabel(card.kind)) · \(Int(f.width.rounded()))×\(Int(f.height.rounded())) pt" } else { leftStatus.stringValue = "\(boardContainer.currentTool.name) tool · " + "\(bv.mainPageSize.shortLabel) \(bv.mainOrientation == .landscape ? "Landscape" : "Portrait")" } centerStatus.stringValue = "Board · \(theme.name) · \(boardContainer.zoomPercent)%" let n = bv.cards.count rightStatus.stringValue = "\(n) card\(n == 1 ? "" : "s") · \(words) words · \(chars) chars" return } // editor / split — line:col needs the live string let ns = editorTV.string as NSString let sel = editorTV.selectedRange() var line = 1, col = 1, i = 0 while i < sel.location && i < ns.length { if ns.character(at: i) == 10 { line += 1; col = 1 } else { col += 1 } i += 1 } var left = "Ln \(line), Col \(col)" if sel.length > 0 { left += " · \(sel.length) selected" } leftStatus.stringValue = left let modeName = mode == .split ? "Split" : "Editor" // Make it obvious when the editor is showing a board note page, not the .txt. let pageNote = (activeEditorCard?.kind == .text) ? "Board note · " : "" centerStatus.stringValue = "\(pageNote)\(modeName) · \(theme.name) · \(AppSettings.fontName) \(Int(AppSettings.fontSize))pt" let mins = max(1, Int((Double(words) / 200.0).rounded(.up))) rightStatus.stringValue = "\(words) words · \(chars) chars · \(lines) lines · ~\(mins) min read" } private func cardKindLabel(_ k: BoardCardKind) -> String { switch k { case .mainText: return "Main page" case .text: return "Text page" case .image: return "Image" case .box: return "Box" } } func setStatus(_ message: String) { centerStatus.stringValue = message } // MARK: - Toolbar private enum ID { static let mode = NSToolbarItem.Identifier("mode") static let font = NSToolbarItem.Identifier("font") static let fontSize = NSToolbarItem.Identifier("fontSize") static let theme = NSToolbarItem.Identifier("theme") static let markup = NSToolbarItem.Identifier("markup") static let wrap = NSToolbarItem.Identifier("wrap") static let sync = NSToolbarItem.Identifier("sync") static let postgres = NSToolbarItem.Identifier("postgres") } func installToolbar(on window: NSWindow) { let toolbar = NSToolbar(identifier: "TextPlusToolbar") toolbar.delegate = self toolbar.displayMode = .iconOnly toolbar.allowsUserCustomization = true toolbar.autosavesConfiguration = true window.toolbar = toolbar window.toolbarStyle = .unified } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [ID.mode, .flexibleSpace, ID.markup, .space, ID.font, ID.fontSize, ID.theme, ID.wrap, .flexibleSpace, ID.sync, ID.postgres] } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [ID.mode, ID.markup, ID.font, ID.fontSize, ID.theme, ID.wrap, ID.sync, ID.postgres, .flexibleSpace, .space] } func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier id: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { switch id { case ID.mode: return modeItem() case ID.markup: return markupItem() case ID.font: return fontItem() case ID.fontSize: return fontSizeItem() case ID.theme: return themeItem() case ID.wrap: return wrapItem() case ID.sync: return buttonItem(id: id, symbol: "arrow.up.circle", label: "Sync", tip: "Sync Now (⌥⌘S)", action: #selector(toolbarSync)) case ID.postgres: return buttonItem(id: id, symbol: "cylinder.split.1x2", label: "Postgres", tip: "Send Page to Postgres (⌥⌘P)", action: #selector(toolbarPostgres)) default: return nil } } private func modeItem() -> NSToolbarItem { let seg = NSSegmentedControl(labels: ["Editor", "Split", "Board"], trackingMode: .selectOne, target: self, action: #selector(cycleMode(_:))) seg.selectedSegment = mode.rawValue seg.segmentStyle = .texturedRounded modeSegment = seg let item = NSToolbarItem(itemIdentifier: ID.mode) item.view = seg item.label = "View" item.visibilityPriority = .high return item } private func markupItem() -> NSToolbarItem { let symbols = ["bold", "italic", "chevron.left.forwardslash.chevron.right", "number", "list.bullet", "link"] let seg = NSSegmentedControl() seg.segmentCount = symbols.count seg.trackingMode = .momentary seg.segmentStyle = .texturedRounded for (i, sym) in symbols.enumerated() { seg.setImage(NSImage(systemSymbolName: sym, accessibilityDescription: sym), forSegment: i) seg.setWidth(30, forSegment: i) } seg.target = self seg.action = #selector(markupTapped(_:)) let item = NSToolbarItem(itemIdentifier: ID.markup) item.view = seg item.label = "Markup" return item } private func fontItem() -> NSToolbarItem { let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 130, height: 24), pullsDown: false) popup.addItems(withTitles: AppSettings.fontChoices) popup.selectItem(withTitle: AppSettings.fontName) popup.target = self popup.action = #selector(fontChanged(_:)) let item = NSToolbarItem(itemIdentifier: ID.font) item.view = popup item.label = "Font" return item } private func fontSizeItem() -> NSToolbarItem { let seg = NSSegmentedControl(labels: ["A−", "A+"], trackingMode: .momentary, target: self, action: #selector(fontSizeTapped(_:))) seg.segmentStyle = .texturedRounded let item = NSToolbarItem(itemIdentifier: ID.fontSize) item.view = seg item.label = "Size" return item } private func themeItem() -> NSToolbarItem { let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 120, height: 24), pullsDown: false) popup.addItems(withTitles: EditorTheme.all.map { $0.name }) if let idx = EditorTheme.all.firstIndex(where: { $0.id == AppSettings.theme.id }) { popup.selectItem(at: idx) } popup.target = self popup.action = #selector(themeChanged(_:)) let item = NSToolbarItem(itemIdentifier: ID.theme) item.view = popup item.label = "Theme" return item } private func wrapItem() -> NSToolbarItem { let button = NSButton(image: NSImage(systemSymbolName: "text.alignleft", accessibilityDescription: "Wrap")!, target: self, action: #selector(toggleWrap)) button.setButtonType(.pushOnPushOff) button.bezelStyle = .texturedRounded button.state = AppSettings.softWrap ? .on : .off let item = NSToolbarItem(itemIdentifier: ID.wrap) item.view = button item.label = "Wrap" item.toolTip = "Toggle soft wrap" return item } private func buttonItem(id: NSToolbarItem.Identifier, symbol: String, label: String, tip: String, action: Selector) -> NSToolbarItem { let button = NSButton(image: NSImage(systemSymbolName: symbol, accessibilityDescription: label)!, target: self, action: action) button.bezelStyle = .texturedRounded let item = NSToolbarItem(itemIdentifier: id) item.view = button item.label = label item.toolTip = tip return item } // MARK: - Toolbar actions @objc private func markupTapped(_ sender: NSSegmentedControl) { switch sender.selectedSegment { case 0: document?.markupBold(sender) case 1: document?.markupItalic(sender) case 2: document?.markupCode(sender) case 3: document?.markupHeading(sender) case 4: document?.markupList(sender) case 5: document?.markupLink(sender) default: break } } @objc private func fontChanged(_ sender: NSPopUpButton) { AppSettings.set(AppSettings.fontNameKey, sender.titleOfSelectedItem ?? "SF Mono") } @objc private func fontSizeTapped(_ sender: NSSegmentedControl) { let delta: CGFloat = sender.selectedSegment == 1 ? 1 : -1 let size = max(8, min(48, AppSettings.fontSize + delta)) AppSettings.set(AppSettings.fontSizeKey, Double(size)) } @objc private func themeChanged(_ sender: NSPopUpButton) { let theme = EditorTheme.all[sender.indexOfSelectedItem] AppSettings.set(AppSettings.themeKey, theme.id) } @objc private func toggleWrap(_ sender: NSButton) { AppSettings.set(AppSettings.softWrapKey, sender.state == .on) } @objc private func toolbarSync() { document?.syncNow(nil) } @objc private func toolbarPostgres() { document?.sendToPostgres(nil) } }