123 lines
5.9 KiB
Swift
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) }
|
|
}
|