Files
Termind/TFI/Features/MobileTerminalKeyboard.swift
T
2026-09-11 21:07:44 +08:00

156 lines
5.9 KiB
Swift

import GhosttyTerminal
import SwiftUI
struct MobileTerminalKeyboard: View {
static let barHeight: CGFloat = 44
@ObservedObject var state: TerminalViewState
@State private var control: TerminalPublicStickyActivation = .inactive
@State private var alt: TerminalPublicStickyActivation = .inactive
@State private var shift = MobileTerminalShiftState()
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)
}
.padding(.leading, 8)
}
.frame(maxWidth: .infinity)
Divider().padding(.vertical, 8)
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)
}
.tint(shift.activation == .inactive ? .secondary : .accentColor)
.accessibilityValue(accessibilityValue(for: shift.activation))
}
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)
}
.tint(activation == .inactive ? .secondary : .accentColor)
.accessibilityValue(accessibilityValue(for: activation))
}
private func modifierLabel(
_ title: LocalizedStringKey, systemImage: String, activation: TerminalPublicStickyActivation
) -> some View {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.frame(width: 44, height: 44)
.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)
}
}
}
nonisolated struct MobileTerminalShiftState: Equatable {
private(set) var activation: TerminalPublicStickyActivation = .inactive
private var lastTap = Date.distantPast
private let doubleTapInterval: TimeInterval = 0.3
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
}
}