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) } }