Compare commits
11 Commits
12a6cc2a52
...
65239ea0f0
| Author | SHA1 | Date |
|---|---|---|
|
|
65239ea0f0 | |
|
|
ae20a6d5fd | |
|
|
752f5a1595 | |
|
|
063063895a | |
|
|
77bf5a113c | |
|
|
c326903e84 | |
|
|
eefca4f05e | |
|
|
1ccea45a6f | |
|
|
fef2935ae5 | |
|
|
db1a0aaefa | |
|
|
9209619473 |
5
build.sh
5
build.sh
|
|
@ -12,6 +12,7 @@ SDK=$(xcrun --show-sdk-path)
|
|||
|
||||
RUST_LIB="$ROOT/core/target/release"
|
||||
export MACOSX_DEPLOYMENT_TARGET=14.0
|
||||
export ZERO_AR_DATE=0
|
||||
echo "Building Rust core (release)..."
|
||||
cd "$ROOT/core" && cargo build --release
|
||||
if [ $? -ne 0 ]; then
|
||||
|
|
@ -25,7 +26,7 @@ if [ ! -f "$RUST_LIB/libswiftly_core.a" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
RUST_FLAGS="-import-objc-header $ROOT/core/include/swiftly.h -L $RUST_LIB -lswiftly_core"
|
||||
RUST_FLAGS=(-import-objc-header "$ROOT/core/include/swiftly.h" -L "$RUST_LIB" -lswiftly_core)
|
||||
|
||||
# --- App icon from pre-rendered PNGs ---
|
||||
ICONS="$ROOT/assets/icon_sources"
|
||||
|
|
@ -59,7 +60,7 @@ echo "Compiling Swift (release)..."
|
|||
swiftc \
|
||||
-target arm64-apple-macosx14.0 \
|
||||
-sdk "$SDK" \
|
||||
$RUST_FLAGS \
|
||||
"${RUST_FLAGS[@]}" \
|
||||
-framework Cocoa \
|
||||
-framework SwiftUI \
|
||||
-O \
|
||||
|
|
|
|||
|
|
@ -31,15 +31,13 @@ tree-sitter-css = "0.23"
|
|||
tree-sitter-json = "0.24"
|
||||
tree-sitter-lua = "0.4"
|
||||
tree-sitter-php = "0.23"
|
||||
tree-sitter-toml = "0.20"
|
||||
tree-sitter-toml-ng = "0.7"
|
||||
tree-sitter-yaml = "0.6"
|
||||
tree-sitter-swift = "0.6"
|
||||
tree-sitter-zig = "1"
|
||||
tree-sitter-sql = "0.0.2"
|
||||
tree-sitter-sequel = "0.3"
|
||||
tree-sitter-md = "0.5"
|
||||
tree-sitter-make = "1"
|
||||
tree-sitter-dockerfile = "0.2"
|
||||
tree-sitter-kotlin = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = "0.27"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
|
||||
use tree_sitter::Language;
|
||||
|
||||
/// Convert an old-API tree-sitter Language (v0.19/v0.20) to current v0.24.
|
||||
/// Both are newtype wrappers around `*const TSLanguage`; the C ABI is identical.
|
||||
unsafe fn lang_compat<T>(old: T) -> Language {
|
||||
std::mem::transmute_copy(&old)
|
||||
}
|
||||
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
|
||||
|
||||
const HIGHLIGHT_NAMES: &[&str] = &[
|
||||
"keyword",
|
||||
|
|
@ -147,13 +141,13 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
|
|||
locals: "",
|
||||
},
|
||||
"toml" => LangDef {
|
||||
language: unsafe { lang_compat(tree_sitter_toml::language()) },
|
||||
highlights: tree_sitter_toml::HIGHLIGHT_QUERY,
|
||||
language: tree_sitter_toml_ng::LANGUAGE.into(),
|
||||
highlights: tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
|
||||
injections: "",
|
||||
locals: "",
|
||||
},
|
||||
"yaml" => LangDef {
|
||||
language: unsafe { lang_compat(tree_sitter_yaml::language()) },
|
||||
language: tree_sitter_yaml::language(),
|
||||
highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY,
|
||||
injections: "",
|
||||
locals: "",
|
||||
|
|
@ -171,8 +165,8 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
|
|||
locals: "",
|
||||
},
|
||||
"sql" => LangDef {
|
||||
language: unsafe { lang_compat(tree_sitter_sql::language()) },
|
||||
highlights: include_str!("../queries/sql-highlights.scm"),
|
||||
language: tree_sitter_sequel::LANGUAGE.into(),
|
||||
highlights: tree_sitter_sequel::HIGHLIGHTS_QUERY,
|
||||
injections: "",
|
||||
locals: "",
|
||||
},
|
||||
|
|
@ -182,18 +176,6 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
|
|||
injections: "",
|
||||
locals: "",
|
||||
},
|
||||
"dockerfile" => LangDef {
|
||||
language: unsafe { lang_compat(tree_sitter_dockerfile::language()) },
|
||||
highlights: include_str!("../queries/dockerfile-highlights.scm"),
|
||||
injections: "",
|
||||
locals: "",
|
||||
},
|
||||
"kotlin" => LangDef {
|
||||
language: unsafe { lang_compat(tree_sitter_kotlin::language()) },
|
||||
highlights: include_str!("../queries/kotlin-highlights.scm"),
|
||||
injections: "",
|
||||
locals: "",
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some(ld)
|
||||
|
|
|
|||
5
debug.sh
5
debug.sh
|
|
@ -12,10 +12,11 @@ LOGFILE="$HOME/swiftly/debug.log"
|
|||
SDK=$(xcrun --show-sdk-path)
|
||||
|
||||
RUST_LIB="$ROOT/core/target/debug"
|
||||
export ZERO_AR_DATE=0
|
||||
echo "Building Rust core (debug)..."
|
||||
cd "$ROOT/core" && cargo build
|
||||
cd "$ROOT"
|
||||
RUST_FLAGS="-import-objc-header $ROOT/core/include/swiftly.h -L $RUST_LIB -lswiftly_core"
|
||||
RUST_FLAGS=(-import-objc-header "$ROOT/core/include/swiftly.h" -L "$RUST_LIB" -lswiftly_core)
|
||||
|
||||
# --- Bundle structure ---
|
||||
mkdir -p "$MACOS" "$RESOURCES"
|
||||
|
|
@ -26,7 +27,7 @@ echo "Compiling Swift (debug)..."
|
|||
swiftc \
|
||||
-target arm64-apple-macosx14.0 \
|
||||
-sdk "$SDK" \
|
||||
$RUST_FLAGS \
|
||||
"${RUST_FLAGS[@]}" \
|
||||
-framework Cocoa \
|
||||
-framework SwiftUI \
|
||||
-Onone -g \
|
||||
|
|
|
|||
|
|
@ -3,12 +3,22 @@ import Combine
|
|||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class WindowController {
|
||||
let window: NSWindow
|
||||
let appState: AppState
|
||||
init(window: NSWindow, appState: AppState) {
|
||||
self.window = window
|
||||
self.appState = appState
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var window: NSWindow!
|
||||
var appState: AppState!
|
||||
private var titleCancellable: AnyCancellable?
|
||||
private var titleBarView: TitleBarView?
|
||||
private var focusTitleObserver: NSObjectProtocol?
|
||||
private var windowControllers: [WindowController] = []
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
_ = ConfigManager.shared
|
||||
|
|
@ -37,6 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
setupMenuBar()
|
||||
observeDocumentTitle()
|
||||
|
||||
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(settingsDidChange),
|
||||
name: .settingsChanged, object: nil
|
||||
|
|
@ -99,9 +111,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "File")
|
||||
|
||||
let newItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "n")
|
||||
newItem.target = self
|
||||
menu.addItem(newItem)
|
||||
let newWindowItem = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "n")
|
||||
newWindowItem.target = self
|
||||
menu.addItem(newWindowItem)
|
||||
|
||||
let newNoteItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "N")
|
||||
newNoteItem.keyEquivalentModifierMask = [.command, .shift]
|
||||
newNoteItem.target = self
|
||||
menu.addItem(newNoteItem)
|
||||
|
||||
let openItem = NSMenuItem(title: "Open...", action: #selector(openNote), keyEquivalent: "o")
|
||||
openItem.target = self
|
||||
|
|
@ -117,6 +134,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
saveAsItem.target = self
|
||||
menu.addItem(saveAsItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let openStorageItem = NSMenuItem(title: "Open Storage Directory", action: #selector(openStorageDirectory), keyEquivalent: "")
|
||||
openStorageItem.target = self
|
||||
menu.addItem(openStorageItem)
|
||||
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
|
@ -133,6 +156,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||
menu.addItem(.separator())
|
||||
|
||||
let boldItem = NSMenuItem(title: "Bold", action: #selector(boldSelection), keyEquivalent: "b")
|
||||
boldItem.target = self
|
||||
menu.addItem(boldItem)
|
||||
|
||||
let italicItem = NSMenuItem(title: "Italic", action: #selector(italicizeSelection), keyEquivalent: "i")
|
||||
italicItem.target = self
|
||||
menu.addItem(italicItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let tableItem = NSMenuItem(title: "Insert Table", action: #selector(insertTable), keyEquivalent: "t")
|
||||
tableItem.target = self
|
||||
menu.addItem(tableItem)
|
||||
|
||||
let evalItem = NSMenuItem(title: "Smart Eval", action: #selector(smartEval), keyEquivalent: "e")
|
||||
evalItem.target = self
|
||||
menu.addItem(evalItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let findItem = NSMenuItem(title: "Find...", action: #selector(NSTextView.performFindPanelAction(_:)), keyEquivalent: "f")
|
||||
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
|
||||
menu.addItem(findItem)
|
||||
|
|
@ -151,7 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
private func buildViewMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "View")
|
||||
let toggleItem = NSMenuItem(title: "Toggle Sidebar", action: #selector(toggleSidebar), keyEquivalent: "b")
|
||||
let toggleItem = NSMenuItem(title: "Document Browser", action: #selector(toggleBrowser), keyEquivalent: "b")
|
||||
toggleItem.keyEquivalentModifierMask = .control
|
||||
toggleItem.target = self
|
||||
menu.addItem(toggleItem)
|
||||
|
|
@ -190,6 +233,51 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
appState.newNote()
|
||||
}
|
||||
|
||||
@objc private func newWindow() {
|
||||
let state = AppState()
|
||||
let contentView = ContentView(state: state)
|
||||
let hostingView = NSHostingView(rootView: contentView)
|
||||
|
||||
let win = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
win.titlebarAppearsTransparent = true
|
||||
win.titleVisibility = .hidden
|
||||
win.backgroundColor = Theme.current.base
|
||||
win.title = "Swiftly"
|
||||
win.contentView = hostingView
|
||||
win.center()
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
|
||||
let controller = WindowController(window: win, appState: state)
|
||||
windowControllers.append(controller)
|
||||
}
|
||||
|
||||
@objc private func openStorageDirectory() {
|
||||
let dir = ConfigManager.shared.autoSaveDirectory
|
||||
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
@objc private func boldSelection() {
|
||||
NotificationCenter.default.post(name: .boldSelection, object: nil)
|
||||
}
|
||||
|
||||
@objc private func italicizeSelection() {
|
||||
NotificationCenter.default.post(name: .italicizeSelection, object: nil)
|
||||
}
|
||||
|
||||
@objc private func insertTable() {
|
||||
NotificationCenter.default.post(name: .insertTable, object: nil)
|
||||
}
|
||||
|
||||
@objc private func smartEval() {
|
||||
NotificationCenter.default.post(name: .smartEval, object: nil)
|
||||
}
|
||||
|
||||
@objc private func openNote() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = Self.supportedContentTypes
|
||||
|
|
@ -316,16 +404,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
window.contentView?.needsDisplay = true
|
||||
}
|
||||
|
||||
@objc private func toggleSidebar() {
|
||||
NotificationCenter.default.post(name: .toggleSidebar, object: nil)
|
||||
@objc private func toggleBrowser() {
|
||||
DocumentBrowserController.shared?.toggle()
|
||||
}
|
||||
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -2,33 +2,26 @@ import SwiftUI
|
|||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var sidebarVisible: Bool = false
|
||||
@State private var themeVersion: Int = 0
|
||||
|
||||
var body: some View {
|
||||
let _ = themeVersion
|
||||
HSplitView {
|
||||
if sidebarVisible {
|
||||
SidebarView(state: state)
|
||||
.frame(minWidth: 180, idealWidth: 250, maxWidth: 350)
|
||||
EditorView(state: state)
|
||||
.frame(minWidth: 400)
|
||||
.frame(minWidth: 700, minHeight: 400)
|
||||
.background(Color(ns: Theme.current.base))
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||
themeVersion += 1
|
||||
}
|
||||
EditorView(state: state)
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.frame(minWidth: 700, minHeight: 400)
|
||||
.background(Color(ns: Theme.current.base))
|
||||
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in
|
||||
withAnimation { sidebarVisible.toggle() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||
themeVersion += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let toggleSidebar = Notification.Name("toggleSidebar")
|
||||
static let focusEditor = Notification.Name("focusEditor")
|
||||
static let focusTitle = Notification.Name("focusTitle")
|
||||
static let formatDocument = Notification.Name("formatDocument")
|
||||
static let insertTable = Notification.Name("insertTable")
|
||||
static let boldSelection = Notification.Name("boldSelection")
|
||||
static let italicizeSelection = Notification.Name("italicizeSelection")
|
||||
static let smartEval = Notification.Name("smartEval")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,477 @@
|
|||
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("SwiftlyBrowser")
|
||||
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 lines = text.components(separatedBy: "\n")
|
||||
return lines.prefix(20).joined(separator: "\n")
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,10 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||
case .horizontalRule:
|
||||
drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||
case .checkbox, .tableBlock:
|
||||
case .checkbox:
|
||||
break
|
||||
case .tableBlock:
|
||||
drawTableBackground(glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +61,8 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
drawCheckbox(checked: checked, glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||
case .horizontalRule:
|
||||
skipRanges.append(glyphRange)
|
||||
case .tableBlock:
|
||||
skipRanges.append(glyphRange)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
@ -152,76 +156,17 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
}
|
||||
}
|
||||
|
||||
private func drawTableBorders(glyphRange: NSRange, columns: Int, origin: NSPoint, container: NSTextContainer) {
|
||||
guard columns > 0 else { return }
|
||||
private func drawTableBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
||||
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
||||
rect.origin.x = origin.x + 4
|
||||
rect.origin.y += origin.y
|
||||
rect.size.width = container.containerSize.width - 8
|
||||
|
||||
let outerPath = NSBezierPath(rect: rect)
|
||||
outerPath.lineWidth = 1
|
||||
Theme.current.surface2.setStroke()
|
||||
outerPath.stroke()
|
||||
|
||||
guard let ts = textStorage else { return }
|
||||
let charRange = characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
|
||||
let text = ts.string as NSString
|
||||
let tableText = text.substring(with: charRange)
|
||||
let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
|
||||
var charOffset = charRange.location
|
||||
for (i, line) in lines.enumerated() {
|
||||
let lineLen = (line as NSString).length
|
||||
if i > 0 {
|
||||
let lineGlyphRange = self.glyphRange(forCharacterRange: NSRange(location: charOffset, length: 1), actualCharacterRange: nil)
|
||||
let lineRect = boundingRect(forGlyphRange: lineGlyphRange, in: container)
|
||||
let y = lineRect.origin.y + origin.y
|
||||
let rowLine = NSBezierPath()
|
||||
rowLine.move(to: NSPoint(x: rect.origin.x, y: y))
|
||||
rowLine.line(to: NSPoint(x: rect.origin.x + rect.size.width, y: y))
|
||||
rowLine.lineWidth = 0.5
|
||||
Theme.current.surface2.setStroke()
|
||||
rowLine.stroke()
|
||||
}
|
||||
charOffset += lineLen + 1
|
||||
}
|
||||
|
||||
if let firstLine = lines.first {
|
||||
let nsFirstLine = firstLine as NSString
|
||||
var pipeOffsets: [Int] = []
|
||||
for i in 0..<nsFirstLine.length {
|
||||
if nsFirstLine.character(at: i) == UInt16(UnicodeScalar("|").value) {
|
||||
pipeOffsets.append(i)
|
||||
}
|
||||
}
|
||||
if pipeOffsets.count > 2 {
|
||||
for pi in 1..<(pipeOffsets.count - 1) {
|
||||
let charPos = charRange.location + pipeOffsets[pi]
|
||||
let pipeGlyph = self.glyphRange(forCharacterRange: NSRange(location: charPos, length: 1), actualCharacterRange: nil)
|
||||
let pipeRect = boundingRect(forGlyphRange: pipeGlyph, in: container)
|
||||
let x = pipeRect.origin.x + origin.x + pipeRect.size.width / 2
|
||||
let colLine = NSBezierPath()
|
||||
colLine.move(to: NSPoint(x: x, y: rect.origin.y))
|
||||
colLine.line(to: NSPoint(x: x, y: rect.origin.y + rect.size.height))
|
||||
colLine.lineWidth = 0.5
|
||||
Theme.current.surface2.setStroke()
|
||||
colLine.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
if lines.count > 1 {
|
||||
let firstLineRange = NSRange(location: charRange.location, length: nsFirstLine.length)
|
||||
let headerGlyphRange = self.glyphRange(forCharacterRange: firstLineRange, actualCharacterRange: nil)
|
||||
var headerRect = boundingRect(forGlyphRange: headerGlyphRange, in: container)
|
||||
headerRect.origin.x = rect.origin.x
|
||||
headerRect.origin.y += origin.y
|
||||
headerRect.size.width = rect.size.width
|
||||
Theme.current.surface0.setFill()
|
||||
headerRect.fill()
|
||||
}
|
||||
}
|
||||
let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4)
|
||||
Theme.current.base.setFill()
|
||||
path.fill()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Interactive Table Component
|
||||
|
|
@ -1218,6 +1163,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
applySyntaxHighlighting(to: ts, format: fileFormat)
|
||||
ts.endEditing()
|
||||
}
|
||||
textView.applyEvalSpacing()
|
||||
textView.typingAttributes = [
|
||||
.font: Theme.editorFont,
|
||||
.foregroundColor: Theme.current.text
|
||||
|
|
@ -1262,26 +1208,25 @@ struct EditorTextView: NSViewRepresentable {
|
|||
private var isUpdatingImages = false
|
||||
private var isUpdatingTables = false
|
||||
private var embeddedTableViews: [MarkdownTableView] = []
|
||||
private var focusObserver: NSObjectProtocol?
|
||||
private var settingsObserver: NSObjectProtocol?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init(_ parent: EditorTextView) {
|
||||
self.parent = parent
|
||||
super.init()
|
||||
focusObserver = NotificationCenter.default.addObserver(
|
||||
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .focusEditor, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let tv = self?.textView else { return }
|
||||
tv.window?.makeFirstResponder(tv)
|
||||
tv.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .formatDocument, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.formatCurrentDocument()
|
||||
}
|
||||
|
||||
settingsObserver = NotificationCenter.default.addObserver(
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .settingsChanged, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let tv = self?.textView, let ts = tv.textStorage else { return }
|
||||
|
|
@ -1296,15 +1241,33 @@ struct EditorTextView: NSViewRepresentable {
|
|||
ts.beginEditing()
|
||||
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||
ts.endEditing()
|
||||
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||
tv.needsDisplay = true
|
||||
}
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .boldSelection, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.wrapSelection(with: "**")
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .italicizeSelection, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.wrapSelection(with: "*")
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .insertTable, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.insertBlankTable()
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .smartEval, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.performSmartEval()
|
||||
})
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let obs = focusObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
}
|
||||
if let obs = settingsObserver {
|
||||
for obs in observers {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
}
|
||||
}
|
||||
|
|
@ -1317,6 +1280,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
ts.beginEditing()
|
||||
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||
ts.endEditing()
|
||||
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||
tv.typingAttributes = [
|
||||
.font: Theme.editorFont,
|
||||
.foregroundColor: Theme.current.text
|
||||
|
|
@ -1420,6 +1384,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
applySyntaxHighlighting(to: ts, format: format)
|
||||
ts.endEditing()
|
||||
}
|
||||
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||
tv.selectedRanges = sel
|
||||
tv.needsDisplay = true
|
||||
}
|
||||
|
|
@ -1497,6 +1462,47 @@ struct EditorTextView: NSViewRepresentable {
|
|||
textView.insertText("\n" + indent, replacementRange: textView.selectedRange())
|
||||
}
|
||||
|
||||
private func wrapSelection(with wrapper: String) {
|
||||
guard let tv = textView else { return }
|
||||
let sel = tv.selectedRange()
|
||||
guard sel.length > 0 else { return }
|
||||
let str = tv.string as NSString
|
||||
let selected = str.substring(with: sel)
|
||||
let wrapped = wrapper + selected + wrapper
|
||||
tv.insertText(wrapped, replacementRange: sel)
|
||||
tv.setSelectedRange(NSRange(location: sel.location + wrapper.count, length: selected.count))
|
||||
}
|
||||
|
||||
private func insertBlankTable() {
|
||||
guard let tv = textView else { return }
|
||||
let table = "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n"
|
||||
tv.insertText(table, replacementRange: tv.selectedRange())
|
||||
}
|
||||
|
||||
private func performSmartEval() {
|
||||
guard let tv = textView else { return }
|
||||
let str = tv.string as NSString
|
||||
let cursor = tv.selectedRange().location
|
||||
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
||||
let line = str.substring(with: lineRange).trimmingCharacters(in: .newlines)
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.hasPrefix("let ") {
|
||||
if let eqIdx = trimmed.firstIndex(of: "="), eqIdx > trimmed.startIndex {
|
||||
let afterLet = trimmed[trimmed.index(trimmed.startIndex, offsetBy: 4)..<eqIdx]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let insertion = "\n/= \(afterLet)\n"
|
||||
let endOfLine = NSMaxRange(lineRange)
|
||||
tv.insertText(insertion, replacementRange: NSRange(location: endOfLine, length: 0))
|
||||
}
|
||||
} else if !trimmed.isEmpty {
|
||||
let lineStart = lineRange.location
|
||||
let whitespacePrefix = line.prefix(while: { $0 == " " || $0 == "\t" })
|
||||
let insertLoc = lineStart + whitespacePrefix.count
|
||||
tv.insertText("/= ", replacementRange: NSRange(location: insertLoc, length: 0))
|
||||
}
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
||||
var urlString: String?
|
||||
if let url = link as? URL {
|
||||
|
|
@ -1528,19 +1534,21 @@ struct EditorTextView: NSViewRepresentable {
|
|||
}
|
||||
embeddedTableViews.removeAll()
|
||||
|
||||
let origin = tv.textContainerOrigin
|
||||
let text = tv.string as NSString
|
||||
for block in lm.blockRanges {
|
||||
guard case .tableBlock = block.kind else { continue }
|
||||
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
||||
|
||||
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
||||
var rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
||||
rect.origin.x += tv.textContainerInset.width
|
||||
rect.origin.y += tv.textContainerInset.height
|
||||
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
||||
|
||||
let tableX = origin.x + 4
|
||||
let tableY = rect.origin.y + origin.y
|
||||
let tableWidth = tc.containerSize.width - 8
|
||||
|
||||
let tableView = MarkdownTableView(table: parsed, width: tableWidth)
|
||||
tableView.frame.origin = NSPoint(x: rect.origin.x + 4, y: rect.origin.y)
|
||||
tableView.frame.origin = NSPoint(x: tableX, y: tableY)
|
||||
tableView.textView = tv
|
||||
|
||||
tableView.onTableChanged = { [weak self] updatedTable in
|
||||
|
|
@ -1564,6 +1572,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
ts.replaceCharacters(in: range, with: newMarkdown)
|
||||
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||
ts.endEditing()
|
||||
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||
tv.selectedRanges = sel
|
||||
parent.text = tv.string
|
||||
updateBlockRanges(for: tv)
|
||||
|
|
@ -1946,7 +1955,7 @@ private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: N
|
|||
let contentStart = hashRange.location + hashRange.length
|
||||
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
||||
if contentRange.length > 0 {
|
||||
let h3Font = NSFont.systemFont(ofSize: 15, weight: .bold)
|
||||
let h3Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.15), weight: .bold)
|
||||
textStorage.addAttribute(.font, value: h3Font, range: contentRange)
|
||||
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
|
||||
}
|
||||
|
|
@ -1961,7 +1970,7 @@ private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: N
|
|||
let contentStart = hashRange.location + hashRange.length
|
||||
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
||||
if contentRange.length > 0 {
|
||||
let h2Font = NSFont.systemFont(ofSize: 18, weight: .bold)
|
||||
let h2Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.38), weight: .bold)
|
||||
textStorage.addAttribute(.font, value: h2Font, range: contentRange)
|
||||
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
|
||||
}
|
||||
|
|
@ -1976,7 +1985,7 @@ private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: N
|
|||
let contentStart = hashRange.location + hashRange.length
|
||||
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
||||
if contentRange.length > 0 {
|
||||
let h1Font = NSFont.systemFont(ofSize: 22, weight: .bold)
|
||||
let h1Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.69), weight: .bold)
|
||||
textStorage.addAttribute(.font, value: h1Font, range: contentRange)
|
||||
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
|
||||
}
|
||||
|
|
@ -2377,8 +2386,8 @@ private func highlightFootnoteDefinition(textStorage: NSTextStorage, lineRange:
|
|||
// MARK: - Tables
|
||||
|
||||
private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont, isHeader: Bool, isSeparator: Bool) {
|
||||
textStorage.addAttribute(.foregroundColor, value: palette.base, range: lineRange)
|
||||
textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: lineRange)
|
||||
textStorage.addAttribute(.foregroundColor, value: NSColor.clear, range: lineRange)
|
||||
textStorage.addAttribute(.font, value: baseFont, range: lineRange)
|
||||
}
|
||||
|
||||
// MARK: - Lists and Horizontal Rules
|
||||
|
|
@ -2696,7 +2705,11 @@ private func resolveLocalImagePath(_ rawPath: String) -> String? {
|
|||
|
||||
class LineNumberTextView: NSTextView {
|
||||
static let gutterWidth: CGFloat = 50
|
||||
var evalResults: [Int: EvalEntry] = [:]
|
||||
static let evalLeftMargin: CGFloat = 80
|
||||
|
||||
var evalResults: [Int: EvalEntry] = [:] {
|
||||
didSet { applyEvalSpacing() }
|
||||
}
|
||||
|
||||
override var textContainerOrigin: NSPoint {
|
||||
return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height)
|
||||
|
|
@ -2800,9 +2813,9 @@ class LineNumberTextView: NSTextView {
|
|||
if let entry = evalResults[lineNumber - 1] {
|
||||
switch entry.format {
|
||||
case .table:
|
||||
drawTableResult(entry.result, at: y, origin: origin, resultAttrs: resultAttrs)
|
||||
drawTableResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs)
|
||||
case .tree:
|
||||
drawTreeResult(entry.result, at: y, origin: origin, resultAttrs: resultAttrs)
|
||||
drawTreeResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs)
|
||||
case .inline:
|
||||
let resultStr = NSAttributedString(string: "\u{2192} \(entry.result)", attributes: resultAttrs)
|
||||
let size = resultStr.size()
|
||||
|
|
@ -2818,13 +2831,13 @@ class LineNumberTextView: NSTextView {
|
|||
|
||||
// MARK: - Table/Tree Rendering
|
||||
|
||||
private func drawTableResult(_ json: String, at y: CGFloat, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) {
|
||||
private func drawTableResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) {
|
||||
guard let data = json.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data),
|
||||
let rows = parsed as? [[Any]] else {
|
||||
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
||||
let size = fallback.size()
|
||||
fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: y))
|
||||
fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: lineRect.origin.y + origin.y))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2865,9 +2878,8 @@ class LineNumberTextView: NSTextView {
|
|||
let tableWidth = colWidths.reduce(0, +) + cellPad * CGFloat(colCount + 1) + CGFloat(colCount - 1)
|
||||
let tableHeight = rowHeight * CGFloat(stringRows.count) + CGFloat(stringRows.count + 1)
|
||||
|
||||
let rightEdge = visibleRect.maxX
|
||||
let tableX = rightEdge - tableWidth - 12
|
||||
let tableY = y
|
||||
let tableX = LineNumberTextView.evalLeftMargin
|
||||
let tableY = lineRect.origin.y + origin.y + lineRect.height + 4
|
||||
|
||||
let tableRect = NSRect(x: tableX, y: tableY, width: tableWidth, height: tableHeight)
|
||||
palette.mantle.setFill()
|
||||
|
|
@ -2899,12 +2911,12 @@ class LineNumberTextView: NSTextView {
|
|||
}
|
||||
}
|
||||
|
||||
private func drawTreeResult(_ json: String, at y: CGFloat, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) {
|
||||
private func drawTreeResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) {
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) else {
|
||||
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
||||
let size = fallback.size()
|
||||
fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: y))
|
||||
fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: lineRect.origin.y + origin.y))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2953,9 +2965,8 @@ class LineNumberTextView: NSTextView {
|
|||
|
||||
let treeHeight = lineHeight * CGFloat(lines.count) + 4
|
||||
let treeWidth = maxWidth + 16
|
||||
let rightEdge = visibleRect.maxX
|
||||
let treeX = rightEdge - treeWidth - 8
|
||||
let treeY = y
|
||||
let treeX = LineNumberTextView.evalLeftMargin
|
||||
let treeY = lineRect.origin.y + origin.y + lineRect.height + 4
|
||||
|
||||
let treeRect = NSRect(x: treeX, y: treeY, width: treeWidth, height: treeHeight)
|
||||
palette.mantle.setFill()
|
||||
|
|
@ -2975,6 +2986,79 @@ class LineNumberTextView: NSTextView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Eval Spacing
|
||||
|
||||
func applyEvalSpacing() {
|
||||
guard let ts = textStorage else { return }
|
||||
let text = ts.string as NSString
|
||||
guard text.length > 0 else { return }
|
||||
|
||||
ts.beginEditing()
|
||||
|
||||
var lineStart = 0
|
||||
var lineNum = 0
|
||||
while lineStart < text.length {
|
||||
let lineRange = text.lineRange(for: NSRange(location: lineStart, length: 0))
|
||||
if let entry = evalResults[lineNum] {
|
||||
let spacing: CGFloat
|
||||
switch entry.format {
|
||||
case .tree:
|
||||
spacing = evalTreeHeight(entry.result) + 8
|
||||
case .table:
|
||||
spacing = evalTableHeight(entry.result) + 8
|
||||
case .inline:
|
||||
spacing = 0
|
||||
}
|
||||
if spacing > 0 {
|
||||
let para = NSMutableParagraphStyle()
|
||||
if let existing = ts.attribute(.paragraphStyle, at: lineRange.location, effectiveRange: nil) as? NSParagraphStyle {
|
||||
para.setParagraphStyle(existing)
|
||||
}
|
||||
para.paragraphSpacing = spacing
|
||||
ts.addAttribute(.paragraphStyle, value: para, range: lineRange)
|
||||
}
|
||||
}
|
||||
lineNum += 1
|
||||
lineStart = NSMaxRange(lineRange)
|
||||
}
|
||||
|
||||
ts.endEditing()
|
||||
}
|
||||
|
||||
private func evalTreeHeight(_ json: String) -> CGFloat {
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) else { return 0 }
|
||||
let font = Theme.gutterFont
|
||||
let lineHeight = font.pointSize + 4
|
||||
var count = 0
|
||||
func walk(_ node: Any) {
|
||||
if let arr = node as? [Any] {
|
||||
for item in arr {
|
||||
count += 1
|
||||
if item is [Any] { walk(item) }
|
||||
}
|
||||
} else {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
if root is [Any] {
|
||||
count = 1
|
||||
walk(root)
|
||||
} else {
|
||||
count = 1
|
||||
}
|
||||
return lineHeight * CGFloat(count) + 4
|
||||
}
|
||||
|
||||
private func evalTableHeight(_ json: String) -> CGFloat {
|
||||
guard let data = json.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data),
|
||||
let rows = parsed as? [[Any]] else { return 0 }
|
||||
let font = Theme.gutterFont
|
||||
let rowHeight = font.pointSize + 6
|
||||
return rowHeight * CGFloat(rows.count) + CGFloat(rows.count + 1)
|
||||
}
|
||||
|
||||
// MARK: - Paste
|
||||
|
||||
override func paste(_ sender: Any?) {
|
||||
|
|
|
|||
|
|
@ -1,183 +1 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var lastClickedID: UUID?
|
||||
@State private var previewNote: NoteInfo?
|
||||
@FocusState private var sidebarFocused: Bool
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .short
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Notes")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
Spacer()
|
||||
Button(action: { state.newNote() }) {
|
||||
Image(systemName: "plus")
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("New Note")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
if state.noteList.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("No notes yet")
|
||||
.foregroundColor(Color(ns: Theme.current.overlay1))
|
||||
.font(Font(Theme.sidebarFont as CTFont))
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(state.noteList.enumerated()), id: \.element.id) { index, note in
|
||||
NoteRow(
|
||||
note: note,
|
||||
isSelected: state.selectedNoteIDs.contains(note.id),
|
||||
isActive: note.id == state.currentNoteID,
|
||||
dateFormatter: dateFormatter
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 1) {
|
||||
handleClick(note: note, index: index)
|
||||
}
|
||||
.contextMenu {
|
||||
if state.selectedNoteIDs.count > 1 && state.selectedNoteIDs.contains(note.id) {
|
||||
Button("Delete \(state.selectedNoteIDs.count) Notes") {
|
||||
state.deleteNotes(state.selectedNoteIDs)
|
||||
lastClickedID = nil
|
||||
}
|
||||
} else {
|
||||
Button("Delete") { state.deleteNote(note.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.focused($sidebarFocused)
|
||||
.onDeleteCommand {
|
||||
guard !state.selectedNoteIDs.isEmpty else { return }
|
||||
state.deleteNotes(state.selectedNoteIDs)
|
||||
lastClickedID = nil
|
||||
}
|
||||
.onKeyPress(.space) {
|
||||
guard let id = state.selectedNoteIDs.first,
|
||||
state.selectedNoteIDs.count == 1,
|
||||
let note = state.noteList.first(where: { $0.id == id }) else {
|
||||
return .ignored
|
||||
}
|
||||
if previewNote?.id == note.id {
|
||||
previewNote = nil
|
||||
} else {
|
||||
previewNote = note
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.popover(item: $previewNote, arrowEdge: .trailing) { note in
|
||||
NotePreviewView(note: note, state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(ns: Theme.current.base))
|
||||
.onAppear { state.refreshNoteList() }
|
||||
}
|
||||
|
||||
private func handleClick(note: NoteInfo, index: Int) {
|
||||
sidebarFocused = true
|
||||
let flags = NSEvent.modifierFlags
|
||||
if flags.contains(.command) {
|
||||
state.selectNote(note.id, extend: true)
|
||||
lastClickedID = note.id
|
||||
} else if flags.contains(.shift), let lastID = lastClickedID {
|
||||
if let lastIndex = state.noteList.firstIndex(where: { $0.id == lastID }) {
|
||||
let range = min(lastIndex, index)...max(lastIndex, index)
|
||||
var ids = Set<UUID>()
|
||||
for i in range {
|
||||
ids.insert(state.noteList[i].id)
|
||||
}
|
||||
state.selectedNoteIDs = ids
|
||||
}
|
||||
} else {
|
||||
state.selectNote(note.id)
|
||||
state.openNote(note.id)
|
||||
lastClickedID = note.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotePreviewView: View {
|
||||
let note: NoteInfo
|
||||
@ObservedObject var state: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(note.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(previewText)
|
||||
.font(.body)
|
||||
.foregroundColor(Color(ns: Theme.current.subtext0))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 320, height: 240)
|
||||
.background(Color(ns: Theme.current.base))
|
||||
}
|
||||
|
||||
private var previewText: String {
|
||||
let bridge = RustBridge.shared
|
||||
if bridge.cacheLoad(note.id) {
|
||||
let text = bridge.getText(note.id)
|
||||
return String(text.prefix(2000))
|
||||
}
|
||||
return "(unable to load preview)"
|
||||
}
|
||||
}
|
||||
|
||||
struct NoteRow: View {
|
||||
let note: NoteInfo
|
||||
let isSelected: Bool
|
||||
var isActive: Bool = false
|
||||
let dateFormatter: DateFormatter
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(note.title)
|
||||
.font(Font(isActive
|
||||
? NSFontManager.shared.convert(Theme.sidebarFont, toHaveTrait: .boldFontMask) as CTFont
|
||||
: Theme.sidebarFont as CTFont))
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
.lineLimit(1)
|
||||
Text(dateFormatter.string(from: note.lastModified))
|
||||
.font(Font(Theme.sidebarDateFont as CTFont))
|
||||
.foregroundColor(Color(ns: Theme.current.subtext0))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
isActive
|
||||
? Color(ns: Theme.current.surface1)
|
||||
: isSelected
|
||||
? Color(ns: Theme.current.surface0)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue