diff --git a/Sources/UI/Input/ModifierBar.swift b/Sources/UI/Input/ModifierBar.swift new file mode 100644 index 0000000..7c0dfe8 --- /dev/null +++ b/Sources/UI/Input/ModifierBar.swift @@ -0,0 +1,274 @@ +// 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 + } +} diff --git a/Sources/UI/Input/ModifierState.swift b/Sources/UI/Input/ModifierState.swift new file mode 100644 index 0000000..6c1c88b --- /dev/null +++ b/Sources/UI/Input/ModifierState.swift @@ -0,0 +1,51 @@ +// ModifierState.swift +// Observable sticky-modifier state for the ModifierBar. +// +// Design: Ctrl is "sticky" — tap once to arm, the next key sent +// includes the Ctrl modifier, then the state automatically disarms +// via `reset()`. `isRepeating` reflects whether an arrow key is +// currently being held down in repeat mode. + +import Foundation + +// MARK: - ModifierState + +/// Observable state for sticky keyboard modifiers used by `ModifierBar`. +/// +/// All mutations must happen on the main actor; consumers should observe +/// via `@ObservedObject` or `@StateObject`. +@MainActor +final class ModifierState: ObservableObject { + + // MARK: Published + + /// Whether the Ctrl modifier is armed. + /// + /// When `true`, the next key dispatched by `ModifierBar` is sent as a + /// Ctrl combo (e.g. `ctrl-c`). The modifier disarms automatically after + /// the key is sent. + @Published var ctrlActive: Bool = false + + /// Whether an arrow key is currently being held down in repeat mode. + /// + /// Set to `true` by `ModifierBar` when a long-press repeat cycle begins, + /// and back to `false` when the touch is released. Consumers may observe + /// this to suppress other UI interactions while repeating. + @Published var isRepeating: Bool = false + + // MARK: Mutations + + /// Toggles the Ctrl sticky modifier on / off. + func toggleCtrl() { + ctrlActive.toggle() + } + + /// Disarms all modifiers (Ctrl and repeat state). + /// + /// Call this after any key has been dispatched to return to a neutral + /// modifier state. + func reset() { + ctrlActive = false + isRepeating = false + } +} diff --git a/Sources/UI/Input/PasteSheet.swift b/Sources/UI/Input/PasteSheet.swift new file mode 100644 index 0000000..68081c9 --- /dev/null +++ b/Sources/UI/Input/PasteSheet.swift @@ -0,0 +1,133 @@ +// PasteSheet.swift +// Confirm-before-paste sheet that previews clipboard content and lets +// the user approve or cancel before sending a bracketed-paste frame. +// +// Privacy note: `UIPasteboard.general.string` is accessed lazily when the +// sheet appears. iOS 16+ shows a system banner ("App pasted from …") but +// does not require an explicit entitlement for this access pattern. + +import SwiftUI +import UIKit + +// MARK: - PasteSheet + +/// A modal sheet that displays the current clipboard text and asks the user +/// to confirm before sending it to the terminal as a `paste` frame. +/// +/// Dismiss flow: +/// - **Paste** → encodes content as `{ type:"paste", data:"…" }` and calls +/// `onSend`, then sets `isPresented` to `false`. +/// - **Cancel** → sets `isPresented` to `false` with no send. +@MainActor +struct PasteSheet: View { + + // MARK: Bindings / callbacks + + @Binding var isPresented: Bool + let onSend: (ClientToServer) -> Void + + // MARK: Private state + + /// The clipboard string captured when the view appears. + @State private var clipboardContent: String? = nil + + // MARK: Body + + var body: some View { + NavigationStack { + Group { + if let content = clipboardContent { + if content.isEmpty { + emptyClipboardView + } else { + previewView(content: content) + } + } else { + emptyClipboardView + } + } + .navigationTitle("Paste") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isPresented = false + } + } + if let content = clipboardContent, !content.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button("Paste") { + onSend(.paste(data: content)) + isPresented = false + } + .fontWeight(.semibold) + } + } + } + } + .onAppear { + // Capture clipboard when the sheet is presented. + // Accessing on the main actor satisfies UIKit's thread requirement. + clipboardContent = UIPasteboard.general.string + } + } + + // MARK: Sub-views + + /// Shown when the clipboard is nil or empty. + private var emptyClipboardView: some View { + VStack(spacing: 12) { + Image(systemName: "clipboard") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Clipboard is empty") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + /// Scrollable preview of the clipboard text with a bottom Paste button. + private func previewView(content: String) -> some View { + VStack(spacing: 0) { + // Scrollable text preview + ScrollView([.vertical, .horizontal]) { + Text(content) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .textSelection(.enabled) + } + .background(Color(uiColor: .systemGroupedBackground)) + + Divider() + + // Character count footer + HStack { + Label( + "\(content.count) character\(content.count == 1 ? "" : "s")", + systemImage: "text.cursor" + ) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + + // Primary action button + Button { + onSend(.paste(data: content)) + isPresented = false + } label: { + Label("Paste into Terminal", systemImage: "doc.on.clipboard") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + .padding(.bottom, 16) + } + } +}