450 lines
16 KiB
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()
|
|
}
|
|
}
|