From d50547bb42f8705976ca77238c30ee8e7c046b35 Mon Sep 17 00:00:00 2001 From: RIKI YANAI HERNANDEZ <33168192+rikiyanai@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:08:10 +0900 Subject: [PATCH] feat: Notion-style implicit toggle body + debounced saves - toggle body is now implicit: everything under a title down to the next toggle (or EOF) collapses; ledger maintains only title lines (shift / re-anchor / dedupe), removing the boundary bug class - title-touching edits re-anchor instead of dropping the toggle - saves coalesced to 1/sec (was full-canvas write per keystroke), flushed synchronously on quit/Clear/terminate --- TermiNotesAppKit.swift | 138 +++++++++++++++++++++++++++-------------- docs/FAILURE_LOG.md | 4 ++ 2 files changed, 96 insertions(+), 46 deletions(-) diff --git a/TermiNotesAppKit.swift b/TermiNotesAppKit.swift index e52d212..f16357b 100644 --- a/TermiNotesAppKit.swift +++ b/TermiNotesAppKit.swift @@ -20,12 +20,14 @@ class TerminalSanitizer { // MARK: - Toggle List Model -/// A Notion-style toggle: `titleLine` stays visible, lines titleLine+1...bodyEndLine fold away. -/// Line numbers are 0-based global line indexes into the canvas; adjusted on every text edit -/// by the ledger in TermiNotesController.textStorage(didProcessEditing:). +/// A Notion-style toggle: `titleLine` stays visible; EVERYTHING below it — down to the +/// next toggle's title (or end of document) — folds away. The body end is IMPLICIT, like +/// Notion's container: no boundary to maintain, and anything typed/pasted underneath the +/// title is automatically inside. Line numbers are 0-based global line indexes, adjusted +/// on every text edit by the ledger in TermiNotesController.textStorage(didProcessEditing:). +/// (Old toggles.json entries with an explicit bodyEndLine still decode — extra keys ignored.) struct ToggleEntry: Codable { var titleLine: Int - var bodyEndLine: Int var collapsed: Bool } @@ -375,7 +377,7 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD } func textDidChange(_ notification: Notification) { - saveContent() + scheduleSave() if !toggles.isEmpty || foldsRendered { saveToggles() renderToggles() @@ -384,6 +386,24 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD } } + /// Coalesce per-keystroke writes: the canvas is saved at most once per second + /// instead of on every keystroke (rewriting a 430KB+ file per keystroke was the + /// real performance cost). Flushed synchronously via flushSave() on quit. + private var saveWorkItem: DispatchWorkItem? + + func scheduleSave() { + saveWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in self?.saveContent() } + saveWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: work) + } + + func flushSave() { + saveWorkItem?.cancel() + saveWorkItem = nil + saveContent() + } + func updateWidth() { guard let textContainer = textView.textContainer, let layoutManager = textView.layoutManager else { return } @@ -429,11 +449,11 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD if textView.shouldChangeText(in: range, replacementString: "") { textView.textStorage?.replaceCharacters(in: range, with: "") textView.didChangeText() - saveContent() + flushSave() } } @objc func quitApp() { - saveContent() + flushSave() NSApplication.shared.terminate(nil) } @@ -475,9 +495,22 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD return NSRange(location: ra.location, length: rb.upperBound - ra.location) } + /// The implicit body end (inclusive): the line before the next toggle's title, + /// or the last line of the document. `i` is the index into `toggles` (sorted). + func bodyEnd(forToggleAt i: Int) -> Int { + let s = textView.string as NSString + let lastLine = lineIndex(ofChar: s.length, in: s) + guard i >= 0, i < toggles.count else { return lastLine } + let next = i + 1 < toggles.count ? toggles[i + 1].titleLine - 1 : lastLine + return min(next, lastLine) + } + func toggle(containing line: Int) -> ToggleEntry? { guard line >= 0 else { return nil } - return toggles.first { line >= $0.titleLine && line <= $0.bodyEndLine } + for (i, t) in toggles.enumerated() where line >= t.titleLine { + if line <= bodyEnd(forToggleAt: i) { return t } + } + return nil } func hasSelection() -> Bool { @@ -508,10 +541,11 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD } guard lastBodyLine > title else { return } - // v1: no nesting — a new toggle flattens any it overlaps. - toggles.removeAll { $0.titleLine <= lastBodyLine && $0.bodyEndLine >= title } + // Same-line titles replace; a new title inside another toggle's body simply + // splits it (the body above now ends at this title — implicit ends). + toggles.removeAll { $0.titleLine == title } // Created expanded (Notion behavior): the body stays visible until the user collapses it. - toggles.append(ToggleEntry(titleLine: title, bodyEndLine: lastBodyLine, collapsed: false)) + toggles.append(ToggleEntry(titleLine: title, collapsed: false)) toggles.sort { $0.titleLine < $1.titleLine } saveToggles() renderToggles() @@ -523,13 +557,17 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD @discardableResult func flipToggle(atLine line: Int, titleOnly: Bool = false) -> Bool { - guard line >= 0, let i = toggles.firstIndex(where: { - line >= $0.titleLine && line <= $0.bodyEndLine && (!titleOnly || line == $0.titleLine) - }) else { return false } - toggles[i].collapsed.toggle() - saveToggles() - renderToggles() - return true + guard line >= 0 else { return false } + for (i, t) in toggles.enumerated() { + let inRange = titleOnly ? (line == t.titleLine) : (line >= t.titleLine && line <= bodyEnd(forToggleAt: i)) + if inRange { + toggles[i].collapsed.toggle() + saveToggles() + renderToggles() + return true + } + } + return false } @objc func flipContextToggle(_ sender: Any?) { @@ -538,12 +576,10 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD @objc func removeContextToggle(_ sender: Any?) { let line = textView.contextMenuLine - let before = toggles.count - toggles.removeAll { $0.titleLine <= line && $0.bodyEndLine >= line } - if toggles.count != before { - saveToggles() - renderToggles() - } + guard let t = toggle(containing: line) else { return } + toggles.removeAll { $0.titleLine == t.titleLine } + saveToggles() + renderToggles() } /// Cmd+\ — flip the toggle containing the caret, if any. @@ -551,8 +587,10 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD _ = flipToggle(atLine: lineIndex(ofChar: textView.selectedRange().location)) } - /// Line-number ledger: shift toggle boundaries on every edit so folds track the text. - /// Runs in didProcessEditing (pre-save), against lastString (pre-edit snapshot). + /// Line-number ledger: shift toggle TITLE lines on every edit so folds track the text. + /// Body ends are implicit (next title / EOF), so the only state to maintain is the + /// title line itself — no per-edit boundary bookkeeping. Runs in didProcessEditing + /// (pre-save), against lastString (pre-edit snapshot). func adjustToggles(editedRange: NSRange, changeInLength delta: Int) { let newS = textView.string as NSString let oldS = lastString as NSString @@ -564,36 +602,37 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD let oldEditEndLine = lineIndex(ofChar: preRange.upperBound, in: oldS) let newEditEndLine = lineIndex(ofChar: min(editedRange.upperBound, newS.length), in: newS) let shift = newEditEndLine - oldEditEndLine + let lastLine = lineIndex(ofChar: newS.length, in: newS) var out: [ToggleEntry] = [] out.reserveCapacity(toggles.count) for var t in toggles { - if t.bodyEndLine < editStartLine { - out.append(t) // entirely before the edit - } else if isInsertion { - // Nothing destroyed: shift the toggle only if the insertion lands - // at or before the title line's first character. + if isInsertion { + // Nothing destroyed: shift the title only if the insertion lands + // at or before the title line's first character. Anything below the + // title is automatically inside the toggle (implicit end). let titleStart = charRangeOfLine(t.titleLine)?.location ?? Int.max if editedRange.location <= titleStart { t.titleLine += shift - t.bodyEndLine += shift - } else { - t.bodyEndLine += shift } out.append(t) } else if t.titleLine > oldEditEndLine { t.titleLine += shift // entirely after the replaced region - t.bodyEndLine += shift out.append(t) } else if t.titleLine >= editStartLine { - continue // title line destroyed — drop the toggle + // Title line was inside the replaced region — re-anchor, don't drop: + // the line now at editStartLine becomes the title. (newS.length == 0 + // means the whole document was deleted — then the toggle dies too.) + t.titleLine = editStartLine + if t.titleLine <= lastLine && newS.length > 0 { out.append(t) } } else { - // Body partially eaten: clamp or shift the body end. - t.bodyEndLine = t.bodyEndLine > oldEditEndLine ? t.bodyEndLine + shift : newEditEndLine - if t.bodyEndLine > t.titleLine { out.append(t) } + out.append(t) // edit below the title: nothing to maintain } } - toggles = out + // Re-anchoring can collapse ordering or merge two titles onto one line. + out.sort { $0.titleLine < $1.titleLine } + var seen = Set() + toggles = out.filter { seen.insert($0.titleLine).inserted } } func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { @@ -609,8 +648,8 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD let sel = textView.selectedRange() guard sel.length == 0 else { return } let line = lineIndex(ofChar: sel.location) - for t in toggles where t.collapsed { - if line > t.titleLine && line <= t.bodyEndLine { + for (i, t) in toggles.enumerated() where t.collapsed { + if line > t.titleLine && line <= bodyEnd(forToggleAt: i) { if let tr = charRangeOfLine(t.titleLine) { let pos = max(tr.location, tr.upperBound - 1) if pos != sel.location { @@ -627,9 +666,10 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD let arr = try? JSONDecoder().decode([ToggleEntry].self, from: data) else { return } let s = textView.string as NSString let maxLine = lineIndex(ofChar: s.length, in: s) + var seen = Set() toggles = arr.compactMap { t in - guard t.titleLine >= 0, t.bodyEndLine > t.titleLine, t.titleLine <= maxLine else { return nil } - return ToggleEntry(titleLine: t.titleLine, bodyEndLine: min(t.bodyEndLine, maxLine), collapsed: t.collapsed) + guard t.titleLine >= 0, t.titleLine <= maxLine, seen.insert(t.titleLine).inserted else { return nil } + return ToggleEntry(titleLine: t.titleLine, collapsed: t.collapsed) }.sorted { $0.titleLine < $1.titleLine } } @@ -648,13 +688,15 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD lm.removeTemporaryAttribute(.font, forCharacterRange: full) lm.removeTemporaryAttribute(.foregroundColor, forCharacterRange: full) foldsRendered = !toggles.isEmpty - for t in toggles { + for (i, t) in toggles.enumerated() { guard let titleRange = charRangeOfLine(t.titleLine) else { continue } let tint = NSRange(location: titleRange.location, length: max(0, titleRange.length - 1)) lm.addTemporaryAttribute(.backgroundColor, value: NSColor.labelColor.withAlphaComponent(t.collapsed ? 0.14 : 0.07), forCharacterRange: tint) - if t.collapsed, let bodyRange = charRange(fromLine: t.titleLine + 1, throughLine: t.bodyEndLine), bodyRange.length > 0 { + let end = bodyEnd(forToggleAt: i) + if t.collapsed, end > t.titleLine, + let bodyRange = charRange(fromLine: t.titleLine + 1, throughLine: end), bodyRange.length > 0 { lm.addTemporaryAttribute(.font, value: NSFont.systemFont(ofSize: 0.1), forCharacterRange: bodyRange) lm.addTemporaryAttribute(.foregroundColor, value: NSColor.clear, forCharacterRange: bodyRange) } @@ -919,6 +961,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } + + func applicationWillTerminate(_ notification: Notification) { + controller.flushSave() + } } // MARK: - Screenshot sidebar views diff --git a/docs/FAILURE_LOG.md b/docs/FAILURE_LOG.md index a69bbaa..0ea05cc 100644 --- a/docs/FAILURE_LOG.md +++ b/docs/FAILURE_LOG.md @@ -13,6 +13,10 @@ - Screenshot thumbnail single-click now copies the entry's **file path** (the image copy was silent and indistinguishable from failure; text copy also can't boomerang into the image watcher). Double-click still inserts a markdown image link. - Thumbnail clicks were being swallowed: `mouseDragged` started a drag session with **zero movement threshold**, so any click with even 1-2px of hand jitter became a micro-drag and `mouseUp` never fired. Fixed with a 4px drag threshold plus explicit down/drag state; verified with synthetic wobble-click (fires) vs real-drag (doesn't) through `NSApp.sendEvent`. - Crash vector removed: `NSApp.currentEvent?.clickCount` throws `NSInternalInconsistencyException` when the current event is not a mouse event (reproduced with a synthetic `KeyUp`). Click count is now passed from the actual `mouseUp` event. + - Pasting with a selection that covers a toggle's title line **dropped the toggle** (chevron "disappears" while the title text remains): the ledger's only drop path fired on any title-touching edit. Replaced with re-anchoring — the line at the edit position becomes the title (first pasted line on replace, first surviving body line on delete); the toggle now dies only when title *and* body are both deleted. Locked by 13 replica ledger cases. + - Notion container semantics for the body: an insertion at the toggle's tail (Enter at the end of the last body line, or a paste on the line right after the body) now **joins the toggle** instead of falling outside it; replacements/deletions below never grow it. Locked by 17 replica ledger cases. + - **Implicit body end (the actual Notion model):** the explicit `bodyEndLine` is gone — a toggle's body is now *everything under its title down to the next toggle's title (or EOF)*. Anything typed/pasted underneath is automatically inside; the ledger maintains only title lines (shift on edits above, re-anchor on title edit, dedupe on merge), which removes the entire class of boundary bugs. Old `toggles.json` entries decode transparently (unknown keys ignored). Locked by 8 new-model replica cases. + - **Performance:** the canvas was being rewritten to disk on *every keystroke* (430KB+). Saves are now debounced/coalesced to at most once per second (`DispatchWorkItem`), flushed synchronously on quit / Clear / `applicationWillTerminate`. - The reported "app dies when clicking Toggle List" was **not a crash**: the unified log shows `termination reported by launchd (0, 0, 0)` — a clean exit via the app's own Quit path (Quit button / `Cmd+Q`). All toggle logic paths (create/flip/ledger/containment/clear) were verified headlessly against the real 7534-line document. - Toggle title is now the non-empty line directly above the selection; if none exists, a `Toggle` line is inserted. Previously the first selected line was consumed as the title. - Screenshots taken with `Cmd+Shift+3/4` (save-to-file) were never captured because they bypass the pasteboard; the watcher now also polls the macOS screenshot save folder (`com.apple.screencapture location`, default `~/Desktop`) and imports new image files once their size is stable across two polls.