PadXcode-iPad/Editor/EditorKeyboardToolbar.swift

290 lines
9.9 KiB
Swift
Raw Permalink Normal View History

2026-04-12 00:46:30 -07:00
import SwiftUI
2026-04-12 22:07:35 -07:00
import UIKit
2026-04-12 00:46:30 -07:00
import Runestone
2026-04-12 22:07:35 -07:00
// MARK: - Keyboard Bridge
2026-04-12 00:46:30 -07:00
final class EditorKeyboardBridge: ObservableObject {
weak var activeTextView: Runestone.TextView?
2026-04-12 22:07:35 -07:00
// Called by CodeEditorView when G / G hardware shortcut fires
var onFindNext: (() -> Void)?
var onFindPrev: (() -> Void)?
func triggerFindNext() { onFindNext?() }
func triggerFindPrev() { onFindPrev?() }
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
// MARK: - Toolbar
2026-04-12 00:46:30 -07:00
struct EditorKeyboardToolbar: View {
@ObservedObject var bridge: EditorKeyboardBridge
@State private var showFindBar = false
@State private var searchQuery = ""
2026-04-12 22:07:35 -07:00
@State private var lastMatchRange: NSRange = NSRange(location: NSNotFound, length: 0)
2026-04-12 00:46:30 -07:00
var body: some View {
VStack(spacing: 0) {
if showFindBar {
2026-04-12 22:07:35 -07:00
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))
2026-04-12 00:46:30 -07:00
Divider()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
2026-04-12 22:07:35 -07:00
KeyToolbarButton(icon: "increase.indent", label: "Indent") { indent(increase: true) }
KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { indent(increase: false) }
2026-04-12 00:46:30 -07:00
KeyToolbarDivider()
2026-04-12 22:07:35 -07:00
KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() }
2026-04-12 00:46:30 -07:00
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()
2026-04-12 22:07:35 -07:00
KeyToolbarButton(icon: "magnifyingglass", label: "Find") {
withAnimation { showFindBar.toggle() }
}
2026-04-12 00:46:30 -07:00
Spacer()
KeyToolbarButton(icon: "keyboard.chevron.compact.down", label: "Done") {
bridge.activeTextView?.resignFirstResponder()
}
}
2026-04-12 22:07:35 -07:00
.padding(.horizontal, 8)
.padding(.vertical, 6)
2026-04-12 00:46:30 -07:00
}
.background(.regularMaterial)
}
2026-04-12 22:07:35 -07:00
.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)
2026-04-12 00:46:30 -07:00
}
private func moveCursor(_ offset: Int) {
2026-04-12 22:07:35 -07:00
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)
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
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)
}
2026-04-12 00:46:30 -07:00
private func toggleComment() {
2026-04-12 22:07:35 -07:00
guard let tv = bridge.activeTextView else { return }
let sel = tv.selectedRange
2026-04-12 00:46:30 -07:00
let text = tv.text as NSString
2026-04-12 22:07:35 -07:00
let lineRange = text.lineRange(for: sel)
let line = text.substring(with: lineRange)
2026-04-12 00:46:30 -07:00
let trimmed = line.trimmingCharacters(in: .whitespaces)
let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
let newLine: String
2026-04-12 22:07:35 -07:00
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)
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
// MARK: - Find (manual NSString search no Runestone-specific API)
2026-04-12 00:46:30 -07:00
private func findNext() {
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return }
2026-04-12 22:07:35 -07:00
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)
}
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
2026-04-12 00:46:30 -07:00
private func findPrev() {
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return }
2026-04-12 22:07:35 -07:00
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)
}
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
// MARK: - Inline Find Bar
2026-04-12 00:46:30 -07:00
struct InlineFindBar: View {
2026-04-12 22:07:35 -07:00
@Binding var query: String
var onNext: () -> Void
var onPrev: () -> Void
var onDismiss: () -> Void
2026-04-12 00:46:30 -07:00
var body: some View {
HStack(spacing: 8) {
2026-04-12 22:07:35 -07:00
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)
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
.padding(.horizontal, 12)
.padding(.vertical, 6)
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
// MARK: - Toolbar Sub-components
2026-04-12 00:46:30 -07:00
struct KeyToolbarButton: View {
2026-04-12 22:07:35 -07:00
let icon: String
let label: String
let action: () -> Void
2026-04-12 00:46:30 -07:00
var body: some View {
2026-04-12 22:07:35 -07:00
Button(action: action) {
Image(systemName: icon)
.frame(minWidth: 32, minHeight: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.help(label)
2026-04-12 00:46:30 -07:00
}
}
struct KeyToolbarText: View {
2026-04-12 22:07:35 -07:00
let text: String
let action: () -> Void
init(_ text: String, action: @escaping () -> Void) {
self.text = text
self.action = action
}
2026-04-12 00:46:30 -07:00
var body: some View {
Button(action: action) {
2026-04-12 22:07:35 -07:00
Text(text)
.font(.system(.callout, design: .monospaced).bold())
.frame(minWidth: 32, minHeight: 32)
.contentShape(Rectangle())
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
.buttonStyle(.plain)
.foregroundStyle(.primary)
2026-04-12 00:46:30 -07:00
}
}
struct KeyToolbarDivider: View {
2026-04-12 22:07:35 -07:00
var body: some View {
Divider()
.frame(height: 20)
.padding(.horizontal, 2)
}
2026-04-12 00:46:30 -07:00
}