merge integration into ux-polish-1f610c
This commit is contained in:
commit
53e1832644
|
|
@ -1,6 +1,7 @@
|
||||||
import Cocoa
|
import Cocoa
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var window: NSWindow!
|
var window: NSWindow!
|
||||||
|
|
@ -10,6 +11,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private var focusTitleObserver: NSObjectProtocol?
|
private var focusTitleObserver: NSObjectProtocol?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
_ = ConfigManager.shared
|
||||||
appState = AppState()
|
appState = AppState()
|
||||||
|
|
||||||
let contentView = ContentView(state: appState)
|
let contentView = ContentView(state: appState)
|
||||||
|
|
@ -152,7 +154,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
@objc private func openNote() {
|
@objc private func openNote() {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.allowedContentTypes = [.plainText]
|
panel.allowedContentTypes = [UTType(filenameExtension: "md")!, .plainText]
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
|
|
@ -168,8 +170,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
@objc private func saveNoteAs() {
|
@objc private func saveNoteAs() {
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.allowedContentTypes = [.plainText]
|
panel.allowedContentTypes = [UTType(filenameExtension: "md")!]
|
||||||
panel.nameFieldStringValue = "note.txt"
|
panel.nameFieldStringValue = "note.md"
|
||||||
panel.beginSheetModal(for: window) { [weak self] response in
|
panel.beginSheetModal(for: window) { [weak self] response in
|
||||||
guard response == .OK, let url = panel.url else { return }
|
guard response == .OK, let url = panel.url else { return }
|
||||||
self?.appState.saveNoteToFile(url)
|
self?.appState.saveNoteToFile(url)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ class AppState: ObservableObject {
|
||||||
if documentText != oldValue {
|
if documentText != oldValue {
|
||||||
modified = true
|
modified = true
|
||||||
bridge.setText(currentNoteID, text: documentText)
|
bridge.setText(currentNoteID, text: documentText)
|
||||||
|
scheduleAutoSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +17,10 @@ class AppState: ObservableObject {
|
||||||
@Published var modified: Bool = false
|
@Published var modified: Bool = false
|
||||||
|
|
||||||
private let bridge = RustBridge.shared
|
private let bridge = RustBridge.shared
|
||||||
|
private var autoSaveTimer: DispatchSourceTimer?
|
||||||
|
private var autoSaveDirty = false
|
||||||
|
private var autoSaveCoolingDown = false
|
||||||
|
private let autoSaveQueue = DispatchQueue(label: "com.swiftly.autosave")
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let id = bridge.newDocument()
|
let id = bridge.newDocument()
|
||||||
|
|
@ -23,8 +28,90 @@ class AppState: ObservableObject {
|
||||||
refreshNoteList()
|
refreshNoteList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Auto-save
|
||||||
|
|
||||||
|
private func scheduleAutoSave() {
|
||||||
|
if autoSaveCoolingDown {
|
||||||
|
autoSaveDirty = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performAutoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performAutoSave() {
|
||||||
|
guard shouldAutoSave() else { return }
|
||||||
|
|
||||||
|
autoSaveCoolingDown = true
|
||||||
|
autoSaveDirty = false
|
||||||
|
|
||||||
|
let text = documentText
|
||||||
|
let noteID = currentNoteID
|
||||||
|
let title = extractTitle(from: text)
|
||||||
|
|
||||||
|
autoSaveQueue.async { [weak self] in
|
||||||
|
self?.writeAutoSaveFile(noteID: noteID, title: title, text: text)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.autoSaveCoolingDown = false
|
||||||
|
if self.autoSaveDirty {
|
||||||
|
self.autoSaveDirty = false
|
||||||
|
self.performAutoSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge.setText(currentNoteID, text: documentText)
|
||||||
|
let _ = bridge.cacheSave(currentNoteID)
|
||||||
|
modified = false
|
||||||
|
refreshNoteList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldAutoSave() -> Bool {
|
||||||
|
let trimmed = documentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
let title = extractTitle(from: documentText)
|
||||||
|
return title != "Untitled" && !title.isEmpty
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractTitle(from text: String) -> String {
|
||||||
|
let firstLine = text.components(separatedBy: "\n").first?
|
||||||
|
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
|
let clean = firstLine.replacingOccurrences(
|
||||||
|
of: "^#+\\s*", with: "", options: .regularExpression
|
||||||
|
)
|
||||||
|
return clean.isEmpty ? "Untitled" : String(clean.prefix(60))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sanitizeFilename(_ name: String) -> String {
|
||||||
|
let illegal = CharacterSet(charactersIn: "/\\:*?\"<>|")
|
||||||
|
let parts = name.unicodeScalars.filter { !illegal.contains($0) }
|
||||||
|
let cleaned = String(String.UnicodeScalarView(parts))
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
return cleaned.isEmpty ? UUID().uuidString : cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeAutoSaveFile(noteID: UUID, title: String, text: String) {
|
||||||
|
let dir = ConfigManager.shared.autoSaveDirectory
|
||||||
|
let dirURL = URL(fileURLWithPath: dir)
|
||||||
|
try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let filename: String
|
||||||
|
if title == "Untitled" {
|
||||||
|
filename = noteID.uuidString.lowercased()
|
||||||
|
} else {
|
||||||
|
filename = sanitizeFilename(title)
|
||||||
|
}
|
||||||
|
let fileURL = dirURL.appendingPathComponent(filename + ".md")
|
||||||
|
try? text.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Note operations
|
||||||
|
|
||||||
func newNote() {
|
func newNote() {
|
||||||
saveCurrentIfNeeded()
|
saveCurrentIfNeeded()
|
||||||
|
cleanupBlankNote(currentNoteID)
|
||||||
let id = bridge.newDocument()
|
let id = bridge.newDocument()
|
||||||
currentNoteID = id
|
currentNoteID = id
|
||||||
documentText = ""
|
documentText = ""
|
||||||
|
|
@ -35,6 +122,7 @@ class AppState: ObservableObject {
|
||||||
|
|
||||||
func loadNote(_ id: UUID) {
|
func loadNote(_ id: UUID) {
|
||||||
saveCurrentIfNeeded()
|
saveCurrentIfNeeded()
|
||||||
|
cleanupBlankNote(currentNoteID)
|
||||||
if bridge.cacheLoad(id) {
|
if bridge.cacheLoad(id) {
|
||||||
currentNoteID = id
|
currentNoteID = id
|
||||||
documentText = bridge.getText(id)
|
documentText = bridge.getText(id)
|
||||||
|
|
@ -74,12 +162,40 @@ class AppState: ObservableObject {
|
||||||
refreshNoteList()
|
refreshNoteList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteNotes(_ ids: Set<UUID>) {
|
||||||
|
for id in ids {
|
||||||
|
bridge.deleteNote(id)
|
||||||
|
}
|
||||||
|
if ids.contains(currentNoteID) {
|
||||||
|
let remaining = noteList.first { !ids.contains($0.id) }
|
||||||
|
if let next = remaining {
|
||||||
|
currentNoteID = next.id
|
||||||
|
if bridge.cacheLoad(next.id) {
|
||||||
|
documentText = bridge.getText(next.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let id = bridge.newDocument()
|
||||||
|
currentNoteID = id
|
||||||
|
documentText = ""
|
||||||
|
}
|
||||||
|
evalResults = [:]
|
||||||
|
modified = false
|
||||||
|
}
|
||||||
|
refreshNoteList()
|
||||||
|
}
|
||||||
|
|
||||||
func evaluate() {
|
func evaluate() {
|
||||||
evalResults = bridge.evaluate(currentNoteID)
|
evalResults = bridge.evaluate(currentNoteID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshNoteList() {
|
func refreshNoteList() {
|
||||||
noteList = bridge.listNotes()
|
var notes = bridge.listNotes()
|
||||||
|
notes.removeAll { note in
|
||||||
|
let trimmed = note.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let isBlank = trimmed.isEmpty || trimmed == "Untitled"
|
||||||
|
return isBlank && note.id != currentNoteID
|
||||||
|
}
|
||||||
|
noteList = notes
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveCurrentIfNeeded() {
|
private func saveCurrentIfNeeded() {
|
||||||
|
|
@ -87,4 +203,12 @@ class AppState: ObservableObject {
|
||||||
saveNote()
|
saveNote()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cleanupBlankNote(_ id: UUID) {
|
||||||
|
let text = bridge.getText(id)
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
bridge.deleteNote(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class ConfigManager {
|
||||||
|
static let shared = ConfigManager()
|
||||||
|
|
||||||
|
private let configDir: URL
|
||||||
|
private let configFile: URL
|
||||||
|
private let defaultNotesDir: URL
|
||||||
|
private var config: [String: String]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
configDir = home.appendingPathComponent(".swiftly")
|
||||||
|
configFile = configDir.appendingPathComponent("config.json")
|
||||||
|
defaultNotesDir = configDir.appendingPathComponent("notes")
|
||||||
|
config = [:]
|
||||||
|
ensureDirectories()
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureDirectories() {
|
||||||
|
let fm = FileManager.default
|
||||||
|
try? fm.createDirectory(at: configDir, withIntermediateDirectories: true)
|
||||||
|
try? fm.createDirectory(at: defaultNotesDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let data = try? Data(contentsOf: configFile),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String]
|
||||||
|
else { return }
|
||||||
|
config = dict
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard let data = try? JSONSerialization.data(
|
||||||
|
withJSONObject: config, options: [.prettyPrinted, .sortedKeys]
|
||||||
|
) else { return }
|
||||||
|
try? data.write(to: configFile, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
var autoSaveDirectory: String {
|
||||||
|
get { config["autoSaveDirectory"] ?? defaultNotesDir.path }
|
||||||
|
set { config["autoSaveDirectory"] = newValue; save() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeMode: String {
|
||||||
|
get { config["themeMode"] ?? "auto" }
|
||||||
|
set { config["themeMode"] = newValue; save() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineIndicatorMode: String {
|
||||||
|
get { config["lineIndicatorMode"] ?? "on" }
|
||||||
|
set { config["lineIndicatorMode"] = newValue; save() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,10 @@ 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 sidebarVisible: Bool = false
|
||||||
@AppStorage("themeMode") private var themeMode: String = "auto"
|
@State private var themeVersion: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _ = themeMode
|
let _ = themeVersion
|
||||||
HSplitView {
|
HSplitView {
|
||||||
if sidebarVisible {
|
if sidebarVisible {
|
||||||
SidebarView(state: state)
|
SidebarView(state: state)
|
||||||
|
|
@ -20,6 +20,9 @@ struct ContentView: View {
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in
|
||||||
withAnimation { sidebarVisible.toggle() }
|
withAnimation { sidebarVisible.toggle() }
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||||
|
themeVersion += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1865,7 +1865,7 @@ class LineNumberTextView: NSTextView {
|
||||||
let text = string as NSString
|
let text = string as NSString
|
||||||
guard text.length > 0 else { return }
|
guard text.length > 0 else { return }
|
||||||
|
|
||||||
let lineMode = UserDefaults.standard.string(forKey: "lineIndicatorMode") ?? "on"
|
let lineMode = ConfigManager.shared.lineIndicatorMode
|
||||||
|
|
||||||
var containerVisible = visibleRect
|
var containerVisible = visibleRect
|
||||||
containerVisible.origin.x -= origin.x
|
containerVisible.origin.x -= origin.x
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,9 @@ enum LineIndicatorMode: String, CaseIterable {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@AppStorage("themeMode") private var themeMode: String = "auto"
|
@State private var themeMode: String = ConfigManager.shared.themeMode
|
||||||
@AppStorage("lineIndicatorMode") private var lineIndicatorMode: String = "on"
|
@State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode
|
||||||
|
@State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let palette = Theme.current
|
let palette = Theme.current
|
||||||
|
|
@ -53,26 +54,47 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Auto-Save") {
|
||||||
|
HStack {
|
||||||
|
TextField("Directory", text: $autoSaveDir)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button("Choose...") {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
autoSaveDir = url.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.frame(width: 320, height: 180)
|
.frame(width: 400, height: 260)
|
||||||
.background(Color(ns: palette.base))
|
.background(Color(ns: palette.base))
|
||||||
.onChange(of: themeMode) {
|
.onChange(of: themeMode) {
|
||||||
|
ConfigManager.shared.themeMode = themeMode
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
applyThemeAppearance()
|
applyThemeAppearance()
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: lineIndicatorMode) {
|
.onChange(of: lineIndicatorMode) {
|
||||||
|
ConfigManager.shared.lineIndicatorMode = lineIndicatorMode
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: autoSaveDir) {
|
||||||
|
ConfigManager.shared.autoSaveDirectory = autoSaveDir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyThemeAppearance() {
|
func applyThemeAppearance() {
|
||||||
let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto"
|
let mode = ConfigManager.shared.themeMode
|
||||||
switch mode {
|
switch mode {
|
||||||
case "dark":
|
case "dark":
|
||||||
NSApp.appearance = NSAppearance(named: .darkAqua)
|
NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
|
@ -98,10 +120,10 @@ class SettingsWindowController {
|
||||||
|
|
||||||
let settingsView = SettingsView()
|
let settingsView = SettingsView()
|
||||||
let hostingView = NSHostingView(rootView: settingsView)
|
let hostingView = NSHostingView(rootView: settingsView)
|
||||||
hostingView.frame = NSRect(x: 0, y: 0, width: 320, height: 200)
|
hostingView.frame = NSRect(x: 0, y: 0, width: 400, height: 280)
|
||||||
|
|
||||||
let w = NSWindow(
|
let w = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 200),
|
contentRect: NSRect(x: 0, y: 0, width: 400, height: 280),
|
||||||
styleMask: [.titled, .closable],
|
styleMask: [.titled, .closable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import SwiftUI
|
||||||
|
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
|
@State private var selection: Set<UUID> = []
|
||||||
|
@State private var lastClickedID: UUID?
|
||||||
|
|
||||||
private let dateFormatter: DateFormatter = {
|
private let dateFormatter: DateFormatter = {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
|
|
@ -38,46 +40,106 @@ struct SidebarView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
List(state.noteList) { note in
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(state.noteList.enumerated()), id: \.element.id) { index, note in
|
||||||
NoteRow(
|
NoteRow(
|
||||||
note: note,
|
note: note,
|
||||||
isSelected: note.id == state.currentNoteID,
|
isActive: note.id == state.currentNoteID,
|
||||||
|
isSelected: selection.contains(note.id),
|
||||||
dateFormatter: dateFormatter
|
dateFormatter: dateFormatter
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { state.loadNote(note.id) }
|
.onTapGesture(count: 2) {
|
||||||
|
selection = [note.id]
|
||||||
|
lastClickedID = note.id
|
||||||
|
state.loadNote(note.id)
|
||||||
|
}
|
||||||
|
.onTapGesture(count: 1) {
|
||||||
|
handleClick(note: note, index: index, modifiers: currentModifiers())
|
||||||
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
if selection.count > 1 && selection.contains(note.id) {
|
||||||
|
Button("Delete \(selection.count) Notes") {
|
||||||
|
state.deleteNotes(selection)
|
||||||
|
selection.removeAll()
|
||||||
|
lastClickedID = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Button("Delete") { state.deleteNote(note.id) }
|
Button("Delete") { state.deleteNote(note.id) }
|
||||||
}
|
}
|
||||||
.listRowBackground(
|
|
||||||
note.id == state.currentNoteID
|
|
||||||
? Color(ns: Theme.current.surface1)
|
|
||||||
: Color.clear
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDeleteCommand {
|
||||||
|
guard !selection.isEmpty else { return }
|
||||||
|
state.deleteNotes(selection)
|
||||||
|
selection.removeAll()
|
||||||
|
lastClickedID = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(ns: Theme.current.base))
|
.background(Color(ns: Theme.current.base))
|
||||||
.onAppear { state.refreshNoteList() }
|
.onAppear { state.refreshNoteList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleClick(note: NoteInfo, index: Int, modifiers: EventModifiers) {
|
||||||
|
if modifiers.contains(.command) {
|
||||||
|
if selection.contains(note.id) {
|
||||||
|
selection.remove(note.id)
|
||||||
|
} else {
|
||||||
|
selection.insert(note.id)
|
||||||
|
}
|
||||||
|
lastClickedID = note.id
|
||||||
|
} else if modifiers.contains(.shift), let lastID = lastClickedID {
|
||||||
|
if let lastIndex = state.noteList.firstIndex(where: { $0.id == lastID }) {
|
||||||
|
let range = min(lastIndex, index)...max(lastIndex, index)
|
||||||
|
for i in range {
|
||||||
|
selection.insert(state.noteList[i].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selection = [note.id]
|
||||||
|
lastClickedID = note.id
|
||||||
|
state.loadNote(note.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentModifiers() -> EventModifiers {
|
||||||
|
let flags = NSEvent.modifierFlags
|
||||||
|
var mods: EventModifiers = []
|
||||||
|
if flags.contains(.command) { mods.insert(.command) }
|
||||||
|
if flags.contains(.shift) { mods.insert(.shift) }
|
||||||
|
return mods
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoteRow: View {
|
struct NoteRow: View {
|
||||||
let note: NoteInfo
|
let note: NoteInfo
|
||||||
|
let isActive: Bool
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let dateFormatter: DateFormatter
|
let dateFormatter: DateFormatter
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(note.title)
|
Text(note.title)
|
||||||
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
.font(.system(size: 13, weight: isActive ? .semibold : .regular))
|
||||||
.foregroundColor(Color(ns: Theme.current.text))
|
.foregroundColor(Color(ns: Theme.current.text))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(dateFormatter.string(from: note.lastModified))
|
Text(dateFormatter.string(from: note.lastModified))
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(Color(ns: Theme.current.subtext0))
|
.foregroundColor(Color(ns: Theme.current.subtext0))
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ struct Theme {
|
||||||
)
|
)
|
||||||
|
|
||||||
static var current: CatppuccinPalette {
|
static var current: CatppuccinPalette {
|
||||||
let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto"
|
let mode = ConfigManager.shared.themeMode
|
||||||
switch mode {
|
switch mode {
|
||||||
case "dark": return mocha
|
case "dark": return mocha
|
||||||
case "light": return latte
|
case "light": return latte
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue