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 } }