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 focusTitle = Notification.Name("focusTitle")
|
||||
static let newNoteSeeded = Notification.Name("newNoteSeeded")
|
||||
static let settingsChanged = Notification.Name("settingsChanged")
|
||||
}
|
||||
|
||||
func applyThemeAppearance() {
|
||||
let mode = ConfigManager.shared.themeMode
|
||||
switch mode {
|
||||
case "dark":
|
||||
NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||
case "light":
|
||||
NSApp.appearance = NSAppearance(named: .aqua)
|
||||
default:
|
||||
NSApp.appearance = nil
|
||||
}
|
||||
}
|
||||
|
||||
class WindowController {
|
||||
|
|
@ -75,6 +88,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
observeDocumentText()
|
||||
syncThemeToViewport()
|
||||
syncGutterPrefsToViewport()
|
||||
syncSettingsToViewport()
|
||||
startAutosaveTimer()
|
||||
|
||||
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
|
||||
|
|
@ -428,11 +442,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
|
||||
@objc private func saveNote() {
|
||||
syncTextFromViewport()
|
||||
if appState.currentFileURL != nil {
|
||||
appState.saveNote()
|
||||
} else {
|
||||
saveNoteAs()
|
||||
}
|
||||
appState.bindAutoSaveURL()
|
||||
appState.saveNote()
|
||||
}
|
||||
|
||||
@objc private func saveNoteAs() {
|
||||
|
|
@ -628,16 +639,73 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
}
|
||||
|
||||
@objc private func openSettings() {
|
||||
SettingsWindowController.show()
|
||||
syncSettingsToViewport()
|
||||
viewport?.sendCommand(16)
|
||||
}
|
||||
|
||||
@objc private func settingsDidChange() {
|
||||
window.backgroundColor = Theme.current.base
|
||||
syncThemeToViewport()
|
||||
syncGutterPrefsToViewport()
|
||||
syncSettingsToViewport()
|
||||
window.contentView?.needsDisplay = true
|
||||
}
|
||||
|
||||
private func syncSettingsToViewport() {
|
||||
viewport?.setSettingsView(
|
||||
themeMode: ConfigManager.shared.themeMode,
|
||||
lineIndicator: ConfigManager.shared.lineIndicatorMode,
|
||||
gutterRainbow: ConfigManager.shared.gutterRainbow,
|
||||
autoSaveDir: ConfigManager.shared.autoSaveDirectory
|
||||
)
|
||||
}
|
||||
|
||||
private func drainShellActions() {
|
||||
guard let vp = viewport else { return }
|
||||
while let raw = vp.takeShellAction() {
|
||||
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
let kind = parts[0]
|
||||
let value = parts.count > 1 ? parts[1] : ""
|
||||
switch kind {
|
||||
case "new_note": newNote()
|
||||
case "open": openNote()
|
||||
case "save": saveNote()
|
||||
case "save_as": saveNoteAs()
|
||||
case "quit": NSApp.terminate(nil)
|
||||
case "settings": break
|
||||
case "export_crate": exportCrate()
|
||||
case "toggle_browser": toggleBrowser()
|
||||
case "set_theme_mode":
|
||||
ConfigManager.shared.themeMode = value
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "set_line_indicator":
|
||||
ConfigManager.shared.lineIndicatorMode = value
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "set_gutter_rainbow":
|
||||
ConfigManager.shared.gutterRainbow = (value == "true")
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "pick_auto_save_dir":
|
||||
pickAutoSaveDirectory()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pickAutoSaveDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
ConfigManager.shared.autoSaveDirectory = url.path
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncThemeToViewport() {
|
||||
let mode = ConfigManager.shared.themeMode
|
||||
let name: String
|
||||
|
|
@ -663,19 +731,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
}
|
||||
|
||||
@objc private func zoomIn() {
|
||||
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
|
||||
browser.browserState.scaleUp()
|
||||
return
|
||||
}
|
||||
ConfigManager.shared.zoomLevel += 1
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
@objc private func zoomOut() {
|
||||
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
|
||||
browser.browserState.scaleDown()
|
||||
return
|
||||
}
|
||||
let current = ConfigManager.shared.zoomLevel
|
||||
if 11 + current > 8 {
|
||||
ConfigManager.shared.zoomLevel -= 1
|
||||
|
|
@ -763,6 +823,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
private func startAutosaveTimer() {
|
||||
autosaveTimer?.invalidate()
|
||||
autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
self?.drainShellActions()
|
||||
self?.persistViewportToNotesDir()
|
||||
}
|
||||
}
|
||||
|
|
@ -272,6 +272,13 @@ class AppState: ObservableObject {
|
|||
return url
|
||||
}
|
||||
|
||||
func bindAutoSaveURL() {
|
||||
if currentFileURL != nil { return }
|
||||
let url = resolveAutoSaveURL(noteID: currentNoteID, text: documentText)
|
||||
currentFileURL = url
|
||||
currentFileFormat = .markdown
|
||||
}
|
||||
|
||||
/// Background-safe atomic write. No path resolution here — the URL was
|
||||
/// resolved on the main thread before dispatch.
|
||||
private static func writeAutoSaveFile(at url: URL, text: String) {
|
||||
|
|
@ -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 }
|
||||
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).
|
||||
|
||||
mkdir -p "$MACOS" "$RESOURCES"
|
||||
cp "$ROOT/Info.plist" "$CONTENTS/Info.plist"
|
||||
cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist"
|
||||
[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns"
|
||||
|
||||
echo "Compiling Swift (Universal)..."
|
||||
SWIFT_FILES=("$ROOT"/src/*.swift)
|
||||
SWIFT_FILES=("$ROOT"/macos/src/*.swift)
|
||||
RUST_INCLUDES=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$ROOT/target/universal" -lacord_viewport)
|
||||
|
||||
swiftc -target arm64-apple-macosx14.0 -sdk "$SDK" "${RUST_INCLUDES[@]}" \
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ if [ -f "$SVG" ]; then
|
|||
fi
|
||||
|
||||
mkdir -p "$MACOS" "$RESOURCES"
|
||||
cp "$ROOT/Info.plist" "$CONTENTS/Info.plist"
|
||||
cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist"
|
||||
[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns"
|
||||
|
||||
echo "Compiling Swift (release)..."
|
||||
|
|
@ -75,7 +75,7 @@ swiftc \
|
|||
-framework CoreFoundation \
|
||||
-O \
|
||||
-o "$MACOS/Acord" \
|
||||
"$ROOT"/src/*.swift
|
||||
"$ROOT"/macos/src/*.swift
|
||||
|
||||
codesign --force --sign - "$APP"
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ fi
|
|||
RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport)
|
||||
|
||||
mkdir -p "$MACOS" "$RESOURCES"
|
||||
cp "$ROOT/Info.plist" "$CONTENTS/Info.plist"
|
||||
cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist"
|
||||
[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns"
|
||||
|
||||
echo "Compiling Swift (debug)..."
|
||||
|
|
@ -54,7 +54,7 @@ swiftc \
|
|||
-framework CoreFoundation \
|
||||
-Onone -g \
|
||||
-o "$MACOS/Acord" \
|
||||
"$ROOT"/src/*.swift
|
||||
"$ROOT"/macos/src/*.swift
|
||||
|
||||
codesign --force --sign - "$APP"
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ build_macos() {
|
|||
local app="$stage/Acord.app"
|
||||
rm -rf "$stage"
|
||||
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
|
||||
cp "$ROOT/Info.plist" "$app/Contents/Info.plist"
|
||||
cp "$ROOT/macos/Info.plist" "$app/Contents/Info.plist"
|
||||
[ -f "$ROOT/build/AppIcon.icns" ] && cp "$ROOT/build/AppIcon.icns" "$app/Contents/Resources/AppIcon.icns"
|
||||
|
||||
local sdk
|
||||
|
|
@ -164,7 +164,7 @@ build_macos() {
|
|||
-framework QuartzCore -framework CoreGraphics -framework CoreFoundation \
|
||||
-O \
|
||||
-o "$app/Contents/MacOS/Acord" \
|
||||
"$ROOT"/src/*.swift
|
||||
"$ROOT"/macos/src/*.swift
|
||||
|
||||
codesign --force --sign - "$app"
|
||||
zip_target "macos-${arch}" "$app"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
* Owns the browser window's wgpu surface, iced renderer, and BrowserState.
|
||||
*/
|
||||
typedef struct BrowserHandle BrowserHandle;
|
||||
|
||||
typedef struct TextPos TextPos;
|
||||
|
||||
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_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
|
||||
* 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);
|
||||
|
||||
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);
|
||||
|
||||
#endif /* ACORD_VIEWPORT_H */
|
||||
|
|
|
|||
|
|
@ -42,14 +42,21 @@ pub fn push_key_event(
|
|||
pressed: bool,
|
||||
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
|
||||
// state.mods stays current. Without this, holding Cmd silently and
|
||||
// then clicking would leave state.mods stale (the click event carries
|
||||
// no modifier info), and click handlers reading self.mods would see
|
||||
// the wrong state. Idempotent — handle.rs only stores the latest.
|
||||
handle.events.push(Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)));
|
||||
pub fn build_key_events(
|
||||
keycode: u32,
|
||||
modifier_flags: u32,
|
||||
pressed: bool,
|
||||
text: Option<&str>,
|
||||
) -> 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));
|
||||
|
||||
|
|
@ -70,7 +77,7 @@ pub fn push_key_event(
|
|||
};
|
||||
|
||||
if pressed {
|
||||
handle.events.push(Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
out.push(Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: logical.clone(),
|
||||
modified_key: logical,
|
||||
physical_key: physical,
|
||||
|
|
@ -80,7 +87,7 @@ pub fn push_key_event(
|
|||
repeat: false,
|
||||
}));
|
||||
} else {
|
||||
handle.events.push(Event::Keyboard(keyboard::Event::KeyReleased {
|
||||
out.push(Event::Keyboard(keyboard::Event::KeyReleased {
|
||||
key: logical.clone(),
|
||||
modified_key: logical,
|
||||
physical_key: physical,
|
||||
|
|
@ -88,6 +95,7 @@ pub fn push_key_event(
|
|||
modifiers,
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn keycode_to_named(keycode: u32) -> Option<keyboard::key::Named> {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ use iced_wgpu::core::{
|
|||
};
|
||||
use iced_wgpu::Engine;
|
||||
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 super::state::{BrowserMessage, BrowserState};
|
||||
|
|
@ -260,3 +268,43 @@ pub fn refresh(handle: &mut BrowserHandle) {
|
|||
handle.state.refresh();
|
||||
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)
|
||||
.into();
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
if self.settings_open {
|
||||
return iced_widget::stack![body, self.settings_panel()].into();
|
||||
}
|
||||
|
|
@ -4208,7 +4207,6 @@ impl EditorState {
|
|||
.into()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn settings_panel(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
||||
let p = palette::current();
|
||||
let f = self.font_size;
|
||||
|
|
@ -4241,9 +4239,9 @@ impl EditorState {
|
|||
"Line indicator",
|
||||
label_size,
|
||||
&[
|
||||
("Off", "off"),
|
||||
("Line", "line"),
|
||||
("On", "on"),
|
||||
("On", "on"),
|
||||
("Off", "off"),
|
||||
("Vim", "vim"),
|
||||
],
|
||||
&self.settings_view.line_indicator,
|
||||
|v| Message::Shell(ShellAction::SetLineIndicator(v.to_string())),
|
||||
|
|
@ -4337,7 +4335,6 @@ impl EditorState {
|
|||
.into()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn settings_segment_row<'a>(
|
||||
&'a self,
|
||||
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)),
|
||||
12 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::Editor)),
|
||||
13 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::View)),
|
||||
16 => h.state.settings_open = !h.state.settings_open,
|
||||
_ => return,
|
||||
};
|
||||
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
|
||||
/// a heap-allocated C string on success (the absolute path of the created
|
||||
/// 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)]
|
||||
pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 {
|
||||
let h = match unsafe { handle.as_mut() } {
|
||||
|
|
|
|||
Loading…
Reference in New Issue