feat(T-2.4): ModifierBar, ModifierState, PasteSheet
This commit is contained in:
parent
6b953008ce
commit
dc4f08d8ee
|
|
@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue