PadXcode-iPad/Editor/EditorKeyboardToolbar.swift
2026-04-12 00:46:30 -07:00

123 lines
5.9 KiB
Swift

import SwiftUI
import Runestone
final class EditorKeyboardBridge: ObservableObject {
weak var activeTextView: Runestone.TextView?
}
struct EditorKeyboardToolbar: View {
@ObservedObject var bridge: EditorKeyboardBridge
@State private var showFindBar = false
@State private var searchQuery = ""
@State private var isRegex = false
var body: some View {
VStack(spacing: 0) {
if showFindBar {
InlineFindBar(query: $searchQuery, isRegex: $isRegex,
onNext: findNext, onPrev: findPrev,
onDismiss: { withAnimation { showFindBar = false } })
.transition(.move(edge: .top).combined(with: .opacity))
Divider()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
KeyToolbarButton(icon: "increase.indent", label: "Indent") { bridge.activeTextView?.shiftRight() }
KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { bridge.activeTextView?.shiftLeft() }
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)
}
}
private func insert(_ t: String) { bridge.activeTextView?.insertText(t) }
private func insertPair(_ l: String, _ r: String) { bridge.activeTextView?.insertText(l+r); moveCursor(-1) }
private func moveCursor(_ offset: Int) {
guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return }
tv.selectedRange = NSRange(location: max(0, sel.location + offset), length: 0)
}
private func toggleComment() {
guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return }
let text = tv.text as NSString
let lr = text.lineRange(for: sel)
let line = text.substring(with: lr)
let trimmed = line.trimmingCharacters(in: .whitespaces)
let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
let newLine: String
if trimmed.hasPrefix("// ") { newLine = line.replacingFirstOccurrence(of: "// ", with: "") }
else if trimmed.hasPrefix("//") { newLine = line.replacingFirstOccurrence(of: "//", with: "") }
else { newLine = indent + "// " + line.dropFirst(indent.count) }
tv.replace(lr, withText: newLine)
}
private func findNext() {
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return }
tv.selectNextOccurrence(matching: SearchOptions(query: searchQuery))
}
private func findPrev() {
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return }
tv.selectPreviousOccurrence(matching: SearchOptions(query: searchQuery))
}
}
struct InlineFindBar: View {
@Binding var query: String; @Binding var isRegex: Bool
var onNext: () -> Void; var onPrev: () -> Void; var onDismiss: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Find…", text: $query).textFieldStyle(.plain)
.font(.system(.body, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none)
.onSubmit(onNext)
Toggle(isOn: $isRegex) { Image(systemName: "ellipsis.curlybraces") }
.toggleStyle(.button).buttonStyle(.bordered).tint(isRegex ? .accentColor : .secondary)
Button(action: onPrev) { Image(systemName: "chevron.up") }.buttonStyle(.bordered).disabled(query.isEmpty)
Button(action: onNext) { Image(systemName: "chevron.down") }.buttonStyle(.bordered).disabled(query.isEmpty)
Button(action: onDismiss) { Image(systemName: "xmark") }.buttonStyle(.bordered)
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
}
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) }
}