From a4f0a4ef8672e04aaf68496f4eb9fceb33a46e2a Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 22 Feb 2026 12:20:35 +0100 Subject: [PATCH 1/2] Improve editor scrollbar visibility behavior --- Ghostly/Styles/GhostlyModifiers.swift | 241 ++++++++++++++++++++++++++ Ghostly/Views/ContentView.swift | 1 + 2 files changed, 242 insertions(+) diff --git a/Ghostly/Styles/GhostlyModifiers.swift b/Ghostly/Styles/GhostlyModifiers.swift index 7518801..0ba3508 100644 --- a/Ghostly/Styles/GhostlyModifiers.swift +++ b/Ghostly/Styles/GhostlyModifiers.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit // MARK: - Inner Glow Modifier @@ -62,3 +63,243 @@ extension View { modifier(CatShadowModifier(color: color, radius: radius, x: x, y: y)) } } + +// MARK: - TextEditor Scrollbar Behavior + +struct TextEditorScrollbarBehavior: NSViewRepresentable { + var fadeDelay: TimeInterval = 1.0 + + func makeCoordinator() -> Coordinator { + Coordinator(fadeDelay: fadeDelay) + } + + func makeNSView(context: Context) -> NSView { + let anchorView = NSView(frame: .zero) + Task { @MainActor in + context.coordinator.attachIfNeeded(from: anchorView) + } + return anchorView + } + + func updateNSView(_ nsView: NSView, context: Context) { + Task { @MainActor in + context.coordinator.fadeDelay = fadeDelay + context.coordinator.attachIfNeeded(from: nsView) + context.coordinator.updateScrollerVisibility() + } + } +} + +extension TextEditorScrollbarBehavior { + @MainActor + final class Coordinator { + var fadeDelay: TimeInterval + + private weak var scrollView: NSScrollView? + private weak var observedDocumentView: NSView? + private var clipBoundsObserver: NSObjectProtocol? + private var documentFrameObserver: NSObjectProtocol? + private var scrollViewFrameObserver: NSObjectProtocol? + private var fadeWorkItem: DispatchWorkItem? + + init(fadeDelay: TimeInterval) { + self.fadeDelay = fadeDelay + } + + func attachIfNeeded(from anchorView: NSView) { + guard let resolvedScrollView = locateTextEditorScrollView(from: anchorView) else { + return + } + + guard resolvedScrollView !== scrollView else { + return + } + + detachObservers() + scrollView = resolvedScrollView + configure(scrollView: resolvedScrollView) + updateScrollerVisibility() + } + + func updateScrollerVisibility() { + guard let scrollView else { + return + } + + updateDocumentFrameObserverIfNeeded(for: scrollView) + + let canScroll = canScrollVertically(in: scrollView) + scrollView.scrollerStyle = .overlay + scrollView.hasVerticalScroller = canScroll + + guard let verticalScroller = scrollView.verticalScroller else { + return + } + + verticalScroller.isHidden = !canScroll + if !canScroll { + fadeWorkItem?.cancel() + verticalScroller.alphaValue = 0 + } + } + + private func configure(scrollView: NSScrollView) { + scrollView.scrollerStyle = .overlay + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = false + scrollView.contentView.postsBoundsChangedNotifications = true + scrollView.postsFrameChangedNotifications = true + + clipBoundsObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleScrollActivity() + } + } + + scrollViewFrameObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateScrollerVisibility() + } + } + } + + private func updateDocumentFrameObserverIfNeeded(for scrollView: NSScrollView) { + guard scrollView.documentView !== observedDocumentView else { + return + } + + if let documentFrameObserver { + NotificationCenter.default.removeObserver(documentFrameObserver) + self.documentFrameObserver = nil + } + + observedDocumentView = scrollView.documentView + observedDocumentView?.postsFrameChangedNotifications = true + + if let observedDocumentView { + documentFrameObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: observedDocumentView, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateScrollerVisibility() + } + } + } + } + + private func handleScrollActivity() { + guard let scrollView else { + return + } + + updateScrollerVisibility() + + guard canScrollVertically(in: scrollView), let verticalScroller = scrollView.verticalScroller else { + return + } + + verticalScroller.isHidden = false + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + verticalScroller.animator().alphaValue = 1 + } + + scheduleFade() + } + + private func scheduleFade() { + fadeWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + self?.fadeScrollerIfNeeded() + } + } + fadeWorkItem = workItem + + DispatchQueue.main.asyncAfter(deadline: .now() + fadeDelay, execute: workItem) + } + + private func fadeScrollerIfNeeded() { + guard let scrollView, canScrollVertically(in: scrollView), let verticalScroller = scrollView.verticalScroller else { + return + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + verticalScroller.animator().alphaValue = 0 + } + } + + private func canScrollVertically(in scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { + return false + } + + let visibleHeight = scrollView.contentView.bounds.height + guard visibleHeight > 0 else { + return false + } + + let contentHeight = max(documentView.frame.height, documentView.fittingSize.height) + return (contentHeight - visibleHeight) > 1 + } + + private func locateTextEditorScrollView(from anchorView: NSView) -> NSScrollView? { + var node: NSView? = anchorView + while let currentNode = node { + if let scrollView = findTextEditorScrollView(in: currentNode) { + return scrollView + } + node = currentNode.superview + } + return nil + } + + private func findTextEditorScrollView(in view: NSView) -> NSScrollView? { + if let scrollView = view as? NSScrollView, scrollView.documentView is NSTextView { + return scrollView + } + + for subview in view.subviews { + if let found = findTextEditorScrollView(in: subview) { + return found + } + } + + return nil + } + + private func detachObservers() { + if let clipBoundsObserver { + NotificationCenter.default.removeObserver(clipBoundsObserver) + self.clipBoundsObserver = nil + } + + if let documentFrameObserver { + NotificationCenter.default.removeObserver(documentFrameObserver) + self.documentFrameObserver = nil + } + + if let scrollViewFrameObserver { + NotificationCenter.default.removeObserver(scrollViewFrameObserver) + self.scrollViewFrameObserver = nil + } + + fadeWorkItem?.cancel() + fadeWorkItem = nil + observedDocumentView = nil + scrollView = nil + } + } +} diff --git a/Ghostly/Views/ContentView.swift b/Ghostly/Views/ContentView.swift index 191c128..cb97402 100644 --- a/Ghostly/Views/ContentView.swift +++ b/Ghostly/Views/ContentView.swift @@ -63,6 +63,7 @@ struct ContentView: View { .tracking(0.3) .lineSpacing(4) .scrollContentBackground(.hidden) + .background(TextEditorScrollbarBehavior(fadeDelay: 0.9)) .padding(.leading, -5) .foregroundStyle(Color.catText) .accessibilityIdentifier("mainTextEditor") From d9f96934a98de2d685df6351b3b46047f2e84cf9 Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 22 Feb 2026 23:46:46 +0100 Subject: [PATCH 2/2] sync beads: update gitignore and issue data --- .beads/.gitignore | 55 +++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/.beads/.gitignore b/.beads/.gitignore index 2b12848..dba6914 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,38 +1,20 @@ -# SQLite databases -*.db -*.db?* -*.db-journal -*.db-wal -*.db-shm +# Dolt database (managed by Dolt, not git) +dolt/ +dolt-access.lock -# Daemon runtime files -daemon.lock -daemon.log -daemon-*.log.gz -daemon.pid +# Runtime files bd.sock +bd.sock.startlock sync-state.json last-touched # Local version tracking (prevents upgrade notification spam after git ops) .local_version -# Legacy database files -db.sqlite -bd.db - # Worktree redirect file (contains relative path to main repo's .beads/) # Must not be committed as paths would be wrong in other clones redirect -# Merge artifacts (temporary files from 3-way merge) -beads.base.jsonl -beads.base.meta.json -beads.left.jsonl -beads.left.meta.json -beads.right.jsonl -beads.right.meta.json - # Sync state (local-only, per-machine) # These files are machine-specific and should not be shared across clones .sync.lock @@ -40,9 +22,30 @@ beads.right.meta.json sync_base.jsonl export-state/ -# Dolt database (managed by Dolt remotes, not git) -dolt/ -dolt-access.lock +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Legacy files (from pre-Dolt versions) +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db +daemon.lock +daemon.log +daemon-*.log.gz +daemon.pid +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json # NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. # They would override fork protection in .git/info/exclude, allowing