Swiftly/src/AppDelegate.swift

368 lines
13 KiB
Swift

import Cocoa
import Combine
import SwiftUI
import UniformTypeIdentifiers
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
var appState: AppState!
private var titleCancellable: AnyCancellable?
private var titleBarView: TitleBarView?
private var focusTitleObserver: NSObjectProtocol?
func applicationDidFinishLaunching(_ notification: Notification) {
_ = ConfigManager.shared
appState = AppState()
let contentView = ContentView(state: appState)
let hostingView = NSHostingView(rootView: contentView)
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.backgroundColor = Theme.current.base
window.title = "Swiftly"
window.contentView = hostingView
window.center()
window.setFrameAutosaveName("SwiftlyMainWindow")
window.makeKeyAndOrderFront(nil)
applyThemeAppearance()
setupTitleBar()
setupMenuBar()
observeDocumentTitle()
NotificationCenter.default.addObserver(
self, selector: #selector(settingsDidChange),
name: .settingsChanged, object: nil
)
}
func application(_ application: NSApplication, open urls: [URL]) {
guard let url = urls.first else { return }
appState.loadNoteFromFile(url)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func applicationWillTerminate(_ notification: Notification) {
appState.saveNote()
}
// MARK: - Menu bar
private func setupMenuBar() {
let mainMenu = NSMenu()
mainMenu.addItem(buildAppMenu())
mainMenu.addItem(buildFileMenu())
mainMenu.addItem(buildEditMenu())
mainMenu.addItem(buildViewMenu())
mainMenu.addItem(buildWindowMenu())
NSApp.mainMenu = mainMenu
}
private func buildAppMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu()
menu.addItem(withTitle: "About Swiftly", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
menu.addItem(.separator())
let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
menu.addItem(.separator())
menu.addItem(withTitle: "Quit Swiftly", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
item.submenu = menu
return item
}
private func buildFileMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "File")
let newItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "n")
newItem.target = self
menu.addItem(newItem)
let openItem = NSMenuItem(title: "Open...", action: #selector(openNote), keyEquivalent: "o")
openItem.target = self
menu.addItem(openItem)
menu.addItem(.separator())
let saveItem = NSMenuItem(title: "Save", action: #selector(saveNote), keyEquivalent: "s")
saveItem.target = self
menu.addItem(saveItem)
let saveAsItem = NSMenuItem(title: "Save As...", action: #selector(saveNoteAs), keyEquivalent: "S")
saveAsItem.target = self
menu.addItem(saveAsItem)
item.submenu = menu
return item
}
private func buildEditMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Edit")
menu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
menu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
menu.addItem(.separator())
menu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
menu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
menu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
menu.addItem(.separator())
let findItem = NSMenuItem(title: "Find...", action: #selector(NSTextView.performFindPanelAction(_:)), keyEquivalent: "f")
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
menu.addItem(findItem)
menu.addItem(.separator())
let formatItem = NSMenuItem(title: "Format Document", action: #selector(formatDocument), keyEquivalent: "F")
formatItem.keyEquivalentModifierMask = [.command, .shift]
formatItem.target = self
menu.addItem(formatItem)
item.submenu = menu
return item
}
private func buildViewMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "View")
let toggleItem = NSMenuItem(title: "Toggle Sidebar", action: #selector(toggleSidebar), keyEquivalent: "b")
toggleItem.keyEquivalentModifierMask = .control
toggleItem.target = self
menu.addItem(toggleItem)
menu.addItem(.separator())
let zoomInItem = NSMenuItem(title: "Zoom In", action: #selector(zoomIn), keyEquivalent: "=")
zoomInItem.target = self
menu.addItem(zoomInItem)
let zoomOutItem = NSMenuItem(title: "Zoom Out", action: #selector(zoomOut), keyEquivalent: "-")
zoomOutItem.target = self
menu.addItem(zoomOutItem)
let actualSizeItem = NSMenuItem(title: "Actual Size", action: #selector(zoomReset), keyEquivalent: "0")
actualSizeItem.target = self
menu.addItem(actualSizeItem)
item.submenu = menu
return item
}
private func buildWindowMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Window")
menu.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m")
menu.addItem(withTitle: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "")
item.submenu = menu
NSApp.windowsMenu = menu
return item
}
// MARK: - Actions
@objc private func newNote() {
appState.newNote()
}
@objc private func openNote() {
let panel = NSOpenPanel()
panel.allowedContentTypes = Self.supportedContentTypes
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return }
self?.appState.loadNoteFromFile(url)
}
}
@objc private func saveNote() {
if appState.currentFileURL != nil {
appState.saveNote()
} else {
saveNoteAs()
}
}
@objc private func saveNoteAs() {
let panel = NSSavePanel()
panel.allowedContentTypes = Self.supportedContentTypes
panel.nameFieldStringValue = defaultFilename()
if let url = appState.currentFileURL {
panel.directoryURL = url.deletingLastPathComponent()
panel.nameFieldStringValue = url.lastPathComponent
}
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return }
self?.appState.saveNoteToFile(url)
}
}
private func defaultFilename() -> String {
if let url = appState.currentFileURL {
return url.lastPathComponent
}
let firstLine = appState.documentText
.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
let stripped = firstLine.replacingOccurrences(
of: "^#+\\s*", with: "", options: .regularExpression
)
let trimmed = stripped.trimmingCharacters(in: .whitespaces)
let ext = extensionForFormat(appState.currentFileFormat)
guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" }
let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined()
return sanitized.prefix(80) + ".\(ext)"
}
private func extensionForFormat(_ format: FileFormat) -> String {
switch format {
case .markdown: return "md"
case .csv: return "csv"
case .json: return "json"
case .toml: return "toml"
case .yaml: return "yaml"
case .xml: return "xml"
case .svg: return "svg"
case .rust: return "rs"
case .c: return "c"
case .cpp: return "cpp"
case .objc: return "m"
case .javascript: return "js"
case .typescript: return "ts"
case .jsx: return "jsx"
case .tsx: return "tsx"
case .html: return "html"
case .css: return "css"
case .scss: return "scss"
case .less: return "less"
case .python: return "py"
case .go: return "go"
case .ruby: return "rb"
case .php: return "php"
case .lua: return "lua"
case .shell: return "sh"
case .java: return "java"
case .kotlin: return "kt"
case .swift: return "swift"
case .zig: return "zig"
case .sql: return "sql"
case .makefile: return "mk"
case .dockerfile: return "Dockerfile"
case .config: return "conf"
case .lock: return "lock"
case .plainText, .unknown: return "txt"
}
}
private static let supportedContentTypes: [UTType] = {
let extensions = [
"md", "markdown", "mdown",
"csv", "json", "toml", "yaml", "yml", "xml", "svg",
"rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx",
"js", "jsx", "ts", "tsx",
"html", "htm", "css", "scss", "less",
"py", "go", "rb", "php", "lua",
"sh", "bash", "zsh", "fish",
"java", "kt", "kts", "swift", "zig", "sql",
"mk", "ini", "cfg", "conf", "env",
"lock", "txt", "text", "log"
]
var types: [UTType] = [.plainText]
for ext in extensions {
if let t = UTType(filenameExtension: ext) {
types.append(t)
}
}
return Array(Set(types))
}()
@objc private func formatDocument() {
NotificationCenter.default.post(name: .formatDocument, object: nil)
}
@objc private func openSettings() {
SettingsWindowController.show()
}
@objc private func settingsDidChange() {
window.backgroundColor = Theme.current.base
window.contentView?.needsDisplay = true
}
@objc private func toggleSidebar() {
NotificationCenter.default.post(name: .toggleSidebar, object: nil)
}
@objc private func zoomIn() {
ConfigManager.shared.zoomLevel += 1
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
@objc private func zoomOut() {
let current = ConfigManager.shared.zoomLevel
if 11 + current > 8 {
ConfigManager.shared.zoomLevel -= 1
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
@objc private func zoomReset() {
ConfigManager.shared.zoomLevel = 0
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
private func setupTitleBar() {
let accessory = TitleBarAccessoryController()
window.addTitlebarAccessoryViewController(accessory)
let tbv = accessory.titleView
tbv.onCommit = { [weak self] rawTitle in
guard let self = self else { return }
let lines = self.appState.documentText.components(separatedBy: "\n")
var rest = Array(lines.dropFirst())
if rest.isEmpty && self.appState.documentText.isEmpty { rest = [] }
self.appState.documentText = ([rawTitle] + rest).joined(separator: "\n")
}
titleBarView = tbv
focusTitleObserver = NotificationCenter.default.addObserver(
forName: .focusTitle, object: nil, queue: .main
) { [weak self] _ in
self?.titleBarView?.beginEditing()
}
}
private func observeDocumentTitle() {
titleCancellable = appState.$documentText
.receive(on: RunLoop.main)
.sink { [weak self] text in
guard let self = self else { return }
let firstLine = text.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
let clean = firstLine.replacingOccurrences(
of: "^#+\\s*", with: "", options: .regularExpression
)
let displayTitle = clean.isEmpty ? "Swiftly" : String(clean.prefix(60))
self.window.title = displayTitle
self.titleBarView?.title = firstLine
}
}
}