diff --git a/Info.plist b/macos/Info.plist similarity index 100% rename from Info.plist rename to macos/Info.plist diff --git a/src/AppDelegate.swift b/macos/src/AppDelegate.swift similarity index 90% rename from src/AppDelegate.swift rename to macos/src/AppDelegate.swift index 8af9ba7..3b2b103 100644 --- a/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -7,6 +7,19 @@ extension Notification.Name { static let focusEditor = Notification.Name("focusEditor") static let focusTitle = Notification.Name("focusTitle") static let newNoteSeeded = Notification.Name("newNoteSeeded") + static let settingsChanged = Notification.Name("settingsChanged") +} + +func applyThemeAppearance() { + let mode = ConfigManager.shared.themeMode + switch mode { + case "dark": + NSApp.appearance = NSAppearance(named: .darkAqua) + case "light": + NSApp.appearance = NSAppearance(named: .aqua) + default: + NSApp.appearance = nil + } } class WindowController { @@ -75,6 +88,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { observeDocumentText() syncThemeToViewport() syncGutterPrefsToViewport() + syncSettingsToViewport() startAutosaveTimer() DocumentBrowserController.shared = DocumentBrowserController(appState: appState) @@ -428,11 +442,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { @objc private func saveNote() { syncTextFromViewport() - if appState.currentFileURL != nil { - appState.saveNote() - } else { - saveNoteAs() - } + appState.bindAutoSaveURL() + appState.saveNote() } @objc private func saveNoteAs() { @@ -628,16 +639,73 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } @objc private func openSettings() { - SettingsWindowController.show() + syncSettingsToViewport() + viewport?.sendCommand(16) } @objc private func settingsDidChange() { window.backgroundColor = Theme.current.base syncThemeToViewport() syncGutterPrefsToViewport() + syncSettingsToViewport() window.contentView?.needsDisplay = true } + private func syncSettingsToViewport() { + viewport?.setSettingsView( + themeMode: ConfigManager.shared.themeMode, + lineIndicator: ConfigManager.shared.lineIndicatorMode, + gutterRainbow: ConfigManager.shared.gutterRainbow, + autoSaveDir: ConfigManager.shared.autoSaveDirectory + ) + } + + private func drainShellActions() { + guard let vp = viewport else { return } + while let raw = vp.takeShellAction() { + let parts = raw.split(separator: ":", maxSplits: 1).map(String.init) + let kind = parts[0] + let value = parts.count > 1 ? parts[1] : "" + switch kind { + case "new_note": newNote() + case "open": openNote() + case "save": saveNote() + case "save_as": saveNoteAs() + case "quit": NSApp.terminate(nil) + case "settings": break + case "export_crate": exportCrate() + case "toggle_browser": toggleBrowser() + 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 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) + } + } + private func syncThemeToViewport() { let mode = ConfigManager.shared.themeMode let name: String @@ -663,19 +731,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } @objc private func zoomIn() { - if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { - browser.browserState.scaleUp() - return - } ConfigManager.shared.zoomLevel += 1 NotificationCenter.default.post(name: .settingsChanged, object: nil) } @objc private func zoomOut() { - if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { - browser.browserState.scaleDown() - return - } let current = ConfigManager.shared.zoomLevel if 11 + current > 8 { ConfigManager.shared.zoomLevel -= 1 @@ -763,6 +823,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private func startAutosaveTimer() { autosaveTimer?.invalidate() autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + self?.drainShellActions() self?.persistViewportToNotesDir() } } diff --git a/src/AppState.swift b/macos/src/AppState.swift similarity index 98% rename from src/AppState.swift rename to macos/src/AppState.swift index 9321b8c..9c576a9 100644 --- a/src/AppState.swift +++ b/macos/src/AppState.swift @@ -272,6 +272,13 @@ class AppState: ObservableObject { return url } + func bindAutoSaveURL() { + if currentFileURL != nil { return } + let url = resolveAutoSaveURL(noteID: currentNoteID, text: documentText) + currentFileURL = url + currentFileFormat = .markdown + } + /// Background-safe atomic write. No path resolution here — the URL was /// resolved on the main thread before dispatch. private static func writeAutoSaveFile(at url: URL, text: String) { diff --git a/src/ConfigManager.swift b/macos/src/ConfigManager.swift similarity index 100% rename from src/ConfigManager.swift rename to macos/src/ConfigManager.swift diff --git a/macos/src/DocumentBrowserWindow.swift b/macos/src/DocumentBrowserWindow.swift new file mode 100644 index 0000000..198015b --- /dev/null +++ b/macos/src/DocumentBrowserWindow.swift @@ -0,0 +1,48 @@ +import Cocoa + +class DocumentBrowserController { + static var shared: DocumentBrowserController? + + let window: NSWindow + private let view: IcedBrowserView + private let appState: AppState + + init(appState: AppState) { + self.appState = appState + let dir = ConfigManager.shared.autoSaveDirectory + let frame = NSRect(x: 0, y: 0, width: 900, height: 650) + view = IcedBrowserView(frame: frame, notesDir: dir) + view.autoresizingMask = [.width, .height] + + window = NSWindow( + contentRect: frame, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Documents" + window.backgroundColor = Theme.current.base + window.contentView = view + window.setFrameAutosaveName("AcordBrowser") + window.center() + window.isReleasedWhenClosed = false + + view.onOpenPath = { [weak self] path in + guard let self = self else { return } + let url = URL(fileURLWithPath: path) + DispatchQueue.main.async { + self.appState.loadNoteFromFile(url) + self.window.orderOut(nil) + } + } + } + + func toggle() { + if window.isVisible { + window.orderOut(nil) + } else { + view.refresh() + window.makeKeyAndOrderFront(nil) + } + } +} diff --git a/macos/src/IcedBrowserView.swift b/macos/src/IcedBrowserView.swift new file mode 100644 index 0000000..30db90d --- /dev/null +++ b/macos/src/IcedBrowserView.swift @@ -0,0 +1,150 @@ +import AppKit + +class IcedBrowserView: NSView { + private(set) var browserHandle: OpaquePointer? + private var displayLink: CVDisplayLink? + private var isTornDown = false + private let notesDir: String + var onOpenPath: ((String) -> Void)? + + init(frame: NSRect, notesDir: String) { + self.notesDir = notesDir + super.init(frame: frame) + wantsLayer = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not used") } + + override var isFlipped: Bool { true } + override var wantsUpdateLayer: Bool { true } + override var acceptsFirstResponder: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil && browserHandle == nil && !isTornDown { + createBrowser() + startDisplayLink() + window?.makeFirstResponder(self) + } else if window == nil { + teardown() + } + } + + private func createBrowser() { + let scale = Float(window?.backingScaleFactor ?? 2.0) + let w = Float(bounds.width) + let h = Float(bounds.height) + let nsviewPtr = Unmanaged.passUnretained(self).toOpaque() + notesDir.withCString { cstr in + browserHandle = browser_create(nsviewPtr, w, h, scale, cstr) + } + } + + func teardown() { + if isTornDown { return } + isTornDown = true + stopDisplayLink() + if let h = browserHandle { + browser_destroy(h) + browserHandle = nil + } + } + + deinit { teardown() } + + private func startDisplayLink() { + guard displayLink == nil else { return } + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link = link else { return } + let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, userInfo -> CVReturn in + guard let userInfo = userInfo else { return kCVReturnSuccess } + let view = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + DispatchQueue.main.async { view.renderFrame() } + return kCVReturnSuccess + }, selfPtr) + CVDisplayLinkStart(link) + displayLink = link + } + + private func stopDisplayLink() { + guard let link = displayLink else { return } + CVDisplayLinkStop(link) + displayLink = nil + } + + private func renderFrame() { + if isTornDown { return } + guard let h = browserHandle else { return } + browser_render(h) + if let cstr = browser_take_pending_open(h) { + let path = String(cString: cstr) + viewport_free_string(cstr) + onOpenPath?(path) + } + } + + func refresh() { + guard let h = browserHandle else { return } + browser_refresh(h) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + guard let h = browserHandle else { return } + let scale = Float(window?.backingScaleFactor ?? 2.0) + browser_resize(h, Float(bounds.width), Float(bounds.height), scale) + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + guard let h = browserHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + browser_mouse_event(h, Float(pt.x), Float(pt.y), 0, true) + } + + override func mouseUp(with event: NSEvent) { + guard let h = browserHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + browser_mouse_event(h, Float(pt.x), Float(pt.y), 0, false) + } + + override func mouseMoved(with event: NSEvent) { + guard let h = browserHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + browser_mouse_event(h, Float(pt.x), Float(pt.y), 255, false) + } + + override func mouseDragged(with event: NSEvent) { + guard let h = browserHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + browser_mouse_event(h, Float(pt.x), Float(pt.y), 255, false) + } + + override func scrollWheel(with event: NSEvent) { + guard let h = browserHandle else { return } + browser_scroll_event(h, Float(event.scrollingDeltaX), Float(event.scrollingDeltaY)) + } + + override func keyDown(with event: NSEvent) { + guard let h = browserHandle else { return } + let text = event.characters ?? "" + text.withCString { cstr in + browser_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, cstr) + } + } + + override func keyUp(with event: NSEvent) { + guard let h = browserHandle else { return } + let text = event.characters ?? "" + text.withCString { cstr in + browser_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), false, cstr) + } + } + + override func flagsChanged(with event: NSEvent) { + guard let h = browserHandle else { return } + browser_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, nil) + } +} diff --git a/src/IcedViewportView.swift b/macos/src/IcedViewportView.swift similarity index 93% rename from src/IcedViewportView.swift rename to macos/src/IcedViewportView.swift index cb5fe4b..94c2c99 100644 --- a/src/IcedViewportView.swift +++ b/macos/src/IcedViewportView.swift @@ -275,4 +275,23 @@ class IcedViewportView: NSView { guard let h = viewportHandle else { return 0 } return viewport_render_mode(h) } + + func setSettingsView(themeMode: String, lineIndicator: String, gutterRainbow: Bool, autoSaveDir: String) { + guard let h = viewportHandle else { return } + themeMode.withCString { t in + lineIndicator.withCString { l in + autoSaveDir.withCString { d in + viewport_set_settings_view(h, t, l, gutterRainbow, d) + } + } + } + } + + func takeShellAction() -> String? { + guard let h = viewportHandle else { return nil } + guard let cstr = viewport_take_shell_action(h) else { return nil } + let s = String(cString: cstr) + viewport_free_string(cstr) + return s + } } diff --git a/src/RustBridge.swift b/macos/src/RustBridge.swift similarity index 100% rename from src/RustBridge.swift rename to macos/src/RustBridge.swift diff --git a/src/Theme.swift b/macos/src/Theme.swift similarity index 100% rename from src/Theme.swift rename to macos/src/Theme.swift diff --git a/src/TitleBarView.swift b/macos/src/TitleBarView.swift similarity index 100% rename from src/TitleBarView.swift rename to macos/src/TitleBarView.swift diff --git a/src/main.swift b/macos/src/main.swift similarity index 100% rename from src/main.swift rename to macos/src/main.swift diff --git a/scripts/macos/build-universal.sh b/scripts/macos/build-universal.sh index 5b4547e..dcc9305 100755 --- a/scripts/macos/build-universal.sh +++ b/scripts/macos/build-universal.sh @@ -32,11 +32,11 @@ lipo -create \ # TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh). mkdir -p "$MACOS" "$RESOURCES" -cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" +cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" [ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" echo "Compiling Swift (Universal)..." -SWIFT_FILES=("$ROOT"/src/*.swift) +SWIFT_FILES=("$ROOT"/macos/src/*.swift) RUST_INCLUDES=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$ROOT/target/universal" -lacord_viewport) swiftc -target arm64-apple-macosx14.0 -sdk "$SDK" "${RUST_INCLUDES[@]}" \ diff --git a/scripts/macos/build.sh b/scripts/macos/build.sh index 32c4b4d..04fb1cd 100755 --- a/scripts/macos/build.sh +++ b/scripts/macos/build.sh @@ -58,7 +58,7 @@ if [ -f "$SVG" ]; then fi mkdir -p "$MACOS" "$RESOURCES" -cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" +cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" [ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" echo "Compiling Swift (release)..." @@ -75,7 +75,7 @@ swiftc \ -framework CoreFoundation \ -O \ -o "$MACOS/Acord" \ - "$ROOT"/src/*.swift + "$ROOT"/macos/src/*.swift codesign --force --sign - "$APP" diff --git a/scripts/macos/debug.sh b/scripts/macos/debug.sh index 2cc51df..a4a65e5 100755 --- a/scripts/macos/debug.sh +++ b/scripts/macos/debug.sh @@ -37,7 +37,7 @@ fi RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) mkdir -p "$MACOS" "$RESOURCES" -cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" +cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" [ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" echo "Compiling Swift (debug)..." @@ -54,7 +54,7 @@ swiftc \ -framework CoreFoundation \ -Onone -g \ -o "$MACOS/Acord" \ - "$ROOT"/src/*.swift + "$ROOT"/macos/src/*.swift codesign --force --sign - "$APP" diff --git a/scripts/macos/package.sh b/scripts/macos/package.sh index a5f7c8e..99306c4 100755 --- a/scripts/macos/package.sh +++ b/scripts/macos/package.sh @@ -149,7 +149,7 @@ build_macos() { local app="$stage/Acord.app" rm -rf "$stage" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" - cp "$ROOT/Info.plist" "$app/Contents/Info.plist" + cp "$ROOT/macos/Info.plist" "$app/Contents/Info.plist" [ -f "$ROOT/build/AppIcon.icns" ] && cp "$ROOT/build/AppIcon.icns" "$app/Contents/Resources/AppIcon.icns" local sdk @@ -164,7 +164,7 @@ build_macos() { -framework QuartzCore -framework CoreGraphics -framework CoreFoundation \ -O \ -o "$app/Contents/MacOS/Acord" \ - "$ROOT"/src/*.swift + "$ROOT"/macos/src/*.swift codesign --force --sign - "$app" zip_target "macos-${arch}" "$app" diff --git a/src/DocumentBrowserWindow.swift b/src/DocumentBrowserWindow.swift deleted file mode 100644 index c9369cf..0000000 --- a/src/DocumentBrowserWindow.swift +++ /dev/null @@ -1,520 +0,0 @@ -import Cocoa -import SwiftUI -import Combine -import UniformTypeIdentifiers - -// MARK: - Model - -enum BrowserItemKind { - case file - case folder -} - -struct BrowserItem: Identifiable, Hashable { - let id: String - let url: URL - let name: String - let kind: BrowserItemKind - let modified: Date - var preview: String - - static func == (lhs: BrowserItem, rhs: BrowserItem) -> Bool { - lhs.url == rhs.url - } - func hash(into hasher: inout Hasher) { - hasher.combine(url) - } -} - -// MARK: - Controller - -class DocumentBrowserController { - static var shared: DocumentBrowserController? - - let window: NSWindow - let browserState: BrowserState - private let hostingView: NSHostingView - - init(appState: AppState) { - browserState = BrowserState(appState: appState) - - let view = DocumentBrowserView(state: browserState) - hostingView = NSHostingView(rootView: view) - - window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.title = "Documents" - window.backgroundColor = Theme.current.base - window.contentView = hostingView - window.setFrameAutosaveName("AcordBrowser") - window.center() - window.isReleasedWhenClosed = false - } - - func toggle() { - if window.isVisible { - window.orderOut(nil) - } else { - browserState.refresh() - window.makeKeyAndOrderFront(nil) - } - } -} - -// MARK: - State - -class BrowserState: ObservableObject { - @Published var items: [BrowserItem] = [] - @Published var cardScale: CGFloat = 1.0 - @Published var selectedURL: URL? - @Published var currentPath: URL - - let appState: AppState - private let fm = FileManager.default - private static let supportedExtensions: Set = ["md", "txt", "markdown", "mdown"] - - var rootPath: URL { - URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) - } - - var pathSegments: [(name: String, url: URL)] { - var segments: [(String, URL)] = [] - var path = currentPath.standardizedFileURL - let root = rootPath.standardizedFileURL - - while path != root && path.path.hasPrefix(root.path) { - segments.insert((path.lastPathComponent, path), at: 0) - path = path.deletingLastPathComponent().standardizedFileURL - } - segments.insert(("Documents", root), at: 0) - return segments - } - - init(appState: AppState) { - self.appState = appState - self.currentPath = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) - refresh() - } - - func refresh() { - items = scanDirectory(currentPath) - } - - func navigate(to url: URL) { - currentPath = url - selectedURL = nil - refresh() - } - - private func scanDirectory(_ dir: URL) -> [BrowserItem] { - guard let contents = try? fm.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { return [] } - - var folders: [BrowserItem] = [] - var files: [BrowserItem] = [] - - for url in contents { - guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]) else { continue } - let mtime = values.contentModificationDate ?? .distantPast - - if values.isDirectory == true { - folders.append(BrowserItem( - id: url.path, - url: url, - name: url.lastPathComponent, - kind: .folder, - modified: mtime, - preview: folderSummary(url) - )) - } else { - let ext = url.pathExtension.lowercased() - guard Self.supportedExtensions.contains(ext) else { continue } - files.append(BrowserItem( - id: url.path, - url: url, - name: url.deletingPathExtension().lastPathComponent, - kind: .file, - modified: mtime, - preview: filePreview(url) - )) - } - } - - folders.sort { $0.modified > $1.modified } - files.sort { $0.modified > $1.modified } - return folders + files - } - - private func filePreview(_ url: URL) -> String { - guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), - let text = String(data: data, encoding: .utf8) else { return "" } - let body = Self.stripSidecarArchive(text) - if Self.bodyLooksBlank(body) { - return "(empty note)" - } - let lines = body.components(separatedBy: "\n") - return lines.prefix(20).joined(separator: "\n") - } - - /// Remove the `` base64 sidecar comment before - /// previewing. Without this, phantom notes that were saved with only - /// an empty default table render their archive blob as tile text. - private static func stripSidecarArchive(_ text: String) -> String { - guard let marker = text.range(of: "