forked from jess/Acord
1
0
Fork 0

Reorganized structure of files. Transitioned macos to new implementations of browser viewer and settings menu that are shared across all platforms now.

This commit is contained in:
jess 2026-04-29 19:37:00 -07:00
parent 3d357209e6
commit 07550b5c31
22 changed files with 563 additions and 697 deletions

View File

@ -7,6 +7,19 @@ extension Notification.Name {
static let focusEditor = Notification.Name("focusEditor") static let focusEditor = Notification.Name("focusEditor")
static let focusTitle = Notification.Name("focusTitle") static let focusTitle = Notification.Name("focusTitle")
static let newNoteSeeded = Notification.Name("newNoteSeeded") 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 { class WindowController {
@ -75,6 +88,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
observeDocumentText() observeDocumentText()
syncThemeToViewport() syncThemeToViewport()
syncGutterPrefsToViewport() syncGutterPrefsToViewport()
syncSettingsToViewport()
startAutosaveTimer() startAutosaveTimer()
DocumentBrowserController.shared = DocumentBrowserController(appState: appState) DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
@ -428,11 +442,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
@objc private func saveNote() { @objc private func saveNote() {
syncTextFromViewport() syncTextFromViewport()
if appState.currentFileURL != nil { appState.bindAutoSaveURL()
appState.saveNote() appState.saveNote()
} else {
saveNoteAs()
}
} }
@objc private func saveNoteAs() { @objc private func saveNoteAs() {
@ -628,16 +639,73 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
} }
@objc private func openSettings() { @objc private func openSettings() {
SettingsWindowController.show() syncSettingsToViewport()
viewport?.sendCommand(16)
} }
@objc private func settingsDidChange() { @objc private func settingsDidChange() {
window.backgroundColor = Theme.current.base window.backgroundColor = Theme.current.base
syncThemeToViewport() syncThemeToViewport()
syncGutterPrefsToViewport() syncGutterPrefsToViewport()
syncSettingsToViewport()
window.contentView?.needsDisplay = true 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() { private func syncThemeToViewport() {
let mode = ConfigManager.shared.themeMode let mode = ConfigManager.shared.themeMode
let name: String let name: String
@ -663,19 +731,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
} }
@objc private func zoomIn() { @objc private func zoomIn() {
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
browser.browserState.scaleUp()
return
}
ConfigManager.shared.zoomLevel += 1 ConfigManager.shared.zoomLevel += 1
NotificationCenter.default.post(name: .settingsChanged, object: nil) NotificationCenter.default.post(name: .settingsChanged, object: nil)
} }
@objc private func zoomOut() { @objc private func zoomOut() {
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
browser.browserState.scaleDown()
return
}
let current = ConfigManager.shared.zoomLevel let current = ConfigManager.shared.zoomLevel
if 11 + current > 8 { if 11 + current > 8 {
ConfigManager.shared.zoomLevel -= 1 ConfigManager.shared.zoomLevel -= 1
@ -763,6 +823,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
private func startAutosaveTimer() { private func startAutosaveTimer() {
autosaveTimer?.invalidate() autosaveTimer?.invalidate()
autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
self?.drainShellActions()
self?.persistViewportToNotesDir() self?.persistViewportToNotesDir()
} }
} }

View File

@ -272,6 +272,13 @@ class AppState: ObservableObject {
return url 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 /// Background-safe atomic write. No path resolution here the URL was
/// resolved on the main thread before dispatch. /// resolved on the main thread before dispatch.
private static func writeAutoSaveFile(at url: URL, text: String) { private static func writeAutoSaveFile(at url: URL, text: String) {

View File

@ -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)
}
}
}

View File

@ -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<IcedBrowserView>.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)
}
}

View File

@ -275,4 +275,23 @@ class IcedViewportView: NSView {
guard let h = viewportHandle else { return 0 } guard let h = viewportHandle else { return 0 }
return viewport_render_mode(h) 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
}
} }

View File

@ -32,11 +32,11 @@ lipo -create \
# TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh). # TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh).
mkdir -p "$MACOS" "$RESOURCES" 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" [ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns"
echo "Compiling Swift (Universal)..." 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) 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[@]}" \ swiftc -target arm64-apple-macosx14.0 -sdk "$SDK" "${RUST_INCLUDES[@]}" \

View File

@ -58,7 +58,7 @@ if [ -f "$SVG" ]; then
fi fi
mkdir -p "$MACOS" "$RESOURCES" 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" [ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns"
echo "Compiling Swift (release)..." echo "Compiling Swift (release)..."
@ -75,7 +75,7 @@ swiftc \
-framework CoreFoundation \ -framework CoreFoundation \
-O \ -O \
-o "$MACOS/Acord" \ -o "$MACOS/Acord" \
"$ROOT"/src/*.swift "$ROOT"/macos/src/*.swift
codesign --force --sign - "$APP" codesign --force --sign - "$APP"

View File

@ -37,7 +37,7 @@ fi
RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport)
mkdir -p "$MACOS" "$RESOURCES" 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" [ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns"
echo "Compiling Swift (debug)..." echo "Compiling Swift (debug)..."
@ -54,7 +54,7 @@ swiftc \
-framework CoreFoundation \ -framework CoreFoundation \
-Onone -g \ -Onone -g \
-o "$MACOS/Acord" \ -o "$MACOS/Acord" \
"$ROOT"/src/*.swift "$ROOT"/macos/src/*.swift
codesign --force --sign - "$APP" codesign --force --sign - "$APP"

View File

@ -149,7 +149,7 @@ build_macos() {
local app="$stage/Acord.app" local app="$stage/Acord.app"
rm -rf "$stage" rm -rf "$stage"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" 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" [ -f "$ROOT/build/AppIcon.icns" ] && cp "$ROOT/build/AppIcon.icns" "$app/Contents/Resources/AppIcon.icns"
local sdk local sdk
@ -164,7 +164,7 @@ build_macos() {
-framework QuartzCore -framework CoreGraphics -framework CoreFoundation \ -framework QuartzCore -framework CoreGraphics -framework CoreFoundation \
-O \ -O \
-o "$app/Contents/MacOS/Acord" \ -o "$app/Contents/MacOS/Acord" \
"$ROOT"/src/*.swift "$ROOT"/macos/src/*.swift
codesign --force --sign - "$app" codesign --force --sign - "$app"
zip_target "macos-${arch}" "$app" zip_target "macos-${arch}" "$app"

View File

@ -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<DocumentBrowserView>
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<String> = ["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 `<!-- acord-archive -->` 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: "<!-- acord-archive") else { return text }
return String(text[..<marker.lowerBound])
}
/// `true` when the body contains no real content either all whitespace
/// or nothing but an empty default-header table with no user data. These
/// show up for notes the user opened but never filled in; calling them
/// out as `(empty note)` beats rendering three rows of `| | |`.
private static func bodyLooksBlank(_ body: String) -> Bool {
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let meaningful = trimmed.components(separatedBy: "\n").filter { line in
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty { return false }
if !t.hasPrefix("|") { return true }
let cells = t
.trimmingCharacters(in: CharacterSet(charactersIn: "|"))
.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
// Separator row: cells are all dashes/colons.
if cells.allSatisfy({ !$0.isEmpty && $0.allSatisfy { "-:".contains($0) } }) {
return false
}
// All cells empty or the default `Header N` placeholder.
let isDefaultHeader = cells.enumerated().allSatisfy { (i, cell) in
cell == "Header \(i + 1)"
}
if cells.allSatisfy({ $0.isEmpty }) || isDefaultHeader {
return false
}
return true
}
return meaningful.isEmpty
}
private func folderSummary(_ url: URL) -> String {
let contents = (try? fm.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)) ?? []
let fileCount = contents.filter {
Self.supportedExtensions.contains($0.pathExtension.lowercased())
}.count
let folderCount = contents.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
}.count
var parts: [String] = []
if fileCount > 0 { parts.append("\(fileCount) file\(fileCount == 1 ? "" : "s")") }
if folderCount > 0 { parts.append("\(folderCount) folder\(folderCount == 1 ? "" : "s")") }
return parts.isEmpty ? "Empty" : parts.joined(separator: ", ")
}
// MARK: - Actions
func openFile(_ item: BrowserItem) {
guard item.kind == .file else { return }
appState.loadNoteFromFile(item.url)
DocumentBrowserController.shared?.window.orderOut(nil)
}
func renameItem(_ item: BrowserItem, to newName: String) {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let ext = item.kind == .file ? "." + item.url.pathExtension : ""
let dest = item.url.deletingLastPathComponent().appendingPathComponent(trimmed + ext)
guard !fm.fileExists(atPath: dest.path) else { return }
try? fm.moveItem(at: item.url, to: dest)
refresh()
}
func duplicateItem(_ item: BrowserItem) {
guard item.kind == .file else { return }
let dir = item.url.deletingLastPathComponent()
let base = item.url.deletingPathExtension().lastPathComponent
let ext = item.url.pathExtension
var n = 1
var dest: URL
repeat {
dest = dir.appendingPathComponent("\(base) \(n).\(ext)")
n += 1
} while fm.fileExists(atPath: dest.path)
try? fm.copyItem(at: item.url, to: dest)
refresh()
}
func trashItem(_ item: BrowserItem) {
try? fm.trashItem(at: item.url, resultingItemURL: nil)
if selectedURL == item.url { selectedURL = nil }
refresh()
}
func revealInFinder(_ item: BrowserItem) {
NSWorkspace.shared.activateFileViewerSelecting([item.url])
}
func createFolder() {
var name = "New Folder"
var n = 1
while fm.fileExists(atPath: currentPath.appendingPathComponent(name).path) {
n += 1
name = "New Folder \(n)"
}
let url = currentPath.appendingPathComponent(name)
try? fm.createDirectory(at: url, withIntermediateDirectories: false)
refresh()
}
func moveItem(_ item: BrowserItem, into folder: BrowserItem) {
guard folder.kind == .folder else { return }
let dest = folder.url.appendingPathComponent(item.url.lastPathComponent)
guard !fm.fileExists(atPath: dest.path) else { return }
try? fm.moveItem(at: item.url, to: dest)
refresh()
}
func scaleUp() {
cardScale = min(cardScale + 0.1, 3.0)
}
func scaleDown() {
cardScale = max(cardScale - 0.1, 0.4)
}
}
// MARK: - Browser View
struct DocumentBrowserView: View {
@ObservedObject var state: BrowserState
var body: some View {
VStack(spacing: 0) {
BreadcrumbBar(state: state)
Divider().background(Color(ns: Theme.current.surface1))
ScrollView {
if state.items.isEmpty {
emptyState
} else {
LazyVGrid(
columns: [GridItem(.adaptive(
minimum: 200 * state.cardScale,
maximum: 400 * state.cardScale
))],
spacing: 16 * state.cardScale
) {
ForEach(state.items) { item in
BrowserCardView(item: item, state: state)
.onDrag {
NSItemProvider(object: item.url as NSURL)
}
}
}
.padding(16 * state.cardScale)
}
}
.background(Color(ns: Theme.current.base))
.contextMenu {
Button("New Folder") { state.createFolder() }
Divider()
Button("Reveal in Finder") {
NSWorkspace.shared.open(state.currentPath)
}
}
}
.background(Color(ns: Theme.current.base))
.frame(minWidth: 400, minHeight: 300)
}
private var emptyState: some View {
VStack(spacing: 8) {
Text("No documents")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color(ns: Theme.current.subtext0))
Text("Create a new note or add files to this folder")
.font(.system(size: 12))
.foregroundColor(Color(ns: Theme.current.overlay0))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 100)
}
}
// MARK: - Breadcrumb Bar
struct BreadcrumbBar: View {
@ObservedObject var state: BrowserState
var body: some View {
HStack(spacing: 4) {
ForEach(Array(state.pathSegments.enumerated()), id: \.offset) { index, segment in
if index > 0 {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(Color(ns: Theme.current.overlay0))
}
Button(action: { state.navigate(to: segment.url) }) {
Text(segment.name)
.font(.system(size: 12, weight: isLast(index) ? .semibold : .regular))
.foregroundColor(Color(ns: isLast(index) ? Theme.current.text : Theme.current.subtext0))
}
.buttonStyle(.plain)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(ns: Theme.current.mantle))
}
private func isLast(_ index: Int) -> Bool {
index == state.pathSegments.count - 1
}
}
// MARK: - Card View
struct BrowserCardView: View {
let item: BrowserItem
@ObservedObject var state: BrowserState
@State private var isRenaming = false
@State private var renameText = ""
@State private var isDropTarget = false
private var isSelected: Bool {
state.selectedURL == item.url
}
var body: some View {
VStack(alignment: .leading, spacing: 6 * state.cardScale) {
previewArea
titleArea
}
.padding(10 * state.cardScale)
.background(
RoundedRectangle(cornerRadius: 8 * state.cardScale)
.fill(Color(ns: isSelected ? Theme.current.surface1 : Theme.current.surface0))
)
.overlay(
RoundedRectangle(cornerRadius: 8 * state.cardScale)
.stroke(
isDropTarget ? Color(ns: Theme.current.green) :
isSelected ? Color(ns: Theme.current.blue) : Color.clear,
lineWidth: 2
)
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
switch item.kind {
case .folder: state.navigate(to: item.url)
case .file: state.openFile(item)
}
}
.onTapGesture(count: 1) {
state.selectedURL = item.url
}
.contextMenu { contextMenuItems }
.onDrop(of: [.fileURL], isTargeted: item.kind == .folder ? $isDropTarget : .constant(false)) { providers in
guard item.kind == .folder else { return false }
for provider in providers {
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in
guard let urlData = data as? Data,
let sourceURL = URL(dataRepresentation: urlData, relativeTo: nil) else { return }
DispatchQueue.main.async {
let source = BrowserItem(
id: sourceURL.path, url: sourceURL,
name: sourceURL.lastPathComponent,
kind: .file, modified: .now, preview: ""
)
state.moveItem(source, into: item)
}
}
}
return true
}
}
@ViewBuilder
private var previewArea: some View {
if item.kind == .folder {
HStack(spacing: 8 * state.cardScale) {
Image(systemName: "folder.fill")
.font(.system(size: 28 * state.cardScale))
.foregroundColor(Color(ns: Theme.current.blue))
Text(item.preview)
.font(.system(size: 10 * state.cardScale))
.foregroundColor(Color(ns: Theme.current.subtext0))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8 * state.cardScale)
.background(Color(ns: Theme.current.mantle))
.cornerRadius(4 * state.cardScale)
} else {
Text(item.preview)
.font(.system(size: 10 * state.cardScale, design: .monospaced))
.foregroundColor(Color(ns: Theme.current.subtext0))
.lineLimit(nil)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(8 * state.cardScale)
.background(Color(ns: Theme.current.mantle))
.cornerRadius(4 * state.cardScale)
}
}
@ViewBuilder
private var titleArea: some View {
if isRenaming {
TextField("Name", text: $renameText, onCommit: {
state.renameItem(item, to: renameText)
isRenaming = false
})
.textFieldStyle(.plain)
.font(.system(size: 12 * state.cardScale, weight: .semibold))
.foregroundColor(Color(ns: Theme.current.text))
.padding(.horizontal, 4)
} else {
Text(item.name)
.font(.system(size: 12 * state.cardScale, weight: .semibold))
.foregroundColor(Color(ns: Theme.current.text))
.lineLimit(2)
.padding(.horizontal, 4)
}
}
@ViewBuilder
private var contextMenuItems: some View {
switch item.kind {
case .file:
Button("Open") { state.openFile(item) }
Button("Rename") {
renameText = item.name
isRenaming = true
}
Button("Duplicate") { state.duplicateItem(item) }
Divider()
Button("Move to Trash") { state.trashItem(item) }
Divider()
Button("Reveal in Finder") { state.revealInFinder(item) }
case .folder:
Button("Open") { state.navigate(to: item.url) }
Button("Rename") {
renameText = item.name
isRenaming = true
}
Divider()
Button("Move to Trash") { state.trashItem(item) }
Divider()
Button("Reveal in Finder") { state.revealInFinder(item) }
}
}
}

View File

@ -1,140 +0,0 @@
import SwiftUI
import Cocoa
enum ThemeMode: String, CaseIterable {
case auto = "auto"
case dark = "dark"
case light = "light"
var label: String {
switch self {
case .auto: return "Auto"
case .dark: return "Dark"
case .light: return "Light"
}
}
}
enum LineIndicatorMode: String, CaseIterable {
case on = "on"
case off = "off"
case vim = "vim"
var label: String {
switch self {
case .on: return "On"
case .off: return "Off"
case .vim: return "Vim"
}
}
}
struct SettingsView: View {
@State private var themeMode: String = ConfigManager.shared.themeMode
@State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode
@State private var gutterRainbow: Bool = ConfigManager.shared.gutterRainbow
@State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory
var body: some View {
let palette = Theme.current
Form {
Section("Theme") {
Picker("Mode", selection: $themeMode) {
ForEach(ThemeMode.allCases, id: \.rawValue) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
.pickerStyle(.segmented)
}
Section("Line Numbers") {
Picker("Mode", selection: $lineIndicatorMode) {
ForEach(LineIndicatorMode.allCases, id: \.rawValue) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
.pickerStyle(.segmented)
Toggle("Gutter rainbow", isOn: $gutterRainbow)
}
Section("Auto-Save") {
HStack {
TextField("Directory", text: $autoSaveDir)
.textFieldStyle(.roundedBorder)
Button("Choose...") {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
autoSaveDir = url.path
}
}
}
}
}
.formStyle(.grouped)
.frame(width: 400, height: 300)
.background(Color(ns: palette.base))
.onChange(of: themeMode) {
ConfigManager.shared.themeMode = themeMode
applyThemeAppearance()
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
.onChange(of: lineIndicatorMode) {
ConfigManager.shared.lineIndicatorMode = lineIndicatorMode
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
.onChange(of: gutterRainbow) {
ConfigManager.shared.gutterRainbow = gutterRainbow
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
.onChange(of: autoSaveDir) {
ConfigManager.shared.autoSaveDirectory = autoSaveDir
}
}
}
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
}
}
extension Notification.Name {
static let settingsChanged = Notification.Name("settingsChanged")
}
class SettingsWindowController {
private static var window: NSWindow?
static func show() {
if let existing = window, existing.isVisible {
existing.makeKeyAndOrderFront(nil)
return
}
let settingsView = SettingsView()
let hostingView = NSHostingView(rootView: settingsView)
hostingView.frame = NSRect(x: 0, y: 0, width: 400, height: 280)
let w = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 280),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
w.title = "Settings"
w.contentView = hostingView
w.center()
w.isReleasedWhenClosed = false
w.makeKeyAndOrderFront(nil)
window = w
}
}

View File

@ -39,6 +39,11 @@
#define USER_IDENT_HOP 3 #define USER_IDENT_HOP 3
/**
* Owns the browser window's wgpu surface, iced renderer, and BrowserState.
*/
typedef struct BrowserHandle BrowserHandle;
typedef struct TextPos TextPos; typedef struct TextPos TextPos;
typedef struct ViewportHandle ViewportHandle; typedef struct ViewportHandle ViewportHandle;
@ -91,6 +96,14 @@ uint32_t viewport_get_auto_pair_flags(void);
void viewport_send_command(struct ViewportHandle *handle, uint32_t command); void viewport_send_command(struct ViewportHandle *handle, uint32_t command);
void viewport_set_settings_view(struct ViewportHandle *handle,
const char *theme_mode,
const char *line_indicator,
bool gutter_rainbow,
const char *auto_save_dir);
char *viewport_take_shell_action(struct ViewportHandle *handle);
/** /**
* Export the note as a standalone Rust crate at `out_dir/name/`. Returns * Export the note as a standalone Rust crate at `out_dir/name/`. Returns
* a heap-allocated C string on success (the absolute path of the created * a heap-allocated C string on success (the absolute path of the created
@ -99,6 +112,36 @@ void viewport_send_command(struct ViewportHandle *handle, uint32_t command);
*/ */
char *viewport_export_crate(struct ViewportHandle *handle, const char *out_dir, const char *name); char *viewport_export_crate(struct ViewportHandle *handle, const char *out_dir, const char *name);
struct BrowserHandle *browser_create(void *nsview,
float width,
float height,
float scale,
const char *notes_dir);
void browser_destroy(struct BrowserHandle *handle);
void browser_render(struct BrowserHandle *handle);
void browser_resize(struct BrowserHandle *handle, float width, float height, float scale);
void browser_mouse_event(struct BrowserHandle *handle,
float x,
float y,
uint8_t button,
bool pressed);
void browser_scroll_event(struct BrowserHandle *handle, float delta_x, float delta_y);
void browser_key_event(struct BrowserHandle *handle,
uint32_t key,
uint32_t modifiers,
bool pressed,
const char *text);
char *browser_take_pending_open(struct BrowserHandle *handle);
void browser_refresh(struct BrowserHandle *handle);
uint32_t viewport_render_mode(struct ViewportHandle *handle); uint32_t viewport_render_mode(struct ViewportHandle *handle);
#endif /* ACORD_VIEWPORT_H */ #endif /* ACORD_VIEWPORT_H */

View File

@ -42,14 +42,21 @@ pub fn push_key_event(
pressed: bool, pressed: bool,
text: Option<&str>, text: Option<&str>,
) { ) {
let modifiers = decode_modifiers(modifier_flags); for ev in build_key_events(keycode, modifier_flags, pressed, text) {
handle.events.push(ev);
}
}
// Always emit a ModifiersChanged BEFORE the key event so handle.rs's pub fn build_key_events(
// state.mods stays current. Without this, holding Cmd silently and keycode: u32,
// then clicking would leave state.mods stale (the click event carries modifier_flags: u32,
// no modifier info), and click handlers reading self.mods would see pressed: bool,
// the wrong state. Idempotent — handle.rs only stores the latest. text: Option<&str>,
handle.events.push(Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers))); ) -> Vec<Event> {
let modifiers = decode_modifiers(modifier_flags);
let mut out = Vec::with_capacity(2);
out.push(Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)));
let physical = key::Physical::Unidentified(key::NativeCode::MacOS(keycode as u16)); let physical = key::Physical::Unidentified(key::NativeCode::MacOS(keycode as u16));
@ -70,7 +77,7 @@ pub fn push_key_event(
}; };
if pressed { if pressed {
handle.events.push(Event::Keyboard(keyboard::Event::KeyPressed { out.push(Event::Keyboard(keyboard::Event::KeyPressed {
key: logical.clone(), key: logical.clone(),
modified_key: logical, modified_key: logical,
physical_key: physical, physical_key: physical,
@ -80,7 +87,7 @@ pub fn push_key_event(
repeat: false, repeat: false,
})); }));
} else { } else {
handle.events.push(Event::Keyboard(keyboard::Event::KeyReleased { out.push(Event::Keyboard(keyboard::Event::KeyReleased {
key: logical.clone(), key: logical.clone(),
modified_key: logical, modified_key: logical,
physical_key: physical, physical_key: physical,
@ -88,6 +95,7 @@ pub fn push_key_event(
modifiers, modifiers,
})); }));
} }
out
} }
fn keycode_to_named(keycode: u32) -> Option<keyboard::key::Named> { fn keycode_to_named(keycode: u32) -> Option<keyboard::key::Named> {

View File

@ -8,6 +8,14 @@ use iced_wgpu::core::{
}; };
use iced_wgpu::Engine; use iced_wgpu::Engine;
use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
#[cfg(target_os = "macos")]
use raw_window_handle::{AppKitDisplayHandle, AppKitWindowHandle};
#[cfg(target_os = "windows")]
use raw_window_handle::{Win32WindowHandle, WindowsDisplayHandle};
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::ptr::NonNull;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::os::raw::c_void;
use crate::palette; use crate::palette;
use super::state::{BrowserMessage, BrowserState}; use super::state::{BrowserMessage, BrowserState};
@ -260,3 +268,43 @@ pub fn refresh(handle: &mut BrowserHandle) {
handle.state.refresh(); handle.state.refresh();
handle.needs_redraw = true; handle.needs_redraw = true;
} }
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub fn create_from_native(
native_handle: *mut c_void,
width: f32,
height: f32,
scale: f32,
notes_dir: PathBuf,
) -> Option<BrowserHandle> {
let ptr = NonNull::new(native_handle)?;
#[cfg(target_os = "macos")]
let (raw_window, raw_display) = (
RawWindowHandle::AppKit(AppKitWindowHandle::new(ptr)),
RawDisplayHandle::AppKit(AppKitDisplayHandle::new()),
);
#[cfg(target_os = "windows")]
let (raw_window, raw_display) = {
let wh = Win32WindowHandle::new(std::num::NonZero::new(ptr.as_ptr() as isize).unwrap());
(
RawWindowHandle::Win32(wh),
RawDisplayHandle::Windows(WindowsDisplayHandle::new()),
)
};
create(raw_display, raw_window, width, height, scale, notes_dir)
}
pub fn push_key_native(
handle: &mut BrowserHandle,
keycode: u32,
modifier_flags: u32,
pressed: bool,
text: Option<&str>,
) {
for ev in crate::bridge::build_key_events(keycode, modifier_flags, pressed, text) {
handle.events.push(ev);
}
handle.needs_redraw = true;
}

View File

@ -3382,7 +3382,6 @@ impl EditorState {
.height(Length::Fill) .height(Length::Fill)
.into(); .into();
#[cfg(any(target_os = "linux", target_os = "windows"))]
if self.settings_open { if self.settings_open {
return iced_widget::stack![body, self.settings_panel()].into(); return iced_widget::stack![body, self.settings_panel()].into();
} }
@ -4208,7 +4207,6 @@ impl EditorState {
.into() .into()
} }
#[cfg(any(target_os = "linux", target_os = "windows"))]
fn settings_panel(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { fn settings_panel(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current(); let p = palette::current();
let f = self.font_size; let f = self.font_size;
@ -4241,9 +4239,9 @@ impl EditorState {
"Line indicator", "Line indicator",
label_size, label_size,
&[ &[
("Off", "off"),
("Line", "line"),
("On", "on"), ("On", "on"),
("Off", "off"),
("Vim", "vim"),
], ],
&self.settings_view.line_indicator, &self.settings_view.line_indicator,
|v| Message::Shell(ShellAction::SetLineIndicator(v.to_string())), |v| Message::Shell(ShellAction::SetLineIndicator(v.to_string())),
@ -4337,7 +4335,6 @@ impl EditorState {
.into() .into()
} }
#[cfg(any(target_os = "linux", target_os = "windows"))]
fn settings_segment_row<'a>( fn settings_segment_row<'a>(
&'a self, &'a self,
label: &str, label: &str,

View File

@ -321,11 +321,63 @@ pub extern "C" fn viewport_send_command(handle: *mut ViewportHandle, command: u3
11 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::Live)), 11 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::Live)),
12 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::Editor)), 12 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::Editor)),
13 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::View)), 13 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::View)),
16 => h.state.settings_open = !h.state.settings_open,
_ => return, _ => return,
}; };
h.needs_redraw = true; h.needs_redraw = true;
} }
#[unsafe(no_mangle)]
pub extern "C" fn viewport_set_settings_view(
handle: *mut ViewportHandle,
theme_mode: *const c_char,
line_indicator: *const c_char,
gutter_rainbow: bool,
auto_save_dir: *const c_char,
) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
let read = |p: *const c_char| -> String {
if p.is_null() { return String::new(); }
unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
};
h.state.settings_view = editor::SettingsView {
theme_mode: read(theme_mode),
line_indicator: read(line_indicator),
gutter_rainbow,
auto_save_dir: read(auto_save_dir),
};
h.needs_redraw = true;
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_take_shell_action(handle: *mut ViewportHandle) -> *mut c_char {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return std::ptr::null_mut(),
};
let Some(action) = h.state.take_pending_shell_action() else {
return std::ptr::null_mut();
};
let s = match action {
editor::ShellAction::NewNote => "new_note".to_string(),
editor::ShellAction::Open => "open".to_string(),
editor::ShellAction::Save => "save".to_string(),
editor::ShellAction::SaveAs => "save_as".to_string(),
editor::ShellAction::Quit => "quit".to_string(),
editor::ShellAction::Settings => "settings".to_string(),
editor::ShellAction::ExportCrate => "export_crate".to_string(),
editor::ShellAction::ToggleBrowser => "toggle_browser".to_string(),
editor::ShellAction::SetThemeMode(v) => format!("set_theme_mode:{}", v),
editor::ShellAction::SetLineIndicator(v) => format!("set_line_indicator:{}", v),
editor::ShellAction::SetGutterRainbow(b) => format!("set_gutter_rainbow:{}", b),
editor::ShellAction::PickAutoSaveDir => "pick_auto_save_dir".to_string(),
};
CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
}
/// Export the note as a standalone Rust crate at `out_dir/name/`. Returns /// Export the note as a standalone Rust crate at `out_dir/name/`. Returns
/// a heap-allocated C string on success (the absolute path of the created /// a heap-allocated C string on success (the absolute path of the created
/// folder), or null on failure. Free the returned string with /// folder), or null on failure. Free the returned string with
@ -360,6 +412,99 @@ pub extern "C" fn viewport_export_crate(
} }
} }
use browser::BrowserHandle;
#[unsafe(no_mangle)]
pub extern "C" fn browser_create(
nsview: *mut c_void,
width: f32,
height: f32,
scale: f32,
notes_dir: *const c_char,
) -> *mut BrowserHandle {
if nsview.is_null() || notes_dir.is_null() { return std::ptr::null_mut(); }
let dir = match unsafe { CStr::from_ptr(notes_dir) }.to_str() {
Ok(s) => std::path::PathBuf::from(s),
Err(_) => return std::ptr::null_mut(),
};
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
match browser::handle::create_from_native(nsview, width, height, scale, dir) {
Some(h) => Box::into_raw(Box::new(h)),
None => std::ptr::null_mut(),
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = (width, height, scale, dir);
std::ptr::null_mut()
}
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_destroy(handle: *mut BrowserHandle) {
if handle.is_null() { return; }
unsafe { drop(Box::from_raw(handle)); }
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_render(handle: *mut BrowserHandle) {
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
browser::handle::render(h);
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_resize(handle: *mut BrowserHandle, width: f32, height: f32, scale: f32) {
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
browser::handle::resize(h, width, height, scale);
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_mouse_event(handle: *mut BrowserHandle, x: f32, y: f32, button: u8, pressed: bool) {
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
browser::handle::push_mouse_move(h, x, y);
if button != 255 {
browser::handle::push_mouse_button(h, button, pressed);
}
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_scroll_event(handle: *mut BrowserHandle, delta_x: f32, delta_y: f32) {
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
browser::handle::push_scroll(h, delta_x, delta_y);
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_key_event(
handle: *mut BrowserHandle,
key: u32,
modifiers: u32,
pressed: bool,
text: *const c_char,
) {
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
let text_str = if text.is_null() {
None
} else {
Some(unsafe { CStr::from_ptr(text) }.to_string_lossy())
};
browser::handle::push_key_native(h, key, modifiers, pressed, text_str.as_deref());
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_take_pending_open(handle: *mut BrowserHandle) -> *mut c_char {
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return std::ptr::null_mut() };
let Some(path) = browser::handle::take_pending_open(h) else { return std::ptr::null_mut() };
let s = path.to_string_lossy().into_owned();
CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
}
#[unsafe(no_mangle)]
pub extern "C" fn browser_refresh(handle: *mut BrowserHandle) {
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
browser::handle::refresh(h);
}
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 { pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 {
let h = match unsafe { handle.as_mut() } { let h = match unsafe { handle.as_mut() } {