Syntax Highlighting NSTextView for SWIFT macOS

Here is a NSTextView subclass the does user defined Syntax Highlighting… complete with Line Numbers. It consists of ONE public class

public class syntaxTextView

and one public ENUM that contains all the rules, and visual attributes for the TextView

public enum TheHighlighter

It is self-contained and supports both Light and Dark Modes

There may still be some tweaks required… And I’ll be making an iOS version as well ..(Might modify this one to work on either platform)

1 Like
//┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
//┃        Simple Syntax Highlighting NSTextView        ┃
//┣━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
//┃    Author ┃  R.David Sisemore                       ┃
//┃   Written ┃  04Oct2025                              ┃
//┃  Language ┃  Swift 5                                ┃
//┃   Min. OS ┃  macOS 15.6                             ┃
//┃ Highlight ┃  installed keywords for Swift 5         ┃
//┗━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import Foundation
import AppKit

fileprivate let syntaxHighlighter : TheHighlighter = .keywords

public class syntaxTextView: NSView, NSTextViewDelegate {
    private var textView   : uxTextView?  // The NSTextView inside the scroll view
    private var scrollView : NSScrollView?   // The scroll view containing the text view

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup(frame: frame)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup(frame:CGRect.zero)
    }

    private func setup(frame:CGRect) {
        // Create the scroll view sized to our frame
        scrollView                        = NSScrollView(frame: frame)
        scrollView!.hasVerticalScroller   = true
        scrollView!.hasHorizontalScroller = true
        scrollView!.borderType            = .bezelBorder
        scrollView!.autoresizingMask      = [.width, .height]

        // Create NSTextView with frame from scroll view's content size
        let contentSize                                = CGSize(width:2560,height:scrollView!.contentSize.height)
        textView                                       = uxTextView(frame: NSRect(origin: .zero, size: contentSize))
        // Configure text view properties
        textView!.minSize                              = NSSize(width: 0.0, height: contentSize.height)
        textView!.maxSize                              = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        textView!.isVerticallyResizable                = true
        textView!.isHorizontallyResizable              = false
        textView!.autoresizingMask                     = [.width]
        textView!.textContainer?.containerSize         = NSSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude)
        textView!.textContainer?.widthTracksTextView   = true
        textView!.isAutomaticTextReplacementEnabled    = false
        textView!.isAutomaticQuoteSubstitutionEnabled  = false
        textView!.isAutomaticDataDetectionEnabled      = false
        textView!.isAutomaticSpellingCorrectionEnabled = false
        textView!.isAutomaticDashSubstitutionEnabled   = false
        textView!.allowsUndo                           = true

        // Embed the text view into the scroll view's document view
        scrollView!.documentView = textView

        /* this is for line #'s, but it needs some work still - see class below */
        //      if let scrollView            = textView!.enclosingScrollView {
        let lineNumberRulerView       = LineNumberRulerView(textView: textView!)
        scrollView!.hasVerticalRuler  = true
        scrollView!.verticalRulerView = lineNumberRulerView
        scrollView!.autoresizingMask  = [.height]
        scrollView!.rulersVisible     = true
        //    }

        // Observe scroll changes for synchronization
        NotificationCenter.default.addObserver(self, selector: #selector(boundsDidChange),
                                               name: NSView.boundsDidChangeNotification, object: scrollView?.contentView)


        // Add the scroll view as subview of this view
        addSubview(scrollView!)
    }

    @objc func boundsDidChange(_ notification: Notification) {
        scrollView!.verticalRulerView!.needsDisplay = true

    }
}

fileprivate class uxTextView: NSTextView, NSTextViewDelegate {
    // Dispatch queue for background parsing
    private let highlightQueue = DispatchQueue(label: "syntax.highlight.queue", qos: .userInitiated)

    // Timer to debounce rapid text changes
    private var debounceTimer: Timer?

    override var string: String {  didSet { scheduleHighlighting() } }

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup(frame: frame)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup(frame:CGRect.zero)
    }

    override var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
        return [.string] + super.readablePasteboardTypes
    }

    override var writablePasteboardTypes: [NSPasteboard.PasteboardType] {
        return [.string] + super.writablePasteboardTypes
    }

    override func performKeyEquivalent(with event: NSEvent) -> Bool {
        if event.type == .keyDown, event.modifierFlags.contains(.command) {
            switch event.charactersIgnoringModifiers {
                case "x": self.cut(nil)      ; return true
                case "c": self.copy(nil)     ; return true
                case "v": self.paste(nil)    ; self.needsDisplay = true; return true
                case "a": self.selectAll(nil); return true
                case "z": self.undoManager?.undo(); return true
                case "r": self.undoManager?.redo(); return true
                default: break
            }
        }
        return super.performKeyEquivalent(with: event)
    }

    private func scheduleHighlighting() {
        // Cancel previous timer if still running
        debounceTimer?.invalidate()
        // Debounce highlighting by 0.3 seconds
        debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false, block: { [weak self] _ in
            self?.highlightTextAsync()
        })
    }

    private func highlightTextAsync() {
        let backingStore = self.textStorage!
        let bs           = backingStore.string
        let colorRange   = NSRange(location: 0, length: bs.count)
        backingStore.beginEditing()
        for pattern in TheHighlighter.allCases {
            pattern.regEX!.enumerateMatches(in: bs, range: colorRange ) {
                match, flags, stop in
                if let matchRange = match?.range(at: 1) {
                    let color = pattern.color
                    backingStore.addAttribute(.foregroundColor, value:color, range: matchRange)
                }
            }
        }
        backingStore.endEditing()
        self.needsDisplay = true
        self.enclosingScrollView?.verticalRulerView?.needsDisplay=true
    }

    private func setup(frame:CGRect) {
        let theFont           = NSFont(name: syntaxHighlighter.fontName,size: syntaxHighlighter.fontsize)
        self.font             = theFont
        self.delegate         = self
        self.typingAttributes = [
            .font: theFont! ,
            .foregroundColor: NSColor.textColor
        ]
        // Disable font panel if not needed to stop NSTextView from auto-updating fonts
        self.usesFontPanel = false
        //
        // set text selection color
        self.selectedTextAttributes = [NSAttributedString.Key.backgroundColor: syntaxHighlighter.selectColor]
        scheduleHighlighting()
    }

    func textDidChange(_ notification: Notification) {
        scheduleHighlighting()
        self.enclosingScrollView?.verticalRulerView?.needsDisplay=true
    }
}

fileprivate class LineNumberRulerView: NSRulerView {
    weak var textView: NSTextView?

    let attrs: [NSAttributedString.Key: Any] = [
        .font            : NSFont.systemFont(ofSize:  syntaxHighlighter.fontsize),
        .foregroundColor : syntaxHighlighter.gutterFG
    ]

    required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

    init(textView: NSTextView) {
        self.textView      = textView
        super.init(scrollView: textView.enclosingScrollView!, orientation: .verticalRuler)
        self.ruleThickness = syntaxHighlighter.gutterWidth  // Width of the ruler gutter
        self.clientView    = textView
        self.clipsToBounds = true
    }

    override func drawHashMarksAndLabels(in rect: NSRect) {
        guard let textView  = textView,let layoutManager = textView.layoutManager else { return }
        let backgroundColor = syntaxHighlighter.gutterBG
        let string          = textView.string as NSString
        let relativePoint   = self.convert(NSPoint.zero, from: textView)
        let visibleRect     = textView.enclosingScrollView!.contentView.bounds

        var lineNumber      = 1
        var searchRange     = NSRange(location: 0, length: string.length)

        backgroundColor.setFill()
        rect.fill()

        while searchRange.location < string.length {
            let lineRange  = string.lineRange(for: NSRange(location: searchRange.location, length: 0))
            let glyphIndex = layoutManager.glyphIndexForCharacter(at: NSMaxRange(lineRange) - 1)
            let lineRect   = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil, withoutAdditionalLayout: true)

            // Only draw line number if the lineRect intersects visible area
            if visibleRect.intersects(lineRect) {
                let y = relativePoint.y + lineRect.minY
                let lineNumberString = "\(lineNumber)" as NSString
                let x = self.ruleThickness - 5 - lineNumberString.size(withAttributes: attrs).width
                lineNumberString.draw(at: NSPoint(x: x, y: y), withAttributes: attrs)
            }

            lineNumber          += 1
            searchRange.location = NSMaxRange(lineRange)
            searchRange.length   = string.length - searchRange.location
        }
    }
}

```


1 Like

here is the Enum that goes with it

//
//  File.swift
//  rapid2026
//
//  Created by David Sisemore on 10/12/25.
//
import AppKit
import Foundation

// lite
fileprivate let liteNumbers      : NSColor = NSColor(rgb:0x1C00CF)
fileprivate let liteKeywords     : NSColor = NSColor(rgb:0x0F68A0)
fileprivate let liteStrings      : NSColor = NSColor(rgb:0xC41A16)
fileprivate let litePreprocessor : NSColor = NSColor(rgb:0xFD8008)
fileprivate let liteComments     : NSColor = NSColor(rgb:0x007f00)
fileprivate let liteMarks        : NSColor = NSColor(rgb:0xFF0000)
fileprivate let liteTypes        : NSColor = NSColor(rgb:0x3900A0)
fileprivate let liteClass        : NSColor = NSColor(rgb:0x3900A0)
                                                        // Dark
fileprivate let darkNumbers      : NSColor = NSColor(rgb:0xD0BF69)
fileprivate let darkKeywords     : NSColor = NSColor(rgb:0xFC5FA3)
fileprivate let darkStrings      : NSColor = NSColor(rgb:0xFC6A5D)
fileprivate let darkPreprocessor : NSColor = NSColor(rgb:0xFD8F3F)
fileprivate let darkComments     : NSColor = NSColor(rgb:0x6C7986)
fileprivate let darkMarks        : NSColor = NSColor(rgb:0x92A1B1)
fileprivate let darkTypes        : NSColor = NSColor(rgb:0xD0A8FF)
fileprivate let darkClass        : NSColor = NSColor(rgb:0xD0A8FF)

public enum TheHighlighter : CaseIterable {
    // MARK: Rules must be in proper order
case plain_text
case numbers
case keywords
case otherClass
case otherTypes
case string
case preprocessor
case lineComment
case marks
case blockComments
    // Visual Attributes
    var fontName         : String  { return "Menlo" } // should be a mono-space font
    var fontsize         : CGFloat { return 24 }
    var selectColor      : NSColor { return NSColor(rgb:0x8aCaff) }
    var gutterWidth      : CGFloat { return 60 }
    var gutterBG         : NSColor { return NSColor.windowBackgroundColor }
    var gutterFG         : NSColor { return .labelColor }
    //
    var regEX :  NSRegularExpression?{ // This keyword list is for Swift 5
        var rule : String
        switch self {
            case .plain_text    :
                rule            = "(.*$)"
            case .numbers       :
                let dec         = #"([-+]?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+))"#
                let hex         = #"(0[xX][0-9a-fA-F]+)"#
                rule            = #"(\#(hex)|\#(dec))"#
            case .keywords      :
                let keywordList = "if|for|func|var|let|else|return|switch|case|while|as|repeat|inout|class|struct|enum|import|guard|break|continue|in|is|try|throws|throw|catch|do|default|protocol|extension|public|private|internal|fileprivate|open|static|init|self|super|true|false|nil|Anyasync|await|actor|associatedtype|defer|final|convenience|mutating|nonmutating|required|override|weak|unowned|lazy|optional|some|any|get|set|willSet|didSet|dynamic|distributed|prefix|postfix|infix|precedencegroup|precedence|associativity|operator|indirect|consuming|consume|borrowing|rethrows|typealias|subscript|where|fallingthrough|fileprivateType|deinit|left|none|right"
                rule            = "(?-i)(?<!\\B)("+keywordList+")\\b"
            case .otherClass  :
                let otherList   = "NSColor|NSFont|NSAlert|NSApplication|NSBezierPath|NSBox|NSButton|NSClipView|NSCollectionView|NSComboBox|NSDrawer|NSEvent|NSDateFormatter|NSNumberFormatter|NSFormatter|NSImage|NSLayoutManager|NSLocale|NSMenu|NSMenuItem|NSNotificationCenter|NSOpenPanel|NSOutlineView|NSPageController|NSPopUpButton|NSResponder|NSRunLoop|NSSavePanel|NSScrollView|NSSlider|NSStatusBar|NSTableView|NSTextField|NSTextStorage|NSTextView|NSToolbar|NSUUID|NSView|NSWindow|NSWorkspace|CGColor|CGColorSpace|CGContext|CGPath|CGAffineTransform|CGPoint|CGSize|CGRect|CGGradient|CGImage|CGFont|CGMutablePath|CGDataProvider|CGPattern|CGPDFDocument|CGPDFPage|CGShading"
                rule            = "(?-i)(?<!\\B)("+otherList+")\\b"
            case .otherTypes         :
                let typeList    = "String|Int8|Int16|Int32|UInt8|UInt16|UInt32|UInt64|Int|Float|Double|Bool|Character|Set|Range|CGSize|CGPoint|CGRect|CGVector|CGAffineTransform|AttributedString|CBool|CChar16|CChar32|CChar8|CChar|CDouble|CFloat16|CFloat|CInt|CLongDouble|CLongLong|CLong|CShort|CSignedChar|CUnsignedChar|CUnsignedInt|CUnsignedLong|CUnsignedLongLong|CUnsignedShort|CVaListPointer|CWideChar|OpaquePointer|Stride"
                rule            = "(?-i)(?<!\\B)("+typeList+")\\b"
            case .string        :
                rule = "(\"(\"\"|.)+\")"
            case .preprocessor  :
                let pp          = #"if\s|#elseif\s|#else\s|#endif|#available|#colorLiteral|#column|#file|#fileID|#fileLiteral|#filePath|#function|#imageLiteral|#line|#selector\(|#sourceLocation\(|#error\(|#warning\(|"#
                rule            = #"(?-i)^\s*("#+pp+#").*$"#
            case .lineComment   : 
                rule            = "(//.*$)"
            case .marks         :
                rule            =  #"(?-i)//\s*((MARK|TODO|FIXME): ).*$"#
            case .blockComments :
                rule            =  "(/\\*[\\s\\S]*?\\*/)"
        }
     return try? NSRegularExpression(pattern: rule, options: [NSRegularExpression.Options.anchorsMatchLines,NSRegularExpression.Options.caseInsensitive])
    }
    var color : NSColor {
        let isDark       = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark"
        if isDark==false {
            switch self {
                case .plain_text    : return NSColor.labelColor
                case .numbers       : return liteNumbers
                case .keywords      : return liteKeywords
                case .otherClass    : return liteClass
                case .otherTypes    : return liteTypes
                case .string        : return liteStrings
                case .preprocessor  : return litePreprocessor
                case .lineComment   : return liteComments
                case .marks         : return liteMarks
                case .blockComments : return liteComments
            }
        } else {
            switch self {
                case .plain_text    : return NSColor.labelColor
                case .numbers       : return darkNumbers
                case .keywords      : return darkKeywords
                case .otherClass    : return darkClass
                case .otherTypes    : return darkTypes
                case .string        : return darkStrings
                case .preprocessor  : return darkPreprocessor
                case .lineComment   : return darkComments
                case .marks         : return darkMarks
                case .blockComments : return darkComments
            }
        }
    }
}



1 Like

Dave, why you don’t use a public git repo for this?

codeberg.org or on your own forgejo.org instance?

I do not trust any Version Control software… I got burned by one big time many years ago, and ended up losing almost an entire years worth of work… So, I’m sorry if the manner in which I give away code isn’t up to community standards… :slight_smile:

this way just means

  • its harder to obtain than it needs to be
  • its harder for people to contribute any fixes, changes, updates (that you still get to approve or reject)

Millions, probably tens or hundreds of millions, of people use things like Git every day
They get reviewed & updated & patched by millions
So the free software movements promise of lots of eyes looking things over to make sure there are not bugs & glitches actually works

that all may be true (and I have no doubt that it is)…. but I really have no incentive to learn how those work anymore. If people do not wish to take advantage of what I do offer, that is 100% up to them

But as I said, I got burned REALLY bad by that kind of process in the past, and perhaps if I had a “team” and large projects, it might be different… but I don’t

1 Like

Git is terribly easy to learn
And dead easy to use esp for a single developer
Could teach it to you in 5 min or less - seriously

Xcode already sets up most of it for you when you create a new project

1 Like

Understand. I forget the name of the source host we were using but they got caught in a ransomware attack and they lost everything. They folded up shop the next day as their backups were deleted as well. I lost a couple of projects that I didn’t have backed up locally. Thankfully nothing super important.

I don’t remember what the software was, but it was all on company servers as it was highly sensitive healthcare data and proprietary code. But something happened between server and client and all it delivered was gibberish

I have an updated version of this code if anyone is interested

  1. added highlight of current line

  2. added bookmarks complete with navigation

  3. added contextual menu

  4. various refactoring

I will NOT be posting the code here based on previous comments, so if you are interested, private me you email address and I’ll send a server link (as soon as I zip it up)

:snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowflake: :snowman:

Hey people calm down. Dave is doing all of this as hobby work. He don’t like Git? That’s his choice. He will provide a ZIP archive for everybody which is interested in. That’s engough. If you want a GIT: ask him for the ZIP and store the project on your private GIT. Then you have your Git

3 Likes