Skip to content
Merged
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
133 changes: 69 additions & 64 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions Ghostly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
/* Begin PBXBuildFile section */
C82C210725C60B3D00CCCDF7 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C82C210625C60B3D00CCCDF7 /* HeaderView.swift */; };
C82C210A25C60B5E00CCCDF7 /* DropdownMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C82C210925C60B5E00CCCDF7 /* DropdownMenuView.swift */; };
EDSB000125F5000000000001 /* EditorTextViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDSB000025F5000000000000 /* EditorTextViewConfiguration.swift */; };
EDSB000325F5000000000003 /* EditorTextViewConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDSB000225F5000000000002 /* EditorTextViewConfigurationTests.swift */; };
GTED000125F6000000000001 /* GhostlyTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = GTED000025F6000000000000 /* GhostlyTextEditor.swift */; };
TABS000125F3000000000001 /* Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = TABS000025F3000000000000 /* Tab.swift */; };
TABM000125F3000000000001 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = TABM000025F3000000000000 /* TabManager.swift */; };
TABV000125F3000000000001 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TABV000025F3000000000000 /* TabBarView.swift */; };
Expand Down Expand Up @@ -49,6 +52,9 @@
/* Begin PBXFileReference section */
C82C210625C60B3D00CCCDF7 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = "<group>"; };
C82C210925C60B5E00CCCDF7 /* DropdownMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownMenuView.swift; sourceTree = "<group>"; };
EDSB000025F5000000000000 /* EditorTextViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTextViewConfiguration.swift; sourceTree = "<group>"; };
EDSB000225F5000000000002 /* EditorTextViewConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTextViewConfigurationTests.swift; sourceTree = "<group>"; };
GTED000025F6000000000000 /* GhostlyTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostlyTextEditor.swift; sourceTree = "<group>"; };
TABS000025F3000000000000 /* Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = "<group>"; };
TABM000025F3000000000000 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
TABV000025F3000000000000 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -104,6 +110,7 @@
C836C55A25A0171500BEB83F /* ContentView.swift */,
C82C210925C60B5E00CCCDF7 /* DropdownMenuView.swift */,
FOOT000025F1000000000000 /* FooterView.swift */,
GTED000025F6000000000000 /* GhostlyTextEditor.swift */,
C82C210625C60B3D00CCCDF7 /* HeaderView.swift */,
SETT000325D0C00000000003 /* SettingsView.swift */,
TABV000025F3000000000000 /* TabBarView.swift */,
Expand Down Expand Up @@ -170,6 +177,7 @@
UTIL000025F2000000000000 /* Utilities */ = {
isa = PBXGroup;
children = (
EDSB000025F5000000000000 /* EditorTextViewConfiguration.swift */,
MDTR000025F4000000000000 /* MarkdownTransformer.swift */,
TXST000025F2000000000000 /* TextStatistics.swift */,
);
Expand All @@ -188,6 +196,7 @@
isa = PBXGroup;
children = (
APPS000225E0000000000002 /* AppStateTests.swift */,
EDSB000225F5000000000002 /* EditorTextViewConfigurationTests.swift */,
KBSH000425E0000000000004 /* KeyboardShortcutTests.swift */,
MDTR000225F4000000000002 /* MarkdownTransformerTests.swift */,
SETT000525D0C00000000005 /* SettingsManagerTests.swift */,
Expand Down Expand Up @@ -311,13 +320,15 @@
CATP000125F0000000000001 /* CatppuccinColors.swift in Sources */,
C836C55B25A0171500BEB83F /* ContentView.swift in Sources */,
C82C210725C60B3D00CCCDF7 /* HeaderView.swift in Sources */,
GTED000125F6000000000001 /* GhostlyTextEditor.swift in Sources */,
KBSH000225E0000000000002 /* KeyboardShortcuts+Extensions.swift in Sources */,
SETT000225D0C00000000002 /* SettingsManager.swift in Sources */,
SETT000425D0C00000000004 /* SettingsView.swift in Sources */,
C82C210A25C60B5E00CCCDF7 /* DropdownMenuView.swift in Sources */,
C836C55925A0171500BEB83F /* GhostlyApp.swift in Sources */,
GMOD000125F1000000000001 /* GhostlyModifiers.swift in Sources */,
FOOT000125F1000000000001 /* FooterView.swift in Sources */,
EDSB000125F5000000000001 /* EditorTextViewConfiguration.swift in Sources */,
TXST000125F2000000000001 /* TextStatistics.swift in Sources */,
TABS000125F3000000000001 /* Tab.swift in Sources */,
TABM000125F3000000000001 /* TabManager.swift in Sources */,
Expand All @@ -331,6 +342,7 @@
buildActionMask = 2147483647;
files = (
APPS000325E0000000000003 /* AppStateTests.swift in Sources */,
EDSB000325F5000000000003 /* EditorTextViewConfigurationTests.swift in Sources */,
KBSH000625E0000000000006 /* KeyboardShortcutTests.swift in Sources */,
SETT000625D0C00000000006 /* SettingsManagerTests.swift in Sources */,
TABT000125F3000000000001 /* TabTests.swift in Sources */,
Expand Down
110 changes: 110 additions & 0 deletions Ghostly/Utilities/EditorTextViewConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// EditorTextViewConfiguration.swift
// Ghostly
//

import AppKit
import SwiftUI

enum EditorTextViewConfiguration {
static let fontSize: CGFloat = 14
static let tracking: CGFloat = 0.3
static let lineSpacing: CGFloat = 4

@MainActor
static func makeScrollView() -> NSScrollView {
let scrollView = NSTextView.scrollablePlainDocumentContentTextView()
configure(scrollView)

if let textView = scrollView.documentView as? NSTextView {
configure(textView)
}

return scrollView
}

@MainActor
static func configure(_ scrollView: NSScrollView) {
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.scrollerStyle = .overlay
scrollView.drawsBackground = false
scrollView.backgroundColor = .clear
scrollView.contentInsets = NSEdgeInsets()
}

@MainActor
static func configure(_ textView: NSTextView) {
textView.isRichText = false
textView.importsGraphics = false
textView.allowsUndo = true
textView.isEditable = true
textView.isSelectable = true
textView.drawsBackground = false
textView.backgroundColor = .clear
textView.textColor = NSColor(Color.catText)
textView.insertionPointColor = NSColor(Color.catLavender)
textView.font = .monospacedSystemFont(ofSize: fontSize, weight: .regular)
textView.minSize = .zero
textView.maxSize = .init(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.textContainerInset = .zero
textView.typingAttributes = textAttributes()

if let textContainer = textView.textContainer {
textContainer.widthTracksTextView = true
textContainer.containerSize = .init(
width: textView.bounds.width,
height: .greatestFiniteMagnitude
)
textContainer.lineFragmentPadding = 0
}
}

@MainActor
static func updateText(_ text: String, in textView: NSTextView) {
guard textView.string != text else {
textView.typingAttributes = textAttributes()
return
}

let selectedRanges = textView.selectedRanges
textView.textStorage?.setAttributedString(normalizedAttributedString(for: text))
textView.selectedRanges = clampedSelectedRanges(selectedRanges, maxLength: text.utf16.count)
textView.typingAttributes = textAttributes()
}

@MainActor
private static func normalizedAttributedString(for text: String) -> NSAttributedString {
NSAttributedString(string: text, attributes: textAttributes())
}

@MainActor
private static func textAttributes() -> [NSAttributedString.Key: Any] {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = lineSpacing

return [
.font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular),
.foregroundColor: NSColor(Color.catText),
.kern: tracking,
.paragraphStyle: paragraphStyle
]
}

private static func clampedSelectedRanges(_ ranges: [Any], maxLength: Int) -> [NSValue] {
ranges.compactMap { value in
guard let range = (value as? NSValue)?.rangeValue else { return nil }

let location = min(range.location, maxLength)
let length = min(range.length, maxLength - location)
return NSValue(range: NSRange(location: location, length: length))
}
}
}
21 changes: 11 additions & 10 deletions Ghostly/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import SwiftUI
import KeyboardShortcuts

struct ContentView: View {
var appState: AppState
Expand All @@ -16,6 +15,12 @@ struct ContentView: View {
private let placeholder = "hello there"
private var tabManager: TabManager { appState.tabManager }
private var settingsManager: SettingsManager { appState.settingsManager }
private var textEditorFocusBinding: Binding<Bool> {
Binding(
get: { isTextEditorFocused },
set: { isTextEditorFocused = $0 }
)
}

/// Binding that transforms markdown patterns to visual symbols on text changes
private var transformedTextBinding: Binding<String> {
Expand Down Expand Up @@ -57,20 +62,16 @@ struct ContentView: View {
}

ZStack(alignment: .topLeading) {
TextEditor(text: transformedTextBinding)
.focused($isTextEditorFocused)
.font(.system(size: 14, weight: .regular, design: .monospaced))
.tracking(0.3)
.lineSpacing(4)
.scrollContentBackground(.hidden)
.padding(.leading, -5)
.foregroundStyle(Color.catText)
.accessibilityIdentifier("mainTextEditor")
GhostlyTextEditor(
text: transformedTextBinding,
isFocused: textEditorFocusBinding
)

if transformedTextBinding.wrappedValue.isEmpty {
Text(placeholder)
.font(.system(size: 14, weight: .regular, design: .monospaced))
.foregroundStyle(Color.catOverlay.opacity(0.6))
.allowsHitTesting(false)
}
}
.tint(.catLavender)
Expand Down
103 changes: 103 additions & 0 deletions Ghostly/Views/GhostlyTextEditor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// GhostlyTextEditor.swift
// Ghostly
//

import AppKit
import SwiftUI

struct GhostlyTextEditor: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool

func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}

@MainActor
func makeNSView(context: Context) -> NSScrollView {
let scrollView = EditorTextViewConfiguration.makeScrollView()
guard let textView = scrollView.documentView as? NSTextView else {
return scrollView
}

EditorTextViewConfiguration.updateText(text, in: textView)
textView.delegate = context.coordinator
textView.setAccessibilityIdentifier("mainTextEditor")

context.coordinator.textView = textView
context.coordinator.applyFocusIfNeeded()

return scrollView
}

@MainActor
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self

guard let textView = scrollView.documentView as? NSTextView else { return }

context.coordinator.performProgrammaticUpdate {
EditorTextViewConfiguration.updateText(text, in: textView)
}

context.coordinator.textView = textView
context.coordinator.applyFocusIfNeeded()
}
}

extension GhostlyTextEditor {
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: GhostlyTextEditor
weak var textView: NSTextView?
private var isProgrammaticUpdate = false

init(parent: GhostlyTextEditor) {
self.parent = parent
}

func textDidChange(_ notification: Notification) {
guard !isProgrammaticUpdate,
let textView = notification.object as? NSTextView else { return }

parent.text = textView.string
}

func textDidBeginEditing(_ notification: Notification) {
if !parent.isFocused {
parent.isFocused = true
}
}

func textDidEndEditing(_ notification: Notification) {
if parent.isFocused {
parent.isFocused = false
}
}

func performProgrammaticUpdate(_ updates: () -> Void) {
isProgrammaticUpdate = true
updates()
isProgrammaticUpdate = false
}

func applyFocusIfNeeded() {
guard parent.isFocused, let textView else { return }

if let window = textView.window {
if window.firstResponder !== textView {
window.makeFirstResponder(textView)
}
return
}

Task { @MainActor [weak textView] in
guard let textView, let window = textView.window else { return }
if window.firstResponder !== textView {
window.makeFirstResponder(textView)
}
}
}
}
}
63 changes: 63 additions & 0 deletions GhostlyTests/EditorTextViewConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// EditorTextViewConfigurationTests.swift
// GhostlyTests
//

import AppKit
import Testing
@testable import Ghostly

@Suite("EditorTextViewConfiguration Tests")
@MainActor
struct EditorTextViewConfigurationTests {

@Test("Configures overlay autohiding scrollbars")
func configuresOverlayAutohidingScrollbars() {
let scrollView = EditorTextViewConfiguration.makeScrollView()
let textView = scrollView.documentView as? NSTextView

#expect(scrollView.scrollerStyle == .overlay)
#expect(scrollView.autohidesScrollers)
#expect(scrollView.hasVerticalScroller)
#expect(scrollView.hasHorizontalScroller == false)
#expect(scrollView.drawsBackground == false)
#expect(textView != nil)
#expect(textView?.isRichText == false)
#expect(textView?.isHorizontallyResizable == false)
#expect(textView?.textContainer?.widthTracksTextView == true)
}

@Test("Configures text view for plain text editing")
func configuresTextViewForPlainTextEditing() {
let textView = NSTextView()

EditorTextViewConfiguration.configure(textView)

#expect(textView.isRichText == false)
#expect(textView.importsGraphics == false)
#expect(textView.drawsBackground == false)
#expect(textView.allowsUndo)
#expect(textView.isHorizontallyResizable == false)
#expect(textView.textContainerInset == .zero)
#expect(textView.textContainer?.lineFragmentPadding == 0)
#expect(textView.font?.pointSize == EditorTextViewConfiguration.fontSize)
}

@Test("Updating text preserves style and clamps selection")
func updatingTextPreservesStyleAndClampsSelection() {
let textView = NSTextView()
EditorTextViewConfiguration.configure(textView)
textView.string = "Hello world"
textView.selectedRanges = [NSValue(range: NSRange(location: 11, length: 0))]

EditorTextViewConfiguration.updateText("Hi", in: textView)

#expect(textView.string == "Hi")

let selectedRange = (textView.selectedRanges.first as? NSValue)?.rangeValue
#expect(selectedRange == NSRange(location: 2, length: 0))

let attributes = textView.textStorage?.attributes(at: 0, effectiveRange: nil)
#expect((attributes?[.kern] as? CGFloat) == EditorTextViewConfiguration.tracking)
}
}