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"
|
RUST_LIB="$ROOT/core/target/release"
|
||||||
export MACOSX_DEPLOYMENT_TARGET=14.0
|
export MACOSX_DEPLOYMENT_TARGET=14.0
|
||||||
|
export ZERO_AR_DATE=0
|
||||||
echo "Building Rust core (release)..."
|
echo "Building Rust core (release)..."
|
||||||
cd "$ROOT/core" && cargo build --release
|
cd "$ROOT/core" && cargo build --release
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
|
|
@ -25,7 +26,7 @@ if [ ! -f "$RUST_LIB/libswiftly_core.a" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ---
|
# --- App icon from pre-rendered PNGs ---
|
||||||
ICONS="$ROOT/assets/icon_sources"
|
ICONS="$ROOT/assets/icon_sources"
|
||||||
|
|
@ -59,7 +60,7 @@ echo "Compiling Swift (release)..."
|
||||||
swiftc \
|
swiftc \
|
||||||
-target arm64-apple-macosx14.0 \
|
-target arm64-apple-macosx14.0 \
|
||||||
-sdk "$SDK" \
|
-sdk "$SDK" \
|
||||||
$RUST_FLAGS \
|
"${RUST_FLAGS[@]}" \
|
||||||
-framework Cocoa \
|
-framework Cocoa \
|
||||||
-framework SwiftUI \
|
-framework SwiftUI \
|
||||||
-O \
|
-O \
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,13 @@ tree-sitter-css = "0.23"
|
||||||
tree-sitter-json = "0.24"
|
tree-sitter-json = "0.24"
|
||||||
tree-sitter-lua = "0.4"
|
tree-sitter-lua = "0.4"
|
||||||
tree-sitter-php = "0.23"
|
tree-sitter-php = "0.23"
|
||||||
tree-sitter-toml = "0.20"
|
tree-sitter-toml-ng = "0.7"
|
||||||
tree-sitter-yaml = "0.6"
|
tree-sitter-yaml = "0.6"
|
||||||
tree-sitter-swift = "0.6"
|
tree-sitter-swift = "0.6"
|
||||||
tree-sitter-zig = "1"
|
tree-sitter-zig = "1"
|
||||||
tree-sitter-sql = "0.0.2"
|
tree-sitter-sequel = "0.3"
|
||||||
tree-sitter-md = "0.5"
|
tree-sitter-md = "0.5"
|
||||||
tree-sitter-make = "1"
|
tree-sitter-make = "1"
|
||||||
tree-sitter-dockerfile = "0.2"
|
|
||||||
tree-sitter-kotlin = "0.3"
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
cbindgen = "0.27"
|
cbindgen = "0.27"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
|
|
||||||
use tree_sitter::Language;
|
use tree_sitter::Language;
|
||||||
|
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
const HIGHLIGHT_NAMES: &[&str] = &[
|
const HIGHLIGHT_NAMES: &[&str] = &[
|
||||||
"keyword",
|
"keyword",
|
||||||
|
|
@ -147,13 +141,13 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
|
||||||
locals: "",
|
locals: "",
|
||||||
},
|
},
|
||||||
"toml" => LangDef {
|
"toml" => LangDef {
|
||||||
language: unsafe { lang_compat(tree_sitter_toml::language()) },
|
language: tree_sitter_toml_ng::LANGUAGE.into(),
|
||||||
highlights: tree_sitter_toml::HIGHLIGHT_QUERY,
|
highlights: tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
|
||||||
injections: "",
|
injections: "",
|
||||||
locals: "",
|
locals: "",
|
||||||
},
|
},
|
||||||
"yaml" => LangDef {
|
"yaml" => LangDef {
|
||||||
language: unsafe { lang_compat(tree_sitter_yaml::language()) },
|
language: tree_sitter_yaml::language(),
|
||||||
highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY,
|
highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY,
|
||||||
injections: "",
|
injections: "",
|
||||||
locals: "",
|
locals: "",
|
||||||
|
|
@ -171,8 +165,8 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
|
||||||
locals: "",
|
locals: "",
|
||||||
},
|
},
|
||||||
"sql" => LangDef {
|
"sql" => LangDef {
|
||||||
language: unsafe { lang_compat(tree_sitter_sql::language()) },
|
language: tree_sitter_sequel::LANGUAGE.into(),
|
||||||
highlights: include_str!("../queries/sql-highlights.scm"),
|
highlights: tree_sitter_sequel::HIGHLIGHTS_QUERY,
|
||||||
injections: "",
|
injections: "",
|
||||||
locals: "",
|
locals: "",
|
||||||
},
|
},
|
||||||
|
|
@ -182,18 +176,6 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
|
||||||
injections: "",
|
injections: "",
|
||||||
locals: "",
|
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,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(ld)
|
Some(ld)
|
||||||
|
|
|
||||||
5
debug.sh
5
debug.sh
|
|
@ -12,10 +12,11 @@ LOGFILE="$HOME/swiftly/debug.log"
|
||||||
SDK=$(xcrun --show-sdk-path)
|
SDK=$(xcrun --show-sdk-path)
|
||||||
|
|
||||||
RUST_LIB="$ROOT/core/target/debug"
|
RUST_LIB="$ROOT/core/target/debug"
|
||||||
|
export ZERO_AR_DATE=0
|
||||||
echo "Building Rust core (debug)..."
|
echo "Building Rust core (debug)..."
|
||||||
cd "$ROOT/core" && cargo build
|
cd "$ROOT/core" && cargo build
|
||||||
cd "$ROOT"
|
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 ---
|
# --- Bundle structure ---
|
||||||
mkdir -p "$MACOS" "$RESOURCES"
|
mkdir -p "$MACOS" "$RESOURCES"
|
||||||
|
|
@ -26,7 +27,7 @@ echo "Compiling Swift (debug)..."
|
||||||
swiftc \
|
swiftc \
|
||||||
-target arm64-apple-macosx14.0 \
|
-target arm64-apple-macosx14.0 \
|
||||||
-sdk "$SDK" \
|
-sdk "$SDK" \
|
||||||
$RUST_FLAGS \
|
"${RUST_FLAGS[@]}" \
|
||||||
-framework Cocoa \
|
-framework Cocoa \
|
||||||
-framework SwiftUI \
|
-framework SwiftUI \
|
||||||
-Onone -g \
|
-Onone -g \
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,22 @@ import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
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 {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var window: NSWindow!
|
var window: NSWindow!
|
||||||
var appState: AppState!
|
var appState: AppState!
|
||||||
private var titleCancellable: AnyCancellable?
|
private var titleCancellable: AnyCancellable?
|
||||||
private var titleBarView: TitleBarView?
|
private var titleBarView: TitleBarView?
|
||||||
private var focusTitleObserver: NSObjectProtocol?
|
private var focusTitleObserver: NSObjectProtocol?
|
||||||
|
private var windowControllers: [WindowController] = []
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
_ = ConfigManager.shared
|
_ = ConfigManager.shared
|
||||||
|
|
@ -37,6 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
setupMenuBar()
|
setupMenuBar()
|
||||||
observeDocumentTitle()
|
observeDocumentTitle()
|
||||||
|
|
||||||
|
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(settingsDidChange),
|
self, selector: #selector(settingsDidChange),
|
||||||
name: .settingsChanged, object: nil
|
name: .settingsChanged, object: nil
|
||||||
|
|
@ -99,9 +111,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let item = NSMenuItem()
|
let item = NSMenuItem()
|
||||||
let menu = NSMenu(title: "File")
|
let menu = NSMenu(title: "File")
|
||||||
|
|
||||||
let newItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "n")
|
let newWindowItem = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "n")
|
||||||
newItem.target = self
|
newWindowItem.target = self
|
||||||
menu.addItem(newItem)
|
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")
|
let openItem = NSMenuItem(title: "Open...", action: #selector(openNote), keyEquivalent: "o")
|
||||||
openItem.target = self
|
openItem.target = self
|
||||||
|
|
@ -117,6 +134,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
saveAsItem.target = self
|
saveAsItem.target = self
|
||||||
menu.addItem(saveAsItem)
|
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
|
item.submenu = menu
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +156,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||||
menu.addItem(.separator())
|
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")
|
let findItem = NSMenuItem(title: "Find...", action: #selector(NSTextView.performFindPanelAction(_:)), keyEquivalent: "f")
|
||||||
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
|
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
|
||||||
menu.addItem(findItem)
|
menu.addItem(findItem)
|
||||||
|
|
@ -151,7 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private func buildViewMenu() -> NSMenuItem {
|
private func buildViewMenu() -> NSMenuItem {
|
||||||
let item = NSMenuItem()
|
let item = NSMenuItem()
|
||||||
let menu = NSMenu(title: "View")
|
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.keyEquivalentModifierMask = .control
|
||||||
toggleItem.target = self
|
toggleItem.target = self
|
||||||
menu.addItem(toggleItem)
|
menu.addItem(toggleItem)
|
||||||
|
|
@ -190,6 +233,51 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
appState.newNote()
|
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() {
|
@objc private func openNote() {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.allowedContentTypes = Self.supportedContentTypes
|
panel.allowedContentTypes = Self.supportedContentTypes
|
||||||
|
|
@ -316,16 +404,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
window.contentView?.needsDisplay = true
|
window.contentView?.needsDisplay = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func toggleSidebar() {
|
@objc private func toggleBrowser() {
|
||||||
NotificationCenter.default.post(name: .toggleSidebar, object: nil)
|
DocumentBrowserController.shared?.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func zoomIn() {
|
@objc private func zoomIn() {
|
||||||
|
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
|
||||||
|
browser.browserState.scaleUp()
|
||||||
|
return
|
||||||
|
}
|
||||||
ConfigManager.shared.zoomLevel += 1
|
ConfigManager.shared.zoomLevel += 1
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func zoomOut() {
|
@objc private func zoomOut() {
|
||||||
|
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
|
||||||
|
browser.browserState.scaleDown()
|
||||||
|
return
|
||||||
|
}
|
||||||
let current = ConfigManager.shared.zoomLevel
|
let current = ConfigManager.shared.zoomLevel
|
||||||
if 11 + current > 8 {
|
if 11 + current > 8 {
|
||||||
ConfigManager.shared.zoomLevel -= 1
|
ConfigManager.shared.zoomLevel -= 1
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,14 @@ import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
@State private var sidebarVisible: Bool = false
|
|
||||||
@State private var themeVersion: Int = 0
|
@State private var themeVersion: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _ = themeVersion
|
let _ = themeVersion
|
||||||
HSplitView {
|
|
||||||
if sidebarVisible {
|
|
||||||
SidebarView(state: state)
|
|
||||||
.frame(minWidth: 180, idealWidth: 250, maxWidth: 350)
|
|
||||||
}
|
|
||||||
EditorView(state: state)
|
EditorView(state: state)
|
||||||
.frame(minWidth: 400)
|
.frame(minWidth: 400)
|
||||||
}
|
|
||||||
.frame(minWidth: 700, minHeight: 400)
|
.frame(minWidth: 700, minHeight: 400)
|
||||||
.background(Color(ns: Theme.current.base))
|
.background(Color(ns: Theme.current.base))
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in
|
|
||||||
withAnimation { sidebarVisible.toggle() }
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||||
themeVersion += 1
|
themeVersion += 1
|
||||||
}
|
}
|
||||||
|
|
@ -27,8 +17,11 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let toggleSidebar = Notification.Name("toggleSidebar")
|
|
||||||
static let focusEditor = Notification.Name("focusEditor")
|
static let focusEditor = Notification.Name("focusEditor")
|
||||||
static let focusTitle = Notification.Name("focusTitle")
|
static let focusTitle = Notification.Name("focusTitle")
|
||||||
static let formatDocument = Notification.Name("formatDocument")
|
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)
|
drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||||
case .horizontalRule:
|
case .horizontalRule:
|
||||||
drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer)
|
drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||||
case .checkbox, .tableBlock:
|
case .checkbox:
|
||||||
break
|
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)
|
drawCheckbox(checked: checked, glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||||
case .horizontalRule:
|
case .horizontalRule:
|
||||||
skipRanges.append(glyphRange)
|
skipRanges.append(glyphRange)
|
||||||
|
case .tableBlock:
|
||||||
|
skipRanges.append(glyphRange)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -152,76 +156,17 @@ class MarkdownLayoutManager: NSLayoutManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drawTableBorders(glyphRange: NSRange, columns: Int, origin: NSPoint, container: NSTextContainer) {
|
private func drawTableBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
||||||
guard columns > 0 else { return }
|
|
||||||
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
||||||
rect.origin.x = origin.x + 4
|
rect.origin.x = origin.x + 4
|
||||||
rect.origin.y += origin.y
|
rect.origin.y += origin.y
|
||||||
rect.size.width = container.containerSize.width - 8
|
rect.size.width = container.containerSize.width - 8
|
||||||
|
|
||||||
let outerPath = NSBezierPath(rect: rect)
|
let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4)
|
||||||
outerPath.lineWidth = 1
|
Theme.current.base.setFill()
|
||||||
Theme.current.surface2.setStroke()
|
path.fill()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interactive Table Component
|
// MARK: - Interactive Table Component
|
||||||
|
|
@ -1218,6 +1163,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
applySyntaxHighlighting(to: ts, format: fileFormat)
|
applySyntaxHighlighting(to: ts, format: fileFormat)
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
}
|
}
|
||||||
|
textView.applyEvalSpacing()
|
||||||
textView.typingAttributes = [
|
textView.typingAttributes = [
|
||||||
.font: Theme.editorFont,
|
.font: Theme.editorFont,
|
||||||
.foregroundColor: Theme.current.text
|
.foregroundColor: Theme.current.text
|
||||||
|
|
@ -1262,26 +1208,25 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
private var isUpdatingImages = false
|
private var isUpdatingImages = false
|
||||||
private var isUpdatingTables = false
|
private var isUpdatingTables = false
|
||||||
private var embeddedTableViews: [MarkdownTableView] = []
|
private var embeddedTableViews: [MarkdownTableView] = []
|
||||||
private var focusObserver: NSObjectProtocol?
|
private var observers: [NSObjectProtocol] = []
|
||||||
private var settingsObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
init(_ parent: EditorTextView) {
|
init(_ parent: EditorTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
super.init()
|
super.init()
|
||||||
focusObserver = NotificationCenter.default.addObserver(
|
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
forName: .focusEditor, object: nil, queue: .main
|
forName: .focusEditor, object: nil, queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
guard let tv = self?.textView else { return }
|
guard let tv = self?.textView else { return }
|
||||||
tv.window?.makeFirstResponder(tv)
|
tv.window?.makeFirstResponder(tv)
|
||||||
tv.setSelectedRange(NSRange(location: 0, length: 0))
|
tv.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
}
|
})
|
||||||
NotificationCenter.default.addObserver(
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
forName: .formatDocument, object: nil, queue: .main
|
forName: .formatDocument, object: nil, queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.formatCurrentDocument()
|
self?.formatCurrentDocument()
|
||||||
}
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
settingsObserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: .settingsChanged, object: nil, queue: .main
|
forName: .settingsChanged, object: nil, queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
guard let tv = self?.textView, let ts = tv.textStorage else { return }
|
guard let tv = self?.textView, let ts = tv.textStorage else { return }
|
||||||
|
|
@ -1296,15 +1241,33 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
ts.beginEditing()
|
ts.beginEditing()
|
||||||
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
tv.needsDisplay = true
|
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 {
|
deinit {
|
||||||
if let obs = focusObserver {
|
for obs in observers {
|
||||||
NotificationCenter.default.removeObserver(obs)
|
|
||||||
}
|
|
||||||
if let obs = settingsObserver {
|
|
||||||
NotificationCenter.default.removeObserver(obs)
|
NotificationCenter.default.removeObserver(obs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1317,6 +1280,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
ts.beginEditing()
|
ts.beginEditing()
|
||||||
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
tv.typingAttributes = [
|
tv.typingAttributes = [
|
||||||
.font: Theme.editorFont,
|
.font: Theme.editorFont,
|
||||||
.foregroundColor: Theme.current.text
|
.foregroundColor: Theme.current.text
|
||||||
|
|
@ -1420,6 +1384,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
applySyntaxHighlighting(to: ts, format: format)
|
applySyntaxHighlighting(to: ts, format: format)
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
}
|
}
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
tv.selectedRanges = sel
|
tv.selectedRanges = sel
|
||||||
tv.needsDisplay = true
|
tv.needsDisplay = true
|
||||||
}
|
}
|
||||||
|
|
@ -1497,6 +1462,47 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
textView.insertText("\n" + indent, replacementRange: textView.selectedRange())
|
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 {
|
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
||||||
var urlString: String?
|
var urlString: String?
|
||||||
if let url = link as? URL {
|
if let url = link as? URL {
|
||||||
|
|
@ -1528,19 +1534,21 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
embeddedTableViews.removeAll()
|
embeddedTableViews.removeAll()
|
||||||
|
|
||||||
|
let origin = tv.textContainerOrigin
|
||||||
let text = tv.string as NSString
|
let text = tv.string as NSString
|
||||||
for block in lm.blockRanges {
|
for block in lm.blockRanges {
|
||||||
guard case .tableBlock = block.kind else { continue }
|
guard case .tableBlock = block.kind else { continue }
|
||||||
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
||||||
|
|
||||||
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
||||||
var rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
||||||
rect.origin.x += tv.textContainerInset.width
|
|
||||||
rect.origin.y += tv.textContainerInset.height
|
|
||||||
|
|
||||||
|
let tableX = origin.x + 4
|
||||||
|
let tableY = rect.origin.y + origin.y
|
||||||
let tableWidth = tc.containerSize.width - 8
|
let tableWidth = tc.containerSize.width - 8
|
||||||
|
|
||||||
let tableView = MarkdownTableView(table: parsed, width: tableWidth)
|
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.textView = tv
|
||||||
|
|
||||||
tableView.onTableChanged = { [weak self] updatedTable in
|
tableView.onTableChanged = { [weak self] updatedTable in
|
||||||
|
|
@ -1564,6 +1572,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
ts.replaceCharacters(in: range, with: newMarkdown)
|
ts.replaceCharacters(in: range, with: newMarkdown)
|
||||||
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
tv.selectedRanges = sel
|
tv.selectedRanges = sel
|
||||||
parent.text = tv.string
|
parent.text = tv.string
|
||||||
updateBlockRanges(for: tv)
|
updateBlockRanges(for: tv)
|
||||||
|
|
@ -1946,7 +1955,7 @@ private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: N
|
||||||
let contentStart = hashRange.location + hashRange.length
|
let contentStart = hashRange.location + hashRange.length
|
||||||
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
||||||
if contentRange.length > 0 {
|
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(.font, value: h3Font, range: contentRange)
|
||||||
textStorage.addAttribute(.foregroundColor, value: palette.text, 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 contentStart = hashRange.location + hashRange.length
|
||||||
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
||||||
if contentRange.length > 0 {
|
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(.font, value: h2Font, range: contentRange)
|
||||||
textStorage.addAttribute(.foregroundColor, value: palette.text, 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 contentStart = hashRange.location + hashRange.length
|
||||||
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
||||||
if contentRange.length > 0 {
|
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(.font, value: h1Font, range: contentRange)
|
||||||
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
|
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
|
||||||
}
|
}
|
||||||
|
|
@ -2377,8 +2386,8 @@ private func highlightFootnoteDefinition(textStorage: NSTextStorage, lineRange:
|
||||||
// MARK: - Tables
|
// MARK: - Tables
|
||||||
|
|
||||||
private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont, isHeader: Bool, isSeparator: Bool) {
|
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(.foregroundColor, value: NSColor.clear, range: lineRange)
|
||||||
textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: lineRange)
|
textStorage.addAttribute(.font, value: baseFont, range: lineRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Lists and Horizontal Rules
|
// MARK: - Lists and Horizontal Rules
|
||||||
|
|
@ -2696,7 +2705,11 @@ private func resolveLocalImagePath(_ rawPath: String) -> String? {
|
||||||
|
|
||||||
class LineNumberTextView: NSTextView {
|
class LineNumberTextView: NSTextView {
|
||||||
static let gutterWidth: CGFloat = 50
|
static let gutterWidth: CGFloat = 50
|
||||||
var evalResults: [Int: EvalEntry] = [:]
|
static let evalLeftMargin: CGFloat = 80
|
||||||
|
|
||||||
|
var evalResults: [Int: EvalEntry] = [:] {
|
||||||
|
didSet { applyEvalSpacing() }
|
||||||
|
}
|
||||||
|
|
||||||
override var textContainerOrigin: NSPoint {
|
override var textContainerOrigin: NSPoint {
|
||||||
return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height)
|
return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height)
|
||||||
|
|
@ -2800,9 +2813,9 @@ class LineNumberTextView: NSTextView {
|
||||||
if let entry = evalResults[lineNumber - 1] {
|
if let entry = evalResults[lineNumber - 1] {
|
||||||
switch entry.format {
|
switch entry.format {
|
||||||
case .table:
|
case .table:
|
||||||
drawTableResult(entry.result, at: y, origin: origin, resultAttrs: resultAttrs)
|
drawTableResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs)
|
||||||
case .tree:
|
case .tree:
|
||||||
drawTreeResult(entry.result, at: y, origin: origin, resultAttrs: resultAttrs)
|
drawTreeResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs)
|
||||||
case .inline:
|
case .inline:
|
||||||
let resultStr = NSAttributedString(string: "\u{2192} \(entry.result)", attributes: resultAttrs)
|
let resultStr = NSAttributedString(string: "\u{2192} \(entry.result)", attributes: resultAttrs)
|
||||||
let size = resultStr.size()
|
let size = resultStr.size()
|
||||||
|
|
@ -2818,13 +2831,13 @@ class LineNumberTextView: NSTextView {
|
||||||
|
|
||||||
// MARK: - Table/Tree Rendering
|
// 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),
|
guard let data = json.data(using: .utf8),
|
||||||
let parsed = try? JSONSerialization.jsonObject(with: data),
|
let parsed = try? JSONSerialization.jsonObject(with: data),
|
||||||
let rows = parsed as? [[Any]] else {
|
let rows = parsed as? [[Any]] else {
|
||||||
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
||||||
let size = fallback.size()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2865,9 +2878,8 @@ class LineNumberTextView: NSTextView {
|
||||||
let tableWidth = colWidths.reduce(0, +) + cellPad * CGFloat(colCount + 1) + CGFloat(colCount - 1)
|
let tableWidth = colWidths.reduce(0, +) + cellPad * CGFloat(colCount + 1) + CGFloat(colCount - 1)
|
||||||
let tableHeight = rowHeight * CGFloat(stringRows.count) + CGFloat(stringRows.count + 1)
|
let tableHeight = rowHeight * CGFloat(stringRows.count) + CGFloat(stringRows.count + 1)
|
||||||
|
|
||||||
let rightEdge = visibleRect.maxX
|
let tableX = LineNumberTextView.evalLeftMargin
|
||||||
let tableX = rightEdge - tableWidth - 12
|
let tableY = lineRect.origin.y + origin.y + lineRect.height + 4
|
||||||
let tableY = y
|
|
||||||
|
|
||||||
let tableRect = NSRect(x: tableX, y: tableY, width: tableWidth, height: tableHeight)
|
let tableRect = NSRect(x: tableX, y: tableY, width: tableWidth, height: tableHeight)
|
||||||
palette.mantle.setFill()
|
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),
|
guard let data = json.data(using: .utf8),
|
||||||
let root = try? JSONSerialization.jsonObject(with: data) else {
|
let root = try? JSONSerialization.jsonObject(with: data) else {
|
||||||
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
||||||
let size = fallback.size()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2953,9 +2965,8 @@ class LineNumberTextView: NSTextView {
|
||||||
|
|
||||||
let treeHeight = lineHeight * CGFloat(lines.count) + 4
|
let treeHeight = lineHeight * CGFloat(lines.count) + 4
|
||||||
let treeWidth = maxWidth + 16
|
let treeWidth = maxWidth + 16
|
||||||
let rightEdge = visibleRect.maxX
|
let treeX = LineNumberTextView.evalLeftMargin
|
||||||
let treeX = rightEdge - treeWidth - 8
|
let treeY = lineRect.origin.y + origin.y + lineRect.height + 4
|
||||||
let treeY = y
|
|
||||||
|
|
||||||
let treeRect = NSRect(x: treeX, y: treeY, width: treeWidth, height: treeHeight)
|
let treeRect = NSRect(x: treeX, y: treeY, width: treeWidth, height: treeHeight)
|
||||||
palette.mantle.setFill()
|
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
|
// MARK: - Paste
|
||||||
|
|
||||||
override func paste(_ sender: Any?) {
|
override func paste(_ sender: Any?) {
|
||||||
|
|
|
||||||
|
|
@ -1,183 +1 @@
|
||||||
import SwiftUI
|
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