Syntax highlighting

This commit is contained in:
2026-04-10 22:47:39 +02:00
parent 2e7e931c3b
commit 389b051ca1
7 changed files with 394 additions and 10 deletions
Vendored
BIN
View File
Binary file not shown.
@@ -0,0 +1,134 @@
import UIKit
import SwiftUI
/// Tokenises .env file text into a syntax-highlighted NSAttributedString.
/// Handles KEY=value pairs, comments, and quoted values.
enum ENVHighlighter {
// MARK: - Palette
private static let keyColor = UIColor(hex: "#3b82f6") // blue variable names
private static let stringColor = UIColor(hex: "#22c55e") // green quoted values
private static let numberColor = UIColor(hex: "#f59e0b") // amber numeric values
private static let boolColor = UIColor(hex: "#c084fc") // violet true/false
private static let commentColor = UIColor(hex: "#6b7280") // gray # comments
private static let eqColor = UIColor(hex: "#94a3b8") // slate = sign
private static let plainColor = UIColor(hex: "#e2e8f0") // slate plain values
private static let monoFont = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)
// MARK: - Public API
static func nsAttributedString(from env: String) -> NSAttributedString {
let result = NSMutableAttributedString()
let lines = env.components(separatedBy: "\n")
for (i, line) in lines.enumerated() {
result.append(processLine(line))
if i < lines.count - 1 {
result.append(seg("\n", plainColor))
}
}
return result
}
static func attributedString(from env: String) -> AttributedString {
(try? AttributedString(nsAttributedString(from: env), including: \.uiKit)) ?? AttributedString(env)
}
// MARK: - Line processing
private static func processLine(_ line: String) -> NSAttributedString {
guard !line.isEmpty else { return seg("", plainColor) }
let stripped = line.trimmingCharacters(in: .whitespaces)
// Blank / whitespace-only
if stripped.isEmpty { return seg(line, plainColor) }
// Comment
if stripped.hasPrefix("#") { return seg(line, commentColor) }
// KEY=value or export KEY=value
var workLine = line
let result = NSMutableAttributedString()
// Strip optional "export " prefix
if workLine.hasPrefix("export ") {
result.append(seg("export ", eqColor))
workLine = String(workLine.dropFirst(7))
}
if let eqIdx = workLine.firstIndex(of: "=") {
let key = String(workLine[..<eqIdx])
let value = String(workLine[workLine.index(after: eqIdx)...])
result.append(seg(key, keyColor))
result.append(seg("=", eqColor))
result.append(colorValue(value))
} else {
// No `=` found render as plain
result.append(seg(workLine, plainColor))
}
return result
}
// MARK: - Value colouring
private static func colorValue(_ value: String) -> NSAttributedString {
// Split inline comment: " # "
if let commentStart = inlineCommentIndex(in: value) {
let valuePart = String(value[..<commentStart])
let commentPart = String(value[commentStart...])
let result = NSMutableAttributedString(attributedString:
seg(valuePart, typeColor(for: valuePart)))
result.append(seg(commentPart, commentColor))
return result
}
return seg(value, typeColor(for: value))
}
private static func typeColor(for raw: String) -> UIColor {
let v = raw.trimmingCharacters(in: .whitespaces)
guard !v.isEmpty else { return plainColor }
// Quoted strings
if v.count >= 2,
(v.hasPrefix("\"") && v.hasSuffix("\"")) ||
(v.hasPrefix("'") && v.hasSuffix("'")) { return stringColor }
// Booleans
switch v.lowercased() {
case "true", "false", "yes", "no", "1", "0": return boolColor
default: break
}
// Numbers
if Int(v) != nil || Double(v) != nil { return numberColor }
return plainColor
}
private static func inlineCommentIndex(in text: String) -> String.Index? {
var inSingle = false, inDouble = false, prev: Character = " "
for idx in text.indices {
let ch = text[idx]
if ch == "\"" && !inSingle { inDouble.toggle() }
else if ch == "'" && !inDouble { inSingle.toggle() }
else if ch == "#" && !inSingle && !inDouble && prev == " " {
return text.index(before: idx)
}
prev = ch
}
return nil
}
// MARK: - Helpers
private static func seg(_ text: String, _ color: UIColor) -> NSAttributedString {
NSAttributedString(string: text, attributes: [
.font: monoFont,
.foregroundColor: color
])
}
}
@@ -0,0 +1,64 @@
import SwiftUI
import UIKit
/// A UITextView-backed editor that applies syntax highlighting on every keystroke.
/// Pass any `(String) -> NSAttributedString` highlighter shared by YAML and ENV.
struct SyntaxHighlightingEditor: UIViewRepresentable {
@Binding var text: String
let highlight: (String) -> NSAttributedString
func makeUIView(context: Context) -> UITextView {
let tv = UITextView()
tv.delegate = context.coordinator
tv.backgroundColor = UIColor(Color.terminalBg)
tv.isScrollEnabled = false // outer ScrollView drives scrolling
tv.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
tv.autocorrectionType = .no
tv.autocapitalizationType = .none
tv.spellCheckingType = .no
tv.smartDashesType = .no
tv.smartQuotesType = .no
tv.keyboardType = .asciiCapable
tv.tintColor = UIColor(Color.appAccent)
tv.attributedText = highlight(text)
context.coordinator.lastText = text
return tv
}
func updateUIView(_ tv: UITextView, context: Context) {
// Only re-highlight when text changed from outside (e.g. initial load / save)
guard context.coordinator.lastText != text else { return }
context.coordinator.lastText = text
let sel = tv.selectedRange
tv.attributedText = highlight(text)
// Clamp selection to new length
let len = (tv.text as NSString).length
tv.selectedRange = NSRange(location: min(sel.location, len), length: 0)
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
// MARK: - Coordinator
final class Coordinator: NSObject, UITextViewDelegate {
var parent: SyntaxHighlightingEditor
var lastText: String = ""
init(_ parent: SyntaxHighlightingEditor) { self.parent = parent }
func textViewDidChange(_ tv: UITextView) {
let newText = tv.text ?? ""
lastText = newText
parent.text = newText
// Re-apply highlighting, preserving cursor position
let sel = tv.selectedRange
tv.attributedText = parent.highlight(newText)
let len = (tv.text as NSString).length
tv.selectedRange = NSRange(
location: min(sel.location, len),
length: min(sel.length, max(0, len - sel.location))
)
}
}
}
@@ -0,0 +1,179 @@
import UIKit
import SwiftUI
/// Tokenises YAML text into a syntax-highlighted NSAttributedString.
/// Designed for docker-compose files displayed on a dark terminal background.
enum YAMLHighlighter {
// MARK: - Palette (dark-terminal optimised, UIColor)
private static let keyColor = UIColor(hex: "#3b82f6") // blue keys
private static let stringColor = UIColor(hex: "#22c55e") // green quoted strings / block scalars
private static let numberColor = UIColor(hex: "#f59e0b") // amber numbers
private static let boolColor = UIColor(hex: "#c084fc") // violet true/false/yes/no
private static let nullColor = UIColor(hex: "#6b7280") // gray null / ~
private static let commentColor = UIColor(hex: "#6b7280") // gray # comments
private static let anchorColor = UIColor(hex: "#fb923c") // orange &anchors / *aliases
private static let plainColor = UIColor(hex: "#e2e8f0") // slate plain values
private static let monoFont = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)
// MARK: - Public API
static func nsAttributedString(from yaml: String) -> NSAttributedString {
let result = NSMutableAttributedString()
let lines = yaml.components(separatedBy: "\n")
for (i, line) in lines.enumerated() {
result.append(processLine(line))
if i < lines.count - 1 {
result.append(seg("\n", plainColor))
}
}
return result
}
/// SwiftUI AttributedString wrapper use for Text() display.
static func attributedString(from yaml: String) -> AttributedString {
(try? AttributedString(nsAttributedString(from: yaml), including: \.uiKit)) ?? AttributedString(yaml)
}
// MARK: - Line processing
private static func processLine(_ line: String) -> NSAttributedString {
guard !line.isEmpty else { return seg("", plainColor) }
let indentEnd = line.firstIndex(where: { $0 != " " && $0 != "\t" }) ?? line.endIndex
let indent = String(line[..<indentEnd])
let content = String(line[indentEnd...])
let result = NSMutableAttributedString(attributedString: seg(indent, plainColor))
guard !content.isEmpty else { return result }
// Comment
if content.hasPrefix("#") {
result.append(seg(content, commentColor))
return result
}
// Document markers
if content == "---" || content == "..." {
result.append(seg(content, commentColor))
return result
}
// List item: "- "
if content.hasPrefix("- ") || content == "-" {
result.append(seg("- ", keyColor))
if content.count > 2 {
result.append(colorAsKeyValueOrValue(String(content.dropFirst(2))))
}
return result
}
result.append(colorAsKeyValueOrValue(content))
return result
}
/// Tries "key: value"; falls back to plain value colouring.
private static func colorAsKeyValueOrValue(_ text: String) -> NSAttributedString {
guard let colonIdx = keySeparatorIndex(in: text) else {
return colorValue(text)
}
let key = String(text[..<colonIdx])
let rest = String(text[text.index(after: colonIdx)...])
let result = NSMutableAttributedString(attributedString: seg(key, keyColor))
result.append(seg(":", keyColor))
guard !rest.isEmpty else { return result }
if rest.hasPrefix(" ") {
result.append(seg(" ", plainColor))
result.append(colorValue(String(rest.dropFirst())))
} else {
result.append(colorValue(rest))
}
return result
}
/// Returns the `:` index that acts as a YAML key separator.
private static func keySeparatorIndex(in text: String) -> String.Index? {
var idx = text.startIndex
while idx < text.endIndex {
let ch = text[idx]
if ch == " " || ch == "\t" || ch == "\"" || ch == "'" { return nil }
if ch == ":" {
guard idx != text.startIndex else { return nil }
let next = text.index(after: idx)
if next == text.endIndex || text[next] == " " || text[next] == "\t" {
return idx
}
return nil // colon inside a value (e.g. nginx:latest)
}
idx = text.index(after: idx)
}
return nil
}
// MARK: - Value colouring
private static func colorValue(_ raw: String) -> NSAttributedString {
if let commentStart = inlineCommentIndex(in: raw) {
let valuePart = String(raw[..<commentStart])
let commentPart = String(raw[commentStart...])
let result = NSMutableAttributedString(attributedString:
seg(valuePart, valueColor(valuePart.trimmingCharacters(in: .whitespaces))))
result.append(seg(commentPart, commentColor))
return result
}
return seg(raw, valueColor(raw.trimmingCharacters(in: .whitespaces)))
}
private static func inlineCommentIndex(in text: String) -> String.Index? {
var inSingle = false, inDouble = false, prev: Character = " "
for idx in text.indices {
let ch = text[idx]
if ch == "\"" && !inSingle { inDouble.toggle() }
else if ch == "'" && !inDouble { inSingle.toggle() }
else if ch == "#" && !inSingle && !inDouble && prev == " " {
return text.index(before: idx) // include the preceding space
}
prev = ch
}
return nil
}
private static func valueColor(_ v: String) -> UIColor {
guard !v.isEmpty else { return plainColor }
if v == "|" || v == ">" || v.hasPrefix("|-") || v.hasPrefix("|+")
|| v.hasPrefix(">-") || v.hasPrefix(">+") { return stringColor }
if v.count >= 2,
(v.hasPrefix("\"") && v.hasSuffix("\"")) ||
(v.hasPrefix("'") && v.hasSuffix("'")) { return stringColor }
switch v.lowercased() {
case "true", "false", "yes", "no", "on", "off": return boolColor
default: break
}
if v.lowercased() == "null" || v == "~" { return nullColor }
if Int(v) != nil || Double(v) != nil { return numberColor }
if v.lowercased().hasPrefix("0x") && Int(v.dropFirst(2), radix: 16) != nil { return numberColor }
if v.hasPrefix("&") || v.hasPrefix("*") { return anchorColor }
return plainColor
}
// MARK: - Helpers
private static func seg(_ text: String, _ color: UIColor) -> NSAttributedString {
NSAttributedString(string: text, attributes: [
.font: monoFont,
.foregroundColor: color
])
}
}
@@ -5,24 +5,32 @@ struct ComposeTabView: View {
var isEditing: Bool
var onSave: () async -> Void
@State private var highlighted = AttributedString()
var body: some View {
ScrollView {
if isEditing {
TextEditor(text: $yaml)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
.scrollContentBackground(.hidden)
.frame(minHeight: 400)
.padding(8)
SyntaxHighlightingEditor(
text: $yaml,
highlight: YAMLHighlighter.nsAttributedString(from:)
)
.frame(minHeight: 400)
} else {
Text(yaml.isEmpty ? " " : yaml)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
Text(highlighted)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
}
.background(Color.terminalBg)
.onAppear { reHighlight() }
.onChange(of: yaml) { _, _ in if !isEditing { reHighlight() } }
.onChange(of: isEditing) { _, editing in if !editing { reHighlight() } }
}
private func reHighlight() {
highlighted = yaml.isEmpty
? { var s = AttributedString(" "); s.font = .monoBody; return s }()
: YAMLHighlighter.attributedString(from: yaml)
}
}
Submodule original-source/dockge deleted from cc180562fc