diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3c7c73c Binary files /dev/null and b/.DS_Store differ diff --git a/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate index 4200542..2d8099a 100644 Binary files a/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate and b/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/dock-g/dock-g/Utilities/ENVHighlighter.swift b/dock-g/dock-g/Utilities/ENVHighlighter.swift new file mode 100644 index 0000000..4a4ce6c --- /dev/null +++ b/dock-g/dock-g/Utilities/ENVHighlighter.swift @@ -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[.. 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 + ]) + } +} diff --git a/dock-g/dock-g/Utilities/SyntaxHighlightingEditor.swift b/dock-g/dock-g/Utilities/SyntaxHighlightingEditor.swift new file mode 100644 index 0000000..0166291 --- /dev/null +++ b/dock-g/dock-g/Utilities/SyntaxHighlightingEditor.swift @@ -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)) + ) + } + } +} diff --git a/dock-g/dock-g/Utilities/YAMLHighlighter.swift b/dock-g/dock-g/Utilities/YAMLHighlighter.swift new file mode 100644 index 0000000..f33e554 --- /dev/null +++ b/dock-g/dock-g/Utilities/YAMLHighlighter.swift @@ -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[.. 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[.. 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[.. 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 + ]) + } +} diff --git a/dock-g/dock-g/Views/Stacks/ComposeTabView.swift b/dock-g/dock-g/Views/Stacks/ComposeTabView.swift index 9ec73f8..1714245 100644 --- a/dock-g/dock-g/Views/Stacks/ComposeTabView.swift +++ b/dock-g/dock-g/Views/Stacks/ComposeTabView.swift @@ -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) } } diff --git a/original-source/dockge b/original-source/dockge deleted file mode 160000 index cc18056..0000000 --- a/original-source/dockge +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cc180562fcd534de7c0890633494cde2c9658d97