289 lines
9.9 KiB
Swift
289 lines
9.9 KiB
Swift
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 lastMatchRange: NSRange = NSRange(location: NSNotFound, length: 0)
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if showFindBar {
|
|
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") { indent(increase: true) }
|
|
KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { indent(increase: false) }
|
|
KeyToolbarDivider()
|
|
KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() }
|
|
KeyToolbarDivider()
|
|
KeyToolbarText("(") { insertPair("(", ")") }
|
|
KeyToolbarText("[") { insertPair("[", "]") }
|
|
KeyToolbarText("{") { insertPair("{", "}") }
|
|
KeyToolbarDivider()
|
|
KeyToolbarText("->") { insert(" -> ") }
|
|
KeyToolbarText("??") { insert(" ?? ") }
|
|
KeyToolbarText("$0") { insert("$0") }
|
|
KeyToolbarDivider()
|
|
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() }
|
|
}
|
|
Spacer()
|
|
KeyToolbarButton(icon: "keyboard.chevron.compact.down", label: "Done") {
|
|
bridge.activeTextView?.resignFirstResponder()
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.background(.regularMaterial)
|
|
}
|
|
.onAppear {
|
|
bridge.onFindNext = { findNext() }
|
|
bridge.onFindPrev = { findPrev() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Text Operations
|
|
|
|
private func insert(_ text: String) {
|
|
bridge.activeTextView?.insertText(text)
|
|
}
|
|
|
|
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 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(lineRange, withText: newLine)
|
|
}
|
|
|
|
// MARK: - Find (manual NSString search — no Runestone-specific API)
|
|
|
|
private func findNext() {
|
|
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return }
|
|
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 }
|
|
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
|
|
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()
|
|
.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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Toolbar Sub-components
|
|
|
|
struct KeyToolbarButton: View {
|
|
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)
|
|
}
|
|
}
|
|
|
|
struct KeyToolbarText: View {
|
|
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())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.primary)
|
|
}
|
|
}
|
|
|
|
struct KeyToolbarDivider: View {
|
|
var body: some View {
|
|
Divider()
|
|
.frame(height: 20)
|
|
.padding(.horizontal, 2)
|
|
}
|
|
}
|