From 425c6b8024f930d7a3471b35b77d46411e08d55f Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 09:47:09 -0700 Subject: [PATCH] first successful build --- .DS_Store | Bin 6148 -> 8196 bytes Config/LocalGitConfiguration.swift | 6 +- Editor/CodeEditorView.swift | 12 + Editor/CompletionController.swift | 22 +- Editor/EditorKeyboardToolbar.swift | 258 ++++++++++--- Editor/EditorState.swift | 5 + Editor/GutterAnnotationView.swift | 26 +- Editor/HardwareKeyboardCommandHandler.swift | 78 +++- Editor/RunestoneEditorView.swift | 113 ++++-- Git/GitPanel.swift | 8 +- Info.plist | 21 +- Layout/ContentView.swift | 4 +- Layout/EnhancedConsoleView.swift | 8 +- Onboarding/OnboardingView.swift | 2 +- PadXcode.xcodeproj/project.pbxproj | 2 + .../UserInterfaceState.xcuserstate | Bin 25620 -> 18929 bytes PadXcodeApp.swift | 21 +- Settings/SettingsView.swift | 304 +++++++++++----- Shared/SharedModels.swift | 23 +- Theme/LanguageDetector.swift | 1 + Theme/PadXcodeTheme.swift | 339 ++++++++++++------ UI/ToastSystem.swift | 2 +- project.yml | 1 + 23 files changed, 926 insertions(+), 330 deletions(-) diff --git a/.DS_Store b/.DS_Store index b0365f69dbf3d7c6a003bf6106e5c4eb10100f34..8467506d6fea0333ebd714d072bf387a7aad7229 100644 GIT binary patch literal 8196 zcmeHM%Wl&^6g`tZNRtf+6?KE;A0QHckcL<&5`>BXVo8&@X(6qvG@)YWefRtcJAQ+G zzkod}IQPy7+2eExMdFS$a~%8J$DA44x()!>TTLGWdjOj3f_K{-)-<^;z1AAx%mbpM zJun^2^T~Lgk84~zVG5W6rhqA63YY@_h5~$NOKaZn-uG8+GzCn7|55>YJ|ygd-GGHh zeROcp5`ehL)7p5VBSUp=^B>OzU(yg9EYLML_u79PDhoHvIHkzJ@z zoJOZzqI9^fM;lE6Q=qPZ)b3m8VTBXSEA_kFNT1oWpW4)P8&8nq3`bnY~ZtoiV-Snc2?QtWf* zJtuZ(QT`$&yOgR{Ebe1O|9%(si84#SB7Qfm%~sT|cxlp$tc@r|o#S0DN>}*X z0;a$pRKRWaNBtfHgxb0mN^0$d{T;hB;T0a$goBpjL@mdOzyC1gaYCxtbORP1d4|nC N1gH!)m;!%QfnNiyL#+S+ literal 6148 zcmeHKOG*Pl5UtW#47v%fT=ofs+@KT2y^xJdO-2++m_ZO+_SqEi0G=j0UwtAm&LX%F zkt*nZHP!X{^I-ZzL_B?L$3!C{s!;@4oB@;YVAFvoS3uT27SvHoH*`i%10DUvkj(vr zF6bKbzNGE>Z~UGfu&Vpz?Rd86R=q7^{k$)4)iuq0+0Bt(SL^qqm)Ez+{@nJ*-2QHB zzML^+SSSz*gaV;JD4+`9%oeMU4WkYPLV-}=Qvul@5{h7UEQY#upwcG*P_EHx@Z~Kb zoaC4tiy>NIY*e68*`F9}bi|YAWyfM@bYdMD{rJe6#|!J|h$l^_xcQn6QHKJ%3LIKs zBUn#&L7AI$ILb*Z_le{7h4E~o(06t_NIn_pM bPvRplI~GIfMdC^a#zjB{i7FKM1qD6;3Fbfu diff --git a/Config/LocalGitConfiguration.swift b/Config/LocalGitConfiguration.swift index 6287d9f..cdb5037 100644 --- a/Config/LocalGitConfiguration.swift +++ b/Config/LocalGitConfiguration.swift @@ -78,9 +78,9 @@ git push -u origin main } private struct SetupStep: View { - let step: String; let title: String; let body: String + let step: String; let title: String; let detail: String init(_ step: String, title: String, body: String) { - self.step = step; self.title = title; self.body = body + self.step = step; self.title = title; self.detail = body } var body: some View { HStack(alignment: .top, spacing: 12) { @@ -89,7 +89,7 @@ private struct SetupStep: View { .background(Color.accentColor, in: Circle()) VStack(alignment: .leading, spacing: 4) { Text(title).font(.headline) - Text(body).font(.subheadline).foregroundStyle(.secondary) + Text(detail).font(.subheadline).foregroundStyle(.secondary) } } } diff --git a/Editor/CodeEditorView.swift b/Editor/CodeEditorView.swift index 93482fb..ce3b708 100644 --- a/Editor/CodeEditorView.swift +++ b/Editor/CodeEditorView.swift @@ -91,6 +91,18 @@ struct CodeEditorView: View { .onReceive(NotificationCenter.default.publisher(for: .navigateToRange)) { note in if let range = note.userInfo?["range"] as? NSRange { navigate(to: range) } } + .onReceive(NotificationCenter.default.publisher(for: .editorFontSizeChange)) { note in + if let delta = note.userInfo?["delta"] as? Double { + let newSize = max(8, min(32, editorState.fontSizeStored + delta)) + editorState.fontSizeStored = newSize + } + } + .onReceive(NotificationCenter.default.publisher(for: .editorFindNext)) { _ in + keyboardBridge.triggerFindNext() + } + .onReceive(NotificationCenter.default.publisher(for: .editorFindPrev)) { _ in + keyboardBridge.triggerFindPrev() + } } private func jumpTo(_ line: Int) { diff --git a/Editor/CompletionController.swift b/Editor/CompletionController.swift index b7357a7..e1b2483 100644 --- a/Editor/CompletionController.swift +++ b/Editor/CompletionController.swift @@ -18,7 +18,8 @@ final class CompletionController: ObservableObject { func attach(to tv: Runestone.TextView) { self.textView = tv } func textDidChange(in tv: Runestone.TextView, filePath: String) { - guard let sel = tv.selectedRange, sel.location > 0 else { dismiss(); return } + let sel = tv.selectedRange + guard sel.location > 0 else { dismiss(); return } let text = tv.text as NSString let c = text.character(at: sel.location - 1) let shouldTrigger = autoTriggers.contains(Character(Unicode.Scalar(c)!)) @@ -42,24 +43,29 @@ final class CompletionController: ObservableObject { func dismiss() { debounceTask?.cancel(); items = []; isVisible = false } func insert(_ item: CompletionItem, into tv: Runestone.TextView) { - guard let sel = tv.selectedRange else { dismiss(); return } + let sel = tv.selectedRange let text = tv.text as NSString let wordRange = text.rangeOfCurrentWord(at: sel.location) let insertText = item.insertText ?? item.label tv.replace(wordRange, withText: insertText) if insertText.hasSuffix("()") { - let loc = (tv.selectedRange?.location ?? 1) - 1 + let loc = tv.selectedRange.location > 0 ? tv.selectedRange.location - 1 : 0 tv.selectedRange = NSRange(location: loc, length: 0) } dismiss() } private func requestCompletions(tv: Runestone.TextView, filePath: String) { - guard let sel = tv.selectedRange, - let loc = tv.textLocation(at: sel.location) else { return } - if let rect = tv.caretRect(for: sel.location) { - anchorRect = tv.convert(rect, to: nil) - } + let sel = tv.selectedRange + guard let loc = tv.textLocation(at: sel.location) else { return } + // caretRect(for:) on Runestone.TextView takes UITextPosition, not Int. + // Approximate anchor position from font metrics for the completion popover. + let font = tv.theme.font + let lineHeight = ceil(font.lineHeight * tv.lineHeightMultiplier) + let approxY = CGFloat(loc.lineNumber) * lineHeight + + tv.textContainerInset.top + - tv.contentOffset.y + anchorRect = CGRect(x: 0, y: approxY, width: 0, height: lineHeight) lastId += 1; let id = lastId Task { [weak self] in guard let self else { return } diff --git a/Editor/EditorKeyboardToolbar.swift b/Editor/EditorKeyboardToolbar.swift index d84c77c..3aafdc2 100644 --- a/Editor/EditorKeyboardToolbar.swift +++ b/Editor/EditorKeyboardToolbar.swift @@ -1,31 +1,50 @@ import SwiftUI +import UIKit import Runestone +// MARK: - Keyboard Bridge + final class EditorKeyboardBridge: ObservableObject { weak var activeTextView: Runestone.TextView? + + // Called by CodeEditorView when ⌘G / ⌘⇧G hardware shortcut fires + var onFindNext: (() -> Void)? + var onFindPrev: (() -> Void)? + + func triggerFindNext() { onFindNext?() } + func triggerFindPrev() { onFindPrev?() } } +// MARK: - Toolbar + struct EditorKeyboardToolbar: View { @ObservedObject var bridge: EditorKeyboardBridge @State private var showFindBar = false @State private var searchQuery = "" - @State private var isRegex = false + @State private var lastMatchRange: NSRange = NSRange(location: NSNotFound, length: 0) var body: some View { VStack(spacing: 0) { if showFindBar { - InlineFindBar(query: $searchQuery, isRegex: $isRegex, - onNext: findNext, onPrev: findPrev, - onDismiss: { withAnimation { showFindBar = false } }) - .transition(.move(edge: .top).combined(with: .opacity)) + InlineFindBar( + query: $searchQuery, + onNext: findNext, + onPrev: findPrev, + onDismiss: { + withAnimation { showFindBar = false } + searchQuery = "" + lastMatchRange = NSRange(location: NSNotFound, length: 0) + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) Divider() } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 4) { - KeyToolbarButton(icon: "increase.indent", label: "Indent") { bridge.activeTextView?.shiftRight() } - KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { bridge.activeTextView?.shiftLeft() } + KeyToolbarButton(icon: "increase.indent", label: "Indent") { indent(increase: true) } + KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { indent(increase: false) } KeyToolbarDivider() - KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() } + KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() } KeyToolbarDivider() KeyToolbarText("(") { insertPair("(", ")") } KeyToolbarText("[") { insertPair("[", "]") } @@ -38,86 +57,233 @@ struct EditorKeyboardToolbar: View { KeyToolbarButton(icon: "arrow.left", label: "Left") { moveCursor(-1) } KeyToolbarButton(icon: "arrow.right", label: "Right") { moveCursor(+1) } KeyToolbarDivider() - KeyToolbarButton(icon: "magnifyingglass", label: "Find") { withAnimation { showFindBar.toggle() } } + KeyToolbarButton(icon: "magnifyingglass", label: "Find") { + withAnimation { showFindBar.toggle() } + } Spacer() KeyToolbarButton(icon: "keyboard.chevron.compact.down", label: "Done") { bridge.activeTextView?.resignFirstResponder() } } - .padding(.horizontal, 8).padding(.vertical, 6) + .padding(.horizontal, 8) + .padding(.vertical, 6) } .background(.regularMaterial) } + .onAppear { + bridge.onFindNext = { findNext() } + bridge.onFindPrev = { findPrev() } + } } - private func insert(_ t: String) { bridge.activeTextView?.insertText(t) } - private func insertPair(_ l: String, _ r: String) { bridge.activeTextView?.insertText(l+r); moveCursor(-1) } - private func moveCursor(_ offset: Int) { - guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return } - tv.selectedRange = NSRange(location: max(0, sel.location + offset), length: 0) + // MARK: - Text Operations + + private func insert(_ text: String) { + bridge.activeTextView?.insertText(text) } - private func toggleComment() { - guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return } + + private func insertPair(_ open: String, _ close: String) { + bridge.activeTextView?.insertText(open + close) + moveCursor(-1) + } + + private func moveCursor(_ offset: Int) { + guard let tv = bridge.activeTextView else { return } + let sel = tv.selectedRange + let newLocation = max(0, sel.location + offset) + tv.selectedRange = NSRange(location: newLocation, length: 0) + } + + private func indent(increase: Bool) { + guard let tv = bridge.activeTextView else { return } + let sel = tv.selectedRange let text = tv.text as NSString - let lr = text.lineRange(for: sel) - let line = text.substring(with: lr) + let lineRange = text.lineRange(for: sel) + let line = text.substring(with: lineRange) + let modified: String + if increase { + modified = " " + line + } else { + if line.hasPrefix(" ") { + modified = String(line.dropFirst(4)) + } else if line.hasPrefix("\t") { + modified = String(line.dropFirst(1)) + } else { + return + } + } + tv.replace(lineRange, withText: modified) + } + + private func toggleComment() { + guard let tv = bridge.activeTextView else { return } + let sel = tv.selectedRange + let text = tv.text as NSString + let lineRange = text.lineRange(for: sel) + let line = text.substring(with: lineRange) let trimmed = line.trimmingCharacters(in: .whitespaces) let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) let newLine: String - if trimmed.hasPrefix("// ") { newLine = line.replacingFirstOccurrence(of: "// ", with: "") } - else if trimmed.hasPrefix("//") { newLine = line.replacingFirstOccurrence(of: "//", with: "") } - else { newLine = indent + "// " + line.dropFirst(indent.count) } - tv.replace(lr, withText: newLine) + if trimmed.hasPrefix("// ") { + newLine = line.replacingFirstOccurrence(of: "// ", with: "") + } else if trimmed.hasPrefix("//") { + newLine = line.replacingFirstOccurrence(of: "//", with: "") + } else { + newLine = indent + "// " + line.dropFirst(indent.count) + } + tv.replace(lineRange, withText: newLine) } + + // MARK: - Find (manual NSString search — no Runestone-specific API) + private func findNext() { guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return } - tv.selectNextOccurrence(matching: SearchOptions(query: searchQuery)) + let fullText = tv.text as NSString + let totalLength = fullText.length + + // Search from just after the last match, wrapping if needed + let searchStart: Int + if lastMatchRange.location != NSNotFound { + searchStart = min(lastMatchRange.location + lastMatchRange.length, totalLength) + } else { + searchStart = 0 + } + + // Try from searchStart to end + var found = fullText.range(of: searchQuery, + options: .caseInsensitive, + range: NSRange(location: searchStart, + length: totalLength - searchStart)) + + // Wrap around if not found + if found.location == NSNotFound && searchStart > 0 { + found = fullText.range(of: searchQuery, + options: .caseInsensitive, + range: NSRange(location: 0, length: searchStart)) + } + + if found.location != NSNotFound { + lastMatchRange = found + tv.selectedRange = found + tv.scrollRangeToVisible(found) + } } + private func findPrev() { guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return } - tv.selectPreviousOccurrence(matching: SearchOptions(query: searchQuery)) + let fullText = tv.text as NSString + let totalLength = fullText.length + + // Search backwards from just before the last match + let searchEnd: Int + if lastMatchRange.location != NSNotFound { + searchEnd = lastMatchRange.location + } else { + searchEnd = totalLength + } + + // Try backwards from searchEnd to start + var found = fullText.range(of: searchQuery, + options: [.caseInsensitive, .backwards], + range: NSRange(location: 0, length: searchEnd)) + + // Wrap around to end + if found.location == NSNotFound && searchEnd < totalLength { + found = fullText.range(of: searchQuery, + options: [.caseInsensitive, .backwards], + range: NSRange(location: searchEnd, length: totalLength - searchEnd)) + } + + if found.location != NSNotFound { + lastMatchRange = found + tv.selectedRange = found + tv.scrollRangeToVisible(found) + } } } +// MARK: - Inline Find Bar + struct InlineFindBar: View { - @Binding var query: String; @Binding var isRegex: Bool - var onNext: () -> Void; var onPrev: () -> Void; var onDismiss: () -> Void + @Binding var query: String + var onNext: () -> Void + var onPrev: () -> Void + var onDismiss: () -> Void + var body: some View { HStack(spacing: 8) { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Find…", text: $query).textFieldStyle(.plain) - .font(.system(.body, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none) - .onSubmit(onNext) - Toggle(isOn: $isRegex) { Image(systemName: "ellipsis.curlybraces") } - .toggleStyle(.button).buttonStyle(.bordered).tint(isRegex ? .accentColor : .secondary) - Button(action: onPrev) { Image(systemName: "chevron.up") }.buttonStyle(.bordered).disabled(query.isEmpty) - Button(action: onNext) { Image(systemName: "chevron.down") }.buttonStyle(.bordered).disabled(query.isEmpty) - Button(action: onDismiss) { Image(systemName: "xmark") }.buttonStyle(.bordered) + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Find...", text: $query) + .textFieldStyle(.plain) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onSubmit { onNext() } + Button(action: onPrev) { + Image(systemName: "chevron.up") + } + .buttonStyle(.bordered) + .disabled(query.isEmpty) + Button(action: onNext) { + Image(systemName: "chevron.down") + } + .buttonStyle(.bordered) + .disabled(query.isEmpty) + Button(action: onDismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.bordered) } - .padding(.horizontal, 12).padding(.vertical, 6) + .padding(.horizontal, 12) + .padding(.vertical, 6) } } +// MARK: - Toolbar Sub-components + struct KeyToolbarButton: View { - let icon: String; let label: String; let action: () -> Void + let icon: String + let label: String + let action: () -> Void + var body: some View { - Button(action: action) { Image(systemName: icon).frame(minWidth: 32, minHeight: 32).contentShape(Rectangle()) } - .buttonStyle(.plain).foregroundStyle(.primary).help(label) + Button(action: action) { + Image(systemName: icon) + .frame(minWidth: 32, minHeight: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .help(label) } } struct KeyToolbarText: View { - let text: String; let action: () -> Void - init(_ text: String, action: @escaping () -> Void) { self.text = text; self.action = action } + let text: String + let action: () -> Void + + init(_ text: String, action: @escaping () -> Void) { + self.text = text + self.action = action + } + var body: some View { Button(action: action) { - Text(text).font(.system(.callout, design: .monospaced).bold()) - .frame(minWidth: 32, minHeight: 32).contentShape(Rectangle()) + Text(text) + .font(.system(.callout, design: .monospaced).bold()) + .frame(minWidth: 32, minHeight: 32) + .contentShape(Rectangle()) } - .buttonStyle(.plain).foregroundStyle(.primary) + .buttonStyle(.plain) + .foregroundStyle(.primary) } } struct KeyToolbarDivider: View { - var body: some View { Divider().frame(height: 20).padding(.horizontal, 2) } + var body: some View { + Divider() + .frame(height: 20) + .padding(.horizontal, 2) + } } diff --git a/Editor/EditorState.swift b/Editor/EditorState.swift index ec1d2bb..358c48f 100644 --- a/Editor/EditorState.swift +++ b/Editor/EditorState.swift @@ -113,5 +113,10 @@ final class EditorState: ObservableObject { } } + + func markSaved(tabId: UUID) { + guard let idx = tabs.firstIndex(where: { $0.id == tabId }) else { return } + tabs[idx].isDirty = false + } var dirtyTabNames: [String] { tabs.filter { $0.isDirty }.map { $0.fileName } } } diff --git a/Editor/GutterAnnotationView.swift b/Editor/GutterAnnotationView.swift index e6b723b..64038cf 100644 --- a/Editor/GutterAnnotationView.swift +++ b/Editor/GutterAnnotationView.swift @@ -1,10 +1,7 @@ -// GutterAnnotationView.swift -// DiagnosticRange is defined in Shared/SharedModels.swift -// No Identifiable conformance extension needed here. import SwiftUI import Runestone -// MARK: - Annotation Model (local to this file — wraps DiagnosticRange with position) +// MARK: - Annotation Model struct GutterAnnotation: Identifiable { let id = UUID() @@ -69,17 +66,19 @@ struct GutterAnnotationOverlay: View { private func computePositions() { guard let tv = textView else { return } var result: [PositionedAnnotation] = [] - for diag in diagnostics { let ann = GutterAnnotation(line: diag.line, severity: diag.severity, message: diag.message) - guard let range = tv.range(atLine: ann.line, column: 0), - let rect = tv.caretRect(for: range.location) else { continue } - let y = rect.midY - tv.contentOffset.y - if y > 0 && y < tv.bounds.height { - result.append(PositionedAnnotation(annotation: ann, y: y)) - } + // caretRect(for:) on Runestone.TextView takes UITextPosition, not Int. + // Compute Y from font metrics instead — reliable for a code editor + // where all lines have equal height. + let font = tv.theme.font + let lineHeight = ceil(font.lineHeight * tv.lineHeightMultiplier) + let y = CGFloat(ann.line - 1) * lineHeight + + tv.textContainerInset.top + - tv.contentOffset.y + guard y > 0 && y < tv.bounds.height else { continue } + result.append(PositionedAnnotation(annotation: ann, y: y)) } - withAnimation(.easeInOut(duration: 0.15)) { annotations = result } } } @@ -108,7 +107,8 @@ struct DiagnosticPopover: View { } } -// MARK: - Hex colour helper (local) +// MARK: - Hex colour helper (private to this file) + private extension Color { init(hex: String) { var h = hex.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Editor/HardwareKeyboardCommandHandler.swift b/Editor/HardwareKeyboardCommandHandler.swift index df3fdb0..9811f8c 100644 --- a/Editor/HardwareKeyboardCommandHandler.swift +++ b/Editor/HardwareKeyboardCommandHandler.swift @@ -87,18 +87,33 @@ final class EditorCommandHandler: UIViewController { @objc func cmdUnindent() { textView?.shiftLeft() } @objc func cmdFind() { onFind?() } @objc func cmdFindProject(){ onFindInProject?() } - @objc func cmdFindNext() {} - @objc func cmdFindPrev() {} + @objc func cmdFindNext() { + NotificationCenter.default.post( + name: .editorFindNext, object: nil) + } + @objc func cmdFindPrev() { + NotificationCenter.default.post( + name: .editorFindPrev, object: nil) + } @objc func cmdJumpToLine() { onJumpToLine?() } @objc func cmdBuild() { onBuild?() } @objc func cmdRun() { onRun?() } @objc func cmdNavigator() { onToggleNavigator?() } @objc func cmdConsole() { onToggleConsole?() } - @objc func cmdFontBigger() {} - @objc func cmdFontSmaller(){} + @objc func cmdFontBigger() { + NotificationCenter.default.post( + name: .editorFontSizeChange, object: nil, + userInfo: ["delta": 1.0]) + } + @objc func cmdFontSmaller() { + NotificationCenter.default.post( + name: .editorFontSizeChange, object: nil, + userInfo: ["delta": -1.0]) + } @objc func cmdComment() { - guard let tv = textView, let sel = tv.selectedRange else { return } + guard let tv = textView else { return } + let sel = tv.selectedRange let text = tv.text as NSString; let lr = text.lineRange(for: sel) let line = text.substring(with: lr); let trimmed = line.trimmingCharacters(in: .whitespaces) let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) @@ -110,7 +125,8 @@ final class EditorCommandHandler: UIViewController { } @objc func cmdMoveUp() { - guard let tv = textView, let sel = tv.selectedRange else { return } + guard let tv = textView else { return } + let sel = tv.selectedRange let text = tv.text as NSString; let cur = text.lineRange(for: sel) guard cur.location > 0 else { return } let prev = text.lineRange(for: NSRange(location: cur.location - 1, length: 0)) @@ -120,7 +136,8 @@ final class EditorCommandHandler: UIViewController { } @objc func cmdMoveDown() { - guard let tv = textView, let sel = tv.selectedRange else { return } + guard let tv = textView else { return } + let sel = tv.selectedRange let text = tv.text as NSString; let cur = text.lineRange(for: sel) let end = cur.location + cur.length; guard end < text.length else { return } let next = text.lineRange(for: NSRange(location: end, length: 0)) @@ -130,14 +147,16 @@ final class EditorCommandHandler: UIViewController { } @objc func cmdDuplicate() { - guard let tv = textView, let sel = tv.selectedRange else { return } + guard let tv = textView else { return } + let sel = tv.selectedRange let text = tv.text as NSString; let lr = text.lineRange(for: sel) let line = text.substring(with: lr) tv.replace(NSRange(location: lr.location + lr.length, length: 0), withText: line) } @objc func cmdDeleteLine() { - guard let tv = textView, let sel = tv.selectedRange else { return } + guard let tv = textView else { return } + let sel = tv.selectedRange let text = tv.text as NSString tv.replace(text.lineRange(for: sel), withText: "") } @@ -153,3 +172,44 @@ final class EditorCommandHandler: UIViewController { tv.selectedRange = end; tv.scrollRangeToVisible(end) } } + + +// MARK: - SwiftUI wrapper + +struct EditorCommandHandlerView: UIViewControllerRepresentable { + @Binding var textViewRef: Runestone.TextView? + var onSave: (() -> Void)? + var onBuild: (() -> Void)? + var onRun: (() -> Void)? + var onToggleNavigator: (() -> Void)? + var onToggleConsole: (() -> Void)? + var onFind: (() -> Void)? + var onJumpToLine: (() -> Void)? + var onFindInProject: (() -> Void)? + + func makeUIViewController(context: Context) -> EditorCommandHandler { + let vc = EditorCommandHandler() + vc.textView = textViewRef + vc.onSave = onSave + vc.onBuild = onBuild + vc.onRun = onRun + vc.onToggleNavigator = onToggleNavigator + vc.onToggleConsole = onToggleConsole + vc.onFind = onFind + vc.onJumpToLine = onJumpToLine + vc.onFindInProject = onFindInProject + return vc + } + + func updateUIViewController(_ vc: EditorCommandHandler, context: Context) { + vc.textView = textViewRef + vc.onSave = onSave + vc.onBuild = onBuild + vc.onRun = onRun + vc.onToggleNavigator = onToggleNavigator + vc.onToggleConsole = onToggleConsole + vc.onFind = onFind + vc.onJumpToLine = onJumpToLine + vc.onFindInProject = onFindInProject + } +} diff --git a/Editor/RunestoneEditorView.swift b/Editor/RunestoneEditorView.swift index 1657263..76007a1 100644 --- a/Editor/RunestoneEditorView.swift +++ b/Editor/RunestoneEditorView.swift @@ -2,6 +2,16 @@ import SwiftUI import UIKit import Runestone +// MARK: - CharacterPair concrete implementation +// CharacterPair is a protocol in Runestone — no concrete BasicCharacterPair exists. + +private struct EditorCharacterPair: CharacterPair { + let leading: String + let trailing: String +} + +// MARK: - RunestoneEditorView + struct RunestoneEditorView: UIViewRepresentable { @Binding var content: String @@ -20,7 +30,8 @@ struct RunestoneEditorView: UIViewRepresentable { func makeUIView(context: Context) -> Runestone.TextView { let tv = Runestone.TextView() - configureAppearance(tv); configureEditing(tv) + configureAppearance(tv) + configureEditing(tv) tv.editorDelegate = context.coordinator completionController.attach(to: tv) DispatchQueue.main.async { textViewRef = tv } @@ -29,19 +40,30 @@ struct RunestoneEditorView: UIViewRepresentable { } func updateUIView(_ tv: Runestone.TextView, context: Context) { - if tv.text != content && !context.coordinator.isEditing { loadState(into: tv) } + if tv.text != content && !context.coordinator.isEditing { + loadState(into: tv) + } tv.showLineNumbers = showLineNumbers tv.isLineWrappingEnabled = lineWrapping - tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth) + tv.indentStrategy = indentWidth == 0 + ? .tab(length: 4) + : .space(length: indentWidth) applyDiagnostics(to: tv) } private func loadState(into tv: Runestone.TextView) { - let lang = LanguageDetector.language(for: filePath) - let capturedContent = content; let capturedTheme = theme + let lang = LanguageDetector.language(for: filePath) + let capturedText = content + let capturedTheme = theme Task.detached(priority: .userInitiated) { - let state = TextViewState(text: capturedContent, theme: capturedTheme, - language: lang.map { TreeSitterLanguageMode(language: $0) }) + // TextViewState init takes TreeSitterLanguage (non-optional). + // Use two separate inits: one with language, one without (plain text). + let state: TextViewState + if let lang = lang { + state = TextViewState(text: capturedText, theme: capturedTheme, language: lang) + } else { + state = TextViewState(text: capturedText, theme: capturedTheme) + } await MainActor.run { tv.setState(state) } } } @@ -49,43 +71,66 @@ struct RunestoneEditorView: UIViewRepresentable { private func configureAppearance(_ tv: Runestone.TextView) { tv.backgroundColor = UIColor(hex: "#1E1E1E") tv.showLineNumbers = showLineNumbers - tv.showSpaces = false; tv.showTabs = false; tv.showLineBreaks = false + tv.showSpaces = false + tv.showTabs = false + tv.showLineBreaks = false tv.isLineWrappingEnabled = lineWrapping - tv.highlightSelectedLine = true - tv.lineHeightMultiplier = 1.4; tv.kern = 0.3 + // highlightSelectedLine was replaced by lineSelectionDisplayType. + // .line highlights the active line in the text area only. + tv.lineSelectionDisplayType = .line + tv.lineHeightMultiplier = 1.4 + tv.kern = 0.3 tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 120, right: 0) tv.verticalOverscrollFactor = 0.5 } private func configureEditing(_ tv: Runestone.TextView) { - tv.isEditable = true; tv.autocorrectionType = .no; tv.autocapitalizationType = .none - tv.smartDashesType = .no; tv.smartQuotesType = .no - tv.smartInsertDeleteType = .no; tv.spellCheckingType = .no + tv.isEditable = true + tv.autocorrectionType = .no + tv.autocapitalizationType = .none + tv.smartDashesType = .no + tv.smartQuotesType = .no + tv.smartInsertDeleteType = .no + tv.spellCheckingType = .no tv.characterPairs = [ - BasicCharacterPair(leading: "(", trailing: ")"), - BasicCharacterPair(leading: "[", trailing: "]"), - BasicCharacterPair(leading: "{", trailing: "}"), - BasicCharacterPair(leading: "\"", trailing: "\""), + EditorCharacterPair(leading: "(", trailing: ")"), + EditorCharacterPair(leading: "[", trailing: "]"), + EditorCharacterPair(leading: "{", trailing: "}"), + EditorCharacterPair(leading: "\"", trailing: "\""), ] tv.characterPairTrailingComponentDeletionMode = .immediatelyFollowingLeadingComponent - tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth) + tv.indentStrategy = indentWidth == 0 + ? .tab(length: 4) + : .space(length: indentWidth) } private func applyDiagnostics(to tv: Runestone.TextView) { - guard !diagnosticRanges.isEmpty else { tv.setHighlightedRanges([]); return } - let highlights: [HighlightedRange] = diagnosticRanges.compactMap { diag in - guard let range = tv.range(atLine: diag.line, column: diag.column) else { return nil } - let color: UIColor = diag.severity == .error ? UIColor(hex: "#F44747") : UIColor(hex: "#CCA700") - return HighlightedRange(range: range, color: color, cornerRadius: 0, underlineStyle: .single) + guard !diagnosticRanges.isEmpty else { + tv.highlightedRanges = [] + return } - tv.setHighlightedRanges(highlights) + var highlights: [HighlightedRange] = [] + for diag in diagnosticRanges { + // TextLocation is 0-indexed; compiler diagnostics are 1-indexed. + let textLoc = TextLocation(lineNumber: diag.line - 1, column: diag.column - 1) + guard let charOffset = tv.location(at: textLoc) else { continue } + let nsRange = NSRange(location: charOffset, length: max(1, diag.length)) + let color: UIColor = diag.severity == .error + ? UIColor(hex: "#F44747") + : UIColor(hex: "#CCA700") + // HighlightedRange only takes range and color — no cornerRadius/underlineStyle. + highlights.append(HighlightedRange(range: nsRange, color: color)) + } + tv.highlightedRanges = highlights } // MARK: - Coordinator + @MainActor final class Coordinator: NSObject, TextViewDelegate { - var parent: RunestoneEditorView - var isEditing = false + var parent: RunestoneEditorView + var isEditing: Bool = false + init(_ parent: RunestoneEditorView) { self.parent = parent } func textViewDidChange(_ tv: Runestone.TextView) { @@ -97,12 +142,20 @@ struct RunestoneEditorView: UIViewRepresentable { } func textViewDidChangeSelection(_ tv: Runestone.TextView) { - guard let sel = tv.selectedRange, let loc = tv.textLocation(at: sel.location) else { return } - parent.onCursorPositionChange?(loc.lineNumber, loc.column) + let sel = tv.selectedRange + if let loc = tv.textLocation(at: sel.location) { + parent.onCursorPositionChange?(loc.lineNumber + 1, loc.column + 1) + } if !isEditing { parent.completionController.dismiss() } } - func textViewDidBeginEditing(_ tv: Runestone.TextView) { isEditing = true } - func textViewDidEndEditing(_ tv: Runestone.TextView) { isEditing = false; parent.completionController.dismiss() } + func textViewDidBeginEditing(_ tv: Runestone.TextView) { + isEditing = true + } + + func textViewDidEndEditing(_ tv: Runestone.TextView) { + isEditing = false + parent.completionController.dismiss() + } } } diff --git a/Git/GitPanel.swift b/Git/GitPanel.swift index 7cff82b..87d6a82 100644 --- a/Git/GitPanel.swift +++ b/Git/GitPanel.swift @@ -85,7 +85,7 @@ struct GitPanel: View { Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch").font(.caption).foregroundStyle(.secondary) Text(branch.name).font(.system(.body, design: .monospaced)) Spacer() - if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(.accentColor) } + if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(Color.accentColor) } } }.buttonStyle(.plain) } @@ -213,12 +213,12 @@ struct GitPanel: View { label: { HStack { Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch") - .font(.caption).foregroundStyle(branch.name == gitStore.currentBranch ? .accentColor : .secondary) + .font(.caption).foregroundStyle(branch.name == gitStore.currentBranch ? Color.accentColor : .secondary) Text(branch.name).font(.system(.body, design: .monospaced)) - .foregroundStyle(branch.name == gitStore.currentBranch ? .accentColor : .primary) + .foregroundStyle(branch.name == gitStore.currentBranch ? Color.accentColor : .primary) .bold(branch.name == gitStore.currentBranch) Spacer() - if branch.name == gitStore.currentBranch { Text("current").font(.caption2).foregroundStyle(.accentColor) } + if branch.name == gitStore.currentBranch { Text("current").font(.caption2).foregroundStyle(Color.accentColor) } } } .buttonStyle(.plain) diff --git a/Info.plist b/Info.plist index 2cc1fe0..e1add6b 100644 --- a/Info.plist +++ b/Info.plist @@ -3,14 +3,22 @@ "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + CFBundleExecutable + $(EXECUTABLE_NAME) CFBundleName PadXcode + CFBundleDisplayName + PadXcode CFBundleIdentifier - ca.dallasgroot.PadXcode + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundlePackageType + APPL CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) + CFBundleInfoDictionaryVersion + 6.0 UILaunchStoryboardName LaunchScreen CFBundleURLTypes @@ -47,5 +55,12 @@ UIRequiresFullScreen + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + diff --git a/Layout/ContentView.swift b/Layout/ContentView.swift index 837c0d9..28be0a5 100644 --- a/Layout/ContentView.swift +++ b/Layout/ContentView.swift @@ -38,7 +38,7 @@ struct ContentView: View { _buildService = StateObject(wrappedValue: bs) _syncPipeline = StateObject(wrappedValue: FileSyncPipeline( buildService: bs, gitService: gitService, - gitStore: store, config: config)) + gitStore: store)) } var body: some View { @@ -340,7 +340,7 @@ struct ProjectPickerSheet: View { Text("Scheme: \(p.scheme)").font(.caption2).foregroundStyle(.tertiary) } Spacer() - if p.id == activeProject?.id { Image(systemName:"checkmark.circle.fill").foregroundStyle(.accentColor) } + if p.id == activeProject?.id { Image(systemName:"checkmark.circle.fill").foregroundStyle(Color.accentColor) } } }.buttonStyle(.plain) } diff --git a/Layout/EnhancedConsoleView.swift b/Layout/EnhancedConsoleView.swift index e615937..f11b98d 100644 --- a/Layout/EnhancedConsoleView.swift +++ b/Layout/EnhancedConsoleView.swift @@ -61,12 +61,12 @@ struct EnhancedConsoleView: View { } Spacer() Button { withAnimation { showSearch.toggle(); if !showSearch { searchText = "" } } } - label: { Image(systemName: "magnifyingglass").foregroundStyle(showSearch ? .accentColor : .secondary) } + label: { Image(systemName: "magnifyingglass").foregroundStyle(showSearch ? Color.accentColor : Color.secondary) } .buttonStyle(.plain) Button { UIPasteboard.general.string = filtered.map(\.text).joined(separator: "\n") } label: { Image(systemName: "doc.on.doc").foregroundStyle(.secondary) }.buttonStyle(.plain) Button { isPinned.toggle() } - label: { Image(systemName: isPinned ? "pin.fill" : "pin.slash").foregroundStyle(isPinned ? .accentColor : .secondary) } + label: { Image(systemName: isPinned ? "pin.fill" : "pin.slash").foregroundStyle(isPinned ? Color.accentColor : Color.secondary) } .buttonStyle(.plain) if let clear = onClear { Button(action: clear) { Image(systemName: "trash").foregroundStyle(.secondary) }.buttonStyle(.plain) @@ -122,13 +122,13 @@ struct EnhancedConsoleView: View { private func pillColor(_ mode: FilterMode) -> Color { switch mode { case .errors: return .red; case .warnings: return .orange - case .info: return .blue; case .all: return .accentColor + case .info: return .blue; case .all: return Color.accentColor } } private func gutterColor(_ type: ConsoleLine.LineType) -> Color { switch type { case .error: return .red; case .warning: return .orange - case .success: return .green; case .info: return .accentColor; default: return .clear + case .success: return .green; case .info: return Color.accentColor; default: return .clear } } } diff --git a/Onboarding/OnboardingView.swift b/Onboarding/OnboardingView.swift index 5151c03..eab135b 100644 --- a/Onboarding/OnboardingView.swift +++ b/Onboarding/OnboardingView.swift @@ -67,7 +67,7 @@ struct OnboardingView: View { private var welcomeStep: some View { VStack(spacing: 28) { - Image(systemName: "laptopcomputer.and.ipad").font(.system(size:72,weight:.thin)).foregroundStyle(.accentColor).padding(.top,40) + Image(systemName: "laptopcomputer.and.ipad").font(.system(size:72,weight:.thin)).foregroundStyle(Color.accentColor).padding(.top,40) VStack(spacing:10) { Text("Welcome to PadXcode").font(.largeTitle.bold()).multilineTextAlignment(.center) Text("A native Swift IDE on your iPad, backed by your Mac.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24) diff --git a/PadXcode.xcodeproj/project.pbxproj b/PadXcode.xcodeproj/project.pbxproj index 8868a81..96e855a 100644 --- a/PadXcode.xcodeproj/project.pbxproj +++ b/PadXcode.xcodeproj/project.pbxproj @@ -439,6 +439,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = PadXcode.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; DEVELOPMENT_TEAM = E9C9AGS9K6; @@ -457,6 +458,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = PadXcode.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; DEVELOPMENT_TEAM = E9C9AGS9K6; diff --git a/PadXcode.xcodeproj/project.xcworkspace/xcuserdata/dallasgroot.xcuserdatad/UserInterfaceState.xcuserstate b/PadXcode.xcodeproj/project.xcworkspace/xcuserdata/dallasgroot.xcuserdatad/UserInterfaceState.xcuserstate index 5a08216581e19ba4ad20fd5ebe053aa5194ad9c4..ab5fd689fe15d4beaf4af50fc0f28d87e8c1f614 100644 GIT binary patch delta 10658 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 literal 25620 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 Ol3-|k8TvCz`o949QSm4M diff --git a/PadXcodeApp.swift b/PadXcodeApp.swift index f5d1b0a..7d5ab6e 100644 --- a/PadXcodeApp.swift +++ b/PadXcodeApp.swift @@ -3,22 +3,27 @@ import Foundation @main struct PadXcodeApp: App { - @StateObject private var daemonConfig = DaemonConfiguration() - @StateObject private var projectStore = ProjectStore() - @StateObject private var editorState = EditorState() - @StateObject private var gitStore = GitStore() - @StateObject private var lspClient: LSPClient - private let gitService: GitService + @StateObject private var daemonConfig: DaemonConfiguration + @StateObject private var projectStore: ProjectStore + @StateObject private var editorState: EditorState + @StateObject private var gitStore: GitStore + @StateObject private var lspClient: LSPClient + + private let gitService: GitService private let gitCallbackHandler: GitCallbackHandler init() { let config = DaemonConfiguration() - let apiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? "" let store = GitStore() + let apiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? "" + _daemonConfig = StateObject(wrappedValue: config) - _lspClient = StateObject(wrappedValue: LSPClient(config: config)) + _projectStore = StateObject(wrappedValue: ProjectStore()) + _editorState = StateObject(wrappedValue: EditorState()) _gitStore = StateObject(wrappedValue: store) + _lspClient = StateObject(wrappedValue: LSPClient(config: config)) + gitService = GitService(apiKey: apiKey) gitCallbackHandler = GitCallbackHandler(store: store) } diff --git a/Settings/SettingsView.swift b/Settings/SettingsView.swift index 12c8bd8..33f302c 100644 --- a/Settings/SettingsView.swift +++ b/Settings/SettingsView.swift @@ -3,8 +3,8 @@ import UIKit struct SettingsView: View { @EnvironmentObject var daemonConfig: DaemonConfiguration - @ObservedObject var editorState: EditorState - @ObservedObject var projectStore: ProjectStore + @ObservedObject var editorState: EditorState + @ObservedObject var projectStore: ProjectStore @State private var pendingHost = "" @State private var pendingPort = "" @@ -12,129 +12,243 @@ struct SettingsView: View { @State private var connStatus: ConnStatus = .untested @State private var showAddProject = false - enum ConnStatus { case untested, testing, success, failure(String) - var label: String { switch self { case .untested: return "Not tested"; case .testing: return "Testing…"; case .success: return "Connected ✓"; case .failure(let m): return "Failed: \(m)" } } - var color: Color { switch self { case .success: return .green; case .failure: return .red; case .testing: return .secondary; default: return .secondary } } - static func ==(a: ConnStatus, b: ConnStatus) -> Bool { - switch (a,b) { case (.success,.success),(.untested,.untested),(.testing,.testing): return true; case (.failure(let x),.failure(let y)): return x==y; default: return false } + // Equatable conformance added so != operator works on line 39 and elsewhere. + enum ConnStatus: Equatable { + case untested, testing, success, failure(String) + + var label: String { + switch self { + case .untested: return "Not tested" + case .testing: return "Testing..." + case .success: return "Connected checkmark" + case .failure(let m): return "Failed: \(m)" + } + } + + var color: Color { + switch self { + case .success: return .green + case .failure: return .red + default: return .secondary + } + } + + // Custom == required because .failure has an associated value. + static func == (a: ConnStatus, b: ConnStatus) -> Bool { + switch (a, b) { + case (.success, .success), (.untested, .untested), (.testing, .testing): return true + case (.failure(let x), .failure(let y)): return x == y + default: return false + } } } var body: some View { NavigationStack { Form { - // ── Daemon ──────────────────────────────────────────────── + + // ── Daemon ────────────────────────────────────────────────── Section { HStack { - Label("Tailscale IP", systemImage:"network"); Spacer() - TextField("100.x.x.x", text:$pendingHost).multilineTextAlignment(.trailing).keyboardType(.numbersAndPunctuation).autocorrectionDisabled().autocapitalization(.none).frame(width:160).onChange(of:pendingHost){_,_ in connStatus = .untested} + Label("Tailscale IP", systemImage: "network") + Spacer() + TextField("100.x.x.x", text: $pendingHost) + .multilineTextAlignment(.trailing) + .keyboardType(.numbersAndPunctuation) + .autocorrectionDisabled() + .autocapitalization(.none) + .frame(width: 160) + .onChange(of: pendingHost) { _, _ in connStatus = .untested } } HStack { - Label("Port", systemImage:"antenna.radiowaves.left.and.right"); Spacer() - TextField("8080", text:$pendingPort).multilineTextAlignment(.trailing).keyboardType(.numberPad).frame(width:80) + Label("Port", systemImage: "antenna.radiowaves.left.and.right") + Spacer() + TextField("8080", text: $pendingPort) + .multilineTextAlignment(.trailing) + .keyboardType(.numberPad) + .frame(width: 80) } - HStack { Text("Status").foregroundStyle(.secondary); Spacer(); Text(connStatus.label).foregroundStyle(connStatus.color).font(.subheadline) } - Button("Test Connection") { Task { await testConnection() } }.disabled(pendingHost.isEmpty) - Button("Save & Apply") { applyConnection() }.buttonStyle(.borderedProminent) + HStack { + Text("Status").foregroundStyle(.secondary) + Spacer() + Text(connStatus.label).foregroundStyle(connStatus.color).font(.subheadline) + } + Button("Test Connection") { Task { await testConnection() } } + .disabled(pendingHost.isEmpty) + Button("Save & Apply") { applyConnection() } + .buttonStyle(.borderedProminent) + // != now works because ConnStatus conforms to Equatable. .disabled(pendingHost.isEmpty || connStatus != .success) } header: { Text("Mac Daemon") } - // ── Projects ────────────────────────────────────────────── + // ── Projects ──────────────────────────────────────────────── Section { ForEach(projectStore.projects) { p in - VStack(alignment:.leading, spacing:2) { + VStack(alignment: .leading, spacing: 2) { Text(p.name).font(.body) Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) - Text("Scheme: \(p.scheme) · WC: \(p.workingCopyRepoName.isEmpty ? "not set" : p.workingCopyRepoName)").font(.caption2).foregroundStyle(.tertiary) - }.padding(.vertical,2) - }.onDelete { projectStore.remove(atOffsets:$0) } - Button { showAddProject = true } label: { Label("Add Project", systemImage:"plus.circle") } - } header: { Text("Projects") } footer: { Text("Mac path should be the directory containing project.yml or .xcodeproj") } + Text("Scheme: \(p.scheme) · WC: \(p.workingCopyRepoName.isEmpty ? "not set" : p.workingCopyRepoName)") + .font(.caption2).foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + } + .onDelete { projectStore.remove(atOffsets: $0) } + Button { showAddProject = true } label: { + Label("Add Project", systemImage: "plus.circle") + } + } header: { + Text("Projects") + } footer: { + Text("Mac path should be the directory containing project.yml or .xcodeproj") + } - // ── Editor ──────────────────────────────────────────────── + // ── Editor ────────────────────────────────────────────────── Section("Editor Appearance") { HStack { - Label("Font Size", systemImage:"textformat.size"); Spacer() - Stepper("\(Int(editorState.fontSize))pt", value:$editorState.fontSize, in:8...32, step:1).frame(width:160) + Label("Font Size", systemImage: "textformat.size") + Spacer() + // fontSizeStored is the @AppStorage Double backing fontSize. + // Binding to the computed fontSize property is not possible; + // bind to the stored Double and display with Int cast. + Stepper( + "\(Int(editorState.fontSizeStored))pt", + value: $editorState.fontSizeStored, + in: 8.0...32.0, + step: 1.0 + ) + .frame(width: 160) } - Picker(selection:$editorState.selectedTheme) { - ForEach(EditorThemeOption.allCases) { o in Text(o.displayName).tag(o) } - } label: { Label("Theme", systemImage:"paintbrush") } - Toggle(isOn:$editorState.showLineNumbers) { Label("Line Numbers", systemImage:"list.number") } - Toggle(isOn:$editorState.lineWrapping) { Label("Line Wrapping", systemImage:"text.word.spacing") } + // selectedTheme is a computed property on EditorState — + // cannot use $ directly; use an explicit Binding. + Picker(selection: Binding( + get: { editorState.selectedTheme }, + set: { editorState.selectedTheme = $0 } + )) { + ForEach(EditorThemeOption.allCases) { o in + Text(o.displayName).tag(o) + } + } label: { + Label("Theme", systemImage: "paintbrush") + } + Toggle(isOn: $editorState.showLineNumbers) { Label("Line Numbers", systemImage: "list.number") } + Toggle(isOn: $editorState.lineWrapping) { Label("Line Wrapping", systemImage: "text.word.spacing") } HStack { - Label("Indent Width", systemImage:"arrow.right.to.line"); Spacer() - Picker("", selection:$editorState.indentWidth) { - Text("2 spaces").tag(2); Text("4 spaces").tag(4); Text("Tab").tag(0) - }.labelsHidden().pickerStyle(.menu) + Label("Indent Width", systemImage: "arrow.right.to.line") + Spacer() + Picker("", selection: $editorState.indentWidth) { + Text("2 spaces").tag(2) + Text("4 spaces").tag(4) + Text("Tab").tag(0) + } + .labelsHidden() + .pickerStyle(.menu) } } - // ── Build ───────────────────────────────────────────────── + // ── Build ──────────────────────────────────────────────────── Section("Build") { HStack { - Label("Team ID", systemImage:"person.badge.key"); Spacer() - TextField("XXXXXXXXXX", text:$daemonConfig.developmentTeam).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:120) + Label("Team ID", systemImage: "person.badge.key") + Spacer() + TextField("XXXXXXXXXX", text: $daemonConfig.developmentTeam) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .autocapitalization(.none) + .frame(width: 120) + } + Toggle(isOn: $daemonConfig.allowProvisioningUpdates) { + Label("Allow Provisioning Updates", systemImage: "checkmark.seal") + } + Toggle(isOn: $daemonConfig.buildInRelease) { + Label("Build in Release", systemImage: "bolt") } - Toggle(isOn:$daemonConfig.allowProvisioningUpdates) { Label("Allow Provisioning Updates", systemImage:"checkmark.seal") } - Toggle(isOn:$daemonConfig.buildInRelease) { Label("Build in Release", systemImage:"bolt") } } - // ── Working Copy ────────────────────────────────────────── + // ── Working Copy ───────────────────────────────────────────── Section { HStack { - Label("API Key", systemImage:"key.fill"); Spacer() - SecureField("Paste from Working Copy", text:$wcApiKey).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:200) - .onChange(of:wcApiKey){_,k in UserDefaults.standard.set(k, forKey:"workingCopyAPIKey")} + Label("API Key", systemImage: "key.fill") + Spacer() + SecureField("Paste from Working Copy", text: $wcApiKey) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .autocapitalization(.none) + .frame(width: 200) + .onChange(of: wcApiKey) { _, k in + UserDefaults.standard.set(k, forKey: "workingCopyAPIKey") + } } - let installed = UIApplication.shared.canOpenURL(URL(string:"working-copy://")!) - Label(installed ? "Working Copy detected ✓" : "Working Copy not installed", - systemImage: installed ? "checkmark.circle.fill" : "exclamationmark.triangle") - .foregroundStyle(installed ? .green : .orange).font(.caption) + let installed = UIApplication.shared.canOpenURL(URL(string: "working-copy://")!) + Label( + installed ? "Working Copy detected" : "Working Copy not installed", + systemImage: installed ? "checkmark.circle.fill" : "exclamationmark.triangle" + ) + .foregroundStyle(installed ? .green : .orange) + .font(.caption) Text("For local git over Tailscale, clone in Working Copy using:\nssh://user@100.x.x.x/path/to/repo.git") .font(.caption).foregroundStyle(.secondary) - } header: { Text("Working Copy (Git)") } + } header: { + Text("Working Copy (Git)") + } - // ── LSP ─────────────────────────────────────────────────── + // ── LSP ────────────────────────────────────────────────────── Section { - Toggle(isOn:$daemonConfig.lspEnabled) { Label("sourcekit-lsp Proxy", systemImage:"wand.and.stars") } + Toggle(isOn: $daemonConfig.lspEnabled) { + Label("sourcekit-lsp Proxy", systemImage: "wand.and.stars") + } if daemonConfig.lspEnabled { HStack { - Label("Status", systemImage:"circle.fill").foregroundStyle(daemonConfig.lspConnected ? Color.green : Color.red) + Label("Status", systemImage: "circle.fill") + .foregroundStyle(daemonConfig.lspConnected ? Color.green : Color.red) Spacer() - Text(daemonConfig.lspConnected ? "Running" : "Disconnected").foregroundStyle(.secondary) + Text(daemonConfig.lspConnected ? "Running" : "Disconnected") + .foregroundStyle(.secondary) } } } header: { Text("Language Server (LSP)") } - // ── About ───────────────────────────────────────────────── + // ── About ───────────────────────────────────────────────────── Section("About") { - LabeledContent("Version", value:"1.0.0-alpha") - LabeledContent("Editor Engine", value:"Runestone 0.5.1 / Tree-sitter") - LabeledContent("Protocol", value:"REST + WebSocket") + LabeledContent("Version", value: "1.0.0-alpha") + LabeledContent("Editor Engine", value: "Runestone 0.5.1 / Tree-sitter") + LabeledContent("Protocol", value: "REST + WebSocket") } } - .navigationTitle("Settings").navigationBarTitleDisplayMode(.inline) - .onAppear { pendingHost = daemonConfig.host; pendingPort = String(daemonConfig.port) - wcApiKey = UserDefaults.standard.string(forKey:"workingCopyAPIKey") ?? "" } - .sheet(isPresented:$showAddProject) { AddProjectView(projectStore:projectStore) } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + pendingHost = daemonConfig.host + pendingPort = String(daemonConfig.port) + wcApiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? "" + } + .sheet(isPresented: $showAddProject) { + AddProjectView(projectStore: projectStore) + } } } + // MARK: - Actions + private func testConnection() async { connStatus = .testing - let host = pendingHost.trimmingCharacters(in:.whitespaces) + let host = pendingHost.trimmingCharacters(in: .whitespaces) let port = Int(pendingPort) ?? 8080 - guard let url = URL(string:"http://\(host):\(port)/health") else { connStatus = .failure("Invalid URL"); return } - var req = URLRequest(url:url); req.timeoutInterval = 4 + guard let url = URL(string: "http://\(host):\(port)/health") else { + connStatus = .failure("Invalid URL"); return + } + var req = URLRequest(url: url); req.timeoutInterval = 4 do { - let (_,resp) = try await URLSession.shared.data(for:req) - connStatus = (resp as? HTTPURLResponse)?.statusCode == 200 ? .success : .failure("Unexpected response") - } catch { connStatus = .failure(error.localizedDescription) } + let (_, resp) = try await URLSession.shared.data(for: req) + connStatus = (resp as? HTTPURLResponse)?.statusCode == 200 + ? .success + : .failure("Unexpected status code") + } catch { + connStatus = .failure(error.localizedDescription) + } } private func applyConnection() { - daemonConfig.host = pendingHost.trimmingCharacters(in:.whitespaces) + daemonConfig.host = pendingHost.trimmingCharacters(in: .whitespaces) daemonConfig.port = Int(pendingPort) ?? 8080 } } @@ -144,33 +258,57 @@ struct SettingsView: View { struct AddProjectView: View { @ObservedObject var projectStore: ProjectStore @Environment(\.dismiss) var dismiss - @State private var name = ""; @State private var path = "" - @State private var scheme = ""; @State private var wcRepo = "" + + @State private var name = "" + @State private var path = "" + @State private var scheme = "" + @State private var wcRepo = "" var body: some View { NavigationStack { Form { Section("Project Details") { - TextField("Name (e.g. NavidromePlayer)", text:$name) - TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text:$path).autocorrectionDisabled().autocapitalization(.none).keyboardType(.URL) - TextField("Xcode Scheme", text:$scheme).autocorrectionDisabled().autocapitalization(.none) + TextField("Name (e.g. NavidromePlayer)", text: $name) + TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text: $path) + .autocorrectionDisabled() + .autocapitalization(.none) + .keyboardType(.URL) + TextField("Xcode Scheme", text: $scheme) + .autocorrectionDisabled() + .autocapitalization(.none) + } + // Section with both header string AND footer requires the + // explicit header:/footer: trailing closure form. + Section { + TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text: $wcRepo) + .autocorrectionDisabled() + .autocapitalization(.none) + } header: { + Text("Working Copy") + } footer: { + Text("Must match the display name in Working Copy's repo list.") } - Section("Working Copy") { - TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text:$wcRepo).autocorrectionDisabled().autocapitalization(.none) - } footer: { Text("Must match the display name in Working Copy's repo list.") } } - .navigationTitle("Add Project").navigationBarTitleDisplayMode(.inline) + .navigationTitle("Add Project") + .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement:.cancellationAction) { Button("Cancel") { dismiss() } } - ToolbarItem(placement:.confirmationAction) { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { Button("Add") { - projectStore.add(SavedProject(name: name.isEmpty ? scheme : name, - rootPath: path, scheme: scheme, - workingCopyRepoName: wcRepo)) + projectStore.add(SavedProject( + name: name.isEmpty ? scheme : name, + rootPath: path, + scheme: scheme, + workingCopyRepoName: wcRepo + )) dismiss() - }.disabled(path.isEmpty || scheme.isEmpty) + } + .disabled(path.isEmpty || scheme.isEmpty) } } - }.presentationDetents([.medium]) + } + .presentationDetents([.medium]) } } diff --git a/Shared/SharedModels.swift b/Shared/SharedModels.swift index b079c07..c9776e3 100644 --- a/Shared/SharedModels.swift +++ b/Shared/SharedModels.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit import SwiftUI // MARK: - File System @@ -68,7 +69,7 @@ struct FileContentResponse: Codable { // MARK: - Console -struct ConsoleLine: Identifiable { +struct ConsoleLine: Identifiable, Equatable { let id = UUID() let text: String let type: LineType @@ -159,6 +160,9 @@ extension Notification.Name { static let navigateToRange = Notification.Name("editor.navigate.range") static let triggerCompletion = Notification.Name("editor.trigger.completion") static let openSettings = Notification.Name("padxcode.open.settings") + static let editorFindNext = Notification.Name("editor.find.next") + static let editorFindPrev = Notification.Name("editor.find.prev") + static let editorFontSizeChange = Notification.Name("editor.font.size.change") } // MARK: - String helpers @@ -172,3 +176,20 @@ extension String { return self.replacingCharacters(in: range, with: replacement) } } + +// MARK: - UIColor hex convenience (used by themes and editor views) + +extension UIColor { + convenience init(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines) + h = h.hasPrefix("#") ? String(h.dropFirst()) : h + var rgb: UInt64 = 0 + Scanner(string: h).scanHexInt64(&rgb) + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255.0, + green: CGFloat((rgb >> 8) & 0xFF) / 255.0, + blue: CGFloat( rgb & 0xFF) / 255.0, + alpha: 1.0 + ) + } +} diff --git a/Theme/LanguageDetector.swift b/Theme/LanguageDetector.swift index e39d4ed..6b89f62 100644 --- a/Theme/LanguageDetector.swift +++ b/Theme/LanguageDetector.swift @@ -1,3 +1,4 @@ +import Foundation import Runestone import TreeSitterSwiftRunestone import TreeSitterJSONRunestone diff --git a/Theme/PadXcodeTheme.swift b/Theme/PadXcodeTheme.swift index fa826fa..dccf2f9 100644 --- a/Theme/PadXcodeTheme.swift +++ b/Theme/PadXcodeTheme.swift @@ -1,151 +1,262 @@ import UIKit import Runestone -final class PadXcodeTheme: Theme { - var backgroundColor: UIColor { UIColor(hex: "#1E1E1E") } - var userInterfaceStyle: UIUserInterfaceStyle { .dark } - var font: UIFont { UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) } - var textColor: UIColor { UIColor(hex: "#D4D4D4") } - var gutterBackgroundColor: UIColor { UIColor(hex: "#252526") } - var gutterHairlineColor: UIColor { UIColor(hex: "#3C3C3C") } - var lineNumberColor: UIColor { UIColor(hex: "#6E7681") } - var lineNumberFont: UIFont { UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) } - var selectedLineBackgroundColor: UIColor { UIColor(hex: "#2A2D2E") } - var selectedLinesLineNumberColor: UIColor { UIColor(hex: "#C8C8C8") } - var selectedLinesGutterBackgroundColor: UIColor { UIColor(hex: "#2A2D2E") } - var markedTextBackgroundColor: UIColor { UIColor(hex: "#264F78").withAlphaComponent(0.4) } - var markedTextBackgroundCornerRadius: CGFloat { 2 } - var invisibleCharactersColor: UIColor { UIColor(hex: "#404040") } - var pageGuideHairlineColor: UIColor { UIColor(hex: "#404040") } - var pageGuideBackgroundColor: UIColor { UIColor(hex: "#1A1A1A") } +// MARK: - Xcode Default Dark Theme - func textStyle(for highlightName: String) -> TextStyle? { +final class PadXcodeTheme: Theme { + + // MARK: Required visual properties + + var font: UIFont { + UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) + } + + var textColor: UIColor { + UIColor(r: 212, g: 212, b: 212) + } + + var backgroundColor: UIColor { + UIColor(r: 30, g: 30, b: 30) + } + + var userInterfaceStyle: UIUserInterfaceStyle { + .dark + } + + var gutterBackgroundColor: UIColor { + UIColor(r: 37, g: 37, b: 38) + } + + var gutterHairlineColor: UIColor { + UIColor(r: 60, g: 60, b: 60) + } + + var lineNumberColor: UIColor { + UIColor(r: 110, g: 118, b: 129) + } + + var lineNumberFont: UIFont { + UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) + } + + var selectedLineBackgroundColor: UIColor { + UIColor(r: 42, g: 45, b: 46) + } + + var selectedLinesLineNumberColor: UIColor { + UIColor(r: 200, g: 200, b: 200) + } + + var selectedLinesGutterBackgroundColor: UIColor { + UIColor(r: 42, g: 45, b: 46) + } + + var markedTextBackgroundColor: UIColor { + UIColor(r: 38, g: 79, b: 120, alpha: 0.4) + } + + var markedTextBackgroundCornerRadius: CGFloat { + 2 + } + + var invisibleCharactersColor: UIColor { + UIColor(r: 64, g: 64, b: 64) + } + + var pageGuideHairlineColor: UIColor { + UIColor(r: 64, g: 64, b: 64) + } + + var pageGuideBackgroundColor: UIColor { + UIColor(r: 26, g: 26, b: 26) + } + + // MARK: Syntax token colours (required by Theme protocol) + + func textColor(for highlightName: String) -> UIColor? { switch highlightName { - case "keyword", "keyword.control", "keyword.operator", "keyword.other", - "keyword.declaration", "storage.type": - return TextStyle(color: UIColor(hex: "#FF7AB2"), bold: true) - case "storage.modifier": - return TextStyle(color: UIColor(hex: "#FF7AB2")) - case "type", "type.builtin", "entity.name.type", "entity.name.type.class", - "entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol": - return TextStyle(color: UIColor(hex: "#DABAFF")) + case "keyword", "keyword.control", "keyword.operator", + "keyword.other", "keyword.declaration", "storage.type", + "storage.modifier": + return UIColor(r: 255, g: 122, b: 178) // pink + + case "type", "type.builtin", "entity.name.type", + "entity.name.type.class", "entity.name.type.struct", + "entity.name.type.enum", "entity.name.type.protocol": + return UIColor(r: 218, g: 186, b: 255) // lavender + case "type.parameter": - return TextStyle(color: UIColor(hex: "#6BDFFF")) - case "function", "entity.name.function", "meta.function-call", "function.method": - return TextStyle(color: UIColor(hex: "#67B7FB")) - case "function.builtin": - return TextStyle(color: UIColor(hex: "#67B7FB"), bold: true) - case "variable", "variable.other", "variable.other.member": - return TextStyle(color: UIColor(hex: "#D4D4D4")) - case "variable.builtin": - return TextStyle(color: UIColor(hex: "#FF7AB2")) + return UIColor(r: 107, g: 223, b: 255) // light blue + + case "function", "entity.name.function", + "meta.function-call", "function.method", "function.builtin": + return UIColor(r: 103, g: 183, b: 251) // blue + case "property", "variable.other.property": - return TextStyle(color: UIColor(hex: "#72B9D5")) + return UIColor(r: 114, g: 185, b: 213) // teal + + case "variable.builtin": + return UIColor(r: 255, g: 122, b: 178) // pink (self, super) + case "constant", "constant.builtin": - return TextStyle(color: UIColor(hex: "#D9C97C"), bold: true) + return UIColor(r: 217, g: 201, b: 124) // gold bold + case "constant.numeric", "number": - return TextStyle(color: UIColor(hex: "#D9C97C")) + return UIColor(r: 217, g: 201, b: 124) // gold + case "string", "string.special": - return TextStyle(color: UIColor(hex: "#FF8170")) + return UIColor(r: 255, g: 129, b: 112) // salmon + case "string.escape": - return TextStyle(color: UIColor(hex: "#FF8170"), bold: true) - case "comment", "comment.line", "comment.block": - return TextStyle(color: UIColor(hex: "#7F8C98"), italic: true) - case "comment.block.documentation": - return TextStyle(color: UIColor(hex: "#6A9955"), italic: true) - case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter": - return TextStyle(color: UIColor(hex: "#D4D4D4")) + return UIColor(r: 255, g: 129, b: 112) // salmon + + case "comment", "comment.line", "comment.block", + "comment.block.documentation": + return UIColor(r: 127, g: 140, b: 152) // grey + case "attribute": - return TextStyle(color: UIColor(hex: "#BF86C8")) + return UIColor(r: 191, g: 134, b: 200) // purple + + case "operator", "punctuation.operator", + "punctuation.bracket", "punctuation.delimiter": + return UIColor(r: 212, g: 212, b: 212) // default text + default: return nil } } } +// MARK: - Xcode Default Light Theme + final class PadXcodeLightTheme: Theme { - var backgroundColor: UIColor { UIColor(hex: "#FFFFFF") } - var userInterfaceStyle: UIUserInterfaceStyle { .light } - var font: UIFont { UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) } - var textColor: UIColor { UIColor(hex: "#000000") } - var gutterBackgroundColor: UIColor { UIColor(hex: "#F2F2F2") } - var gutterHairlineColor: UIColor { UIColor(hex: "#D8D8D8") } - var lineNumberColor: UIColor { UIColor(hex: "#A0A0A0") } - var lineNumberFont: UIFont { UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) } - var selectedLineBackgroundColor: UIColor { UIColor(hex: "#ECF5FF") } - var selectedLinesLineNumberColor: UIColor { UIColor(hex: "#3A3A3A") } - var selectedLinesGutterBackgroundColor: UIColor { UIColor(hex: "#E8F0FA") } - var markedTextBackgroundColor: UIColor { UIColor(hex: "#B5D0F7").withAlphaComponent(0.4) } - var markedTextBackgroundCornerRadius: CGFloat { 2 } - var invisibleCharactersColor: UIColor { UIColor(hex: "#CCCCCC") } - var pageGuideHairlineColor: UIColor { UIColor(hex: "#DDDDDD") } - var pageGuideBackgroundColor: UIColor { UIColor(hex: "#F9F9F9") } - func textStyle(for highlightName: String) -> TextStyle? { + // MARK: Required visual properties + + var font: UIFont { + UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) + } + + var textColor: UIColor { + UIColor(r: 0, g: 0, b: 0) + } + + var backgroundColor: UIColor { + UIColor(r: 255, g: 255, b: 255) + } + + var userInterfaceStyle: UIUserInterfaceStyle { + .light + } + + var gutterBackgroundColor: UIColor { + UIColor(r: 242, g: 242, b: 242) + } + + var gutterHairlineColor: UIColor { + UIColor(r: 216, g: 216, b: 216) + } + + var lineNumberColor: UIColor { + UIColor(r: 160, g: 160, b: 160) + } + + var lineNumberFont: UIFont { + UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) + } + + var selectedLineBackgroundColor: UIColor { + UIColor(r: 236, g: 245, b: 255) + } + + var selectedLinesLineNumberColor: UIColor { + UIColor(r: 58, g: 58, b: 58) + } + + var selectedLinesGutterBackgroundColor: UIColor { + UIColor(r: 232, g: 240, b: 250) + } + + var markedTextBackgroundColor: UIColor { + UIColor(r: 181, g: 208, b: 247, alpha: 0.4) + } + + var markedTextBackgroundCornerRadius: CGFloat { + 2 + } + + var invisibleCharactersColor: UIColor { + UIColor(r: 204, g: 204, b: 204) + } + + var pageGuideHairlineColor: UIColor { + UIColor(r: 221, g: 221, b: 221) + } + + var pageGuideBackgroundColor: UIColor { + UIColor(r: 249, g: 249, b: 249) + } + + // MARK: Syntax token colours (required by Theme protocol) + + func textColor(for highlightName: String) -> UIColor? { switch highlightName { - case "keyword", "keyword.control", "keyword.operator", "keyword.other", - "keyword.declaration", "storage.type": - return TextStyle(color: UIColor(hex: "#AD3DA4"), bold: true) - case "storage.modifier": - return TextStyle(color: UIColor(hex: "#AD3DA4")) - case "type", "type.builtin", "entity.name.type", "entity.name.type.class", - "entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol": - return TextStyle(color: UIColor(hex: "#703DAA")) + case "keyword", "keyword.control", "keyword.operator", + "keyword.other", "keyword.declaration", "storage.type", + "storage.modifier": + return UIColor(r: 173, g: 61, b: 164) // purple + + case "type", "type.builtin", "entity.name.type", + "entity.name.type.class", "entity.name.type.struct", + "entity.name.type.enum", "entity.name.type.protocol": + return UIColor(r: 112, g: 61, b: 170) // dark purple + case "type.parameter": - return TextStyle(color: UIColor(hex: "#047CB0")) - case "function", "entity.name.function", "meta.function-call", "function.method": - return TextStyle(color: UIColor(hex: "#3900A0")) - case "function.builtin": - return TextStyle(color: UIColor(hex: "#3900A0"), bold: true) - case "variable", "variable.other", "variable.other.member": - return TextStyle(color: UIColor(hex: "#000000")) - case "variable.builtin": - return TextStyle(color: UIColor(hex: "#AD3DA4")) + return UIColor(r: 4, g: 124, b: 176) // blue + + case "function", "entity.name.function", + "meta.function-call", "function.method", "function.builtin": + return UIColor(r: 57, g: 0, b: 160) // indigo + case "property", "variable.other.property": - return TextStyle(color: UIColor(hex: "#047CB0")) + return UIColor(r: 4, g: 124, b: 176) // blue + + case "variable.builtin": + return UIColor(r: 173, g: 61, b: 164) // purple (self, super) + case "constant", "constant.builtin": - return TextStyle(color: UIColor(hex: "#272AD8"), bold: true) + return UIColor(r: 39, g: 42, b: 216) // blue bold + case "constant.numeric", "number": - return TextStyle(color: UIColor(hex: "#272AD8")) + return UIColor(r: 39, g: 42, b: 216) // blue + case "string", "string.special": - return TextStyle(color: UIColor(hex: "#C41A16")) + return UIColor(r: 196, g: 26, b: 22) // red + case "string.escape": - return TextStyle(color: UIColor(hex: "#C41A16"), bold: true) - case "comment", "comment.line", "comment.block": - return TextStyle(color: UIColor(hex: "#5D6C79"), italic: true) - case "comment.block.documentation": - return TextStyle(color: UIColor(hex: "#265B31"), italic: true) - case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter": - return TextStyle(color: UIColor(hex: "#000000")) + return UIColor(r: 196, g: 26, b: 22) // red + + case "comment", "comment.line", "comment.block", + "comment.block.documentation": + return UIColor(r: 93, g: 108, b: 121) // grey + case "attribute": - return TextStyle(color: UIColor(hex: "#6C36A9")) + return UIColor(r: 108, g: 54, b: 169) // purple + + case "operator", "punctuation.operator", + "punctuation.bracket", "punctuation.delimiter": + return nil // inherits default textColor + default: return nil } } } -// MARK: - TextStyle helpers +// MARK: - UIColor RGB convenience (private to this file) -extension TextStyle { - init(color: UIColor, bold: Bool = false, italic: Bool = false) { - self.init() - self.color = color - if bold { self.fontTraits.insert(.traitBold) } - if italic { self.fontTraits.insert(.traitItalic) } - } -} - -// MARK: - UIColor hex - -extension UIColor { - convenience init(hex: String) { - var h = hex.trimmingCharacters(in: .whitespacesAndNewlines) - h = h.hasPrefix("#") ? String(h.dropFirst()) : h - var rgb: UInt64 = 0 - Scanner(string: h).scanHexInt64(&rgb) - self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, - green: CGFloat((rgb >> 8) & 0xFF) / 255, - blue: CGFloat( rgb & 0xFF) / 255, alpha: 1) +private extension UIColor { + convenience init(r: CGFloat, g: CGFloat, b: CGFloat, alpha: CGFloat = 1.0) { + self.init(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: alpha) } } diff --git a/UI/ToastSystem.swift b/UI/ToastSystem.swift index c2b251f..c0bb067 100644 --- a/UI/ToastSystem.swift +++ b/UI/ToastSystem.swift @@ -15,7 +15,7 @@ struct Toast: Identifiable { var color: Color { switch self { case .success: return .green; case .error: return .red - case .warning: return .orange; case .info: return .accentColor; case .git: return .purple + case .warning: return .orange; case .info: return Color.accentColor; case .git: return .purple } } } diff --git a/project.yml b/project.yml index 51d512b..2313ba9 100644 --- a/project.yml +++ b/project.yml @@ -72,6 +72,7 @@ targets: base: INFOPLIST_FILE: Info.plist CODE_SIGN_ENTITLEMENTS: PadXcode.entitlements + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES LD_RUNPATH_SEARCH_PATHS: - "$(inherited)" - "@executable_path/Frameworks"