Syntax highlighting
This commit is contained in:
BIN
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
Reference in New Issue
Block a user