Files
Termind/TFI/Features/Terminal/MobileTerminalKeyboard.swift
T
2026-09-12 22:39:45 +08:00

230 lines
8.1 KiB
Swift

import GhosttyTerminal
import SwiftUI
struct MobileTerminalKeyboard: View {
static let barHeight: CGFloat = 44
@ObservedObject var state: TerminalViewState
let paste: () -> Void
@State private var control: TerminalPublicStickyActivation = .inactive
@State private var alt: TerminalPublicStickyActivation = .inactive
@State private var shift = MobileTerminalShiftState()
init(
state: TerminalViewState, initialShift: MobileTerminalShiftState = MobileTerminalShiftState(),
paste: @escaping () -> Void
) {
self.state = state
self.paste = paste
_shift = State(initialValue: initialShift)
}
var body: some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
key("Escape", systemImage: "escape", .escape)
key("Tab", systemImage: "arrow.right.to.line", .tab)
modifier("Control", systemImage: "control", .ctrl, activation: control)
modifier("Option", systemImage: "option", .alt, activation: alt)
shiftModifier
key("Left Arrow", systemImage: "arrowtriangle.left.fill", .arrowLeft)
key("Down Arrow", systemImage: "arrowtriangle.down.fill", .arrowDown)
key("Up Arrow", systemImage: "arrowtriangle.up.fill", .arrowUp)
key("Right Arrow", systemImage: "arrowtriangle.right.fill", .arrowRight)
key("Home", systemImage: "arrow.up.to.line.compact", .home)
key("End", systemImage: "arrow.down.to.line.compact", .end)
key("Page Up", systemImage: "arrowtriangle.up.square", .pageUp)
key("Page Down", systemImage: "arrowtriangle.down.square", .pageDown)
ForEach(MobileTerminalControlShortcut.allCases) { shortcut in
controlShortcut(shortcut)
}
}
.padding(.leading, 8)
}
.frame(maxWidth: .infinity)
Divider().padding(.vertical, 8)
HStack(spacing: 0) {
pasteButton
keyboardToggle
}
.padding(.horizontal, 8)
}
.buttonStyle(.plain)
.font(.system(.callout, design: .monospaced))
.frame(height: Self.barHeight)
.background(.bar)
.onChange(of: state.isFocused, initial: true) { _, _ in
state.attachedPlatformView?.setStickyModifierChangeHandler {
refreshModifiers()
}
refreshModifiers()
}
.onDisappear { state.attachedPlatformView?.setStickyModifierChangeHandler(nil) }
}
private var shiftModifier: some View {
Button {
shift.toggle()
} label: {
modifierLabel("Shift", systemImage: "shift", activation: shift.activation)
}
.accessibilityValue(accessibilityValue(for: shift.activation))
.accessibilityAddTraits(shift.activation == .inactive ? [] : .isSelected)
}
private var pasteButton: some View {
Button(action: paste) {
Label("Paste", systemImage: "doc.on.clipboard")
.labelStyle(.iconOnly)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
}
private var keyboardToggle: some View {
Button {
if state.isFocused {
state.attachedPlatformView?.resignFirstResponder()
} else {
state.requestFocus()
}
} label: {
Label(
state.isFocused ? "Hide Keyboard" : "Show Keyboard",
systemImage: state.isFocused ? "keyboard.chevron.compact.down" : "keyboard"
)
.labelStyle(.iconOnly)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
}
private func refreshModifiers() {
control = state.attachedPlatformView?.stickyActivation(for: .ctrl) ?? .inactive
alt = state.attachedPlatformView?.stickyActivation(for: .alt) ?? .inactive
}
private func modifier(
_ title: LocalizedStringKey, systemImage: String, _ modifier: TerminalPublicStickyModifier,
activation: TerminalPublicStickyActivation
) -> some View {
Button {
state.attachedPlatformView?.toggleStickyModifier(modifier)
} label: {
modifierLabel(title, systemImage: systemImage, activation: activation)
}
.accessibilityValue(accessibilityValue(for: activation))
.accessibilityAddTraits(activation == .inactive ? [] : .isSelected)
}
private func modifierLabel(
_ title: LocalizedStringKey, systemImage: String, activation: TerminalPublicStickyActivation
) -> some View {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.foregroundStyle(
activation == .inactive
? AnyShapeStyle(.secondary)
: AnyShapeStyle(Color.accentColor)
)
.frame(width: 44, height: 44)
.background(
activation == .inactive ? Color.clear : Color.accentColor.opacity(0.18),
in: .rect(cornerRadius: 8)
)
.contentShape(.rect)
.overlay(alignment: .topTrailing) {
if activation == .locked {
Circle().fill(Color.accentColor).frame(width: 6, height: 6).accessibilityHidden(true)
}
}
}
private func accessibilityValue(for activation: TerminalPublicStickyActivation) -> Text {
switch activation {
case .inactive: Text("Inactive")
case .armed: Text("Armed")
case .locked: Text("Locked")
}
}
private func key(_ title: LocalizedStringKey, systemImage: String, _ key: TerminalKey) -> some View {
Button {
var shiftState = shift
let modifiers = shiftState.consumeForNextKey()
shift = shiftState
_ = state.attachedPlatformView?.sendKey(TerminalKeyPress(key, modifiers: modifiers))
} label: {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
}
private func controlShortcut(_ shortcut: MobileTerminalControlShortcut) -> some View {
Button {
_ = state.attachedPlatformView?.sendKey(shortcut.keyPress)
} label: {
Text(verbatim: shortcut.label)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.accessibilityLabel(Text("Control") + Text(verbatim: " \(shortcut.letter)"))
}
}
nonisolated enum MobileTerminalControlShortcut: String, CaseIterable, Identifiable {
case c = "C"
case i = "I"
case s = "S"
case z = "Z"
var id: Self { self }
var letter: String { rawValue }
var label: String { "^\(letter)" }
var keyPress: TerminalKeyPress {
TerminalKeyPress(key, modifiers: .ctrl)
}
private var key: TerminalKey {
switch self {
case .c: .c
case .i: .i
case .s: .s
case .z: .z
}
}
}
nonisolated struct MobileTerminalShiftState: Equatable {
private(set) var activation: TerminalPublicStickyActivation = .inactive
private var lastTap = Date.distantPast
private let doubleTapInterval: TimeInterval = 0.3
init(activation: TerminalPublicStickyActivation = .inactive) {
self.activation = activation
}
mutating func toggle(at date: Date = .now) {
switch activation {
case .inactive:
activation = .armed
case .armed:
activation = date.timeIntervalSince(lastTap) < doubleTapInterval ? .locked : .inactive
case .locked:
activation = .inactive
}
lastTap = date
}
mutating func consumeForNextKey() -> TerminalInputModifiers {
guard activation != .inactive else { return [] }
if activation == .armed { activation = .inactive }
return .shift
}
}