// ModifierBar.swift // Horizontal accessory bar providing sticky Ctrl, common special keys, // arrow keys with long-press repeat, and a clipboard paste action. // // The bar communicates exclusively through the `onSend` closure — it never // imports or references `WebSocketClient` directly, keeping it fully // testable in isolation. import SwiftUI // MARK: - Ctrl key name map /// Named tmux/terminal keys for Ctrl+letter combos. /// /// Keys present here are sent as `{ type:"key", name:"ctrl-x" }`. /// Characters absent from this map fall back to the raw control-byte /// sequence sent via `{ type:"keys", data:"\x01" }`. private let ctrlKeyMap: [Character: String] = [ "a": "ctrl-a", "b": "ctrl-b", "c": "ctrl-c", "d": "ctrl-d", "e": "ctrl-e", "f": "ctrl-f", "g": "ctrl-g", "h": "ctrl-h", "i": "ctrl-i", "j": "ctrl-j", "k": "ctrl-k", "l": "ctrl-l", "m": "ctrl-m", "n": "ctrl-n", "o": "ctrl-o", "p": "ctrl-p", "q": "ctrl-q", "r": "ctrl-r", "s": "ctrl-s", "t": "ctrl-t", "u": "ctrl-u", "v": "ctrl-v", "w": "ctrl-w", "x": "ctrl-x", "y": "ctrl-y", "z": "ctrl-z", ] // MARK: - ModifierBar /// Horizontal accessory bar: `[Ctrl][Esc][Tab][←][↑][↓][→][⇧↵][📋]` /// /// - **Ctrl**: sticky modifier. When armed (highlighted), the next special key /// is sent prefixed with `ctrl-` (e.g. `ctrl-escape`). After dispatch the /// modifier disarms automatically. /// - **Esc / Tab / ⇧↵**: send the corresponding named key via `onSend`. /// - **← ↑ ↓ →**: single tap sends the key; long-press (≥ 400 ms) repeats /// every 100 ms while the button is held. /// - **📋**: presents `PasteSheet` for a confirm-before-paste flow. @MainActor struct ModifierBar: View { // MARK: State @StateObject private var modifierState = ModifierState() // MARK: Public interface /// Called when a frame should be sent to the server. let onSend: (ClientToServer) -> Void // MARK: Private state @State private var showPasteSheet = false // MARK: Body var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { // ── Ctrl ───────────────────────────────────────────── BarButton( title: "Ctrl", isActive: modifierState.ctrlActive ) { modifierState.toggleCtrl() } Divider() .frame(height: 20) // ── Static special keys ─────────────────────────────── BarButton(title: "Esc") { sendKey("escape") } BarButton(title: "Tab") { sendKey("tab") } Divider() .frame(height: 20) // ── Arrow keys (long-press repeat) ──────────────────── RepeatingBarButton(title: "←") { sendKey("left") } .onRepeatStateChanged { repeating in modifierState.isRepeating = repeating } RepeatingBarButton(title: "↑") { sendKey("up") } .onRepeatStateChanged { repeating in modifierState.isRepeating = repeating } RepeatingBarButton(title: "↓") { sendKey("down") } .onRepeatStateChanged { repeating in modifierState.isRepeating = repeating } RepeatingBarButton(title: "→") { sendKey("right") } .onRepeatStateChanged { repeating in modifierState.isRepeating = repeating } Divider() .frame(height: 20) // ── Shift+Enter ─────────────────────────────────────── BarButton(title: "⇧↵") { sendKey("shift-enter") } // ── Paste ───────────────────────────────────────────── BarButton(title: "📋") { showPasteSheet = true } .sheet(isPresented: $showPasteSheet) { PasteSheet(isPresented: $showPasteSheet, onSend: onSend) } } .padding(.horizontal, 8) .padding(.vertical, 4) } } // MARK: Key dispatch /// Builds and dispatches a key frame, applying the sticky Ctrl modifier /// when armed, then resets all modifiers. /// /// For named special keys the modifier is prepended as a prefix /// (e.g. `ctrl-escape`, `ctrl-up`). For single-character inputs the /// `ctrlKeyMap` is consulted first; unknown characters fall back to the /// raw control-byte sequence via `.keys`. private func sendKey(_ name: String) { if modifierState.ctrlActive { // Special-key path: prefix with "ctrl-" let ctrlName = "ctrl-\(name)" onSend(.key(name: ctrlName)) } else { onSend(.key(name: name)) } modifierState.reset() } // MARK: - Public helper (for external keyboard integration) /// Resolves a raw character typed on a hardware/software keyboard into a /// `ClientToServer` frame, applying the Ctrl modifier when armed. /// /// Callers may use this when integrating `ModifierBar` with a `TextField` /// or `UITextView` delegate to honour the sticky Ctrl state. /// /// - Returns: The frame to send, or `nil` if `ctrlActive` is `false` and /// the caller should handle the character normally. func frameForCharacter(_ char: Character) -> ClientToServer? { guard modifierState.ctrlActive else { return nil } defer { modifierState.reset() } let lower = Character(char.lowercased()) if let keyName = ctrlKeyMap[lower] { return .key(name: keyName) } // Fall back: compute raw control-byte (e.g. Ctrl+A = 0x01) if let scalar = lower.asciiValue, scalar >= 0x61, scalar <= 0x7A { let controlByte = scalar - 0x60 // 'a'=0x61 → 0x01 let raw = String(UnicodeScalar(controlByte)) return .keys(data: raw) } return nil } } // MARK: - BarButton /// A uniformly styled pill button used in `ModifierBar`. @MainActor private struct BarButton: View { let title: String var isActive: Bool = false let action: () -> Void var body: some View { Button(action: action) { Text(title) .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundStyle(isActive ? Color.white : Color.primary) .padding(.horizontal, 10) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(isActive ? Color.accentColor : Color(uiColor: .systemGray5)) ) } .buttonStyle(.plain) // prevent SwiftUI from wrapping in extra chrome } } // MARK: - RepeatingBarButton /// A bar button that fires its `action` immediately on touch-down, then /// repeats every 100 ms after an initial 400 ms delay while the finger /// remains pressed. @MainActor private struct RepeatingBarButton: View { let title: String let action: () -> Void /// Optional callback invoked when the repeat cycle starts (`true`) or /// stops (`false`). Use this to update `ModifierState.isRepeating`. private var repeatStateHandler: ((Bool) -> Void)? @State private var repeatTask: Task? var body: some View { Text(title) .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundStyle(Color.primary) .padding(.horizontal, 10) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(uiColor: .systemGray5)) ) // `onPressingChanged` fires `true` on touch-down and `false` on // touch-up. `minimumDuration: 10` makes the `perform` closure // unreachable in practice, so all logic lives in the callback. .onLongPressGesture( minimumDuration: 10, maximumDistance: 50, perform: { /* unreachable — intentional */ }, onPressingChanged: { isPressing in if isPressing { action() // immediate first fire on touch-down startRepeating() } else { stopRepeating() } } ) } // MARK: Helpers private func startRepeating() { repeatTask?.cancel() repeatTask = Task { @MainActor in do { // Initial pause before the repeat cadence begins. try await Task.sleep(for: .milliseconds(400)) repeatStateHandler?(true) while !Task.isCancelled { action() try await Task.sleep(for: .milliseconds(100)) } } catch { // Task was cancelled — fall through to cleanup below. } repeatStateHandler?(false) } } private func stopRepeating() { repeatTask?.cancel() repeatTask = nil // The handler is called with `false` inside the task's catch/finally // path, but call it here too in case the task hadn't started yet. repeatStateHandler?(false) } // MARK: Modifier-style API /// Attaches a callback that is invoked when repeat mode starts/stops. func onRepeatStateChanged(_ handler: @escaping (Bool) -> Void) -> RepeatingBarButton { var copy = self copy.repeatStateHandler = handler return copy } }