Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e56e677
Add Config Object - Still need to react to changes
thecoolwinter Jun 16, 2025
b97f9f3
Create new `EditorConfig` struct
thecoolwinter Jun 17, 2025
091fc34
Rename to `SourceEditor`, `SourceEditorConfiguration`
thecoolwinter Jun 17, 2025
34387a8
Fix Reformatting Guide X Position
thecoolwinter Jun 17, 2025
ebd4e25
Finish rename, fix tests
thecoolwinter Jun 17, 2025
8c16606
Update TextViewController.swift
thecoolwinter Jun 17, 2025
ae2aa61
Update SourceEditor.swift
thecoolwinter Jun 17, 2025
71ea866
Documentation
thecoolwinter Jun 17, 2025
e915eea
Update README.md
thecoolwinter Jun 17, 2025
78468ef
Update README.md
thecoolwinter Jun 17, 2025
c23027b
Remove Test Code
thecoolwinter Jun 18, 2025
c25bbac
Merge branch 'main' into feat/editor-configuration
thecoolwinter Jun 18, 2025
0680885
Document Adding a Parameter, Finish Merge Invisible Chars API
thecoolwinter Jun 18, 2025
d13e645
lint:fix
thecoolwinter Jun 18, 2025
013860a
Use Generic State Type, Track Scroll Position
thecoolwinter Jun 19, 2025
662a231
Add Find Toggle, Find Text to State
thecoolwinter Jun 19, 2025
cce41f7
Validate Find Text Updating
thecoolwinter Jun 19, 2025
7fc27cf
Fix some missing configuration, remove duplicates
thecoolwinter Jun 20, 2025
8046a17
Merge branch 'feat/editor-configuration' into feat/editor-state-publi…
thecoolwinter Jun 20, 2025
e439425
Completely Optional State Variables
thecoolwinter Jun 20, 2025
42238ae
Merge branch 'main' into feat/editor-state-publishers
thecoolwinter Jun 23, 2025
994c712
Move State Struct
thecoolwinter Jun 23, 2025
25ddd97
Add Replace Text State
thecoolwinter Jun 24, 2025
368c54d
lint:fix, Use Real Package Version
thecoolwinter Jun 24, 2025
0608dda
Update Documentation and README Examples
thecoolwinter Jun 24, 2025
ec5d847
Finish Notification Rename
thecoolwinter Jun 24, 2025
511f9da
Merge branch 'main' into feat/editor-state-publishers
thecoolwinter Jun 24, 2025
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ struct ContentView: View {

@State private var language: CodeLanguage = .default
@State private var theme: EditorTheme = .light
@State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)]
@State private var editorState = SourceEditorState(
cursorPositions: [CursorPosition(line: 1, column: 1)]
)

@State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
@AppStorage("wrapLines") private var wrapLines: Bool = true
Expand Down Expand Up @@ -67,15 +69,15 @@ struct ContentView: View {
warningCharacters: warningCharacters
)
),
cursorPositions: $cursorPositions
state: $editorState
)
.overlay(alignment: .bottom) {
StatusBar(
fileURL: fileURL,
document: $document,
wrapLines: $wrapLines,
useSystemCursor: $useSystemCursor,
cursorPositions: $cursorPositions,
state: $editorState,
isInLongParse: $isInLongParse,
language: $language,
theme: $theme,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct StatusBar: View {
@Binding var document: CodeEditSourceEditorExampleDocument
@Binding var wrapLines: Bool
@Binding var useSystemCursor: Bool
@Binding var cursorPositions: [CursorPosition]
@Binding var state: SourceEditorState
@Binding var isInLongParse: Bool
@Binding var language: CodeLanguage
@Binding var theme: EditorTheme
Expand Down Expand Up @@ -100,11 +100,29 @@ struct StatusBar: View {
.controlSize(.small)
Text("Parsing Document")
}
} else {
Text(getLabel(cursorPositions))
}
scrollPosition
Text(getLabel(state.cursorPositions))
}
.foregroundStyle(.secondary)

Divider()
.frame(height: 12)

Text(state.findText ?? "")
.frame(maxWidth: 30)
.lineLimit(1)
.truncationMode(.head)
.foregroundStyle(.secondary)

Button {
state.findPanelVisible.toggle()
} label: {
Text(state.findPanelVisible ? "Hide" : "Show") + Text(" Find")
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)

Divider()
.frame(height: 12)
LanguagePicker(language: $language)
Expand Down Expand Up @@ -133,6 +151,39 @@ struct StatusBar: View {
}
}

var formatter: NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 0
formatter.allowsFloats = true
return formatter
}

@ViewBuilder private var scrollPosition: some View {
HStack(spacing: 0) {
Text("{")
TextField(
"",
value: Binding(get: { Double(state.scrollPosition?.x ?? 0.0) }, set: { state.scrollPosition?.x = $0 }),
formatter: formatter
)
.textFieldStyle(.plain)
.labelsHidden()
.fixedSize()
Text(",")
TextField(
"",
value: Binding(get: { Double(state.scrollPosition?.y ?? 0.0) }, set: { state.scrollPosition?.y = $0 }),
formatter: formatter
)
.textFieldStyle(.plain)
.labelsHidden()
.fixedSize()
Text("}")
}
}

private func detectLanguage(fileURL: URL?) -> CodeLanguage? {
guard let fileURL else { return nil }
return CodeLanguage.detectLanguageFrom(
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,28 @@ This package is fully documented [here](https://codeeditapp.github.io/CodeEditSo

## Usage (SwiftUI)

CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. The SwiftUI API provides extremely customizable and flexible configuration options, including two-way bindings for state like cursor positions and scroll position.

For more complex features that require access to the underlying text view or text storage, we've developed the <doc:TextViewCoordinators> API. Using this API, developers can inject custom behavior into the editor as events happen, without having to work with state or bindings.

```swift
import CodeEditSourceEditor

struct ContentView: View {
@State var text = "let x = 1.0"

/// Automatically updates with cursor positions, or update the binding to set the user's cursors.
@State var cursorPositions: [CursorPosition] = []
/// Automatically updates with cursor positions, scroll position, find panel text.
/// Everything in this object is two-way, use it to update cursor positions, scroll position, etc.
@State var editorState = SourceEditorState()

/// Configure the editor's appearance, features, and editing behavior...
@State var theme = EditorTheme(...)
@State var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
@State var indentOption = .spaces(count: 4)

/// *Powerful* customization options with our text view coordinators API
@State var autoCompleteCoordinator = AutoCompleteCoordinator()

var body: some View {
SourceEditor(
$text,
Expand All @@ -61,9 +69,27 @@ struct ContentView: View {
appearance: .init(theme: theme, font: font),
behavior: .init(indentOption: indentOption)
),
cursorPositions: $cursorPositions
state: $editorState,
coordinators: [autoCompleteCoordinator]
)
}

/// Autocompletes "Hello" to "Hello world!" whenever it's typed.
final class AutoCompleteCoordinator: TextViewCoordinator {
func prepareCoordinator(controller: TextViewController) { }

func textViewDidChangeText(controller: TextViewController) {
for cursorPosition in controller.cursorPositions where cursorPosition.range.location >= 5 {
let location = cursorPosition.range.location
let previousRange = NSRange(start: location - 5, end: location)
let string = (controller.text as NSString).substring(with: previousRange)

if string.lowercased() == "hello" {
controller.textView.replaceCharacters(in: NSRange(location: location, length: 0), with: " world!")
}
}
}
}
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ extension TextViewController {
guard let clipView = notification.object as? NSClipView else { return }
self?.gutterView.needsDisplay = true
self?.minimapXConstraint?.constant = clipView.bounds.origin.x
NotificationCenter.default.post(name: Self.scrollPositionDidUpdateNotification, object: self)
}
}

Expand All @@ -114,6 +115,7 @@ extension TextViewController {
self?.gutterView.needsDisplay = true
self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets)
self?.updateTextInsets()
NotificationCenter.default.post(name: Self.scrollPositionDidUpdateNotification, object: self)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import TextFormation
public class TextViewController: NSViewController {
// swiftlint:disable:next line_length
public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification")
// swiftlint:disable:next line_length
public static let scrollPositionDidUpdateNotification: Notification.Name = .init("TextViewController.scrollPositionDidUpdateNotification")

// MARK: - Views and Child VCs

// MARK: - Views and Child VCs

Expand Down
34 changes: 29 additions & 5 deletions Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## Usage

CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit.
CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. We provide a fast and efficient SwiftUI API that avoids unnecessary view updates whenever possible. It also provides extremely customizable and flexible configuration options, including two-way bindings for state like cursor positions and scroll position.

For more complex features that require access to the underlying text view or text storage, we've developed the <doc:TextViewCoordinators> API. Using this API, developers can inject custom behavior into the editor as events happen, without having to work with state or bindings.

#### SwiftUI

Expand All @@ -12,11 +14,12 @@ import CodeEditSourceEditor
struct ContentView: View {

@State var text = "let x = 1.0"
// For large documents use (avoids SwiftUI inneficiency)
// For large documents use a text storage object (avoids SwiftUI comparisons)
// var text: NSTextStorage

/// Automatically updates with cursor positions, or update the binding to set the user's cursors.
@State var cursorPositions: [CursorPosition] = []
/// Automatically updates with cursor positions, scroll position, find panel text.
/// Everything in this object is two-way, use it to update cursor positions, scroll position, etc.
@State var editorState = SourceEditorState()

/// Configure the editor's appearance, features, and editing behavior...
@State var theme = EditorTheme(...)
Expand All @@ -25,6 +28,9 @@ struct ContentView: View {
@State var editorOverscroll = 0.3
@State var showMinimap = true

/// *Powerful* customization options with text coordinators
@State var autoCompleteCoordinator = AutoCompleteCoordinator()

var body: some View {
SourceEditor(
$text,
Expand All @@ -35,9 +41,27 @@ struct ContentView: View {
layout: .init(editorOverscroll: editorOverscroll),
peripherals: .init(showMinimap: showMinimap)
),
cursorPositions: $cursorPositions
state: $editorState,
coordinators: [autoCompleteCoordinator]
)
}

/// Autocompletes "Hello" to "Hello world!" whenever it's typed.
class AutoCompleteCoordinator: TextViewCoordinator {
func prepareCoordinator(controller: TextViewController) { }

func textViewDidChangeText(controller: TextViewController) {
for cursorPosition in controller.cursorPositions.reversed() where cursorPosition.range.location >= 5 {
let location = cursorPosition.range.location
let previousRange = NSRange(start: location - 5, end: location)
let string = (controller.text as NSString).substring(with: previousRange)

if string.lowercased() == "hello" {
controller.textView.replaceCharacters(in: NSRange(location: location, length: 0), with: " world!")
}
}
}
}
}
```

Expand Down
10 changes: 10 additions & 0 deletions Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ extension FindViewController {

viewModel.isFocused = true
findPanel.addEventMonitor()

NotificationCenter.default.post(
name: FindPanelViewModel.Notifications.didToggle,
object: viewModel.target
)
}

/// Hide the find panel
Expand All @@ -70,6 +75,11 @@ extension FindViewController {
if let target = viewModel.target {
_ = target.findPanelTargetView.window?.makeFirstResponder(target.findPanelTargetView)
}

NotificationCenter.default.post(
name: FindPanelViewModel.Notifications.didToggle,
object: viewModel.target
)
}

/// Performs an animation with a completion handler, conditionally animating the changes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ struct FindPanelView: View {
.onChange(of: viewModel.findText) { _ in
viewModel.findTextDidChange()
}
.onChange(of: viewModel.replaceText) { _ in
viewModel.replaceTextDidChange()
}
.onChange(of: viewModel.wrapAround) { _ in
viewModel.find()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import Combine
import CodeEditTextView

class FindPanelViewModel: ObservableObject {
enum Notifications {
static let textDidChange = Notification.Name("FindPanelViewModel.textDidChange")
static let replaceTextDidChange = Notification.Name("FindPanelViewModel.replaceTextDidChange")
static let didToggle = Notification.Name("FindPanelViewModel.didToggle")
}

weak var target: FindPanelTarget?
var dismiss: (() -> Void)?

Expand Down Expand Up @@ -99,5 +105,11 @@ class FindPanelViewModel: ObservableObject {
// Clear existing emphases before performing new find
target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find)
find()

NotificationCenter.default.post(name: Self.Notifications.textDidChange, object: target)
}

func replaceTextDidChange() {
NotificationCenter.default.post(name: Self.Notifications.replaceTextDidChange, object: target)
}
}
Loading