first successful build

This commit is contained in:
Dallas Groot 2026-04-12 09:47:09 -07:00
parent 8a4ac6ac3d
commit 425c6b8024
23 changed files with 926 additions and 330 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -78,9 +78,9 @@ git push -u origin main
} }
private struct SetupStep: View { 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) { 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 { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
@ -89,7 +89,7 @@ private struct SetupStep: View {
.background(Color.accentColor, in: Circle()) .background(Color.accentColor, in: Circle())
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline) Text(title).font(.headline)
Text(body).font(.subheadline).foregroundStyle(.secondary) Text(detail).font(.subheadline).foregroundStyle(.secondary)
} }
} }
} }

View file

@ -91,6 +91,18 @@ struct CodeEditorView: View {
.onReceive(NotificationCenter.default.publisher(for: .navigateToRange)) { note in .onReceive(NotificationCenter.default.publisher(for: .navigateToRange)) { note in
if let range = note.userInfo?["range"] as? NSRange { navigate(to: range) } 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) { private func jumpTo(_ line: Int) {

View file

@ -18,7 +18,8 @@ final class CompletionController: ObservableObject {
func attach(to tv: Runestone.TextView) { self.textView = tv } func attach(to tv: Runestone.TextView) { self.textView = tv }
func textDidChange(in tv: Runestone.TextView, filePath: String) { 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 text = tv.text as NSString
let c = text.character(at: sel.location - 1) let c = text.character(at: sel.location - 1)
let shouldTrigger = autoTriggers.contains(Character(Unicode.Scalar(c)!)) let shouldTrigger = autoTriggers.contains(Character(Unicode.Scalar(c)!))
@ -42,24 +43,29 @@ final class CompletionController: ObservableObject {
func dismiss() { debounceTask?.cancel(); items = []; isVisible = false } func dismiss() { debounceTask?.cancel(); items = []; isVisible = false }
func insert(_ item: CompletionItem, into tv: Runestone.TextView) { 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 text = tv.text as NSString
let wordRange = text.rangeOfCurrentWord(at: sel.location) let wordRange = text.rangeOfCurrentWord(at: sel.location)
let insertText = item.insertText ?? item.label let insertText = item.insertText ?? item.label
tv.replace(wordRange, withText: insertText) tv.replace(wordRange, withText: insertText)
if insertText.hasSuffix("()") { 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) tv.selectedRange = NSRange(location: loc, length: 0)
} }
dismiss() dismiss()
} }
private func requestCompletions(tv: Runestone.TextView, filePath: String) { private func requestCompletions(tv: Runestone.TextView, filePath: String) {
guard let sel = tv.selectedRange, let sel = tv.selectedRange
let loc = tv.textLocation(at: sel.location) else { return } guard let loc = tv.textLocation(at: sel.location) else { return }
if let rect = tv.caretRect(for: sel.location) { // caretRect(for:) on Runestone.TextView takes UITextPosition, not Int.
anchorRect = tv.convert(rect, to: nil) // 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 lastId += 1; let id = lastId
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }

View file

@ -1,29 +1,48 @@
import SwiftUI import SwiftUI
import UIKit
import Runestone import Runestone
// MARK: - Keyboard Bridge
final class EditorKeyboardBridge: ObservableObject { final class EditorKeyboardBridge: ObservableObject {
weak var activeTextView: Runestone.TextView? 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 { struct EditorKeyboardToolbar: View {
@ObservedObject var bridge: EditorKeyboardBridge @ObservedObject var bridge: EditorKeyboardBridge
@State private var showFindBar = false @State private var showFindBar = false
@State private var searchQuery = "" @State private var searchQuery = ""
@State private var isRegex = false @State private var lastMatchRange: NSRange = NSRange(location: NSNotFound, length: 0)
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if showFindBar { if showFindBar {
InlineFindBar(query: $searchQuery, isRegex: $isRegex, InlineFindBar(
onNext: findNext, onPrev: findPrev, query: $searchQuery,
onDismiss: { withAnimation { showFindBar = false } }) onNext: findNext,
onPrev: findPrev,
onDismiss: {
withAnimation { showFindBar = false }
searchQuery = ""
lastMatchRange = NSRange(location: NSNotFound, length: 0)
}
)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
Divider() Divider()
} }
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) { HStack(spacing: 4) {
KeyToolbarButton(icon: "increase.indent", label: "Indent") { bridge.activeTextView?.shiftRight() } KeyToolbarButton(icon: "increase.indent", label: "Indent") { indent(increase: true) }
KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { bridge.activeTextView?.shiftLeft() } KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { indent(increase: false) }
KeyToolbarDivider() KeyToolbarDivider()
KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() } KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() }
KeyToolbarDivider() KeyToolbarDivider()
@ -38,86 +57,233 @@ struct EditorKeyboardToolbar: View {
KeyToolbarButton(icon: "arrow.left", label: "Left") { moveCursor(-1) } KeyToolbarButton(icon: "arrow.left", label: "Left") { moveCursor(-1) }
KeyToolbarButton(icon: "arrow.right", label: "Right") { moveCursor(+1) } KeyToolbarButton(icon: "arrow.right", label: "Right") { moveCursor(+1) }
KeyToolbarDivider() KeyToolbarDivider()
KeyToolbarButton(icon: "magnifyingglass", label: "Find") { withAnimation { showFindBar.toggle() } } KeyToolbarButton(icon: "magnifyingglass", label: "Find") {
withAnimation { showFindBar.toggle() }
}
Spacer() Spacer()
KeyToolbarButton(icon: "keyboard.chevron.compact.down", label: "Done") { KeyToolbarButton(icon: "keyboard.chevron.compact.down", label: "Done") {
bridge.activeTextView?.resignFirstResponder() bridge.activeTextView?.resignFirstResponder()
} }
} }
.padding(.horizontal, 8).padding(.vertical, 6) .padding(.horizontal, 8)
.padding(.vertical, 6)
} }
.background(.regularMaterial) .background(.regularMaterial)
} }
.onAppear {
bridge.onFindNext = { findNext() }
bridge.onFindPrev = { findPrev() }
}
} }
private func insert(_ t: String) { bridge.activeTextView?.insertText(t) } // MARK: - Text Operations
private func insertPair(_ l: String, _ r: String) { bridge.activeTextView?.insertText(l+r); moveCursor(-1) }
private func moveCursor(_ offset: Int) { private func insert(_ text: String) {
guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return } bridge.activeTextView?.insertText(text)
tv.selectedRange = NSRange(location: max(0, sel.location + offset), length: 0)
} }
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 text = tv.text as NSString
let lr = text.lineRange(for: sel) let lineRange = text.lineRange(for: sel)
let line = text.substring(with: lr) 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 trimmed = line.trimmingCharacters(in: .whitespaces)
let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
let newLine: String let newLine: String
if trimmed.hasPrefix("// ") { newLine = line.replacingFirstOccurrence(of: "// ", with: "") } if trimmed.hasPrefix("// ") {
else if trimmed.hasPrefix("//") { newLine = line.replacingFirstOccurrence(of: "//", with: "") } newLine = line.replacingFirstOccurrence(of: "// ", with: "")
else { newLine = indent + "// " + line.dropFirst(indent.count) } } else if trimmed.hasPrefix("//") {
tv.replace(lr, withText: newLine) 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() { private func findNext() {
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return } 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() { private func findPrev() {
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return } 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 { struct InlineFindBar: View {
@Binding var query: String; @Binding var isRegex: Bool @Binding var query: String
var onNext: () -> Void; var onPrev: () -> Void; var onDismiss: () -> Void var onNext: () -> Void
var onPrev: () -> Void
var onDismiss: () -> Void
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary) Image(systemName: "magnifyingglass")
TextField("Find…", text: $query).textFieldStyle(.plain) .foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none) TextField("Find...", text: $query)
.onSubmit(onNext) .textFieldStyle(.plain)
Toggle(isOn: $isRegex) { Image(systemName: "ellipsis.curlybraces") } .font(.system(.body, design: .monospaced))
.toggleStyle(.button).buttonStyle(.bordered).tint(isRegex ? .accentColor : .secondary) .autocorrectionDisabled()
Button(action: onPrev) { Image(systemName: "chevron.up") }.buttonStyle(.bordered).disabled(query.isEmpty) .textInputAutocapitalization(.never)
Button(action: onNext) { Image(systemName: "chevron.down") }.buttonStyle(.bordered).disabled(query.isEmpty) .onSubmit { onNext() }
Button(action: onDismiss) { Image(systemName: "xmark") }.buttonStyle(.bordered) Button(action: onPrev) {
Image(systemName: "chevron.up")
} }
.padding(.horizontal, 12).padding(.vertical, 6) .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)
} }
} }
// MARK: - Toolbar Sub-components
struct KeyToolbarButton: View { 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 { var body: some View {
Button(action: action) { Image(systemName: icon).frame(minWidth: 32, minHeight: 32).contentShape(Rectangle()) } Button(action: action) {
.buttonStyle(.plain).foregroundStyle(.primary).help(label) Image(systemName: icon)
.frame(minWidth: 32, minHeight: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.help(label)
} }
} }
struct KeyToolbarText: View { struct KeyToolbarText: View {
let text: String; let action: () -> Void let text: String
init(_ text: String, action: @escaping () -> Void) { self.text = text; self.action = action } let action: () -> Void
init(_ text: String, action: @escaping () -> Void) {
self.text = text
self.action = action
}
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Text(text).font(.system(.callout, design: .monospaced).bold()) Text(text)
.frame(minWidth: 32, minHeight: 32).contentShape(Rectangle()) .font(.system(.callout, design: .monospaced).bold())
.frame(minWidth: 32, minHeight: 32)
.contentShape(Rectangle())
} }
.buttonStyle(.plain).foregroundStyle(.primary) .buttonStyle(.plain)
.foregroundStyle(.primary)
} }
} }
struct KeyToolbarDivider: View { 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)
}
} }

View file

@ -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 } } var dirtyTabNames: [String] { tabs.filter { $0.isDirty }.map { $0.fileName } }
} }

View file

@ -1,10 +1,7 @@
// GutterAnnotationView.swift
// DiagnosticRange is defined in Shared/SharedModels.swift
// No Identifiable conformance extension needed here.
import SwiftUI import SwiftUI
import Runestone import Runestone
// MARK: - Annotation Model (local to this file wraps DiagnosticRange with position) // MARK: - Annotation Model
struct GutterAnnotation: Identifiable { struct GutterAnnotation: Identifiable {
let id = UUID() let id = UUID()
@ -69,17 +66,19 @@ struct GutterAnnotationOverlay: View {
private func computePositions() { private func computePositions() {
guard let tv = textView else { return } guard let tv = textView else { return }
var result: [PositionedAnnotation] = [] var result: [PositionedAnnotation] = []
for diag in diagnostics { for diag in diagnostics {
let ann = GutterAnnotation(line: diag.line, severity: diag.severity, message: diag.message) let ann = GutterAnnotation(line: diag.line, severity: diag.severity, message: diag.message)
guard let range = tv.range(atLine: ann.line, column: 0), // caretRect(for:) on Runestone.TextView takes UITextPosition, not Int.
let rect = tv.caretRect(for: range.location) else { continue } // Compute Y from font metrics instead reliable for a code editor
let y = rect.midY - tv.contentOffset.y // where all lines have equal height.
if y > 0 && y < tv.bounds.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)) result.append(PositionedAnnotation(annotation: ann, y: y))
} }
}
withAnimation(.easeInOut(duration: 0.15)) { annotations = result } 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 { private extension Color {
init(hex: String) { init(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines) var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)

View file

@ -87,18 +87,33 @@ final class EditorCommandHandler: UIViewController {
@objc func cmdUnindent() { textView?.shiftLeft() } @objc func cmdUnindent() { textView?.shiftLeft() }
@objc func cmdFind() { onFind?() } @objc func cmdFind() { onFind?() }
@objc func cmdFindProject(){ onFindInProject?() } @objc func cmdFindProject(){ onFindInProject?() }
@objc func cmdFindNext() {} @objc func cmdFindNext() {
@objc func cmdFindPrev() {} NotificationCenter.default.post(
name: .editorFindNext, object: nil)
}
@objc func cmdFindPrev() {
NotificationCenter.default.post(
name: .editorFindPrev, object: nil)
}
@objc func cmdJumpToLine() { onJumpToLine?() } @objc func cmdJumpToLine() { onJumpToLine?() }
@objc func cmdBuild() { onBuild?() } @objc func cmdBuild() { onBuild?() }
@objc func cmdRun() { onRun?() } @objc func cmdRun() { onRun?() }
@objc func cmdNavigator() { onToggleNavigator?() } @objc func cmdNavigator() { onToggleNavigator?() }
@objc func cmdConsole() { onToggleConsole?() } @objc func cmdConsole() { onToggleConsole?() }
@objc func cmdFontBigger() {} @objc func cmdFontBigger() {
@objc func cmdFontSmaller(){} 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() { @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 text = tv.text as NSString; let lr = text.lineRange(for: sel)
let line = text.substring(with: lr); let trimmed = line.trimmingCharacters(in: .whitespaces) let line = text.substring(with: lr); let trimmed = line.trimmingCharacters(in: .whitespaces)
let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
@ -110,7 +125,8 @@ final class EditorCommandHandler: UIViewController {
} }
@objc func cmdMoveUp() { @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) let text = tv.text as NSString; let cur = text.lineRange(for: sel)
guard cur.location > 0 else { return } guard cur.location > 0 else { return }
let prev = text.lineRange(for: NSRange(location: cur.location - 1, length: 0)) let prev = text.lineRange(for: NSRange(location: cur.location - 1, length: 0))
@ -120,7 +136,8 @@ final class EditorCommandHandler: UIViewController {
} }
@objc func cmdMoveDown() { @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 text = tv.text as NSString; let cur = text.lineRange(for: sel)
let end = cur.location + cur.length; guard end < text.length else { return } let end = cur.location + cur.length; guard end < text.length else { return }
let next = text.lineRange(for: NSRange(location: end, length: 0)) let next = text.lineRange(for: NSRange(location: end, length: 0))
@ -130,14 +147,16 @@ final class EditorCommandHandler: UIViewController {
} }
@objc func cmdDuplicate() { @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 text = tv.text as NSString; let lr = text.lineRange(for: sel)
let line = text.substring(with: lr) let line = text.substring(with: lr)
tv.replace(NSRange(location: lr.location + lr.length, length: 0), withText: line) tv.replace(NSRange(location: lr.location + lr.length, length: 0), withText: line)
} }
@objc func cmdDeleteLine() { @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 let text = tv.text as NSString
tv.replace(text.lineRange(for: sel), withText: "") tv.replace(text.lineRange(for: sel), withText: "")
} }
@ -153,3 +172,44 @@ final class EditorCommandHandler: UIViewController {
tv.selectedRange = end; tv.scrollRangeToVisible(end) 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
}
}

View file

@ -2,6 +2,16 @@ import SwiftUI
import UIKit import UIKit
import Runestone 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 { struct RunestoneEditorView: UIViewRepresentable {
@Binding var content: String @Binding var content: String
@ -20,7 +30,8 @@ struct RunestoneEditorView: UIViewRepresentable {
func makeUIView(context: Context) -> Runestone.TextView { func makeUIView(context: Context) -> Runestone.TextView {
let tv = Runestone.TextView() let tv = Runestone.TextView()
configureAppearance(tv); configureEditing(tv) configureAppearance(tv)
configureEditing(tv)
tv.editorDelegate = context.coordinator tv.editorDelegate = context.coordinator
completionController.attach(to: tv) completionController.attach(to: tv)
DispatchQueue.main.async { textViewRef = tv } DispatchQueue.main.async { textViewRef = tv }
@ -29,19 +40,30 @@ struct RunestoneEditorView: UIViewRepresentable {
} }
func updateUIView(_ tv: Runestone.TextView, context: Context) { 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.showLineNumbers = showLineNumbers
tv.isLineWrappingEnabled = lineWrapping tv.isLineWrappingEnabled = lineWrapping
tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth) tv.indentStrategy = indentWidth == 0
? .tab(length: 4)
: .space(length: indentWidth)
applyDiagnostics(to: tv) applyDiagnostics(to: tv)
} }
private func loadState(into tv: Runestone.TextView) { private func loadState(into tv: Runestone.TextView) {
let lang = LanguageDetector.language(for: filePath) let lang = LanguageDetector.language(for: filePath)
let capturedContent = content; let capturedTheme = theme let capturedText = content
let capturedTheme = theme
Task.detached(priority: .userInitiated) { Task.detached(priority: .userInitiated) {
let state = TextViewState(text: capturedContent, theme: capturedTheme, // TextViewState init takes TreeSitterLanguage (non-optional).
language: lang.map { TreeSitterLanguageMode(language: $0) }) // 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) } await MainActor.run { tv.setState(state) }
} }
} }
@ -49,43 +71,66 @@ struct RunestoneEditorView: UIViewRepresentable {
private func configureAppearance(_ tv: Runestone.TextView) { private func configureAppearance(_ tv: Runestone.TextView) {
tv.backgroundColor = UIColor(hex: "#1E1E1E") tv.backgroundColor = UIColor(hex: "#1E1E1E")
tv.showLineNumbers = showLineNumbers 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.isLineWrappingEnabled = lineWrapping
tv.highlightSelectedLine = true // highlightSelectedLine was replaced by lineSelectionDisplayType.
tv.lineHeightMultiplier = 1.4; tv.kern = 0.3 // .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.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 120, right: 0)
tv.verticalOverscrollFactor = 0.5 tv.verticalOverscrollFactor = 0.5
} }
private func configureEditing(_ tv: Runestone.TextView) { private func configureEditing(_ tv: Runestone.TextView) {
tv.isEditable = true; tv.autocorrectionType = .no; tv.autocapitalizationType = .none tv.isEditable = true
tv.smartDashesType = .no; tv.smartQuotesType = .no tv.autocorrectionType = .no
tv.smartInsertDeleteType = .no; tv.spellCheckingType = .no tv.autocapitalizationType = .none
tv.smartDashesType = .no
tv.smartQuotesType = .no
tv.smartInsertDeleteType = .no
tv.spellCheckingType = .no
tv.characterPairs = [ tv.characterPairs = [
BasicCharacterPair(leading: "(", trailing: ")"), EditorCharacterPair(leading: "(", trailing: ")"),
BasicCharacterPair(leading: "[", trailing: "]"), EditorCharacterPair(leading: "[", trailing: "]"),
BasicCharacterPair(leading: "{", trailing: "}"), EditorCharacterPair(leading: "{", trailing: "}"),
BasicCharacterPair(leading: "\"", trailing: "\""), EditorCharacterPair(leading: "\"", trailing: "\""),
] ]
tv.characterPairTrailingComponentDeletionMode = .immediatelyFollowingLeadingComponent 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) { private func applyDiagnostics(to tv: Runestone.TextView) {
guard !diagnosticRanges.isEmpty else { tv.setHighlightedRanges([]); return } guard !diagnosticRanges.isEmpty else {
let highlights: [HighlightedRange] = diagnosticRanges.compactMap { diag in tv.highlightedRanges = []
guard let range = tv.range(atLine: diag.line, column: diag.column) else { return nil } return
let color: UIColor = diag.severity == .error ? UIColor(hex: "#F44747") : UIColor(hex: "#CCA700")
return HighlightedRange(range: range, color: color, cornerRadius: 0, underlineStyle: .single)
} }
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 // MARK: - Coordinator
@MainActor
final class Coordinator: NSObject, TextViewDelegate { final class Coordinator: NSObject, TextViewDelegate {
var parent: RunestoneEditorView var parent: RunestoneEditorView
var isEditing = false var isEditing: Bool = false
init(_ parent: RunestoneEditorView) { self.parent = parent } init(_ parent: RunestoneEditorView) { self.parent = parent }
func textViewDidChange(_ tv: Runestone.TextView) { func textViewDidChange(_ tv: Runestone.TextView) {
@ -97,12 +142,20 @@ struct RunestoneEditorView: UIViewRepresentable {
} }
func textViewDidChangeSelection(_ tv: Runestone.TextView) { func textViewDidChangeSelection(_ tv: Runestone.TextView) {
guard let sel = tv.selectedRange, let loc = tv.textLocation(at: sel.location) else { return } let sel = tv.selectedRange
parent.onCursorPositionChange?(loc.lineNumber, loc.column) if let loc = tv.textLocation(at: sel.location) {
parent.onCursorPositionChange?(loc.lineNumber + 1, loc.column + 1)
}
if !isEditing { parent.completionController.dismiss() } if !isEditing { parent.completionController.dismiss() }
} }
func textViewDidBeginEditing(_ tv: Runestone.TextView) { isEditing = true } func textViewDidBeginEditing(_ tv: Runestone.TextView) {
func textViewDidEndEditing(_ tv: Runestone.TextView) { isEditing = false; parent.completionController.dismiss() } isEditing = true
}
func textViewDidEndEditing(_ tv: Runestone.TextView) {
isEditing = false
parent.completionController.dismiss()
}
} }
} }

View file

@ -85,7 +85,7 @@ struct GitPanel: View {
Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch").font(.caption).foregroundStyle(.secondary) Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch").font(.caption).foregroundStyle(.secondary)
Text(branch.name).font(.system(.body, design: .monospaced)) Text(branch.name).font(.system(.body, design: .monospaced))
Spacer() Spacer()
if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(.accentColor) } if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(Color.accentColor) }
} }
}.buttonStyle(.plain) }.buttonStyle(.plain)
} }
@ -213,12 +213,12 @@ struct GitPanel: View {
label: { label: {
HStack { HStack {
Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch") 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)) 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) .bold(branch.name == gitStore.currentBranch)
Spacer() 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) .buttonStyle(.plain)

View file

@ -3,14 +3,22 @@
"http://www.apple.com/DTDs/PropertyList-1.0.dtd"> "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>PadXcode</string> <string>PadXcode</string>
<key>CFBundleDisplayName</key>
<string>PadXcode</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>ca.dallasgroot.PadXcode</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -47,5 +55,12 @@
</dict> </dict>
<key>UIRequiresFullScreen</key> <key>UIRequiresFullScreen</key>
<false/> <false/>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -38,7 +38,7 @@ struct ContentView: View {
_buildService = StateObject(wrappedValue: bs) _buildService = StateObject(wrappedValue: bs)
_syncPipeline = StateObject(wrappedValue: FileSyncPipeline( _syncPipeline = StateObject(wrappedValue: FileSyncPipeline(
buildService: bs, gitService: gitService, buildService: bs, gitService: gitService,
gitStore: store, config: config)) gitStore: store))
} }
var body: some View { var body: some View {
@ -340,7 +340,7 @@ struct ProjectPickerSheet: View {
Text("Scheme: \(p.scheme)").font(.caption2).foregroundStyle(.tertiary) Text("Scheme: \(p.scheme)").font(.caption2).foregroundStyle(.tertiary)
} }
Spacer() 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) }.buttonStyle(.plain)
} }

View file

@ -61,12 +61,12 @@ struct EnhancedConsoleView: View {
} }
Spacer() Spacer()
Button { withAnimation { showSearch.toggle(); if !showSearch { searchText = "" } } } 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) .buttonStyle(.plain)
Button { UIPasteboard.general.string = filtered.map(\.text).joined(separator: "\n") } Button { UIPasteboard.general.string = filtered.map(\.text).joined(separator: "\n") }
label: { Image(systemName: "doc.on.doc").foregroundStyle(.secondary) }.buttonStyle(.plain) label: { Image(systemName: "doc.on.doc").foregroundStyle(.secondary) }.buttonStyle(.plain)
Button { isPinned.toggle() } 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) .buttonStyle(.plain)
if let clear = onClear { if let clear = onClear {
Button(action: clear) { Image(systemName: "trash").foregroundStyle(.secondary) }.buttonStyle(.plain) Button(action: clear) { Image(systemName: "trash").foregroundStyle(.secondary) }.buttonStyle(.plain)
@ -122,13 +122,13 @@ struct EnhancedConsoleView: View {
private func pillColor(_ mode: FilterMode) -> Color { private func pillColor(_ mode: FilterMode) -> Color {
switch mode { switch mode {
case .errors: return .red; case .warnings: return .orange 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 { private func gutterColor(_ type: ConsoleLine.LineType) -> Color {
switch type { switch type {
case .error: return .red; case .warning: return .orange 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
} }
} }
} }

View file

@ -67,7 +67,7 @@ struct OnboardingView: View {
private var welcomeStep: some View { private var welcomeStep: some View {
VStack(spacing: 28) { 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) { VStack(spacing:10) {
Text("Welcome to PadXcode").font(.largeTitle.bold()).multilineTextAlignment(.center) 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) Text("A native Swift IDE on your iPad, backed by your Mac.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24)

View file

@ -439,6 +439,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = PadXcode.entitlements; CODE_SIGN_ENTITLEMENTS = PadXcode.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = E9C9AGS9K6; DEVELOPMENT_TEAM = E9C9AGS9K6;
@ -457,6 +458,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = PadXcode.entitlements; CODE_SIGN_ENTITLEMENTS = PadXcode.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = E9C9AGS9K6; DEVELOPMENT_TEAM = E9C9AGS9K6;

View file

@ -3,10 +3,11 @@ import Foundation
@main @main
struct PadXcodeApp: App { struct PadXcodeApp: App {
@StateObject private var daemonConfig = DaemonConfiguration()
@StateObject private var projectStore = ProjectStore() @StateObject private var daemonConfig: DaemonConfiguration
@StateObject private var editorState = EditorState() @StateObject private var projectStore: ProjectStore
@StateObject private var gitStore = GitStore() @StateObject private var editorState: EditorState
@StateObject private var gitStore: GitStore
@StateObject private var lspClient: LSPClient @StateObject private var lspClient: LSPClient
private let gitService: GitService private let gitService: GitService
@ -14,11 +15,15 @@ struct PadXcodeApp: App {
init() { init() {
let config = DaemonConfiguration() let config = DaemonConfiguration()
let apiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? ""
let store = GitStore() let store = GitStore()
let apiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? ""
_daemonConfig = StateObject(wrappedValue: config) _daemonConfig = StateObject(wrappedValue: config)
_lspClient = StateObject(wrappedValue: LSPClient(config: config)) _projectStore = StateObject(wrappedValue: ProjectStore())
_editorState = StateObject(wrappedValue: EditorState())
_gitStore = StateObject(wrappedValue: store) _gitStore = StateObject(wrappedValue: store)
_lspClient = StateObject(wrappedValue: LSPClient(config: config))
gitService = GitService(apiKey: apiKey) gitService = GitService(apiKey: apiKey)
gitCallbackHandler = GitCallbackHandler(store: store) gitCallbackHandler = GitCallbackHandler(store: store)
} }

View file

@ -12,125 +12,239 @@ struct SettingsView: View {
@State private var connStatus: ConnStatus = .untested @State private var connStatus: ConnStatus = .untested
@State private var showAddProject = false @State private var showAddProject = false
enum ConnStatus { case untested, testing, success, failure(String) // Equatable conformance added so != operator works on line 39 and elsewhere.
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)" } } enum ConnStatus: Equatable {
var color: Color { switch self { case .success: return .green; case .failure: return .red; case .testing: return .secondary; default: return .secondary } } 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 { 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 } 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 { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
// Daemon
// Daemon
Section { Section {
HStack { HStack {
Label("Tailscale IP", systemImage:"network"); Spacer() Label("Tailscale IP", systemImage: "network")
TextField("100.x.x.x", text:$pendingHost).multilineTextAlignment(.trailing).keyboardType(.numbersAndPunctuation).autocorrectionDisabled().autocapitalization(.none).frame(width:160).onChange(of:pendingHost){_,_ in connStatus = .untested} 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 { HStack {
Label("Port", systemImage:"antenna.radiowaves.left.and.right"); Spacer() Label("Port", systemImage: "antenna.radiowaves.left.and.right")
TextField("8080", text:$pendingPort).multilineTextAlignment(.trailing).keyboardType(.numberPad).frame(width:80) 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) } HStack {
Button("Test Connection") { Task { await testConnection() } }.disabled(pendingHost.isEmpty) Text("Status").foregroundStyle(.secondary)
Button("Save & Apply") { applyConnection() }.buttonStyle(.borderedProminent) 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) .disabled(pendingHost.isEmpty || connStatus != .success)
} header: { Text("Mac Daemon") } } header: { Text("Mac Daemon") }
// Projects // Projects
Section { Section {
ForEach(projectStore.projects) { p in ForEach(projectStore.projects) { p in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(p.name).font(.body) Text(p.name).font(.body)
Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) 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) Text("Scheme: \(p.scheme) · WC: \(p.workingCopyRepoName.isEmpty ? "not set" : p.workingCopyRepoName)")
}.padding(.vertical,2) .font(.caption2).foregroundStyle(.tertiary)
}.onDelete { projectStore.remove(atOffsets:$0) } }
Button { showAddProject = true } label: { Label("Add Project", systemImage:"plus.circle") } .padding(.vertical, 2)
} header: { Text("Projects") } footer: { Text("Mac path should be the directory containing project.yml or .xcodeproj") } }
.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") { Section("Editor Appearance") {
HStack { HStack {
Label("Font Size", systemImage:"textformat.size"); Spacer() Label("Font Size", systemImage: "textformat.size")
Stepper("\(Int(editorState.fontSize))pt", value:$editorState.fontSize, in:8...32, step:1).frame(width:160) 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)
}
// 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")
} }
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.showLineNumbers) { Label("Line Numbers", systemImage: "list.number") }
Toggle(isOn: $editorState.lineWrapping) { Label("Line Wrapping", systemImage: "text.word.spacing") } Toggle(isOn: $editorState.lineWrapping) { Label("Line Wrapping", systemImage: "text.word.spacing") }
HStack { HStack {
Label("Indent Width", systemImage:"arrow.right.to.line"); Spacer() Label("Indent Width", systemImage: "arrow.right.to.line")
Spacer()
Picker("", selection: $editorState.indentWidth) { Picker("", selection: $editorState.indentWidth) {
Text("2 spaces").tag(2); Text("4 spaces").tag(4); Text("Tab").tag(0) Text("2 spaces").tag(2)
}.labelsHidden().pickerStyle(.menu) Text("4 spaces").tag(4)
Text("Tab").tag(0)
}
.labelsHidden()
.pickerStyle(.menu)
} }
} }
// Build // Build
Section("Build") { Section("Build") {
HStack { HStack {
Label("Team ID", systemImage:"person.badge.key"); Spacer() Label("Team ID", systemImage: "person.badge.key")
TextField("XXXXXXXXXX", text:$daemonConfig.developmentTeam).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:120) 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 { Section {
HStack { HStack {
Label("API Key", systemImage:"key.fill"); Spacer() Label("API Key", systemImage: "key.fill")
SecureField("Paste from Working Copy", text:$wcApiKey).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:200) Spacer()
.onChange(of:wcApiKey){_,k in UserDefaults.standard.set(k, forKey:"workingCopyAPIKey")} 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://")!) let installed = UIApplication.shared.canOpenURL(URL(string: "working-copy://")!)
Label(installed ? "Working Copy detected ✓" : "Working Copy not installed", Label(
systemImage: installed ? "checkmark.circle.fill" : "exclamationmark.triangle") installed ? "Working Copy detected" : "Working Copy not installed",
.foregroundStyle(installed ? .green : .orange).font(.caption) 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") 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) .font(.caption).foregroundStyle(.secondary)
} header: { Text("Working Copy (Git)") } } header: {
Text("Working Copy (Git)")
}
// LSP // LSP
Section { 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 { if daemonConfig.lspEnabled {
HStack { 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() Spacer()
Text(daemonConfig.lspConnected ? "Running" : "Disconnected").foregroundStyle(.secondary) Text(daemonConfig.lspConnected ? "Running" : "Disconnected")
.foregroundStyle(.secondary)
} }
} }
} header: { Text("Language Server (LSP)") } } header: { Text("Language Server (LSP)") }
// About // About
Section("About") { Section("About") {
LabeledContent("Version", value: "1.0.0-alpha") LabeledContent("Version", value: "1.0.0-alpha")
LabeledContent("Editor Engine", value: "Runestone 0.5.1 / Tree-sitter") LabeledContent("Editor Engine", value: "Runestone 0.5.1 / Tree-sitter")
LabeledContent("Protocol", value: "REST + WebSocket") LabeledContent("Protocol", value: "REST + WebSocket")
} }
} }
.navigationTitle("Settings").navigationBarTitleDisplayMode(.inline) .navigationTitle("Settings")
.onAppear { pendingHost = daemonConfig.host; pendingPort = String(daemonConfig.port) .navigationBarTitleDisplayMode(.inline)
wcApiKey = UserDefaults.standard.string(forKey:"workingCopyAPIKey") ?? "" } .onAppear {
.sheet(isPresented:$showAddProject) { AddProjectView(projectStore:projectStore) } 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 { private func testConnection() async {
connStatus = .testing connStatus = .testing
let host = pendingHost.trimmingCharacters(in: .whitespaces) let host = pendingHost.trimmingCharacters(in: .whitespaces)
let port = Int(pendingPort) ?? 8080 let port = Int(pendingPort) ?? 8080
guard let url = URL(string:"http://\(host):\(port)/health") else { connStatus = .failure("Invalid URL"); return } guard let url = URL(string: "http://\(host):\(port)/health") else {
connStatus = .failure("Invalid URL"); return
}
var req = URLRequest(url: url); req.timeoutInterval = 4 var req = URLRequest(url: url); req.timeoutInterval = 4
do { do {
let (_, resp) = try await URLSession.shared.data(for: req) let (_, resp) = try await URLSession.shared.data(for: req)
connStatus = (resp as? HTTPURLResponse)?.statusCode == 200 ? .success : .failure("Unexpected response") connStatus = (resp as? HTTPURLResponse)?.statusCode == 200
} catch { connStatus = .failure(error.localizedDescription) } ? .success
: .failure("Unexpected status code")
} catch {
connStatus = .failure(error.localizedDescription)
}
} }
private func applyConnection() { private func applyConnection() {
@ -144,33 +258,57 @@ struct SettingsView: View {
struct AddProjectView: View { struct AddProjectView: View {
@ObservedObject var projectStore: ProjectStore @ObservedObject var projectStore: ProjectStore
@Environment(\.dismiss) var dismiss @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 { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section("Project Details") { Section("Project Details") {
TextField("Name (e.g. NavidromePlayer)", text: $name) TextField("Name (e.g. NavidromePlayer)", text: $name)
TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text:$path).autocorrectionDisabled().autocapitalization(.none).keyboardType(.URL) TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text: $path)
TextField("Xcode Scheme", text:$scheme).autocorrectionDisabled().autocapitalization(.none) .autocorrectionDisabled()
.autocapitalization(.none)
.keyboardType(.URL)
TextField("Xcode Scheme", text: $scheme)
.autocorrectionDisabled()
.autocapitalization(.none)
} }
Section("Working Copy") { // Section with both header string AND footer requires the
TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text:$wcRepo).autocorrectionDisabled().autocapitalization(.none) // explicit header:/footer: trailing closure form.
} footer: { Text("Must match the display name in Working Copy's repo list.") } 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.")
} }
.navigationTitle("Add Project").navigationBarTitleDisplayMode(.inline) }
.navigationTitle("Add Project")
.navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement:.cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Add") { Button("Add") {
projectStore.add(SavedProject(name: name.isEmpty ? scheme : name, projectStore.add(SavedProject(
rootPath: path, scheme: scheme, name: name.isEmpty ? scheme : name,
workingCopyRepoName: wcRepo)) rootPath: path,
scheme: scheme,
workingCopyRepoName: wcRepo
))
dismiss() dismiss()
}.disabled(path.isEmpty || scheme.isEmpty) }
.disabled(path.isEmpty || scheme.isEmpty)
} }
} }
}.presentationDetents([.medium]) }
.presentationDetents([.medium])
} }
} }

View file

@ -1,4 +1,5 @@
import Foundation import Foundation
import UIKit
import SwiftUI import SwiftUI
// MARK: - File System // MARK: - File System
@ -68,7 +69,7 @@ struct FileContentResponse: Codable {
// MARK: - Console // MARK: - Console
struct ConsoleLine: Identifiable { struct ConsoleLine: Identifiable, Equatable {
let id = UUID() let id = UUID()
let text: String let text: String
let type: LineType let type: LineType
@ -159,6 +160,9 @@ extension Notification.Name {
static let navigateToRange = Notification.Name("editor.navigate.range") static let navigateToRange = Notification.Name("editor.navigate.range")
static let triggerCompletion = Notification.Name("editor.trigger.completion") static let triggerCompletion = Notification.Name("editor.trigger.completion")
static let openSettings = Notification.Name("padxcode.open.settings") 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 // MARK: - String helpers
@ -172,3 +176,20 @@ extension String {
return self.replacingCharacters(in: range, with: replacement) 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
)
}
}

View file

@ -1,3 +1,4 @@
import Foundation
import Runestone import Runestone
import TreeSitterSwiftRunestone import TreeSitterSwiftRunestone
import TreeSitterJSONRunestone import TreeSitterJSONRunestone

View file

@ -1,151 +1,262 @@
import UIKit import UIKit
import Runestone import Runestone
final class PadXcodeTheme: Theme { // MARK: - Xcode Default Dark 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") }
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 { switch highlightName {
case "keyword", "keyword.control", "keyword.operator", "keyword.other", case "keyword", "keyword.control", "keyword.operator",
"keyword.declaration", "storage.type": "keyword.other", "keyword.declaration", "storage.type",
return TextStyle(color: UIColor(hex: "#FF7AB2"), bold: true) "storage.modifier":
case "storage.modifier": return UIColor(r: 255, g: 122, b: 178) // pink
return TextStyle(color: UIColor(hex: "#FF7AB2"))
case "type", "type.builtin", "entity.name.type", "entity.name.type.class", case "type", "type.builtin", "entity.name.type",
"entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol": "entity.name.type.class", "entity.name.type.struct",
return TextStyle(color: UIColor(hex: "#DABAFF")) "entity.name.type.enum", "entity.name.type.protocol":
return UIColor(r: 218, g: 186, b: 255) // lavender
case "type.parameter": case "type.parameter":
return TextStyle(color: UIColor(hex: "#6BDFFF")) return UIColor(r: 107, g: 223, b: 255) // light blue
case "function", "entity.name.function", "meta.function-call", "function.method":
return TextStyle(color: UIColor(hex: "#67B7FB")) case "function", "entity.name.function",
case "function.builtin": "meta.function-call", "function.method", "function.builtin":
return TextStyle(color: UIColor(hex: "#67B7FB"), bold: true) return UIColor(r: 103, g: 183, b: 251) // blue
case "variable", "variable.other", "variable.other.member":
return TextStyle(color: UIColor(hex: "#D4D4D4"))
case "variable.builtin":
return TextStyle(color: UIColor(hex: "#FF7AB2"))
case "property", "variable.other.property": 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": 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": case "constant.numeric", "number":
return TextStyle(color: UIColor(hex: "#D9C97C")) return UIColor(r: 217, g: 201, b: 124) // gold
case "string", "string.special": case "string", "string.special":
return TextStyle(color: UIColor(hex: "#FF8170")) return UIColor(r: 255, g: 129, b: 112) // salmon
case "string.escape": case "string.escape":
return TextStyle(color: UIColor(hex: "#FF8170"), bold: true) return UIColor(r: 255, g: 129, b: 112) // salmon
case "comment", "comment.line", "comment.block":
return TextStyle(color: UIColor(hex: "#7F8C98"), italic: true) case "comment", "comment.line", "comment.block",
case "comment.block.documentation": "comment.block.documentation":
return TextStyle(color: UIColor(hex: "#6A9955"), italic: true) return UIColor(r: 127, g: 140, b: 152) // grey
case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter":
return TextStyle(color: UIColor(hex: "#D4D4D4"))
case "attribute": 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: default:
return nil return nil
} }
} }
} }
// MARK: - Xcode Default Light Theme
final class PadXcodeLightTheme: 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 { switch highlightName {
case "keyword", "keyword.control", "keyword.operator", "keyword.other", case "keyword", "keyword.control", "keyword.operator",
"keyword.declaration", "storage.type": "keyword.other", "keyword.declaration", "storage.type",
return TextStyle(color: UIColor(hex: "#AD3DA4"), bold: true) "storage.modifier":
case "storage.modifier": return UIColor(r: 173, g: 61, b: 164) // purple
return TextStyle(color: UIColor(hex: "#AD3DA4"))
case "type", "type.builtin", "entity.name.type", "entity.name.type.class", case "type", "type.builtin", "entity.name.type",
"entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol": "entity.name.type.class", "entity.name.type.struct",
return TextStyle(color: UIColor(hex: "#703DAA")) "entity.name.type.enum", "entity.name.type.protocol":
return UIColor(r: 112, g: 61, b: 170) // dark purple
case "type.parameter": case "type.parameter":
return TextStyle(color: UIColor(hex: "#047CB0")) return UIColor(r: 4, g: 124, b: 176) // blue
case "function", "entity.name.function", "meta.function-call", "function.method":
return TextStyle(color: UIColor(hex: "#3900A0")) case "function", "entity.name.function",
case "function.builtin": "meta.function-call", "function.method", "function.builtin":
return TextStyle(color: UIColor(hex: "#3900A0"), bold: true) return UIColor(r: 57, g: 0, b: 160) // indigo
case "variable", "variable.other", "variable.other.member":
return TextStyle(color: UIColor(hex: "#000000"))
case "variable.builtin":
return TextStyle(color: UIColor(hex: "#AD3DA4"))
case "property", "variable.other.property": 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": 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": case "constant.numeric", "number":
return TextStyle(color: UIColor(hex: "#272AD8")) return UIColor(r: 39, g: 42, b: 216) // blue
case "string", "string.special": case "string", "string.special":
return TextStyle(color: UIColor(hex: "#C41A16")) return UIColor(r: 196, g: 26, b: 22) // red
case "string.escape": case "string.escape":
return TextStyle(color: UIColor(hex: "#C41A16"), bold: true) return UIColor(r: 196, g: 26, b: 22) // red
case "comment", "comment.line", "comment.block":
return TextStyle(color: UIColor(hex: "#5D6C79"), italic: true) case "comment", "comment.line", "comment.block",
case "comment.block.documentation": "comment.block.documentation":
return TextStyle(color: UIColor(hex: "#265B31"), italic: true) return UIColor(r: 93, g: 108, b: 121) // grey
case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter":
return TextStyle(color: UIColor(hex: "#000000"))
case "attribute": 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: default:
return nil return nil
} }
} }
} }
// MARK: - TextStyle helpers // MARK: - UIColor RGB convenience (private to this file)
extension TextStyle { private extension UIColor {
init(color: UIColor, bold: Bool = false, italic: Bool = false) { convenience init(r: CGFloat, g: CGFloat, b: CGFloat, alpha: CGFloat = 1.0) {
self.init() self.init(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: alpha)
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)
} }
} }

View file

@ -15,7 +15,7 @@ struct Toast: Identifiable {
var color: Color { var color: Color {
switch self { switch self {
case .success: return .green; case .error: return .red 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
} }
} }
} }

View file

@ -72,6 +72,7 @@ targets:
base: base:
INFOPLIST_FILE: Info.plist INFOPLIST_FILE: Info.plist
CODE_SIGN_ENTITLEMENTS: PadXcode.entitlements CODE_SIGN_ENTITLEMENTS: PadXcode.entitlements
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
LD_RUNPATH_SEARCH_PATHS: LD_RUNPATH_SEARCH_PATHS:
- "$(inherited)" - "$(inherited)"
- "@executable_path/Frameworks" - "@executable_path/Frameworks"