Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 29 additions & 26 deletions .beads/.gitignore
Original file line number Diff line number Diff line change
@@ -1,48 +1,51 @@
# 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
.jsonl.lock
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
Expand Down
241 changes: 241 additions & 0 deletions Ghostly/Styles/GhostlyModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import AppKit

// MARK: - Inner Glow Modifier

Expand Down Expand Up @@ -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
}
}
}
1 change: 1 addition & 0 deletions Ghostly/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down