A high-performance Swift code editor view for macOS and iOS. Drop it into any SwiftUI app as a single CodeEditorView — it handles syntax highlighting, line numbers, decorations, tooltips, bracket matching, and smooth scrolling for files of any size, including 1 GB+.
Distributed as a Swift Package with zero configuration required to get started.
- Handles massive files — B-tree piece tree buffer with O(log n) operations and memory-mapped loading. Opens gigabyte-scale files without loading them into memory.
- Smooth scrolling — Virtual/lazy rendering draws only visible lines. Core Text rendering at 60 fps+ even with active syntax highlighting.
- Full text editing — Cursor, selection, typing, delete (by character/word/line), undo/redo with edit grouping, copy/cut/paste, tab/indent handling, auto-indent on enter.
- Syntax highlighting — Tree-sitter incremental parsing runs asynchronously on background threads. Re-highlights only changed regions. Supports any language with a tree-sitter grammar.
- Themeable — Provide a custom
HighlightThemeto control every color: background, foreground, cursor, selection, gutter, and per-token syntax colors. - Line numbers & gutter — Toggleable line numbers with adaptive width. Custom gutter indicators (SF Symbols, images, or custom Core Graphics drawing) on any line.
- Decorations — Squiggly underlines, background highlights, and borders on arbitrary text ranges. Ranges automatically adjust as the user edits.
- Tooltips — Register hover and/or click tooltips on text ranges showing arbitrary SwiftUI views.
- Bracket matching — Highlights matching brackets when the cursor is adjacent. Auto-closes brackets and quotes.
- Word wrap — Four wrap modes: none, editor width, fixed column, or bounded (min of editor width and column).
- Cross-platform — Single codebase, one SwiftUI view. Works on macOS (via AppKit) and iOS (via UIKit).
- Reactive events —
AsyncStream-based cursor, selection, scroll, and document events viaCodeEditorProxy.
| Minimum | |
|---|---|
| macOS | 14.0+ |
| iOS | 17.0+ |
| Swift | 6.0 |
| Xcode | 16.0+ |
Add Codeditor to your project via Swift Package Manager.
In your Package.swift:
dependencies: [
.package(url: "https://github.com/mikeydotio/Codeditor.git", from: "1.0.0"),
]Then add "Codeditor" as a dependency of your target:
.target(
name: "YourApp",
dependencies: [
.product(name: "Codeditor", package: "Codeditor"),
]
),Or in Xcode: File → Add Package Dependencies → paste the repository URL.
A minimal editor that opens a file:
import SwiftUI
import Codeditor
struct ContentView: View {
@State private var proxy: CodeEditorProxy?
var body: some View {
CodeEditorView(
url: URL(fileURLWithPath: "/path/to/file.swift"),
configuration: EditorConfiguration()
) { editorProxy in
self.proxy = editorProxy
}
}
}That's it — you get line numbers, cursor, selection, undo/redo, and smooth scrolling out of the box.
Syntax highlighting requires a tree-sitter grammar package for your language. For example, to highlight JSON:
// Package.swift — add the grammar dependency
dependencies: [
.package(url: "https://github.com/mikeydotio/Codeditor.git", from: "1.0.0"),
.package(url: "https://github.com/tree-sitter/tree-sitter-json.git", from: "0.24.8"),
],
targets: [
.executableTarget(
name: "YourApp",
dependencies: [
.product(name: "Codeditor", package: "Codeditor"),
.product(name: "TreeSitterJSON", package: "tree-sitter-json"),
]
),
]import Codeditor
import SwiftTreeSitter
import TreeSitterJSON
// Create a language configuration from the grammar
let langConfig = try LanguageConfiguration(tree_sitter_json(), name: "JSON")
let language = HighlightLanguageConfiguration(name: "JSON", languageConfig: langConfig)
// Pass it to EditorConfiguration
let config = EditorConfiguration(language: language)Tree-sitter grammars are available for most languages — Swift, Python, JavaScript, Rust, Go, C, C++, TypeScript, and many more.
EditorConfiguration controls all editor settings. Every property has a sensible default.
EditorConfiguration(
fontName: "SF Mono", // Monospace font family
fontSize: 13, // Font size in points
lineSpacing: 1.0, // Line height multiplier
theme: nil, // Custom theme (nil = system default)
tabSize: 4, // Columns per tab stop
wrapMode: .none, // Word wrap mode
showLineNumbers: true, // Show/hide line number gutter
scrollPastEnd: .halfPage, // Extra space below last line
overdrawLines: 5, // Pre-rendered lines above/below viewport
language: nil // Tree-sitter language for highlighting
)| Mode | Behavior |
|---|---|
.none |
No wrapping — horizontal scrolling for long lines |
.viewport |
Wrap at the editor's current width |
.column(n) |
Wrap at a fixed column (e.g., 80 or 120) |
.bounded(n) |
Wrap at the lesser of the editor width and column n |
| Mode | Behavior |
|---|---|
.none |
Scrolling stops at the last line |
.halfPage |
Half a viewport of space below the last line |
.fullPage |
Full viewport of space (last line can reach the top) |
.lines(n) |
Exactly n extra blank lines below content |
Codeditor uses a two-level theme system:
EditorTheme— Base colors (background, foreground, cursor, selection, gutter, bracket highlight)HighlightTheme— ExtendsEditorThemewith per-token syntax colors
When no theme is set, Codeditor uses SystemEditorTheme which adapts to the system's light/dark mode.
Implement the HighlightTheme protocol:
import Codeditor
struct MonokaiTheme: HighlightTheme {
// Base editor colors
var backgroundColor: PlatformColor {
PlatformColor(red: 0.15, green: 0.16, blue: 0.13, alpha: 1.0)
}
var foregroundColor: PlatformColor {
PlatformColor(red: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)
}
var selectionColor: PlatformColor {
PlatformColor(red: 0.29, green: 0.34, blue: 0.42, alpha: 1.0)
}
var cursorColor: PlatformColor {
PlatformColor(red: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)
}
var currentLineAccentColor: PlatformColor {
PlatformColor(red: 0.40, green: 0.85, blue: 0.94, alpha: 1.0)
}
var gutterTextColor: PlatformColor {
PlatformColor(red: 0.46, green: 0.44, blue: 0.37, alpha: 1.0)
}
var gutterSeparatorColor: PlatformColor {
PlatformColor(red: 0.30, green: 0.30, blue: 0.30, alpha: 1.0)
}
var matchingBracketColor: PlatformColor {
PlatformColor(red: 0.97, green: 0.97, blue: 0.95, alpha: 0.15)
}
// Per-token syntax colors
func tokenColors() -> [HighlightToken: PlatformColor] {
[
.keyword: PlatformColor(red: 0.98, green: 0.15, blue: 0.45, alpha: 1.0),
.string: PlatformColor(red: 0.90, green: 0.86, blue: 0.45, alpha: 1.0),
.comment: PlatformColor(red: 0.46, green: 0.44, blue: 0.37, alpha: 1.0),
.function: PlatformColor(red: 0.40, green: 0.85, blue: 0.94, alpha: 1.0),
.type: PlatformColor(red: 0.40, green: 0.85, blue: 0.94, alpha: 1.0),
.number: PlatformColor(red: 0.68, green: 0.51, blue: 1.00, alpha: 1.0),
.constant: PlatformColor(red: 0.68, green: 0.51, blue: 1.00, alpha: 1.0),
.boolean: PlatformColor(red: 0.68, green: 0.51, blue: 1.00, alpha: 1.0),
]
}
}Wrap it in AnyEditorTheme when passing to configuration:
let config = EditorConfiguration(
theme: AnyEditorTheme(name: "monokai", MonokaiTheme())
)HighlightToken provides 54 token types organized hierarchically. If your theme doesn't define a color for a specific token (e.g., .variableBuiltin), it falls back to the parent token (.variable), then to foregroundColor.
Common tokens: .keyword, .string, .comment, .function, .type, .number, .constant, .boolean, .operator, .property, .attribute, .variable.
See Documentation/APIReference.md for the full list.
Decorations let you annotate text ranges — useful for diagnostics, linting, search highlights, etc. Access the decoration API through CodeEditorProxy.
// Add a red squiggly underline to bytes 0..<20
let id = proxy.addDecoration(
byteRange: 0..<20,
style: .squigglyUnderline(color: .red)
)
// Remove it later
if let id { proxy.removeDecoration(id: id) }
// Or remove everything
proxy.removeAllDecorations()| Style | Usage |
|---|---|
.squigglyUnderline(color:) |
Error/warning indicators |
.background(color:) |
Search result highlights |
.border(color:) |
Subtle range emphasis |
Decorations track their byte ranges across edits. Control how ranges grow at their edges:
| Behavior | Description |
|---|---|
.closedOpen (default) |
Range does not grow at either edge |
.openOpen |
Range grows when text is inserted at either edge |
.closedClosed |
Range grows when text is inserted at the right edge |
proxy.addDecoration(
byteRange: 10..<50,
style: .squigglyUnderline(color: .orange),
behavior: .openOpen
)// SF Symbol indicator
proxy.setGutterIndicator(
.symbol(name: "exclamationmark.triangle.fill", color: .orange),
forLine: 0
)
// Platform image
proxy.setGutterIndicator(.image(myImage), forLine: 5)
// Custom Core Graphics drawing
proxy.setGutterIndicator(.custom { context, rect in
context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1))
context.fillEllipse(in: rect.insetBy(dx: 2, dy: 2))
}, forLine: 10)
// Remove indicator
proxy.setGutterIndicator(nil, forLine: 0)Register tooltip regions on text ranges. Tooltips display arbitrary SwiftUI views.
// Show a tooltip on hover
proxy.addTooltip(
byteRange: 100..<120,
trigger: .hover
) {
AnyView(
VStack(alignment: .leading) {
Text("Type: String")
.font(.headline)
Text("Declared in main.swift:42")
.font(.caption)
}
.padding(8)
)
}
// Show on click instead
proxy.addTooltip(
byteRange: 200..<230,
trigger: .click
) {
AnyView(Text("Click tooltip content"))
}
// Show on both hover and click
proxy.addTooltip(byteRange: 50..<80, trigger: .both) {
AnyView(Text("Works with hover and click"))
}CodeEditorProxy delivers editor events via AsyncStream properties. Use for await loops to react to changes.
Task { @MainActor in
for await cursor in proxy.cursorPositions {
print("Line \(cursor.line + 1), Column \(cursor.column + 1)")
// cursor.byteOffset — byte offset in buffer
}
}Task { @MainActor in
for await selection in proxy.selectionChanges {
if selection.isEmpty {
print("No selection")
} else if let anchor = selection.anchorOffset {
let charCount = abs(anchor - selection.headOffset)
print("Selected \(charCount) bytes")
}
}
}Task { @MainActor in
for await scroll in proxy.scrollPositions {
print("First visible line: \(scroll.firstVisibleLine)")
// scroll.contentOffset — CGPoint scroll offset
}
}Task { @MainActor in
for await event in proxy.documentEvents {
switch event {
case .opened:
print("File opened")
case .saved:
print("File saved")
case .loadFailed(let error):
print("Load failed: \(error)")
case .saveFailed(let error):
print("Save failed: \(error)")
case .dirtyStateChanged(let isDirty):
print("Dirty: \(isDirty)")
}
}
}proxy.scrollTo(line: 42) // Scroll to line 42
proxy.scrollTo(offset: CGPoint(x: 0, y: 500)) // Scroll to offset
proxy.setCursor(byteOffset: 100) // Move cursor
proxy.setSelection(anchor: 10, head: 50) // Select range
proxy.save() // Save to original URL
proxy.saveAs(url: newURL) // Save to new URLCodeditor is built in layers, each producing immutable snapshots for the next:
SwiftUI (CodeEditorView)
↕ EditorConfiguration in, CodeEditorProxy out
Coordinator (CodeEditorCoordinator)
↓ Creates and owns all internals
┌──────────────────────────────────────────┐
│ TextBuffer (B-tree piece tree) │
│ → BufferSnapshot (immutable) │
├──────────────────────────────────────────┤
│ DisplayMap (tab + wrap + viewport) │
│ → DisplaySnapshot (immutable) │
├──────────────────────────────────────────┤
│ SyntaxHighlighter (tree-sitter, async) │
│ → HighlightSnapshot (immutable) │
├──────────────────────────────────────────┤
│ DecorationManager (ranges + gutter) │
│ → DecorationSnapshot (immutable) │
└──────────────────────────────────────────┘
↓ All snapshots feed into
EditorView (NSView/UIView, Core Text rendering)
→ LineRenderer → LineCache (150 CTLines)
→ CursorRenderer, SelectionRenderer, DecorationRenderer
Key design patterns:
- Firewall pattern — SwiftUI communicates inbound via
EditorConfigurationonly. The editor communicates outbound viaCodeEditorProxyAsyncStreamproperties only. No@State,@Binding, or@ObservedObjectcrosses the boundary. - Snapshot isolation — Every subsystem produces immutable snapshots. The rendering pipeline reads snapshots without locks, while mutable state handles writes.
- O(log n) everywhere — The B-tree piece tree gives O(log n) inserts, deletes, and coordinate conversions. Wrap computation uses prefix sums for O(log n) row lookups. Line index uses tree-descent for O(log n) line-to-offset conversion.
- Incremental highlighting — Tree-sitter receives edit notifications and re-parses only affected ranges on a background thread. The highlight snapshot is swapped atomically.
| Module | Files | Purpose |
|---|---|---|
Buffer |
11 | B-tree piece tree, text storage, undo/redo, file loading, snapshots |
Core |
1 | Monoid protocols for tree aggregate metadata |
Display |
8 | Tab expansion, word wrapping, viewport management, coordinate conversion |
Editing |
9 | Cursor/selection state, edit commands, bracket matching, indentation |
Rendering |
9 | Core Text line drawing, font metrics, cursor/selection/decoration rendering |
Syntax |
5 | Tree-sitter integration, highlight tokens/themes/snapshots |
Decorations |
5 | Squiggly underlines, background/border decorations, anchored ranges |
Tooltips |
2 | Hover detection, tooltip popover presentation |
SwiftUI |
6 | CodeEditorView, coordinator, proxy, configuration, events, themes |
Platform |
3 | macOS NSTextInputClient, iOS UITextInput, platform type aliases |
For a detailed architecture walkthrough, see Documentation/Architecture.md.
The repository includes sample apps for both platforms in SampleApp-macOS/ and SampleApp-iOS/. These demonstrate:
- Opening files via file picker
- Configuring font size, word wrap, line numbers
- Loading a tree-sitter grammar (JSON) for syntax highlighting
- Switching between default and custom themes
- Adding decorations and gutter indicators
- Observing cursor position and selection via event streams
Run them by opening the respective Package.swift in Xcode.
| Package | Purpose |
|---|---|
| SwiftTreeSitter | Tree-sitter Swift bindings for incremental parsing |
| Neon | Tree-sitter client and range state management |
Tree-sitter language grammars (e.g., tree-sitter-json, tree-sitter-swift) are not bundled — consumers add only the grammars they need.
See LICENSE for details.