Acord/macos/src/EditorWindow.swift

450 lines
16 KiB
Swift

import Cocoa
import Combine
class EditorWindow: NSObject {
let window: NSWindow
let appState: AppState
let viewport: IcedViewportView
private(set) var titleBarView: TitleBarView?
private var titleCancellable: AnyCancellable?
private var textCancellable: AnyCancellable?
private var autosaveTimer: Timer?
private var lastAutosavedHash: Int?
private var observers: [NSObjectProtocol] = []
init(frameAutosaveName: String?) {
self.appState = AppState()
self.viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800))
viewport.autoresizingMask = [.width, .height]
self.window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.backgroundColor = Theme.current.base
window.title = "Acord"
window.contentView = viewport
if let autosave = frameAutosaveName {
window.setFrameAutosaveName(autosave)
}
window.center()
super.init()
setupTitleBar()
observeDocumentText()
observeDocumentTitle()
wireLoadedTextSync()
syncThemeToViewport()
syncGutterPrefsToViewport()
syncSettingsToViewport()
startAutosaveTimer()
registerObservers()
window.makeKeyAndOrderFront(nil)
}
func loadFromURL(_ url: URL) {
appState.loadNoteFromFile(url)
viewport.setLang(url.pathExtension)
}
// MARK: - Title bar
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 trimmed = rawTitle.trimmingCharacters(in: .whitespaces)
let normalizedTitle: String
if trimmed.isEmpty {
normalizedTitle = ""
} else if trimmed.hasPrefix("#") {
normalizedTitle = trimmed
} else {
normalizedTitle = "# " + trimmed
}
let lines = self.appState.documentText.components(separatedBy: "\n")
let firstIsTitle = lines.first
.map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") }
?? false
let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines
let newLines: [String]
if normalizedTitle.isEmpty {
newLines = body
} else {
newLines = [normalizedTitle] + body
}
self.appState.documentText = newLines.joined(separator: "\n")
}
titleBarView = tbv
}
// MARK: - Combine bindings
private func observeDocumentText() {
textCancellable = appState.$documentText
.receive(on: RunLoop.main)
.sink { [weak self] text in
guard let self = self else { return }
if self.viewport.getText() == text { return }
self.viewport.setText(text)
}
}
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 ? "Acord" : String(clean.prefix(60))
self.window.title = displayTitle
self.titleBarView?.title = firstLine
}
}
private func wireLoadedTextSync() {
appState.onLoadedTextChanged = { [weak self] text in
guard let self = self else { return }
if self.viewport.getText() != text {
self.viewport.setText(text)
}
self.lastAutosavedHash = text.hashValue
}
appState.takeArchiveBytesFromViewport = { [weak self] in
self?.viewport.takeSidecarBytes()
}
appState.applyArchiveBytesToViewport = { [weak self] data in
self?.viewport.applySidecarBytes(data)
}
}
// MARK: - Notifications
private func registerObservers() {
let settings = NotificationCenter.default.addObserver(
forName: .settingsChanged, object: nil, queue: .main
) { [weak self] _ in
self?.applySettingsChange()
}
observers.append(settings)
let newNote = NotificationCenter.default.addObserver(
forName: .newNoteSeeded, object: appState, queue: .main
) { [weak self] _ in
self?.viewport.sendCommand(12)
}
observers.append(newNote)
let focusTitle = NotificationCenter.default.addObserver(
forName: .focusTitle, object: nil, queue: .main
) { [weak self] _ in
guard let self = self, self.window.isKeyWindow else { return }
self.titleBarView?.beginEditing()
}
observers.append(focusTitle)
}
private func applySettingsChange() {
window.backgroundColor = Theme.current.base
syncThemeToViewport()
syncGutterPrefsToViewport()
syncSettingsToViewport()
window.contentView?.needsDisplay = true
}
// MARK: - Viewport sync
func syncSettingsToViewport() {
viewport.setSettingsView(
themeMode: ConfigManager.shared.themeMode,
lineIndicator: ConfigManager.shared.lineIndicatorMode,
gutterRainbow: ConfigManager.shared.gutterRainbow,
autoSaveDir: ConfigManager.shared.autoSaveDirectory
)
}
func syncThemeToViewport() {
let mode = ConfigManager.shared.themeMode
let name: String
switch mode {
case "dark": name = "kicad"
case "light": name = "latte"
default:
let appearance = NSApp.effectiveAppearance
let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
name = isDark ? "kicad" : "latte"
}
viewport.setTheme(name)
}
func syncGutterPrefsToViewport() {
viewport.setLineIndicator(ConfigManager.shared.lineIndicatorMode)
viewport.setGutterRainbow(ConfigManager.shared.gutterRainbow)
viewport.setAutoPairFlags(ConfigManager.shared.autoPairFlags)
}
func syncTextFromViewport() {
let text = viewport.getText()
if !text.isEmpty || appState.documentText.isEmpty {
appState.documentText = text
}
}
// MARK: - Autosave loop
private func startAutosaveTimer() {
autosaveTimer?.invalidate()
autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
self?.drainShellActions()
self?.persistViewportToNotesDir()
}
}
private func drainShellActions() {
while let raw = viewport.takeShellAction() {
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
let kind = parts[0]
let value = parts.count > 1 ? parts[1] : ""
handleShellAction(kind: kind, value: value)
}
}
private func handleShellAction(kind: String, value: String) {
switch kind {
case "new_note": newNote()
case "open": openNotePanel()
case "save": saveNote()
case "save_as": saveNoteAs()
case "quit": NSApp.terminate(nil)
case "settings": break
case "export_crate": exportCrate()
case "toggle_browser": DocumentBrowserController.shared?.toggle()
case "set_theme_mode":
ConfigManager.shared.themeMode = value
NotificationCenter.default.post(name: .settingsChanged, object: nil)
case "set_line_indicator":
ConfigManager.shared.lineIndicatorMode = value
NotificationCenter.default.post(name: .settingsChanged, object: nil)
case "set_gutter_rainbow":
ConfigManager.shared.gutterRainbow = (value == "true")
NotificationCenter.default.post(name: .settingsChanged, object: nil)
case "pick_auto_save_dir":
pickAutoSaveDirectory()
default:
break
}
}
private func persistViewportToNotesDir() {
let text = viewport.getText()
guard !AppState.isEffectivelyBlank(text) else { return }
let hash = text.hashValue
if hash == lastAutosavedHash { return }
appState.writeAutosavedCopy(text: text)
lastAutosavedHash = hash
}
// MARK: - Actions
func newNote() {
appState.newNote()
viewport.setLang("")
}
func saveNote() {
syncTextFromViewport()
appState.bindAutoSaveURL()
appState.saveNote()
}
func saveNoteAs() {
syncTextFromViewport()
let panel = NSSavePanel()
panel.allowedContentTypes = AppDelegate.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)
}
}
/// presents the file picker and forwards the chosen URL to the AppDelegate router.
func openNotePanel() {
let panel = NSOpenPanel()
panel.allowedContentTypes = AppDelegate.supportedContentTypes
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.beginSheetModal(for: window) { response in
guard response == .OK, let url = panel.url else { return }
(NSApp.delegate as? AppDelegate)?.openInNewWindow(url)
}
}
func printNote() {
guard let handle = viewport.viewportHandle else { return }
syncTextFromViewport()
let title = appState.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Acord Document"
var len: UInt = 0
guard let ptr = title.withCString({ t in viewport_render_pdf(handle, t, &len) }), len > 0 else {
let alert = NSAlert()
alert.messageText = "Print failed"
alert.informativeText = "Could not render this document to PDF."
alert.runModal()
return
}
let data = Data(bytes: ptr, count: Int(len))
viewport_free_bytes(ptr, len)
let panel = NSSavePanel()
panel.title = "Print to PDF"
panel.prompt = "Save"
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = "\(title).pdf"
panel.beginSheetModal(for: window) { response in
guard response == .OK, let url = panel.url else { return }
do {
try data.write(to: url)
NSWorkspace.shared.open(url)
} catch {
let alert = NSAlert()
alert.messageText = "Print failed"
alert.informativeText = error.localizedDescription
alert.runModal()
}
}
}
func exportCrate() {
syncTextFromViewport()
guard let handle = viewport.viewportHandle else { return }
let panel = NSSavePanel()
panel.title = "Export as Rust Library"
panel.message = "Choose a location and name for your exported crate"
panel.prompt = "Export"
panel.nameFieldLabel = "Crate name:"
panel.nameFieldStringValue = defaultCrateName()
panel.canCreateDirectories = true
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return }
let parentDir = url.deletingLastPathComponent().path
let name = url.lastPathComponent
parentDir.withCString { pd in
name.withCString { n in
if let cstr = viewport_export_crate(handle, pd, n) {
let resultPath = String(cString: cstr)
viewport_free_string(cstr)
self?.notifyExportComplete(at: resultPath)
} else {
self?.notifyExportFailed()
}
}
}
}
}
func pickAutoSaveDirectory() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
panel.beginSheetModal(for: window) { response in
guard response == .OK, let url = panel.url else { return }
ConfigManager.shared.autoSaveDirectory = url.path
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
// MARK: - Helpers
private func notifyExportComplete(at path: String) {
let alert = NSAlert()
alert.messageText = "Export complete"
alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions."
alert.addButton(withTitle: "Reveal in Finder")
alert.addButton(withTitle: "OK")
if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
private func notifyExportFailed() {
let alert = NSAlert()
alert.messageText = "Export failed"
alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder."
alert.addButton(withTitle: "OK")
alert.runModal()
}
private func defaultCrateName() -> String {
let firstLine = appState.documentText
.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
let stripped = firstLine.replacingOccurrences(
of: "^#+\\s*", with: "", options: .regularExpression
)
let words = stripped.split(separator: " ").prefix(2).joined(separator: "-")
let sanitized = words.lowercased()
.map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined()
return sanitized.isEmpty ? "my-note" : sanitized
}
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 = AppDelegate.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)"
}
// MARK: - Teardown
func teardown() {
autosaveTimer?.invalidate()
autosaveTimer = nil
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
observers = []
titleCancellable = nil
textCancellable = nil
viewport.teardown()
}
}