i7t5--edmund
290 行
11 KiB
Swift
290 行
11 KiB
Swift
import AppKit
|
|
import EdmundCore
|
|
import Sparkle
|
|
|
|
// Entry point for the app the user knows as "Edmund" (CFBundleName). The
|
|
// executable target — and so this binary at Edmund.app/Contents/MacOS/edmd — is
|
|
// named `edmd`, an expansion of "Editor for Markdown". The backronym is the
|
|
// app's original working name; it survives only here and in Package.swift, never
|
|
// in anything user-facing.
|
|
|
|
// --- App Delegate -----------------------------------------------------------
|
|
|
|
@MainActor
|
|
class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|
|
|
var aboutWindowController: AboutWindowController?
|
|
var settingsWindowController: SettingsWindowController?
|
|
// startingUpdater: true kicks off the scheduled background check immediately;
|
|
// the "Check for Updates…" menu item targets this controller directly.
|
|
let updaterController = SPUStandardUpdaterController(
|
|
startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
|
|
|
|
// MARK: - Typewriter Mode (persisted)
|
|
|
|
static let typewriterModeKey = "EditorTypewriterMode"
|
|
|
|
/// Whether typewriter scrolling is enabled. Defaults to on (the historical
|
|
/// behavior) when nothing has been saved yet.
|
|
static func typewriterModeEnabled(_ defaults: UserDefaults = .standard) -> Bool {
|
|
defaults.object(forKey: typewriterModeKey) as? Bool ?? true
|
|
}
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
AppSettings.applyLogging()
|
|
Log.info("Edmund launched", category: .app)
|
|
AppSettings.applyAppearance()
|
|
setupMenuBar()
|
|
|
|
// Opt-in (default off): upload any crash reports macOS wrote for us since
|
|
// last launch. Fire-and-forget; never blocks startup.
|
|
if AppSettings.sendCrashLogs {
|
|
CrashReporter.uploadPendingReports(
|
|
alreadySent: AppSettings.sentCrashReports,
|
|
onSent: { AppSettings.sentCrashReports.insert($0) })
|
|
}
|
|
|
|
// Open file from command-line argument. When a file is given,
|
|
// `applicationShouldOpenUntitledFile` suppresses the otherwise-automatic
|
|
// blank document, so we don't end up with two windows.
|
|
let args = CommandLine.arguments
|
|
if args.count > 1 {
|
|
let url = URL(fileURLWithPath: args[1])
|
|
Log.info("Opening file from launch argument: \(url.path)", category: .document)
|
|
NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { _, _, _ in }
|
|
}
|
|
|
|
#if DEBUG
|
|
ReproScript.runIfRequested()
|
|
#endif
|
|
}
|
|
|
|
// Auto-open a blank document on launch only when no file was passed on the
|
|
// command line (otherwise the file arg + the blank doc make two windows).
|
|
func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool {
|
|
CommandLine.arguments.count <= 1 && AppSettings.startupAction == .createNewDocument
|
|
}
|
|
|
|
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
|
AppSettings.reopenWindows
|
|
}
|
|
|
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
|
true
|
|
}
|
|
|
|
// Reopen a new untitled document when the app is activated with no windows.
|
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
|
if !flag {
|
|
if AppSettings.startupAction == .createNewDocument {
|
|
NSDocumentController.shared.newDocument(nil)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Settings
|
|
|
|
@MainActor @objc func showAbout(_ sender: Any?) {
|
|
if aboutWindowController == nil {
|
|
aboutWindowController = AboutWindowController()
|
|
}
|
|
aboutWindowController?.showWindow(nil)
|
|
aboutWindowController?.window?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
@MainActor @objc func showSettings(_ sender: Any?) {
|
|
if settingsWindowController == nil {
|
|
settingsWindowController = SettingsWindowController()
|
|
}
|
|
settingsWindowController?.showWindow(nil)
|
|
settingsWindowController?.window?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
// MARK: - View
|
|
|
|
/// Toggles typewriter scrolling, persists the choice, and applies it to every
|
|
/// open document immediately.
|
|
@MainActor @objc func toggleTypewriterMode(_ sender: Any?) {
|
|
let newValue = !AppDelegate.typewriterModeEnabled()
|
|
UserDefaults.standard.set(newValue, forKey: AppDelegate.typewriterModeKey)
|
|
for case let doc as Document in NSDocumentController.shared.documents {
|
|
doc.editor?.typewriterModeEnabled = newValue
|
|
}
|
|
}
|
|
|
|
@MainActor func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
|
if menuItem.action == #selector(toggleTypewriterMode(_:)) {
|
|
menuItem.state = AppDelegate.typewriterModeEnabled() ? .on : .off
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Open Document
|
|
|
|
/// Manual Open panel — bypasses NSDocumentController's type validation
|
|
/// which is broken without Info.plist.
|
|
@MainActor @objc func openDocumentManually(_ sender: Any?) {
|
|
let panel = NSOpenPanel()
|
|
panel.allowsMultipleSelection = false
|
|
panel.canChooseDirectories = false
|
|
panel.canChooseFiles = true
|
|
|
|
let complete: (NSApplication.ModalResponse) -> Void = { response in
|
|
guard response == .OK, let url = panel.url else { return }
|
|
NSDocumentController.shared.openDocument(
|
|
withContentsOf: url, display: true
|
|
) { _, _, error in
|
|
if let error = error {
|
|
NSAlert(error: error).runModal()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Attach the panel to the front window as a sheet so it's always visible
|
|
// — a free-floating panel can open off-screen or behind the window
|
|
// (the app's windows can launch off-screen), which looks like a hang.
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
|
|
panel.beginSheetModal(for: window, completionHandler: complete)
|
|
} else {
|
|
panel.begin(completionHandler: complete)
|
|
}
|
|
}
|
|
|
|
// MARK: - Menu Bar
|
|
|
|
@MainActor private func setupMenuBar() {
|
|
let mainMenu = NSMenu()
|
|
|
|
// App menu (required for Cmd+Q)
|
|
let appMenuItem = NSMenuItem()
|
|
let appMenu = NSMenu()
|
|
appMenu.addItem(withTitle: "About Edmund",
|
|
action: #selector(AppDelegate.showAbout(_:)),
|
|
keyEquivalent: "")
|
|
appMenu.addItem(NSMenuItem.separator())
|
|
appMenu.addItem(withTitle: "Settings\u{2026}",
|
|
action: #selector(AppDelegate.showSettings(_:)),
|
|
keyEquivalent: ",")
|
|
|
|
let updatesItem = appMenu.addItem(
|
|
withTitle: "Check for Updates\u{2026}",
|
|
action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)),
|
|
keyEquivalent: "")
|
|
updatesItem.target = updaterController
|
|
|
|
appMenu.addItem(NSMenuItem.separator())
|
|
|
|
appMenu.addItem(withTitle: "Quit Edmund",
|
|
action: #selector(NSApplication.terminate(_:)),
|
|
keyEquivalent: "q")
|
|
appMenuItem.submenu = appMenu
|
|
mainMenu.addItem(appMenuItem)
|
|
|
|
// File menu — NSDocument provides the standard actions
|
|
let fileMenuItem = NSMenuItem()
|
|
let fileMenu = NSMenu(title: "File")
|
|
|
|
fileMenu.addItem(withTitle: "New",
|
|
action: #selector(NSDocumentController.newDocument(_:)),
|
|
keyEquivalent: "n")
|
|
|
|
fileMenu.addItem(withTitle: "Open\u{2026}",
|
|
action: #selector(AppDelegate.openDocumentManually(_:)),
|
|
keyEquivalent: "o")
|
|
|
|
// Recent documents submenu
|
|
let recentMenuItem = NSMenuItem(title: "Open Recent", action: nil, keyEquivalent: "")
|
|
let recentMenu = NSMenu(title: "Open Recent")
|
|
recentMenu.addItem(withTitle: "Clear Menu",
|
|
action: #selector(NSDocumentController.clearRecentDocuments(_:)),
|
|
keyEquivalent: "")
|
|
recentMenuItem.submenu = recentMenu
|
|
fileMenu.addItem(recentMenuItem)
|
|
|
|
fileMenu.addItem(NSMenuItem.separator())
|
|
|
|
fileMenu.addItem(withTitle: "Save",
|
|
action: #selector(NSDocument.save(_:)),
|
|
keyEquivalent: "s")
|
|
|
|
fileMenu.addItem(NSMenuItem.separator())
|
|
|
|
fileMenu.addItem(withTitle: "Rename\u{2026}",
|
|
action: #selector(Document.rename(_:)),
|
|
keyEquivalent: "")
|
|
|
|
fileMenu.addItem(withTitle: "Move To\u{2026}",
|
|
action: #selector(Document.move(_:)),
|
|
keyEquivalent: "")
|
|
|
|
fileMenu.addItem(NSMenuItem.separator())
|
|
|
|
fileMenu.addItem(withTitle: "Export as PDF\u{2026}",
|
|
action: #selector(Document.exportToPDF(_:)),
|
|
keyEquivalent: "")
|
|
|
|
fileMenu.addItem(withTitle: "Print\u{2026}",
|
|
action: #selector(Document.printDocument(_:)),
|
|
keyEquivalent: "p")
|
|
|
|
fileMenuItem.submenu = fileMenu
|
|
mainMenu.addItem(fileMenuItem)
|
|
|
|
// Edit menu (required for Cmd+C/V/X/A/Z)
|
|
let editMenuItem = NSMenuItem()
|
|
let editMenu = NSMenu(title: "Edit")
|
|
|
|
editMenu.addItem(withTitle: "Undo",
|
|
action: #selector(EditorTextView.undo(_:)),
|
|
keyEquivalent: "z")
|
|
|
|
let redoItem = editMenu.addItem(withTitle: "Redo",
|
|
action: #selector(EditorTextView.redo(_:)),
|
|
keyEquivalent: "z")
|
|
redoItem.keyEquivalentModifierMask = [.command, .shift]
|
|
|
|
editMenu.addItem(NSMenuItem.separator())
|
|
|
|
editMenu.addItem(withTitle: "Cut",
|
|
action: #selector(NSText.cut(_:)),
|
|
keyEquivalent: "x")
|
|
|
|
editMenu.addItem(withTitle: "Copy",
|
|
action: #selector(NSText.copy(_:)),
|
|
keyEquivalent: "c")
|
|
|
|
editMenu.addItem(withTitle: "Paste",
|
|
action: #selector(NSText.paste(_:)),
|
|
keyEquivalent: "v")
|
|
|
|
editMenu.addItem(withTitle: "Select All",
|
|
action: #selector(NSText.selectAll(_:)),
|
|
keyEquivalent: "a")
|
|
|
|
editMenuItem.submenu = editMenu
|
|
mainMenu.addItem(editMenuItem)
|
|
|
|
// Format menu — built from the declarative command registry.
|
|
mainMenu.addItem(FormatMenu.build())
|
|
|
|
// View menu — built in its own file (ViewMenu.swift).
|
|
mainMenu.addItem(ViewMenu.build())
|
|
|
|
NSApplication.shared.mainMenu = mainMenu
|
|
}
|
|
}
|
|
|
|
// --- Launch -----------------------------------------------------------------
|
|
let app = NSApplication.shared
|
|
|
|
// Must be created before NSDocumentController.shared is first accessed.
|
|
let _ = DocumentController()
|
|
|
|
let delegate = AppDelegate()
|
|
app.delegate = delegate
|
|
app.setActivationPolicy(.regular)
|
|
app.activate(ignoringOtherApps: true)
|
|
app.run()
|