diff --git a/.swiftlint.yml b/.swiftlint.yml index 2c37621..6dcbfad 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -12,6 +12,10 @@ disabled_rules: - trailing_comma - multiple_closures_with_trailing_closure +file_length: + warning: 400 + ignore_comment_only_lines: true # doc comments are a feature here, not bulk + line_length: warning: 120 error: 200 diff --git a/CLAUDE.md b/CLAUDE.md index 82fae9c..0447244 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,10 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw content fills everything). `isMovableByWindowBackground` is **off** on purpose. - **`NoteWindowManager`** (`@MainActor`) — owns `[UUID: NotePanel]` + `[UUID: NoteController]`, is each panel's `NSWindowDelegate`, and is the single chokepoint for all window mutation: - `restoreAll` (launch), `openNote`, close, debounced frame persistence, roll-up resize, and - applying float-on-top / show-on-all-Spaces. + `restoreAll` (launch: only notes with `isOpen` — the header X clears it, quitting doesn't, so + relaunch shows what was on screen), `openNote`, close, debounced frame persistence, roll-up + resize, and applying float-on-top / show-on-all-Spaces. Frames are global coordinates spanning + all displays; `ensureOnScreen` recentres only when *no* screen contains the note. - **`NoteController`** (`@MainActor @Observable`) — one per open note. Holds the `Note`, streams its tasks live via GRDB `ValueObservation`, and turns user actions into DB writes. It stays **AppKit-free**: window side effects go through closures the manager sets on it @@ -79,7 +81,8 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw id-matched `indentLevelChanges` diff. Kept separate so it's unit-testable in isolation. - **`PlainTextEditor`** (`NSViewRepresentable` over `NSTextView`) — the task / quick-add editor. AppKit, not SwiftUI `TextField`, because that control can't reliably insert newlines or intercept - Tab on macOS (see the editing convention below). **`ShortcutHint`** is the small keycap-chip label. + Tab on macOS (see the editing convention below). **`QuickAddBar`** is the add-a-task bar along the + bottom of a note (owns the typed text and pending indent). **`ShortcutHint`** is the small keycap-chip label. - **`TaskImage`** (pure CoreGraphics/ImageIO, no AppKit / no DB) + **`TaskImageView`** — pasted images: normalise/thumbnail/crop/preview-file helpers and the crop maths (`dragging`, `moving`), and the view that draws a task's image with its hover buttons, context menu, and crop mode. **`ImageViewer`** is the diff --git a/Sources/Tic/AppDelegate.swift b/Sources/Tic/AppDelegate.swift index 084c04c..49413a7 100644 --- a/Sources/Tic/AppDelegate.swift +++ b/Sources/Tic/AppDelegate.swift @@ -29,6 +29,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// Click the Dock icon with no notes on screen → open the Lists palette, so the app never + /// looks dead after every note was closed. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + if AppModel.shared.windows.openCount == 0 { AppModel.shared.openSearch() } + return true + } + /// Right-click the Dock icon → New List. func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { let menu = NSMenu() diff --git a/Sources/Tic/Controllers/NoteController.swift b/Sources/Tic/Controllers/NoteController.swift index 04c66b7..3dcc4fd 100644 --- a/Sources/Tic/Controllers/NoteController.swift +++ b/Sources/Tic/Controllers/NoteController.swift @@ -344,7 +344,11 @@ final class NoteController { return TaskCompletionUpdate(id: a.id, isDone: a.isDone, completedAt: a.completedAt) } } +} + +// MARK: - Note-level actions: title, appearance, window behaviour, display options, lifecycle +extension NoteController { // MARK: - Note actions func commitTitle(_ rawTitle: String) { diff --git a/Sources/Tic/Database/AppDatabase+Tasks.swift b/Sources/Tic/Database/AppDatabase+Tasks.swift new file mode 100644 index 0000000..aaa699a --- /dev/null +++ b/Sources/Tic/Database/AppDatabase+Tasks.swift @@ -0,0 +1,189 @@ +import Foundation +import GRDB + +// MARK: - Tasks and task images + +extension AppDatabase { + // MARK: - Tasks + + func tasks(noteId: UUID) async throws -> [TaskItem] { + try await dbQueue.read { db in + try TaskItem + .filter(TaskItem.Columns.noteId == noteId) + .order(TaskItem.Columns.sortIndex) + .fetchAll(db) + } + } + + func insert(_ task: TaskItem) async throws { + try await dbQueue.write { db in try task.insert(db) } + } + + /// Inserts a task assigning the next `sortIndex` (`MAX + 1` for the note) atomically inside the + /// write, so a `tasks.count`-based index can't collide with a stale row after deletes leave gaps. + /// `imageData`, if given, is stored as the task's image in the same transaction. + func insertTask(_ task: TaskItem, imageData: Data? = nil) async throws { + let snapshot = task + try await dbQueue.write { db in + let maxIndex = try Int.fetchOne( + db, sql: "SELECT MAX(sortIndex) FROM task WHERE noteId = ?", arguments: [snapshot.noteId] + ) ?? -1 + var stored = snapshot + stored.sortIndex = maxIndex + 1 + try stored.insert(db) + if let imageData { try Self.writeImage(imageData, taskId: stored.id, db) } + } + } + + /// Inserts `task` somewhere in the middle of a note's list (e.g. a subtask nested under a row), + /// then renumbers every row's `sortIndex` to its position in `ordered` — all in one transaction + /// so the observation sees a single consistent ordering. `ordered` is the full intended list + /// *including* the new task. + func insertTask(_ task: TaskItem, reordering ordered: [TaskItem]) async throws { + let snapshot = task + let order = ordered + try await dbQueue.write { db in + try snapshot.insert(db) + for (index, t) in order.enumerated() { + try db.execute(sql: "UPDATE task SET sortIndex = ? WHERE id = ?", arguments: [index, t.id]) + } + } + } + + func update(_ task: TaskItem) async throws { + try await dbQueue.write { db in try task.update(db) } + } + + /// One atomic transaction for a structural edit (indent / outdent / delete / drag-reorder / + /// clear-completed), so the live observation never sees a transient invalid outline. Touches only + /// the structural columns (`sortIndex`, `indentLevel`) plus optional row deletes — never `text` or + /// the completion columns, so a structural change can neither clobber a concurrently-edited task + /// body nor alter any tick state (completion only ever changes via `updateTaskCompletion`). + /// `deleteIds` may hold one row (single delete) or many (clear-completed), removed in one `IN (…)`. + func applyStructuralUpdate( + deleteIds: [UUID] = [], + reorder ordered: [TaskItem]? = nil, + levels: [TaskLevelUpdate] = [] + ) async throws { + try await dbQueue.write { db in + if !deleteIds.isEmpty { + _ = try TaskItem.filter(deleteIds.contains(TaskItem.Columns.id)).deleteAll(db) + } + if let ordered { + for (index, task) in ordered.enumerated() where task.sortIndex != index { + try db.execute(sql: "UPDATE task SET sortIndex = ? WHERE id = ?", arguments: [index, task.id]) + } + } + for u in levels { + try db.execute(sql: "UPDATE task SET indentLevel = ? WHERE id = ?", arguments: [u.level, u.id]) + } + } + } + + /// Targeted write of the `isDone` + `completedAt` columns for the given tasks (a checkbox + /// toggle, which cascades through a subtree and bubbles up to ancestors). Writes only the + /// completion columns so it never clobbers a concurrently-edited `text` or a reorder. + func updateTaskCompletion(_ updates: [TaskCompletionUpdate]) async throws { + guard !updates.isEmpty else { return } + try await dbQueue.write { db in + try Self.writeCompletion(updates, db) + } + } + + /// Writes `isDone` + `completedAt` for each update, inside an existing write transaction. + private static func writeCompletion(_ updates: [TaskCompletionUpdate], _ db: Database) throws { + for u in updates { + try db.execute( + sql: "UPDATE task SET isDone = ?, completedAt = ? WHERE id = ?", + arguments: [u.isDone, u.completedAt, u.id] + ) + } + } + + /// Emits a note's ordered tasks whenever any of them change. + func observeTasks(noteId: UUID) -> AsyncValueObservation<[TaskItem]> { + ValueObservation + .tracking { db in + try TaskItem + .filter(TaskItem.Columns.noteId == noteId) + .order(TaskItem.Columns.sortIndex) + .fetchAll(db) + } + .values(in: dbQueue) + } + + // MARK: - Task images + + /// Stores (or replaces) a task's image. A replaced picture starts uncropped again. + func setTaskImage(taskId: UUID, data: Data) async throws { + try await dbQueue.write { db in try Self.writeImage(data, taskId: taskId, db) } + } + + /// `INSERT OR REPLACE` swaps the whole row, so the crop columns fall back to their full-image defaults. + private static func writeImage(_ data: Data, taskId: UUID, _ db: Database) throws { + try db.execute( + sql: "INSERT OR REPLACE INTO taskImage (taskId, data, updatedAt) VALUES (?, ?, ?)", + arguments: [taskId, data, Date()] + ) + } + + /// Targeted write of just an image's crop, so it can't race a replace or a task text commit. + func updateTaskImageCrop(taskId: UUID, crop: CGRect) async throws { + try await dbQueue.write { db in + try db.execute( + sql: "UPDATE taskImage SET cropX = ?, cropY = ?, cropW = ?, cropH = ?, updatedAt = ? WHERE taskId = ?", + arguments: [ + Double(crop.minX), Double(crop.minY), Double(crop.width), Double(crop.height), Date(), taskId, + ] + ) + } + } + + /// Removes a task's image, keeping the task. + func deleteTaskImage(taskId: UUID) async throws { + try await dbQueue.write { db in + try db.execute(sql: "DELETE FROM taskImage WHERE taskId = ?", arguments: [taskId]) + } + } + + /// The stored (original, uncropped) image bytes — read on demand, never via an observation. + func taskImageData(taskId: UUID) async throws -> Data? { + try await dbQueue.read { db in + try Data.fetchOne(db, sql: "SELECT data FROM taskImage WHERE taskId = ?", arguments: [taskId]) + } + } + + /// Emits the crop of every image in a note, keyed by task id. Deliberately never selects `data`, so + /// the blobs are only read on demand (`taskImageData`). + func observeTaskImageCrops(noteId: UUID) -> AsyncValueObservation<[UUID: CGRect]> { + ValueObservation + .tracking { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT i.taskId, i.cropX, i.cropY, i.cropW, i.cropH + FROM taskImage i JOIN task t ON t.id = i.taskId + WHERE t.noteId = ? + """, arguments: [noteId]) + return Dictionary(uniqueKeysWithValues: rows.map { row in + let crop = CGRect( + x: row["cropX"] as Double, y: row["cropY"] as Double, + width: row["cropW"] as Double, height: row["cropH"] as Double + ) + return (row["taskId"] as UUID, crop) + }) + } + .values(in: dbQueue) + } +} + +/// A targeted `indentLevel` change for one task — batched into `applyStructuralUpdate`. +struct TaskLevelUpdate: Sendable { + let id: UUID + let level: Int +} + +/// A targeted completion change for one task — batched by `updateTaskCompletion`. +struct TaskCompletionUpdate: Sendable { + let id: UUID + let isDone: Bool + let completedAt: Date? +} diff --git a/Sources/Tic/Database/AppDatabase.swift b/Sources/Tic/Database/AppDatabase.swift index f03a812..a1c00e8 100644 --- a/Sources/Tic/Database/AppDatabase.swift +++ b/Sources/Tic/Database/AppDatabase.swift @@ -107,6 +107,13 @@ final class AppDatabase: Sendable { } } + // Existing notes default to open, so upgrading changes nothing until a note is closed. + migrator.registerMigration("v5_note_is_open") { db in + try db.alter(table: "note") { t in + t.add(column: "isOpen", .boolean).notNull().defaults(to: true) + } + } + return migrator } @@ -118,6 +125,13 @@ final class AppDatabase: Sendable { } } + /// The notes whose panels were on screen at last quit (launch restore). + func openNotes() async throws -> [Note] { + try await dbQueue.read { db in + try Note.filter(Note.Columns.isOpen == true).order(Note.Columns.sortIndex).fetchAll(db) + } + } + func insert(_ note: Note) async throws { try await dbQueue.write { db in try note.insert(db) } } @@ -201,6 +215,16 @@ final class AppDatabase: Sendable { } } + /// Targeted write of whether a note's panel is on screen (closed via X vs. opened). + func updateNoteOpen(id: UUID, isOpen: Bool) async throws { + try await dbQueue.write { db in + try db.execute( + sql: "UPDATE note SET isOpen = ? WHERE id = ?", + arguments: [isOpen, id] + ) + } + } + /// Targeted write of a note's checklist display options (hide completed / move completed to /// bottom). Kept separate from `updateNoteFlags` (window behaviour) so each stays column-targeted. func updateNoteListOptions(id: UUID, hideCompleted: Bool, moveCompletedToBottom: Bool) async throws { @@ -218,176 +242,6 @@ final class AppDatabase: Sendable { .tracking { db in try Note.order(Note.Columns.sortIndex).fetchAll(db) } .values(in: dbQueue) } - - // MARK: - Tasks - - func tasks(noteId: UUID) async throws -> [TaskItem] { - try await dbQueue.read { db in - try TaskItem - .filter(TaskItem.Columns.noteId == noteId) - .order(TaskItem.Columns.sortIndex) - .fetchAll(db) - } - } - - func insert(_ task: TaskItem) async throws { - try await dbQueue.write { db in try task.insert(db) } - } - - /// Inserts a task assigning the next `sortIndex` (`MAX + 1` for the note) atomically inside the - /// write, so a `tasks.count`-based index can't collide with a stale row after deletes leave gaps. - /// `imageData`, if given, is stored as the task's image in the same transaction. - func insertTask(_ task: TaskItem, imageData: Data? = nil) async throws { - let snapshot = task - try await dbQueue.write { db in - let maxIndex = try Int.fetchOne( - db, sql: "SELECT MAX(sortIndex) FROM task WHERE noteId = ?", arguments: [snapshot.noteId] - ) ?? -1 - var stored = snapshot - stored.sortIndex = maxIndex + 1 - try stored.insert(db) - if let imageData { try Self.writeImage(imageData, taskId: stored.id, db) } - } - } - - /// Inserts `task` somewhere in the middle of a note's list (e.g. a subtask nested under a row), - /// then renumbers every row's `sortIndex` to its position in `ordered` — all in one transaction - /// so the observation sees a single consistent ordering. `ordered` is the full intended list - /// *including* the new task. - func insertTask(_ task: TaskItem, reordering ordered: [TaskItem]) async throws { - let snapshot = task - let order = ordered - try await dbQueue.write { db in - try snapshot.insert(db) - for (index, t) in order.enumerated() { - try db.execute(sql: "UPDATE task SET sortIndex = ? WHERE id = ?", arguments: [index, t.id]) - } - } - } - - func update(_ task: TaskItem) async throws { - try await dbQueue.write { db in try task.update(db) } - } - - /// One atomic transaction for a structural edit (indent / outdent / delete / drag-reorder / - /// clear-completed), so the live observation never sees a transient invalid outline. Touches only - /// the structural columns (`sortIndex`, `indentLevel`) plus optional row deletes — never `text` or - /// the completion columns, so a structural change can neither clobber a concurrently-edited task - /// body nor alter any tick state (completion only ever changes via `updateTaskCompletion`). - /// `deleteIds` may hold one row (single delete) or many (clear-completed), removed in one `IN (…)`. - func applyStructuralUpdate( - deleteIds: [UUID] = [], - reorder ordered: [TaskItem]? = nil, - levels: [TaskLevelUpdate] = [] - ) async throws { - try await dbQueue.write { db in - if !deleteIds.isEmpty { - _ = try TaskItem.filter(deleteIds.contains(TaskItem.Columns.id)).deleteAll(db) - } - if let ordered { - for (index, task) in ordered.enumerated() where task.sortIndex != index { - try db.execute(sql: "UPDATE task SET sortIndex = ? WHERE id = ?", arguments: [index, task.id]) - } - } - for u in levels { - try db.execute(sql: "UPDATE task SET indentLevel = ? WHERE id = ?", arguments: [u.level, u.id]) - } - } - } - - /// Targeted write of the `isDone` + `completedAt` columns for the given tasks (a checkbox - /// toggle, which cascades through a subtree and bubbles up to ancestors). Writes only the - /// completion columns so it never clobbers a concurrently-edited `text` or a reorder. - func updateTaskCompletion(_ updates: [TaskCompletionUpdate]) async throws { - guard !updates.isEmpty else { return } - try await dbQueue.write { db in - try Self.writeCompletion(updates, db) - } - } - - /// Writes `isDone` + `completedAt` for each update, inside an existing write transaction. - private static func writeCompletion(_ updates: [TaskCompletionUpdate], _ db: Database) throws { - for u in updates { - try db.execute( - sql: "UPDATE task SET isDone = ?, completedAt = ? WHERE id = ?", - arguments: [u.isDone, u.completedAt, u.id] - ) - } - } - - /// Emits a note's ordered tasks whenever any of them change. - func observeTasks(noteId: UUID) -> AsyncValueObservation<[TaskItem]> { - ValueObservation - .tracking { db in - try TaskItem - .filter(TaskItem.Columns.noteId == noteId) - .order(TaskItem.Columns.sortIndex) - .fetchAll(db) - } - .values(in: dbQueue) - } - - // MARK: - Task images - - /// Stores (or replaces) a task's image. A replaced picture starts uncropped again. - func setTaskImage(taskId: UUID, data: Data) async throws { - try await dbQueue.write { db in try Self.writeImage(data, taskId: taskId, db) } - } - - /// `INSERT OR REPLACE` swaps the whole row, so the crop columns fall back to their full-image defaults. - private static func writeImage(_ data: Data, taskId: UUID, _ db: Database) throws { - try db.execute( - sql: "INSERT OR REPLACE INTO taskImage (taskId, data, updatedAt) VALUES (?, ?, ?)", - arguments: [taskId, data, Date()] - ) - } - - /// Targeted write of just an image's crop, so it can't race a replace or a task text commit. - func updateTaskImageCrop(taskId: UUID, crop: CGRect) async throws { - try await dbQueue.write { db in - try db.execute( - sql: "UPDATE taskImage SET cropX = ?, cropY = ?, cropW = ?, cropH = ?, updatedAt = ? WHERE taskId = ?", - arguments: [ - Double(crop.minX), Double(crop.minY), Double(crop.width), Double(crop.height), Date(), taskId, - ] - ) - } - } - - /// Removes a task's image, keeping the task. - func deleteTaskImage(taskId: UUID) async throws { - try await dbQueue.write { db in - try db.execute(sql: "DELETE FROM taskImage WHERE taskId = ?", arguments: [taskId]) - } - } - - /// The stored (original, uncropped) image bytes — read on demand, never via an observation. - func taskImageData(taskId: UUID) async throws -> Data? { - try await dbQueue.read { db in - try Data.fetchOne(db, sql: "SELECT data FROM taskImage WHERE taskId = ?", arguments: [taskId]) - } - } - - /// Emits the crop of every image in a note, keyed by task id. Deliberately never selects `data`, so - /// the blobs are only read on demand (`taskImageData`). - func observeTaskImageCrops(noteId: UUID) -> AsyncValueObservation<[UUID: CGRect]> { - ValueObservation - .tracking { db in - let rows = try Row.fetchAll(db, sql: """ - SELECT i.taskId, i.cropX, i.cropY, i.cropW, i.cropH - FROM taskImage i JOIN task t ON t.id = i.taskId - WHERE t.noteId = ? - """, arguments: [noteId]) - return Dictionary(uniqueKeysWithValues: rows.map { row in - let crop = CGRect( - x: row["cropX"] as Double, y: row["cropY"] as Double, - width: row["cropW"] as Double, height: row["cropH"] as Double - ) - return (row["taskId"] as UUID, crop) - }) - } - .values(in: dbQueue) - } } /// One sample row for the welcome notes — a named type instead of a 3-tuple. @@ -461,16 +315,3 @@ extension AppDatabase { } } } - -/// A targeted `indentLevel` change for one task — batched into `applyStructuralUpdate`. -struct TaskLevelUpdate: Sendable { - let id: UUID - let level: Int -} - -/// A targeted completion change for one task — batched by `updateTaskCompletion`. -struct TaskCompletionUpdate: Sendable { - let id: UUID - let isDone: Bool - let completedAt: Date? -} diff --git a/Sources/Tic/Models/Note.swift b/Sources/Tic/Models/Note.swift index 828de9e..3b70095 100644 --- a/Sources/Tic/Models/Note.swift +++ b/Sources/Tic/Models/Note.swift @@ -22,6 +22,9 @@ struct Note: Identifiable, Equatable, Codable, Sendable { var floatOnTop: Bool var showOnAllSpaces: Bool var isCollapsed: Bool + /// Whether the note's panel was on screen when last seen — closing via the header X clears it, + /// opening sets it — so launch restores only what was visible. + var isOpen: Bool var sortIndex: Int // Per-note checklist display options (applied by `NoteController.displayedTasks`, not stored on @@ -43,6 +46,7 @@ struct Note: Identifiable, Equatable, Codable, Sendable { floatOnTop: Bool = false, showOnAllSpaces: Bool = false, isCollapsed: Bool = false, + isOpen: Bool = true, sortIndex: Int = 0, hideCompleted: Bool = false, moveCompletedToBottom: Bool = false @@ -60,6 +64,7 @@ struct Note: Identifiable, Equatable, Codable, Sendable { self.floatOnTop = floatOnTop self.showOnAllSpaces = showOnAllSpaces self.isCollapsed = isCollapsed + self.isOpen = isOpen self.sortIndex = sortIndex self.hideCompleted = hideCompleted self.moveCompletedToBottom = moveCompletedToBottom @@ -74,6 +79,7 @@ extension Note: FetchableRecord, PersistableRecord { static let title = Column("title") static let updatedAt = Column("updatedAt") static let sortIndex = Column("sortIndex") + static let isOpen = Column("isOpen") } } // GRDB's default date storage is readable, sortable UTC text ("yyyy-MM-dd HH:mm:ss.SSS"), diff --git a/Sources/Tic/Views/NoteHeaderView.swift b/Sources/Tic/Views/NoteHeaderView.swift index f0ea1bd..747a620 100644 --- a/Sources/Tic/Views/NoteHeaderView.swift +++ b/Sources/Tic/Views/NoteHeaderView.swift @@ -80,7 +80,8 @@ struct NoteHeaderView: View { .animation(.easeInOut(duration: 0.15), value: isRevealed) .animation(.easeInOut(duration: 0.15), value: controller.completedCount > 0) .confirmationDialog( - "Delete \(controller.completedCount) completed task\(controller.completedCount == 1 ? "" : "s")? This can't be undone.", + "Delete \(controller.completedCount) completed task\(controller.completedCount == 1 ? "" : "s")? " + + "This can't be undone.", isPresented: $showClearConfirm, titleVisibility: .visible ) { diff --git a/Sources/Tic/Views/NoteView.swift b/Sources/Tic/Views/NoteView.swift index b8ca39e..e359d55 100644 --- a/Sources/Tic/Views/NoteView.swift +++ b/Sources/Tic/Views/NoteView.swift @@ -32,8 +32,6 @@ struct NoteView: View { @Bindable var controller: NoteController @State private var titleText: String - @State private var newTaskText: String = "" - @State private var newTaskLevel: Int = 0 @State private var revealed = false @State private var draggingTaskID: TaskItem.ID? @State private var dragOffsetY: CGFloat = 0 @@ -51,7 +49,6 @@ struct NoteView: View { // bottom of that group. Holds the section root (level-0) id, so the affordance stays put as the // pointer moves across the heading, its children, and the affordance itself — it never darts away. @State private var activeSectionID: TaskItem.ID? - @State private var quickAddFocused = false // show the quick-add's newline hint only while typing // The task whose image is under the pointer or being cropped; its row's reorder drag is paused. @State private var imageInteractionTaskID: TaskItem.ID? @FocusState private var titleFocused: Bool @@ -126,7 +123,7 @@ struct NoteView: View { titleFocused: $titleFocused ) taskList - quickAdd + QuickAddBar(controller: controller, theme: theme) } } @@ -182,7 +179,8 @@ struct NoteView: View { } .overlay(alignment: .top) { if display.isEmpty { - Text(controller.tasks.isEmpty ? "No tasks yet — add one below" : "All done — completed tasks hidden") + Text(controller.tasks.isEmpty + ? "No tasks yet — add one below" : "All done — completed tasks hidden") .font(.callout) .foregroundStyle(theme.secondary) .padding(.top, 12) @@ -232,14 +230,14 @@ struct NoteView: View { /// Where to show the "Add subtask" affordance: after the last row of the hovered heading's group, /// indented one level under the heading. Hidden during a drag or while a rapid-add run is already /// in progress (Return drives that, so the affordance would only be noise). - private var activeAdd: (afterIndex: Int, level: Int, parentID: TaskItem.ID)? { + private var activeAdd: ActiveAdd? { guard !isDragging, addingRowID == nil, let sectionID = activeSectionID else { return nil } let display = controller.displayedTasks guard let rootIndex = display.firstIndex(where: { $0.id == sectionID }) else { return nil } let root = display[rootIndex] guard root.indentLevel < TaskItem.maxIndentLevel else { return nil } let afterIndex = TaskOutline.subtreeRange(display, at: rootIndex).upperBound - 1 - return (afterIndex, root.indentLevel + 1, root.id) + return ActiveAdd(afterIndex: afterIndex, level: root.indentLevel + 1, parentID: root.id) } private func row(_ task: TaskItem) -> some View { @@ -295,7 +293,18 @@ struct NoteView: View { including: controller.isReorderable && imageInteractionTaskID != task.id ? .all : .subviews ) } +} + +/// Where the "Add subtask" affordance sits: after row `afterIndex`, indented to `level`, under `parentID`. +private struct ActiveAdd { + let afterIndex: Int + let level: Int + let parentID: TaskItem.ID +} + +// MARK: - Reorder drag +extension NoteView { /// Records whether `id`'s image is being pointed at or cropped; that row's reorder drag pauses meanwhile. private func setImageInteraction(_ id: TaskItem.ID, _ active: Bool) { if active { @@ -362,120 +371,6 @@ struct NoteView: View { .padding(.leading, 12 + CGFloat(level) * NoteLayout.indentStep) .padding(.trailing, 12) } - - // MARK: - Quick add - - private var quickAdd: some View { - VStack(alignment: .leading, spacing: 8) { - if controller.pendingImage != nil { pendingImageChip } - quickAddField - } - .padding(.leading, 16 + CGFloat(effectiveNewTaskLevel) * NoteLayout.indentStep) - .padding(.trailing, 16) - .padding(.vertical, 12) - .background(theme.accent.opacity(theme.isGlass ? 0.04 : 0.08)) - .animation(.snappy(duration: 0.15), value: effectiveNewTaskLevel) - } - - /// An image pasted into the quick-add, waiting for Return: a small thumbnail, lined up with the text, - /// with a ✕ to discard it. - private var pendingImageChip: some View { - ZStack(alignment: .topTrailing) { - Group { - if let thumbnail = controller.pendingThumbnail { - Image(decorative: thumbnail, scale: 1) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(theme.accent.opacity(0.15)) - .aspectRatio(1, contentMode: .fit) - } - } - .frame(maxWidth: 160, maxHeight: 44, alignment: .leading) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - - Button { controller.discardPendingImage() } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .black.opacity(0.55)) - } - .buttonStyle(.plain) - .offset(x: 6, y: -6) - .help("Discard image") - } - .padding(.leading, 24) // the ⊕ icon + spacing, so the chip sits over the text - .transition(.opacity) - } - - /// The ⊕ and editor line of the quick-add bar. - private var quickAddField: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 16)) - .foregroundStyle(theme.accent.opacity(0.7)) - - // Multiline like the rows: Return adds the task, Shift/Option-Return inserts a newline, - // and Shift-Tab / Ctrl-Shift-Tab pre-sets the nesting level of the task being typed. - PlainTextEditor( - text: $newTaskText, - textColor: theme.task, - focusRequest: controller.quickAddFocusRequest, - // Focus loss adds typed text too — but not while an image is waiting: that stays put until - // Return (`onSubmit`), so clicking away can never create an image task by accident. - onCommit: { if controller.pendingImage == nil { submitNewTask() } }, - onSubmit: { submitNewTask() }, - onIndent: { adjustNewTaskLevel(by: 1) }, - onOutdent: { adjustNewTaskLevel(by: -1) }, - onFocusChange: { focused in - withAnimation(.easeInOut(duration: 0.15)) { quickAddFocused = focused } - }, - onPasteImage: { controller.stageImage($0) } - ) - .overlay(alignment: .topLeading) { - if newTaskText.isEmpty { - Text(effectiveNewTaskLevel > 0 ? "Add a subtask…" : "Add a task…") - .foregroundStyle(theme.secondary) - .padding(.top, PlainTextEditor.topInset) // align with the editor's text inset - .allowsHitTesting(false) - } - } - .frame(maxWidth: .infinity, alignment: .leading) // fill the bar so text wraps at the note width - .editorFirstBaseline() - - // Minimal hint: the newline shortcut, shown only while the quick-add has focus. - if quickAddFocused { - ShortcutHint(glyphs: "⇧⏎", label: "line", theme: theme) - .transition(.opacity) - } - } - } - - /// The pending indent clamped to what the current last row allows — i.e. the level a new task - /// will *actually* land at. Computed from the live task list so the field's indent and - /// placeholder stay truthful even after the list changes by delete / outdent / reorder. - private var effectiveNewTaskLevel: Int { - let maxAllowed = controller.tasks.last.map { min(TaskItem.maxIndentLevel, $0.indentLevel + 1) } ?? 0 - return min(max(newTaskLevel, 0), maxAllowed) - } - - /// Adds the pending task (if any) — with any image waiting in the field — at the effective indent - /// level and clears the field. Called on Return, and on focus loss while no image is waiting (the - /// editor keeps focus after Return, so rapid entry still works). - private func submitNewTask() { - let hasText = !newTaskText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - guard hasText || controller.pendingImage != nil else { return } - controller.addTaskFromQuickAdd(newTaskText, level: effectiveNewTaskLevel) - newTaskText = "" - } - - /// Nudges the pending new-task level, clamped to what the last existing row allows so the - /// quick-add indent can never promise a depth the outline wouldn't accept. - private func adjustNewTaskLevel(by delta: Int) { - let maxAllowed = controller.tasks.last.map { min(TaskItem.maxIndentLevel, $0.indentLevel + 1) } ?? 0 - newTaskLevel = min(max(effectiveNewTaskLevel + delta, 0), maxAllowed) - } } /// The vertical midpoint of a task row, reported up so the drag can compute where a dragged row diff --git a/Sources/Tic/Views/QuickAddBar.swift b/Sources/Tic/Views/QuickAddBar.swift new file mode 100644 index 0000000..d6e5071 --- /dev/null +++ b/Sources/Tic/Views/QuickAddBar.swift @@ -0,0 +1,124 @@ +import SwiftUI + +/// The bar along the bottom of a note that adds a task: ⊕ plus a multiline editor, with any image pasted +/// while nothing is being edited waiting above it until Return. Owns the typed text and pending indent. +struct QuickAddBar: View { + let controller: NoteController + let theme: NoteTheme + + @State private var newTaskText: String = "" + @State private var newTaskLevel: Int = 0 + @State private var quickAddFocused = false // show the newline hint only while typing + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if controller.pendingImage != nil { pendingImageChip } + quickAddField + } + .padding(.leading, 16 + CGFloat(effectiveNewTaskLevel) * NoteLayout.indentStep) + .padding(.trailing, 16) + .padding(.vertical, 12) + .background(theme.accent.opacity(theme.isGlass ? 0.04 : 0.08)) + .animation(.snappy(duration: 0.15), value: effectiveNewTaskLevel) + } + + /// An image pasted into the quick-add, waiting for Return: a small thumbnail, lined up with the text, + /// with a ✕ to discard it. + private var pendingImageChip: some View { + ZStack(alignment: .topTrailing) { + Group { + if let thumbnail = controller.pendingThumbnail { + Image(decorative: thumbnail, scale: 1) + .resizable() + .scaledToFit() + } else { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(theme.accent.opacity(0.15)) + .aspectRatio(1, contentMode: .fit) + } + } + .frame(maxWidth: 160, maxHeight: 44, alignment: .leading) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + + Button { controller.discardPendingImage() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.55)) + } + .buttonStyle(.plain) + .offset(x: 6, y: -6) + .help("Discard image") + } + .padding(.leading, 24) // the ⊕ icon + spacing, so the chip sits over the text + .transition(.opacity) + } + + /// The ⊕ and editor line of the quick-add bar. + private var quickAddField: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(theme.accent.opacity(0.7)) + + // Multiline like the rows: Return adds the task, Shift/Option-Return inserts a newline, + // and Shift-Tab / Ctrl-Shift-Tab pre-sets the nesting level of the task being typed. + PlainTextEditor( + text: $newTaskText, + textColor: theme.task, + focusRequest: controller.quickAddFocusRequest, + // Focus loss adds typed text too — but not while an image is waiting: that stays put until + // Return (`onSubmit`), so clicking away can never create an image task by accident. + onCommit: { if controller.pendingImage == nil { submitNewTask() } }, + onSubmit: { submitNewTask() }, + onIndent: { adjustNewTaskLevel(by: 1) }, + onOutdent: { adjustNewTaskLevel(by: -1) }, + onFocusChange: { focused in + withAnimation(.easeInOut(duration: 0.15)) { quickAddFocused = focused } + }, + onPasteImage: { controller.stageImage($0) } + ) + .overlay(alignment: .topLeading) { + if newTaskText.isEmpty { + Text(effectiveNewTaskLevel > 0 ? "Add a subtask…" : "Add a task…") + .foregroundStyle(theme.secondary) + .padding(.top, PlainTextEditor.topInset) // align with the editor's text inset + .allowsHitTesting(false) + } + } + .frame(maxWidth: .infinity, alignment: .leading) // fill the bar so text wraps at the note width + .editorFirstBaseline() + + // Minimal hint: the newline shortcut, shown only while the quick-add has focus. + if quickAddFocused { + ShortcutHint(glyphs: "⇧⏎", label: "line", theme: theme) + .transition(.opacity) + } + } + } + + /// The pending indent clamped to what the current last row allows — i.e. the level a new task + /// will *actually* land at. Computed from the live task list so the field's indent and + /// placeholder stay truthful even after the list changes by delete / outdent / reorder. + private var effectiveNewTaskLevel: Int { + let maxAllowed = controller.tasks.last.map { min(TaskItem.maxIndentLevel, $0.indentLevel + 1) } ?? 0 + return min(max(newTaskLevel, 0), maxAllowed) + } + + /// Adds the pending task (if any) — with any image waiting in the field — at the effective indent + /// level and clears the field. Called on Return, and on focus loss while no image is waiting (the + /// editor keeps focus after Return, so rapid entry still works). + private func submitNewTask() { + let hasText = !newTaskText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard hasText || controller.pendingImage != nil else { return } + controller.addTaskFromQuickAdd(newTaskText, level: effectiveNewTaskLevel) + newTaskText = "" + } + + /// Nudges the pending new-task level, clamped to what the last existing row allows so the + /// quick-add indent can never promise a depth the outline wouldn't accept. + private func adjustNewTaskLevel(by delta: Int) { + let maxAllowed = controller.tasks.last.map { min(TaskItem.maxIndentLevel, $0.indentLevel + 1) } ?? 0 + newTaskLevel = min(max(effectiveNewTaskLevel + delta, 0), maxAllowed) + } +} diff --git a/Sources/Tic/Windows/NoteWindowManager.swift b/Sources/Tic/Windows/NoteWindowManager.swift index 3556d17..5dd7d3a 100644 --- a/Sources/Tic/Windows/NoteWindowManager.swift +++ b/Sources/Tic/Windows/NoteWindowManager.swift @@ -23,10 +23,10 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { // MARK: - Opening notes - /// Opens a floating panel for every saved note (called at launch). + /// Opens a floating panel for every note that was on screen at last quit (called at launch). func restoreAll() async { do { - let notes = try await appDatabase.allNotes() + let notes = try await appDatabase.openNotes() for note in notes { openNote(note, makeKey: false) } // launch: show, don't steal focus NSLog("[Tic] restored \(notes.count) note panel(s)") } catch { @@ -43,6 +43,9 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { existing.makeKeyAndOrderFront(nil) return existing } + if !note.isOpen { + Task { [appDatabase] in try? await appDatabase.updateNoteOpen(id: note.id, isOpen: true) } + } let controller = NoteController(note: note, database: appDatabase) controllers[note.id] = controller controller.start() @@ -56,7 +59,9 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { controller.onApplyBehavior = { [weak panel] floatOnTop, showOnAllSpaces in panel?.apply(floatOnTop: floatOnTop, showOnAllSpaces: showOnAllSpaces) } - controller.onClose = { [weak panel] in + controller.onClose = { [weak panel, appDatabase] in + // Only the user's X marks a note closed (quit doesn't), so launch restores what was visible. + Task { try? await appDatabase.updateNoteOpen(id: note.id, isOpen: false) } panel?.close() // triggers windowWillClose → teardown; does NOT delete the note } controller.onSetCollapsed = { [weak self, weak panel] collapsed in @@ -154,13 +159,15 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { panel.setFrame(newFrame, display: true, animate: animate) } - /// If a restored frame leaves the note essentially off-screen (e.g. a display was - /// disconnected since it was last saved), recenter it on the main screen so it's never lost. + /// If a restored frame leaves the note essentially off every display (e.g. the one it was on + /// was disconnected since it was last saved), recenter it on the main screen so it's never lost. + /// Frames are global coordinates spanning all displays, so a note on a second monitor restores + /// there as long as some screen still contains it. private func ensureOnScreen(_ panel: NotePanel) { guard let screen = NSScreen.main else { return } - let visible = screen.visibleFrame - let overlap = visible.intersection(panel.frame) - if overlap.width < 80 || overlap.height < 80 { + let screens = NSScreen.screens.map(\.visibleFrame) + if !Self.isVisible(panel.frame, onAnyOf: screens) { + let visible = screen.visibleFrame let origin = NSPoint( x: visible.midX - panel.frame.width / 2, y: visible.midY - panel.frame.height / 2 @@ -169,6 +176,14 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { } } + /// True when at least an 80×80 corner of `frame` lies on one of `screens` (enough to grab). + nonisolated static func isVisible(_ frame: CGRect, onAnyOf screens: [CGRect]) -> Bool { + screens.contains { screen in + let overlap = screen.intersection(frame) + return overlap.width >= 80 && overlap.height >= 80 + } + } + // MARK: - Image window /// Shows a task's image in the image window, creating the window on first use. It takes the note's diff --git a/Tests/TicTests/AppDatabaseTests.swift b/Tests/TicTests/AppDatabaseTests.swift index 3e46ef2..ad7a3f8 100644 --- a/Tests/TicTests/AppDatabaseTests.swift +++ b/Tests/TicTests/AppDatabaseTests.swift @@ -60,6 +60,22 @@ struct AppDatabaseTests { #expect(next.title == "List 3") } + @Test("openNotes lists only notes still open; updateNoteOpen flips just that flag") + func openNotesFollowsIsOpen() async throws { + let db = try makeDB() + let kept = try await db.insertNewNote(Note()) + let closed = try await db.insertNewNote(Note(title: "Closed", floatOnTop: true)) + try await db.updateNoteOpen(id: closed.id, isOpen: false) + + #expect(try await db.openNotes().map(\.id) == [kept.id]) + let stillThere = try await db.allNotes().first { $0.id == closed.id } + #expect(stillThere?.isOpen == false) + #expect(stillThere?.title == "Closed" && stillThere?.floatOnTop == true) + + try await db.updateNoteOpen(id: closed.id, isOpen: true) + #expect(try await db.openNotes().map(\.id) == [kept.id, closed.id]) + } + @Test("insertNewNote assigns an increasing sortIndex") func sortIndexIncrements() async throws { let db = try makeDB() diff --git a/Tests/TicTests/NoteWindowManagerTests.swift b/Tests/TicTests/NoteWindowManagerTests.swift new file mode 100644 index 0000000..e56c317 --- /dev/null +++ b/Tests/TicTests/NoteWindowManagerTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import Tic + +@Suite("NoteWindowManager placement") +struct NoteWindowManagerTests { + // Two side-by-side displays in global coordinates: the main one at the origin, a second to its right. + private let main = CGRect(x: 0, y: 0, width: 1440, height: 875) + private let second = CGRect(x: 1440, y: 0, width: 2560, height: 1415) + + @Test("a note on the second display counts as visible, not lost") + func secondDisplayIsVisible() { + let onSecond = CGRect(x: 2000, y: 300, width: 280, height: 360) + #expect(NoteWindowManager.isVisible(onSecond, onAnyOf: [main, second])) + #expect(!NoteWindowManager.isVisible(onSecond, onAnyOf: [main])) // display unplugged + } + + @Test("a sliver hanging off every edge is lost; an 80×80 corner is enough") + func slivers() { + let sliver = CGRect(x: -250, y: 300, width: 280, height: 360) // 30pt showing + #expect(!NoteWindowManager.isVisible(sliver, onAnyOf: [main, second])) + let corner = CGRect(x: -200, y: -280, width: 280, height: 360) // exactly 80×80 showing + #expect(NoteWindowManager.isVisible(corner, onAnyOf: [main, second])) + } +}