import LumaCore import SwiftUI #if canImport(AppKit) import AppKit typealias CodeTextViewBase = NSTextView #else import UIKit typealias CodeTextViewBase = UITextView #endif final class CodeTextView: CodeTextViewBase { var onEdit: ((String) -> Void)? var onFocused: (() -> Void)? var sourceFont = PlatformFont.monospacedSystemFont(ofSize: PlatformFont.systemFontSize, weight: .regular) { didSet { restyle() #if canImport(AppKit) onLineStateChanged?() #endif } } var session: TypeScriptEditorSession? { didSet { bindSession() } } var syntax: SourceSyntax = .typeScript { didSet { if oldValue != syntax { restyle() } } } #if canImport(AppKit) var onLineStateChanged: (() -> Void)? private var foldable: [LSP.FoldingRange] = [] private var folds: [CodeFold] = [] private var colors: [LSP.ColorInformation] = [] #endif private var diagnostics: [LSP.Diagnostic] = [] private var semanticTokens: [SemanticToken] = [] private var fetched: [LSP.CompletionItem] = [] private var isReplacingSource = false #if canImport(AppKit) private let completionPanel = CodeCompletionPanel() private let hover = CodeInfoPopover() private let signature = CodeInfoPopover() private var hoverRequest: DispatchWorkItem? private var signatureRequest: Task? private var argumentPlaceholders: [NSRange] = [] private var foldingLayoutManager: FoldingLayoutManager { layoutManager as! FoldingLayoutManager } #else private let candidates = CompletionCandidates() private let completionBarHeight: CGFloat = 38 init() { let container = NSTextContainer( size: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) let layout = NSLayoutManager() layout.addTextContainer(container) let storage = NSTextStorage() storage.addLayoutManager(layout) super.init(frame: .zero, textContainer: container) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("CodeTextView is not loaded from a nib") } #endif var source: String { storage.string } func setSource(_ newSource: String) { isReplacingSource = true storage.replaceCharacters(in: NSRange(location: 0, length: storage.length), with: newSource) isReplacingSource = false restyle() session?.document.replaceText(newSource) #if canImport(AppKit) onLineStateChanged?() #endif } private func bindSession() { guard let session else { diagnostics = [] semanticTokens = [] restyle() return } session.document.replaceText(source) session.document.onDiagnostics = { [weak self] diagnostics in self?.diagnostics = diagnostics self?.restyle() } session.document.onSemanticTokens = { [weak self] tokens in self?.semanticTokens = tokens self?.restyle() } #if canImport(AppKit) session.document.onFoldingRanges = { [weak self] ranges in self?.updateFoldable(ranges) } session.document.onColors = { [weak self] colors in self?.colors = colors self?.restyle() } #endif } func noteEdited() { guard !isReplacingSource else { return } let text = source restyle() #if canImport(AppKit) onLineStateChanged?() #endif session?.document.replaceText(text) onEdit?(text) } private func restyle() { let text = source let whole = NSRange(location: 0, length: storage.length) storage.beginEditing() let plain: [NSAttributedString.Key: Any] = [.font: sourceFont, .foregroundColor: PlatformColor.platformLabel] storage.setAttributes(plain, range: whole) typingAttributes = plain for token in syntax.tokenize(text) { guard let style = SourceSyntaxPalette.style(for: token.kind, dark: false), let darkStyle = SourceSyntaxPalette.style(for: token.kind, dark: true) else { continue } let range = NSRange(location: token.utf16Range.lowerBound, length: token.utf16Range.count) storage.addAttribute(.foregroundColor, value: PlatformColor.syntaxRun(light: style.color, dark: darkStyle.color), range: range) if style.bold { storage.addAttribute(.font, value: boldSourceFont, range: range) } } let lineMap = LineMap(text: text) let unused = diagnostics.filter(\.isUnnecessary).map { lineMap.utf16Range(of: $0.range) } for token in semanticTokens { let lower = lineMap.utf16Offset(of: LSP.Position(line: token.line, character: token.character)) let upper = min(lower + token.length, storage.length) guard lower < upper else { continue } let faded = unused.contains { $0.overlaps(lower.. 0, range.lowerBound <= storage.length else { continue } storage.addAttribute( .kern, value: FoldingLayoutManager.swatchSize + FoldingLayoutManager.swatchGap, range: NSRange(location: range.lowerBound - 1, length: 1)) swatches.append(CodeSwatch(location: range.lowerBound, color: NSColor(color.color))) } foldingLayoutManager.swatches = swatches for fold in folds where fold.hidden.location > 0 { storage.addAttribute( .kern, value: FoldingLayoutManager.placeholderKern, range: NSRange(location: fold.hidden.location - 1, length: 1)) } #endif for diagnostic in diagnostics where !diagnostic.isUnnecessary { let range = lineMap.utf16Range(of: diagnostic.range) let marked = range.isEmpty ? range.lowerBound.. PlatformColor? { if faded { return PlatformColor.syntaxRun( light: SourceSyntaxPalette.fadedSemanticStyle(for: token.type, modifiers: token.modifiers, dark: false).color, dark: SourceSyntaxPalette.fadedSemanticStyle(for: token.type, modifiers: token.modifiers, dark: true).color) } guard let light = SourceSyntaxPalette.semanticStyle(for: token.type, modifiers: token.modifiers, dark: false), let dark = SourceSyntaxPalette.semanticStyle(for: token.type, modifiers: token.modifiers, dark: true) else { return nil } return PlatformColor.syntaxRun(light: light.color, dark: dark.color) } private var boldSourceFont: PlatformFont { #if canImport(AppKit) return NSFontManager.shared.convert(sourceFont, toHaveTrait: .boldFontMask) #else return PlatformFont.monospacedSystemFont(ofSize: sourceFont.pointSize, weight: .bold) #endif } private var storage: NSTextStorage { #if canImport(AppKit) return textStorage! #else return textStorage #endif } private var caret: Int { #if canImport(AppKit) return selectedRange().location #else return selectedRange.location #endif } private var caretPosition: LSP.Position { LineMap(text: source).position(ofUTF16Offset: caret) } var completionWordRange: NSRange { let units = Array(source.utf16) let cursor = min(caret, units.count) var start = cursor while start > 0, isWordUnit(units[start - 1]) { start -= 1 } return NSRange(location: start, length: cursor - start) } private func isWordUnit(_ unit: UTF16.CodeUnit) -> Bool { (unit >= 48 && unit <= 57) || (unit >= 65 && unit <= 90) || (unit >= 97 && unit <= 122) || unit == 95 || unit == 36 } func askForCompletions(trigger: String?) { guard let document = session?.document else { return } let text = source let position = caretPosition Task { @MainActor in guard let list = try? await document.completions(at: position, triggerCharacter: trigger), self.source == text else { return } fetched = list.items.sorted { ($0.sortText ?? $0.label, $0.label) < ($1.sortText ?? $1.label, $1.label) } offerCompletions() } } private func matchingCandidates() -> [LSP.CompletionItem] { let prefix = (source as NSString).substring(with: completionWordRange).lowercased() return fetched.filter { prefix.isEmpty || ($0.filterText ?? $0.label).lowercased().hasPrefix(prefix) } } private func insertion(for item: LSP.CompletionItem) -> String { let text = item.textEdit?.newText ?? item.insertText ?? item.label return item.isSnippet ? CodeSnippetText.plain(text) : text } private func isCallable(_ item: LSP.CompletionItem) -> Bool { [2, 3, 4].contains(item.kind ?? 0) } private func replace(_ range: NSRange, with replacement: String) { #if canImport(AppKit) super.insertText(replacement, replacementRange: range) #else storage.replaceCharacters(in: range, with: replacement) noteEdited() #endif } private func moveCaret(to offset: Int) { let range = NSRange(location: offset, length: 0) #if canImport(AppKit) setSelectedRange(range, affinity: .downstream, stillSelecting: false) #else selectedRange = range #endif } #if canImport(AppKit) override func becomeFirstResponder() -> Bool { let became = super.becomeFirstResponder() if became { onFocused?() } return became } override func resignFirstResponder() -> Bool { completionPanel.dismiss() signature.dismiss() return super.resignFirstResponder() } override func didChangeText() { super.didChangeText() noteEdited() } override func shouldChangeText(in affectedCharRange: NSRange, replacementString: String?) -> Bool { let allowed = super.shouldChangeText(in: affectedCharRange, replacementString: replacementString) if allowed { let replacementLength = (replacementString ?? "").utf16.count shiftPlaceholders(for: affectedCharRange, replacementLength: replacementLength) shiftFolds(for: affectedCharRange, replacementLength: replacementLength) } return allowed } override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) if let band = CurrentLineBand.rect(in: self, font: sourceFont), band.intersects(rect) { CurrentLineBand.color.setFill() band.fill() } let folded = foldedStartLines ScopeGuides.draw(foldable.filter { !folded.contains($0.startLine) }, in: self, dirtyRect: rect) } override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) { super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting) setNeedsDisplay(visibleRect) onLineStateChanged?() } override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) if let hit = foldingLayoutManager.placeholderRects.first(where: { $0.rect.contains(point) }) { folds.removeAll { $0.hidden == hit.hidden } applyFolds() return } super.mouseDown(with: event) } var foldableStartLines: Set { Set(foldable.map(\.startLine)) } var foldedStartLines: Set { Set(folds.map(\.startLine)) } func toggleFold(atLine line: Int) { if let index = folds.firstIndex(where: { $0.startLine == line }) { folds.remove(at: index) } else if let fold = CodeFold.folds(from: foldable.filter { $0.startLine == line }, in: source).first { folds.append(fold) } applyFolds() } func reveal(_ range: LSP.Range) { let target = LineMap(text: source).utf16Range(of: range) folds.removeAll { NSIntersectionRange($0.hidden, NSRange(location: target.lowerBound, length: max(target.count, 1))).length > 0 || NSLocationInRange(target.lowerBound, $0.hidden) } applyFolds() let selection = NSRange(location: target.lowerBound, length: target.count) setSelectedRange(selection) scrollRangeToVisible(selection) window?.makeFirstResponder(self) } private func updateFoldable(_ ranges: [LSP.FoldingRange]) { foldable = ranges let kept = foldedStartLines folds = CodeFold.folds(from: ranges.filter { kept.contains($0.startLine) }, in: source) applyFolds() } private func shiftFolds(for edited: NSRange, replacementLength: Int) { let delta = replacementLength - edited.length folds = folds.compactMap { fold in let touchesHidden = NSIntersectionRange(edited, fold.hidden).length > 0 || NSLocationInRange(edited.location, fold.hidden) if touchesHidden { return nil } guard fold.hidden.location >= NSMaxRange(edited) else { return fold } return CodeFold(startLine: fold.startLine, hidden: NSRange(location: fold.hidden.location + delta, length: fold.hidden.length)) } foldingLayoutManager.hiddenRanges = folds.map(\.hidden) } private func applyFolds() { foldingLayoutManager.hiddenRanges = folds.map(\.hidden) restyle() needsDisplay = true onLineStateChanged?() } override func insertText(_ string: Any, replacementRange: NSRange) { let typed = (string as? String).flatMap { $0.utf16.count == 1 ? $0.utf16.first : nil } if let typed, isCloser(typed), selectedRange().length == 0, let dedent = SourceIndentation.closerDedent(in: source, atUTF16: caret) { super.insertText("", replacementRange: NSRange(location: dedent.lowerBound, length: dedent.count)) } super.insertText(string, replacementRange: replacementRange) guard let typed else { return } if isWordUnit(typed) { askForCompletions(trigger: nil) } else if typed == 0x2E { askForCompletions(trigger: ".") } else { completionPanel.dismiss() } if typed == 0x28 || typed == 0x2C || signature.isShown { askForSignatureHelp() } if typed == 0x29 { signature.dismiss() } } private func isCloser(_ unit: UTF16.CodeUnit) -> Bool { unit == 0x29 || unit == 0x5D || unit == 0x7D } override func insertNewline(_ sender: Any?) { completionPanel.dismiss() let selection = selectedRange() let newline = SourceIndentation.newline(in: source, atUTF16: selection.location) super.insertText(newline.text, replacementRange: selection) moveCaret(to: selection.location + newline.caretOffset) } override func insertTab(_ sender: Any?) { if selectPlaceholder(forward: true) { return } super.insertText(SourceIndentation.unit, replacementRange: selectedRange()) } override func insertBacktab(_ sender: Any?) { _ = selectPlaceholder(forward: false) } override func deleteBackward(_ sender: Any?) { if selectedRange().length == 0, let dedent = SourceIndentation.backspaceDedent(in: source, atUTF16: caret) { super.insertText("", replacementRange: NSRange(location: dedent.lowerBound, length: dedent.count)) return } super.deleteBackward(sender) } override func doCommand(by selector: Selector) { if completionPanel.isShown { switch selector { case #selector(moveUp(_:)): completionPanel.moveSelection(by: -1) return case #selector(moveDown(_:)): completionPanel.moveSelection(by: 1) return case #selector(insertNewline(_:)), #selector(insertTab(_:)): if let item = completionPanel.selectedItem { acceptCompletion(item) } return case #selector(cancelOperation(_:)): completionPanel.dismiss() return default: break } } if selector == #selector(cancelOperation(_:)), signature.isShown { signature.dismiss() return } super.doCommand(by: selector) } override func complete(_ sender: Any?) { askForCompletions(trigger: nil) } private func offerCompletions() { let matching = matchingCandidates() guard !matching.isEmpty else { completionPanel.dismiss() return } completionPanel.onAccept = { [weak self] item in self?.acceptCompletion(item) } completionPanel.describe = { [weak self] item in (try? await self?.session?.document.resolve(item)) ?? item } completionPanel.classify = { [weak self] code in await self?.session?.document.classify(code) ?? [] } let anchor = firstRect(forCharacterRange: NSRange(location: completionWordRange.location, length: 0), actualRange: nil) completionPanel.show(matching, belowScreenRect: anchor, of: self) } private func acceptCompletion(_ item: LSP.CompletionItem) { completionPanel.dismiss() let range = completionWordRange let name = item.textEdit?.newText ?? item.insertText ?? item.label guard !item.isSnippet, isCallable(item), let document = session?.document else { replace(range, with: insertion(for: item)) return } replace(range, with: name + "(") let text = source let position = caretPosition Task { @MainActor in let parameters = (try? await document.signatureHelp(at: position))?.requiredParameterNames ?? [] guard source == text else { return } layDownCall(CallTemplate(name: name, parameters: parameters), from: range.location) } } private func layDownCall(_ template: CallTemplate, from start: Int) { replace(NSRange(location: start, length: caret - start), with: template.text) argumentPlaceholders = template.placeholders.map { NSRange(location: start + $0.lowerBound, length: $0.count) } if let first = argumentPlaceholders.first { setSelectedRange(first) } else { moveCaret(to: start + template.text.utf16.count) } } private func selectPlaceholder(forward: Bool) -> Bool { guard !argumentPlaceholders.isEmpty else { return false } let current = selectedRange() let ordered = argumentPlaceholders.sorted { $0.location < $1.location } let next = forward ? ordered.first { $0.location > current.location } ?? ordered.first : ordered.last { $0.location < current.location } ?? ordered.last guard let next else { return false } setSelectedRange(next) return true } private func shiftPlaceholders(for edited: NSRange, replacementLength: Int) { guard !argumentPlaceholders.isEmpty else { return } let delta = replacementLength - edited.length argumentPlaceholders = argumentPlaceholders.compactMap { placeholder in if placeholder.location >= NSMaxRange(edited) { return NSRange(location: placeholder.location + delta, length: placeholder.length) } if NSMaxRange(placeholder) <= edited.location { return placeholder } return nil } } private func askForSignatureHelp() { guard let document = session?.document else { return } let text = source let position = caretPosition signatureRequest?.cancel() signatureRequest = Task { @MainActor in let help = try? await document.signatureHelp(at: position) guard !Task.isCancelled, source == text else { return } guard let help, !help.signatures.isEmpty else { signature.dismiss() return } signature.show(signatureText(help), key: caret, relativeTo: caretRect(), of: self, preferredEdge: .minY) } } private func signatureText(_ help: LSP.SignatureHelp) -> NSAttributedString { let active = help.signatures[min(help.activeSignature ?? 0, help.signatures.count - 1)] let activeParameter = active.activeParameter ?? help.activeParameter ?? 0 let font = NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) let text = NSMutableAttributedString(string: active.label, attributes: [.font: font, .foregroundColor: NSColor.labelColor]) if let parameter = (active.parameters ?? []).indices.contains(activeParameter) ? active.parameters?[activeParameter] : nil, let range = parameterRange(parameter, in: active.label) { text.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold), range: range) if let documentation = parameter.documentation?.text, !documentation.isEmpty { text.append(NSAttributedString(string: "\n\n" + documentation, attributes: [ .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), .foregroundColor: NSColor.secondaryLabelColor, ])) } } return text } private func parameterRange(_ parameter: LSP.ParameterInformation, in label: String) -> NSRange? { switch parameter.label { case .offsets(let start, let end): return NSRange(location: start, length: min(end, label.utf16.count) - start) case .text(let text): let found = (label as NSString).range(of: text) return found.location == NSNotFound ? nil : found } } private func caretRect() -> NSRect { let screenRect = firstRect(forCharacterRange: NSRange(location: caret, length: 0), actualRange: nil) guard let window else { return .zero } return convert(window.convertFromScreen(screenRect), from: nil) } override func performKeyEquivalent(with event: NSEvent) -> Bool { guard window?.firstResponder === self else { return super.performKeyEquivalent(with: event) } let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) switch (modifiers, event.charactersIgnoringModifiers?.lowercased()) { case (.command, "f"): find(.showFindInterface); return true case (.command, "e"): find(.setSearchString); return true case (.command, "g"): find(.nextMatch); return true case ([.command, .shift], "g"): find(.previousMatch); return true default: return super.performKeyEquivalent(with: event) } } private func find(_ action: NSTextFinder.Action) { let request = NSMenuItem() request.tag = action.rawValue performTextFinderAction(request) } override func updateTrackingAreas() { super.updateTrackingAreas() for area in trackingAreas where area.owner === self { removeTrackingArea(area) } addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow], owner: self)) } override func mouseMoved(with event: NSEvent) { super.mouseMoved(with: event) let point = convert(event.locationInWindow, from: nil) scheduleHover(at: characterIndexForInsertion(at: point)) } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) hoverRequest?.cancel() hover.dismiss() } override func keyDown(with event: NSEvent) { hover.dismiss() super.keyDown(with: event) } private func scheduleHover(at offset: Int) { hoverRequest?.cancel() guard offset < storage.length else { hover.dismiss() return } if hover.isShowing(key: offset) { return } hover.dismiss() let work = DispatchWorkItem { [weak self] in self?.requestHover(at: offset) } hoverRequest = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: work) } private func requestHover(at offset: Int) { let text = source let messages = diagnostics(atUTF16Offset: offset) guard let document = session?.document else { showHover(hoverText(messages: messages, segments: []), at: offset) return } let position = LineMap(text: text).position(ofUTF16Offset: offset) Task { @MainActor in let answer = try? await document.hover(at: position) guard self.source == text else { return } var segments: [(HoverMarkdown.Segment, [SemanticToken])] = [] for segment in HoverMarkdown.segments(answer?.contents.text ?? "") { segments.append((segment, segment.isCode ? await document.classify(segment.text) : [])) } guard self.source == text else { return } showHover(hoverText(messages: messages, segments: segments), at: offset) } } private func hoverText(messages: [String], segments: [(HoverMarkdown.Segment, [SemanticToken])]) -> NSAttributedString { let font = PlatformFont.monospacedSystemFont(ofSize: PlatformFont.smallSystemFontSize, weight: .regular) let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua let result = NSMutableAttributedString() func appendPlain(_ string: String) { result.append(NSAttributedString(string: string, attributes: [.font: font, .foregroundColor: PlatformColor.platformLabel])) } func separate() { if result.length > 0 { appendPlain("\n\n") } } for message in messages where !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { separate() appendPlain(message) } for (segment, tokens) in segments where !segment.text.isEmpty { separate() guard segment.isCode else { appendPlain(segment.text) continue } let units = Array(segment.text.utf16) for run in SourceHighlighter.runs(of: segment.text, semanticTokens: tokens, dark: dark) { let piece = String(utf16CodeUnits: Array(units[run.range]), count: run.range.count) let color = run.color.map(PlatformColor.init(rgb:)) ?? PlatformColor.platformLabel result.append(NSAttributedString(string: piece, attributes: [.font: font, .foregroundColor: color])) } } return result } private func diagnostics(atUTF16Offset offset: Int) -> [String] { let lineMap = LineMap(text: source) return diagnostics .filter { lineMap.utf16Range(of: $0.range).contains(offset) || lineMap.utf16Offset(of: $0.range.start) == offset } .map(\.message) } private func showHover(_ text: NSAttributedString, at offset: Int) { guard text.length > 0, let layoutManager, let textContainer else { return } let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: offset, length: 1), actualCharacterRange: nil) var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) rect.origin.x += textContainerOrigin.x rect.origin.y += textContainerOrigin.y hover.show(text, key: offset, relativeTo: rect, of: self, preferredEdge: .maxY) } #else override func becomeFirstResponder() -> Bool { let became = super.becomeFirstResponder() if became { onFocused?() } return became } override func insertText(_ text: String) { if text == "\n" { let selection = selectedRange let newline = SourceIndentation.newline(in: source, atUTF16: selection.location) storage.replaceCharacters(in: selection, with: newline.text) moveCaret(to: selection.location + newline.caretOffset) noteEdited() return } if text.utf16.count == 1, let unit = text.utf16.first, unit == 0x29 || unit == 0x5D || unit == 0x7D, selectedRange.length == 0, let dedent = SourceIndentation.closerDedent(in: source, atUTF16: caret) { storage.replaceCharacters(in: NSRange(location: dedent.lowerBound, length: dedent.count), with: "") moveCaret(to: dedent.lowerBound) } super.insertText(text) guard text.utf16.count == 1, let unit = text.utf16.first else { return } if isWordUnit(unit) { askForCompletions(trigger: nil) } else if text == "." { askForCompletions(trigger: ".") } } override func deleteBackward() { if selectedRange.length == 0, let dedent = SourceIndentation.backspaceDedent(in: source, atUTF16: caret) { storage.replaceCharacters(in: NSRange(location: dedent.lowerBound, length: dedent.count), with: "") moveCaret(to: dedent.lowerBound) noteEdited() return } super.deleteBackward() } private func offerCompletions() { let matching = matchingCandidates() candidates.offer(matching.map(\.label)) { [weak self] word in guard let self, let item = matching.first(where: { $0.label == word }) else { return } let range = self.completionWordRange let text = self.isCallable(item) ? CallTemplate(name: self.insertion(for: item), parameters: []).text : self.insertion(for: item) self.replace(range, with: text) self.moveCaret(to: range.location + text.utf16.count) } guard inputAccessoryView == nil, !matching.isEmpty else { return } let bar = PlatformHostingView(rootView: CompletionStrip(candidates: candidates)) bar.frame.size.height = completionBarHeight bar.autoresizingMask = .flexibleWidth inputAccessoryView = bar reloadInputViews() } #endif } enum CodeSnippetText { static func plain(_ snippet: String) -> String { var result = "" var index = snippet.startIndex while index < snippet.endIndex { let character = snippet[index] if character == "\\", snippet.index(after: index) < snippet.endIndex { index = snippet.index(after: index) result.append(snippet[index]) } else if character == "$" { index = skipPlaceholder(in: snippet, from: index, into: &result) continue } else { result.append(character) } index = snippet.index(after: index) } return result } private static func skipPlaceholder(in snippet: String, from dollar: String.Index, into result: inout String) -> String.Index { var index = snippet.index(after: dollar) guard index < snippet.endIndex else { return index } if snippet[index] == "{" { var depth = 1 var body = "" index = snippet.index(after: index) while index < snippet.endIndex, depth > 0 { let character = snippet[index] if character == "{" { depth += 1 } if character == "}" { depth -= 1 } if depth > 0 { body.append(character) } index = snippet.index(after: index) } if let colon = body.firstIndex(of: ":") { result += plain(String(body[body.index(after: colon)...])) } return index } while index < snippet.endIndex, snippet[index].isNumber { index = snippet.index(after: index) } return index } } extension Optional where Wrapped: RangeReplaceableCollection { var orEmpty: Wrapped { self ?? Wrapped() } } extension PlatformColor { static func syntaxRun(light: LumaCore.RGBColor, dark: LumaCore.RGBColor) -> PlatformColor { #if canImport(AppKit) return NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua return NSColor(rgb: isDark ? dark : light) } #else return UIColor { trait in UIColor(rgb: trait.userInterfaceStyle == .dark ? dark : light) } #endif } convenience init(rgb: LumaCore.RGBColor) { let red = CGFloat(rgb.r) / 255 let green = CGFloat(rgb.g) / 255 let blue = CGFloat(rgb.b) / 255 #if canImport(AppKit) self.init(srgbRed: red, green: green, blue: blue, alpha: 1) #else self.init(red: red, green: green, blue: blue, alpha: 1) #endif } } #if canImport(AppKit) @MainActor final class CodeInfoPopover { private let popover = NSPopover() private var key: Int? init() { popover.behavior = .transient popover.animates = false } var isShown: Bool { popover.isShown } func isShowing(key: Int) -> Bool { popover.isShown && self.key == key } func show(_ text: NSAttributedString, key: Int, relativeTo rect: NSRect, of view: NSView, preferredEdge: NSRectEdge) { let label = NSTextField(wrappingLabelWithString: "") label.attributedStringValue = text label.preferredMaxLayoutWidth = 480 let size = label.fittingSize label.frame = NSRect(x: 10, y: 8, width: ceil(size.width), height: ceil(size.height)) let padded = NSView(frame: NSRect(x: 0, y: 0, width: ceil(size.width) + 20, height: ceil(size.height) + 16)) padded.addSubview(label) let controller = NSViewController() controller.view = padded popover.contentSize = padded.frame.size popover.contentViewController = controller self.key = key popover.show(relativeTo: rect, of: view, preferredEdge: preferredEdge) } func dismiss() { guard popover.isShown else { return } popover.performClose(nil) key = nil } } #endif