Skip to content

mikeydotio/Codeditor

Repository files navigation

Codeditor

License: MIT Swift 6.0 Platform: macOS 14+ | iOS 17+ SPM Compatible Lines of Code Tests

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.

Features

  • 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 HighlightTheme to 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 eventsAsyncStream-based cursor, selection, scroll, and document events via CodeEditorProxy.

Requirements

Minimum
macOS 14.0+
iOS 17.0+
Swift 6.0
Xcode 16.0+

Installation

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.

Quick Start

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.

Adding Syntax Highlighting

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.

Configuration

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
)

Word Wrap Modes

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

Scroll Past End

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

Theming

Codeditor uses a two-level theme system:

  1. EditorTheme — Base colors (background, foreground, cursor, selection, gutter, bracket highlight)
  2. HighlightTheme — Extends EditorTheme with per-token syntax colors

When no theme is set, Codeditor uses SystemEditorTheme which adapts to the system's light/dark mode.

Creating a Custom Theme

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())
)

Available Token Types

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

Decorations let you annotate text ranges — useful for diagnostics, linting, search highlights, etc. Access the decoration API through CodeEditorProxy.

Squiggly Underlines

// 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()

Decoration Styles

Style Usage
.squigglyUnderline(color:) Error/warning indicators
.background(color:) Search result highlights
.border(color:) Subtle range emphasis

Range Behavior

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
)

Gutter Indicators

// 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)

Tooltips

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"))
}

Events

CodeEditorProxy delivers editor events via AsyncStream properties. Use for await loops to react to changes.

Cursor Position

Task { @MainActor in
    for await cursor in proxy.cursorPositions {
        print("Line \(cursor.line + 1), Column \(cursor.column + 1)")
        // cursor.byteOffset — byte offset in buffer
    }
}

Selection Changes

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")
        }
    }
}

Scroll Position

Task { @MainActor in
    for await scroll in proxy.scrollPositions {
        print("First visible line: \(scroll.firstVisibleLine)")
        // scroll.contentOffset — CGPoint scroll offset
    }
}

Document Events

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)")
        }
    }
}

Imperative Control

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 URL

Architecture

Codeditor 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 EditorConfiguration only. The editor communicates outbound via CodeEditorProxy AsyncStream properties only. No @State, @Binding, or @ObservedObject crosses 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.

Source Modules

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.

Sample Apps

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.

Dependencies

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.

License

See LICENSE for details.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages