A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
565 lines
24 KiB
Swift
565 lines
24 KiB
Swift
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) }
|
||
}
|