import AppKit import EdmundCore // MARK: - Format menu (declarative command registry) // // The Format menu and its shortcuts are described by a single table of // `MenuCommand`s and built from it. Action methods live on `EditorTextView` // (and `Document` for the view-mode cycle); items use a nil target so they // route through the responder chain to the focused editor — exactly like the // undo/redo items in `setupMenuBar`. // // Groundwork for user-configurable shortcuts: every command carries a stable // `id` and a default `Shortcut`. A later pass can resolve a per-`id` override // from UserDefaults before building the item; nothing is persisted yet. /// A key equivalent: the (lowercased) key plus its modifier flags. struct Shortcut { let key: String let modifiers: NSEvent.ModifierFlags static func cmd(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command]) } static func cmdShift(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command, .shift]) } static func cmdOpt(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command, .option]) } } /// One actionable menu command. struct MenuCommand { let id: String let title: String let action: Selector var shortcut: Shortcut? = nil var tag: Int = 0 var representedObject: Any? = nil func makeItem() -> NSMenuItem { let item = NSMenuItem(title: title, action: action, keyEquivalent: shortcut?.key ?? "") item.keyEquivalentModifierMask = shortcut?.modifiers ?? [] item.tag = tag item.representedObject = representedObject // nil target → responder chain (focused EditorTextView / Document). return item } } @MainActor enum FormatMenu { /// The top-level "Format" menu item (with its submenu). static func build() -> NSMenuItem { let formatItem = NSMenuItem() let menu = NSMenu(title: "Format") menu.addItem(headingSubmenuItem()) menu.addItem(thematicBreakCommand.makeItem()) menu.addItem(.separator()) for cmd in listCommands { menu.addItem(cmd.makeItem()) } menu.addItem(.separator()) for cmd in linkCommands { menu.addItem(cmd.makeItem()) } menu.addItem(.separator()) for cmd in blockCommands { menu.addItem(cmd.makeItem()) } menu.addItem(calloutSubmenuItem()) menu.addItem(footnoteCommand.makeItem()) menu.addItem(.separator()) menu.addItem(fontSubmenuItem()) formatItem.submenu = menu return formatItem } /// The "Toggle View Mode" item (⌘E) for the View menu — bracketed by /// dividers by the caller. static func viewModeToggleItem() -> NSMenuItem { MenuCommand(id: "view.toggleMode", title: "Toggle View Mode", action: #selector(Document.toggleViewMode(_:)), shortcut: .cmd("e")).makeItem() } // MARK: - Groups private static let listCommands: [MenuCommand] = [ MenuCommand(id: "format.bulletedList", title: "Bulleted List", action: #selector(EditorTextView.formatBulletedList(_:)), shortcut: .cmdOpt("b")), MenuCommand(id: "format.numberedList", title: "Numbered List", action: #selector(EditorTextView.formatNumberedList(_:))), MenuCommand(id: "format.checklist", title: "Checklist", action: #selector(EditorTextView.formatChecklist(_:)), shortcut: .cmd("l")), ] private static let linkCommands: [MenuCommand] = [ MenuCommand(id: "format.link", title: "Link", action: #selector(EditorTextView.formatLink(_:)), shortcut: .cmd("k")), MenuCommand(id: "format.wikilink", title: "Wikilink", action: #selector(EditorTextView.formatWikilink(_:))), MenuCommand(id: "format.image", title: "Image", action: #selector(EditorTextView.formatImage(_:))), ] private static let thematicBreakCommand = MenuCommand(id: "format.thematicBreak", title: "Thematic Break", action: #selector(EditorTextView.formatThematicBreak(_:))) private static let footnoteCommand = MenuCommand(id: "format.footnote", title: "Footnote", action: #selector(EditorTextView.formatFootnote(_:))) private static let blockCommands: [MenuCommand] = [ MenuCommand(id: "format.table", title: "Table", action: #selector(EditorTextView.formatTable(_:))), MenuCommand(id: "format.codeBlock", title: "Code Block", action: #selector(EditorTextView.formatCodeBlock(_:))), MenuCommand(id: "format.mathBlock", title: "Math Block", action: #selector(EditorTextView.formatMathBlock(_:))), MenuCommand(id: "format.blockQuote", title: "Block Quote", action: #selector(EditorTextView.formatBlockQuote(_:)), shortcut: .cmdShift("b")), ] private static let fontCommands: [MenuCommand] = [ MenuCommand(id: "format.bold", title: "Bold", action: #selector(EditorTextView.formatBold(_:)), shortcut: .cmd("b")), MenuCommand(id: "format.italic", title: "Italic", action: #selector(EditorTextView.formatItalic(_:)), shortcut: .cmd("i")), MenuCommand(id: "format.underline", title: "Underline", action: #selector(EditorTextView.formatUnderline(_:)), shortcut: .cmd("u")), MenuCommand(id: "format.strikethrough", title: "Strikethrough", action: #selector(EditorTextView.formatStrikethrough(_:))), MenuCommand(id: "format.highlight", title: "Highlight", action: #selector(EditorTextView.formatHighlight(_:))), MenuCommand(id: "format.code", title: "Code", action: #selector(EditorTextView.formatCode(_:))), MenuCommand(id: "format.math", title: "Math", action: #selector(EditorTextView.formatInlineMath(_:))), MenuCommand(id: "format.keyboard", title: "Keyboard", action: #selector(EditorTextView.formatKeyboard(_:))), MenuCommand(id: "format.comment", title: "Comments", action: #selector(EditorTextView.formatComment(_:))), ] /// GitHub alert types (uppercase in source: `> [!NOTE]`). private static let githubCalloutTypes = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"] /// Obsidian-only callout types (lowercase). note/tip/warning are omitted /// since they duplicate NOTE/TIP/WARNING already in the GitHub group. private static let obsidianCalloutTypes = [ "abstract", "info", "todo", "success", "question", "failure", "danger", "bug", "example", "quote", ] // MARK: - Submenus private static func headingSubmenuItem() -> NSMenuItem { let item = NSMenuItem(title: "Heading", action: nil, keyEquivalent: "") let menu = NSMenu(title: "Heading") for level in 1...6 { menu.addItem(MenuCommand(id: "format.heading\(level)", title: "Heading \(level)", action: #selector(EditorTextView.formatHeading(_:)), tag: level).makeItem()) } item.submenu = menu return item } private static func calloutSubmenuItem() -> NSMenuItem { let item = NSMenuItem(title: "Alert / Callout", action: nil, keyEquivalent: "") let menu = NSMenu(title: "Alert / Callout") for type in githubCalloutTypes { menu.addItem(MenuCommand(id: "format.callout.\(type)", title: type.capitalized, action: #selector(EditorTextView.formatCallout(_:)), representedObject: type).makeItem()) } menu.addItem(.separator()) for type in obsidianCalloutTypes { menu.addItem(MenuCommand(id: "format.callout.\(type)", title: type.capitalized, action: #selector(EditorTextView.formatCallout(_:)), representedObject: type).makeItem()) } item.submenu = menu return item } private static func fontSubmenuItem() -> NSMenuItem { let item = NSMenuItem(title: "Font", action: nil, keyEquivalent: "") let menu = NSMenu(title: "Font") for cmd in fontCommands { menu.addItem(cmd.makeItem()) } item.submenu = menu return item } }