pi-remote-ios/Sources/UI/Input/ModifierBar.swift

287 lines
11 KiB
Swift

// 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
.accessibilityLabel(title)
.accessibilityIdentifier("modbar.\(title)")
}
}
// 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
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<Void, Never>?
init(title: String, action: @escaping () -> Void) {
self.title = title
self.action = action
}
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()
}
}
)
// Make it discoverable for XCUITest (Text + gesture isn't a button)
.accessibilityElement()
.accessibilityLabel(title)
.accessibilityIdentifier("modbar.\(title)")
.accessibilityAddTraits(.isButton)
}
// 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
}
}