Compare commits

..

No commits in common. "65239ea0f0b68cb35fc38cbe947fff26167e7f5d" and "12a6cc2a52397bc6b5b3b6a2a111b402d7ecdca3" have entirely different histories.

9 changed files with 337 additions and 787 deletions

View File

@ -12,7 +12,6 @@ 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
@ -26,7 +25,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"
@ -60,7 +59,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 \

View File

@ -31,13 +31,15 @@ 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-ng = "0.7" tree-sitter-toml = "0.20"
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-sequel = "0.3" tree-sitter-sql = "0.0.2"
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"

View File

@ -1,5 +1,11 @@
use tree_sitter::Language;
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; 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)
}
const HIGHLIGHT_NAMES: &[&str] = &[ const HIGHLIGHT_NAMES: &[&str] = &[
"keyword", "keyword",
@ -141,13 +147,13 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
locals: "", locals: "",
}, },
"toml" => LangDef { "toml" => LangDef {
language: tree_sitter_toml_ng::LANGUAGE.into(), language: unsafe { lang_compat(tree_sitter_toml::language()) },
highlights: tree_sitter_toml_ng::HIGHLIGHTS_QUERY, highlights: tree_sitter_toml::HIGHLIGHT_QUERY,
injections: "", injections: "",
locals: "", locals: "",
}, },
"yaml" => LangDef { "yaml" => LangDef {
language: tree_sitter_yaml::language(), language: unsafe { lang_compat(tree_sitter_yaml::language()) },
highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY, highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY,
injections: "", injections: "",
locals: "", locals: "",
@ -165,8 +171,8 @@ fn lang_def(lang_id: &str) -> Option<LangDef> {
locals: "", locals: "",
}, },
"sql" => LangDef { "sql" => LangDef {
language: tree_sitter_sequel::LANGUAGE.into(), language: unsafe { lang_compat(tree_sitter_sql::language()) },
highlights: tree_sitter_sequel::HIGHLIGHTS_QUERY, highlights: include_str!("../queries/sql-highlights.scm"),
injections: "", injections: "",
locals: "", locals: "",
}, },
@ -176,6 +182,18 @@ 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)

View File

@ -12,11 +12,10 @@ 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"
@ -27,7 +26,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 \

View File

@ -3,22 +3,12 @@ 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
@ -47,8 +37,6 @@ 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
@ -111,14 +99,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let item = NSMenuItem() let item = NSMenuItem()
let menu = NSMenu(title: "File") let menu = NSMenu(title: "File")
let newWindowItem = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "n") let newItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "n")
newWindowItem.target = self newItem.target = self
menu.addItem(newWindowItem) menu.addItem(newItem)
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
@ -134,12 +117,6 @@ 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
} }
@ -156,26 +133,6 @@ 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)
@ -194,7 +151,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: "Document Browser", action: #selector(toggleBrowser), keyEquivalent: "b") let toggleItem = NSMenuItem(title: "Toggle Sidebar", action: #selector(toggleSidebar), keyEquivalent: "b")
toggleItem.keyEquivalentModifierMask = .control toggleItem.keyEquivalentModifierMask = .control
toggleItem.target = self toggleItem.target = self
menu.addItem(toggleItem) menu.addItem(toggleItem)
@ -233,51 +190,6 @@ 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
@ -404,24 +316,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
window.contentView?.needsDisplay = true window.contentView?.needsDisplay = true
} }
@objc private func toggleBrowser() { @objc private func toggleSidebar() {
DocumentBrowserController.shared?.toggle() NotificationCenter.default.post(name: .toggleSidebar, object: nil)
} }
@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

View File

@ -2,14 +2,24 @@ 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
} }
@ -17,11 +27,8 @@ 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")
} }

View File

@ -1,477 +0,0 @@
import Cocoa
import SwiftUI
import Combine
import UniformTypeIdentifiers
// MARK: - Model
enum BrowserItemKind {
case file
case folder
}
struct BrowserItem: Identifiable, Hashable {
let id: String
let url: URL
let name: String
let kind: BrowserItemKind
let modified: Date
var preview: String
static func == (lhs: BrowserItem, rhs: BrowserItem) -> Bool {
lhs.url == rhs.url
}
func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
}
// MARK: - Controller
class DocumentBrowserController {
static var shared: DocumentBrowserController?
let window: NSWindow
let browserState: BrowserState
private let hostingView: NSHostingView<DocumentBrowserView>
init(appState: AppState) {
browserState = BrowserState(appState: appState)
let view = DocumentBrowserView(state: browserState)
hostingView = NSHostingView(rootView: view)
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Documents"
window.backgroundColor = Theme.current.base
window.contentView = hostingView
window.setFrameAutosaveName("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) }
}
}
}

View File

@ -36,10 +36,8 @@ 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: case .checkbox, .tableBlock:
break break
case .tableBlock:
drawTableBackground(glyphRange: glyphRange, origin: origin, container: textContainer)
} }
} }
} }
@ -61,8 +59,6 @@ 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
} }
@ -156,17 +152,76 @@ class MarkdownLayoutManager: NSLayoutManager {
} }
} }
private func drawTableBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { private func drawTableBorders(glyphRange: NSRange, columns: Int, 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 path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4) let outerPath = NSBezierPath(rect: rect)
Theme.current.base.setFill() outerPath.lineWidth = 1
path.fill() 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()
}
}
}
} }
// MARK: - Interactive Table Component // MARK: - Interactive Table Component
@ -1163,7 +1218,6 @@ 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
@ -1208,25 +1262,26 @@ 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 observers: [NSObjectProtocol] = [] private var focusObserver: 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))
}) }
observers.append(NotificationCenter.default.addObserver( 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 }
@ -1241,33 +1296,15 @@ 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 {
for obs in observers { if let obs = focusObserver {
NotificationCenter.default.removeObserver(obs)
}
if let obs = settingsObserver {
NotificationCenter.default.removeObserver(obs) NotificationCenter.default.removeObserver(obs)
} }
} }
@ -1280,7 +1317,6 @@ 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
@ -1384,7 +1420,6 @@ 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
} }
@ -1462,47 +1497,6 @@ 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 {
@ -1534,21 +1528,19 @@ 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)
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc) var 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: tableX, y: tableY) tableView.frame.origin = NSPoint(x: rect.origin.x + 4, y: rect.origin.y)
tableView.textView = tv tableView.textView = tv
tableView.onTableChanged = { [weak self] updatedTable in tableView.onTableChanged = { [weak self] updatedTable in
@ -1572,7 +1564,6 @@ 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)
@ -1955,7 +1946,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: round(baseFont.pointSize * 1.15), weight: .bold) let h3Font = NSFont.systemFont(ofSize: 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)
} }
@ -1970,7 +1961,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: round(baseFont.pointSize * 1.38), weight: .bold) let h2Font = NSFont.systemFont(ofSize: 18, 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)
} }
@ -1985,7 +1976,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: round(baseFont.pointSize * 1.69), weight: .bold) let h1Font = NSFont.systemFont(ofSize: 22, 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)
} }
@ -2386,8 +2377,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: NSColor.clear, range: lineRange) textStorage.addAttribute(.foregroundColor, value: palette.base, range: lineRange)
textStorage.addAttribute(.font, value: baseFont, range: lineRange) textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: lineRange)
} }
// MARK: - Lists and Horizontal Rules // MARK: - Lists and Horizontal Rules
@ -2705,11 +2696,7 @@ private func resolveLocalImagePath(_ rawPath: String) -> String? {
class LineNumberTextView: NSTextView { class LineNumberTextView: NSTextView {
static let gutterWidth: CGFloat = 50 static let gutterWidth: CGFloat = 50
static let evalLeftMargin: CGFloat = 80 var evalResults: [Int: EvalEntry] = [:]
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)
@ -2813,9 +2800,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, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs) drawTableResult(entry.result, at: y, origin: origin, resultAttrs: resultAttrs)
case .tree: case .tree:
drawTreeResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs) drawTreeResult(entry.result, at: y, 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()
@ -2831,13 +2818,13 @@ class LineNumberTextView: NSTextView {
// MARK: - Table/Tree Rendering // MARK: - Table/Tree Rendering
private func drawTableResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) { private func drawTableResult(_ json: String, at y: CGFloat, 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: lineRect.origin.y + origin.y)) fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: y))
return return
} }
@ -2878,8 +2865,9 @@ 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 tableX = LineNumberTextView.evalLeftMargin let rightEdge = visibleRect.maxX
let tableY = lineRect.origin.y + origin.y + lineRect.height + 4 let tableX = rightEdge - tableWidth - 12
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()
@ -2911,12 +2899,12 @@ class LineNumberTextView: NSTextView {
} }
} }
private func drawTreeResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) { private func drawTreeResult(_ json: String, at y: CGFloat, 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: lineRect.origin.y + origin.y)) fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: y))
return return
} }
@ -2965,8 +2953,9 @@ 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 treeX = LineNumberTextView.evalLeftMargin let rightEdge = visibleRect.maxX
let treeY = lineRect.origin.y + origin.y + lineRect.height + 4 let treeX = rightEdge - treeWidth - 8
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()
@ -2986,79 +2975,6 @@ 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?) {

View File

@ -1 +1,183 @@
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
)
}
}