forked from jess/Acord
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:
parent
3d357209e6
commit
07550b5c31
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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[@]}" \
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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() } {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue