From c25e06360a10b22376c33c41f43260450d320ceb Mon Sep 17 00:00:00 2001 From: Jhen-Jie Hong Date: Thu, 27 Aug 2026 14:40:53 +0800 Subject: [PATCH] Arrange Keys: drag-to-reorder the key rail and visionOS cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⋯ menu → Arrange Keys…: the keys wiggle and each is a system drag source; the move is the tab strip's UIDragInteraction/UIDropInteraction recipe, shared by the iPad rail and the visionOS cluster through one KeyBarDropCoordinator (nearest-key target, tab-cell highlight, displaced- neighbour landing). TerminalChromeDragPayload/TerminalChromeDragItem are the process-local item recipe tabs and keys now share; the pane's file gate refuses every chrome drag with one check. - The order is a slot permutation (KeyBarOrder, KeyBarOrderStore, device-local); rails reconcile it in layout, gaps and widths stay put. visionOS: a drop on the other slab crosses the UMD; the ARRANGE KEYS bar (RESET · DONE) is its own slab below the console row, one per tab. - visionOS: GUIDE moves from the console row into the ⋯ menu. - MultiplexUITests + `build.sh uitest` (XCUITest press-and-drag), unit tests with fake drop sessions on both platforms, docs and TestFlight notes. --- .claude/skills/build-and-verify/SKILL.md | 7 + .swiftlint.yml | 1 + Multiplex/Localizable.xcstrings | 112 ++ Multiplex/Models/KeyBarOrder.swift | 122 ++ Multiplex/Services/KeyBarOrderStore.swift | 37 + .../Services/TerminalSessionController.swift | 15 + Multiplex/Views/Terminal/TerminalKeyBar.swift | 1414 ++++++++++++++--- .../Views/Terminal/TerminalPaneUIKit.swift | 104 +- .../Views/Terminal/TerminalTabStrip.swift | 133 +- .../Terminal/TerminalVisionOrnaments.swift | 125 +- .../Views/Terminal/TerminalWindowUIKit.swift | 19 + Multiplex/Views/Terminal/UMDBarUIKit.swift | 51 +- MultiplexTests/KeyBarOrderTests.swift | 175 ++ MultiplexTests/TerminalKeyBarUIKitTests.swift | 592 ++++++- MultiplexTests/TerminalWindowUIKitTests.swift | 17 + MultiplexTests/UMDBarUIKitTests.swift | 89 +- MultiplexUITests/ArrangeKeysUITests.swift | 106 ++ README.md | 2 +- Tools/build.sh | 19 + docs/agents/e2e-headless.md | 21 + docs/agents/input-and-windows.md | 28 + fastlane/testflight-whats-new.txt | 4 + project.yml | 22 + 23 files changed, 2826 insertions(+), 389 deletions(-) create mode 100644 Multiplex/Models/KeyBarOrder.swift create mode 100644 Multiplex/Services/KeyBarOrderStore.swift create mode 100644 MultiplexTests/KeyBarOrderTests.swift create mode 100644 MultiplexUITests/ArrangeKeysUITests.swift diff --git a/.claude/skills/build-and-verify/SKILL.md b/.claude/skills/build-and-verify/SKILL.md index 9ec656ac..853104d6 100644 --- a/.claude/skills/build-and-verify/SKILL.md +++ b/.claude/skills/build-and-verify/SKILL.md @@ -34,9 +34,16 @@ Run everything through `Tools/build.sh` from the repo root: ./Tools/build.sh build [vos|ipad] # build one platform (default: vos) ./Tools/build.sh test [vos|ipad] # run MultiplexTests unit tests ./Tools/build.sh verify [vos|ipad] # build + install + drive end-to-end +./Tools/build.sh uitest [vos|ipad] # XCUITest real-touch runs (gesture recognizers) ./Tools/build.sh all # gen + build both platforms + test ``` +`uitest` is the one headless route that drives a **gesture recognizer** with +real touches on an Xcode 27 simulator (`simctl` has no tap/drag, idb is dead): +it starts the harness and runs `MultiplexUITests` (today: the Arrange Keys +press-and-drag). Use it when a change touches how a gesture is recognized — +the DEBUG hooks prove state, not the touch. + `vos` = visionOS simulator (Apple Vision Pro), `ipad` = iPad Pro 13-inch (M5). visionOS is the primary target — default to it unless the task is iPad-specific. diff --git a/.swiftlint.yml b/.swiftlint.yml index 6a346e79..553872f3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -10,6 +10,7 @@ included: - Multiplex - MultiplexTests + - MultiplexUITests - MultiplexWidgets - Tools diff --git a/Multiplex/Localizable.xcstrings b/Multiplex/Localizable.xcstrings index 61390278..82c23071 100644 --- a/Multiplex/Localizable.xcstrings +++ b/Multiplex/Localizable.xcstrings @@ -2328,6 +2328,22 @@ } } }, + "Arrange Keys…" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "キーを並べ替え…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "排列按鍵…" + } + } + } + }, "Arrow down" : { "localizations" : { "ja" : { @@ -5506,6 +5522,38 @@ } } }, + "Done Arranging Keys" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "キーの並べ替えを完了" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "完成排列按鍵" + } + } + } + }, + "Done arranging keys" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "キーの並べ替えを完了" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "完成排列按鍵" + } + } + } + }, "Done selecting" : { "localizations" : { "ja" : { @@ -5554,6 +5602,22 @@ } } }, + "Drag to move this key" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ドラッグしてこのキーを移動" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "拖曳以移動此按鍵" + } + } + } + }, "Drag to reorder within this window" : { "localizations" : { "ja" : { @@ -8382,6 +8446,38 @@ } } }, + "Move left" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "左へ移動" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "向左移動" + } + } + } + }, + "Move right" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "右へ移動" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "向右移動" + } + } + } + }, "Move script down" : { "localizations" : { "ja" : { @@ -11798,6 +11894,22 @@ } } }, + "Restore the standard key order" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "標準のキー順序に戻す" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "恢復標準按鍵順序" + } + } + } + }, "Restoring Purchases…" : { "localizations" : { "ja" : { diff --git a/Multiplex/Models/KeyBarOrder.swift b/Multiplex/Models/KeyBarOrder.swift new file mode 100644 index 00000000..a8c25121 --- /dev/null +++ b/Multiplex/Models/KeyBarOrder.swift @@ -0,0 +1,122 @@ +import CoreGraphics +import Foundation + +/// The iPad/iPhone key rail's slots — one per key face the rail can carry, +/// listed in the shipped order. A slot names a POSITION, never its occupant: +/// `keyboard` is the keyboard-toggle / dictation-mic slot (whichever the +/// hardware picks), `shortcuts` the TMUX / HRDR slot — so an order survives a +/// hardware keyboard coming and going and a backend switch unchanged. The raw +/// values are the persisted tokens; never rename one. +enum KeyBarSlot: String, CaseIterable, Hashable { + case escape + case control + case tab + case tilde + case pipe + case slash + case hyphen + case pageUp + case pageDown + case left + case up + case down + case right + case returnKey = "return" + case talkback + case keyboard + case shortcuts +} + +/// The user's order over every slot — always a full permutation of +/// `KeyBarSlot.allCases`, whatever was stored (unknown tokens drop, +/// duplicates drop, missing slots append in shipped order). +/// +/// A tier renders the subsequence it carries (`arrange`); the gaps stay +/// where the tier puts them (three keys · the symbols · the rest), so the +/// order permutes keys across a tier's slots and never changes the row's +/// rhythm or its width ladder. +struct KeyBarOrder: Equatable { + private(set) var slots: [KeyBarSlot] + + /// The shipped order: `KeyBarSlot.allCases` as declared. + static let standard = KeyBarOrder(slots: KeyBarSlot.allCases) + + init(slots: [KeyBarSlot]) { + self.slots = Self.normalized(slots) + } + + /// From persisted tokens; anything unrecognized is ignored. + init(tokens: [String]) { + self.init(slots: tokens.compactMap(KeyBarSlot.init(rawValue:))) + } + + var tokens: [String] { slots.map(\.rawValue) } + + var isStandard: Bool { self == .standard } + + /// Dedupe, then append every slot the list forgot, in shipped order. + static func normalized(_ slots: [KeyBarSlot]) -> [KeyBarSlot] { + var seen = Set() + var result = slots.filter { seen.insert($0).inserted } + result += KeyBarSlot.allCases.filter { !seen.contains($0) } + return result + } + + /// The keys a tier carries, in this order. + func arrange(_ present: [KeyBarSlot]) -> [KeyBarSlot] { + let present = Set(present) + return slots.filter { present.contains($0) } + } + + /// Moves one visible key to a new index among the keys the tier shows + /// (`visible`, in its current arrangement). Hidden slots — keys a + /// narrower tier dropped — keep their places: a rightward move lands the + /// key just after the visible neighbour it passed last, a leftward move + /// just before it, so only the dragged key moves in the full order and + /// every wider tier shows it beside the key it was dropped against. + func moving( + _ slot: KeyBarSlot, + toVisibleIndex target: Int, + among visible: [KeyBarSlot] + ) -> KeyBarOrder { + guard let from = visible.firstIndex(of: slot), visible.count > 1 else { return self } + let to = min(max(0, target), visible.count - 1) + guard to != from else { return self } + var remaining = visible + remaining.remove(at: from) + var rest = slots.filter { $0 != slot } + let anchor: Int + if to > from { + let neighbour = remaining[to - 1] + anchor = (rest.firstIndex(of: neighbour) ?? rest.count - 1) + 1 + } else { + let neighbour = remaining[to] + anchor = rest.firstIndex(of: neighbour) ?? 0 + } + rest.insert(slot, at: anchor) + return KeyBarOrder(slots: rest) + } +} + +/// Which item a drop lands on, for any row of drop targets (the tab strip, +/// the iPad key rail, the visionOS slabs — frames in one coordinate space): +/// the nearest OTHER item by centre along the row, so a drop over a gap +/// lands beside the item it is nearer. Over the dragged item's own slot +/// (widened by `slack` a side) there is nothing to land on. +enum RowDropGeometry { + static func dropTargetIndex( + x: CGFloat, + restingFrames: [CGRect], + sourceIndex: Int, + slack: CGFloat = 3 + ) -> Int? { + guard restingFrames.count > 1 else { return nil } + if restingFrames.indices.contains(sourceIndex) { + let own = restingFrames[sourceIndex] + if x >= own.minX - slack, x <= own.maxX + slack { return nil } + } + return restingFrames.indices + .filter { $0 != sourceIndex } + .min { abs(x - restingFrames[$0].midX) < abs(x - restingFrames[$1].midX) } + } +} diff --git a/Multiplex/Services/KeyBarOrderStore.swift b/Multiplex/Services/KeyBarOrderStore.swift new file mode 100644 index 00000000..067b0367 --- /dev/null +++ b/Multiplex/Services/KeyBarOrderStore.swift @@ -0,0 +1,37 @@ +import Foundation +import Observation + +/// The iPad/iPhone key rail's remembered order, app-wide and device-local +/// (like the side-panel widths): a phone's arrangement is the phone's +/// business — its tiers show a different subset of keys than an iPad's — +/// so it never rides the synced Host record or the Keychain mirror. Nothing +/// stored means the shipped order, exactly the rail before arranging existed. +@MainActor +@Observable +final class KeyBarOrderStore { + static let shared = KeyBarOrderStore() + + nonisolated static let key = "MultiplexKeyBarOrder" + + private let defaults: UserDefaults + private(set) var order: KeyBarOrder + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + order = KeyBarOrder(tokens: defaults.stringArray(forKey: Self.key) ?? []) + } + + func setOrder(_ order: KeyBarOrder) { + guard order != self.order else { return } + self.order = order + if order.isStandard { + defaults.removeObject(forKey: Self.key) + } else { + defaults.set(order.tokens, forKey: Self.key) + } + } + + func reset() { + setOrder(.standard) + } +} diff --git a/Multiplex/Services/TerminalSessionController.swift b/Multiplex/Services/TerminalSessionController.swift index 6c9a1190..dba92c98 100644 --- a/Multiplex/Services/TerminalSessionController.swift +++ b/Multiplex/Services/TerminalSessionController.swift @@ -362,8 +362,23 @@ final class TerminalSessionController { TerminalFocusArbiter.lock(terminalView) } } + #endif + /// Arrange Keys — the key rail's / cluster's reorder mode, per tab like + /// the message box and never persisted (the ORDER is, in + /// `KeyBarOrderStore`). + private(set) var keyBarArranging = false + + func setKeyBarArranging(_ arranging: Bool) { + guard keyBarArranging != arranging else { return } + keyBarArranging = arranging + } + + func toggleKeyBarArranging() { + setKeyBarArranging(!keyBarArranging) + } + /// Scene became active again: re-assert focus only if this terminal is /// already the app-wide owner — every window's scene activates at once /// on foreground, and notification order must not elect a new owner. diff --git a/Multiplex/Views/Terminal/TerminalKeyBar.swift b/Multiplex/Views/Terminal/TerminalKeyBar.swift index 4fe26572..fece92db 100644 --- a/Multiplex/Views/Terminal/TerminalKeyBar.swift +++ b/Multiplex/Views/Terminal/TerminalKeyBar.swift @@ -1,6 +1,8 @@ import Observation +import os import SwiftTerm import UIKit +import UniformTypeIdentifiers #if DEBUG import notify #endif @@ -9,7 +11,7 @@ import notify /// transparent control can stay at a full touch target while `faceInset` /// narrows only the painted chassis face in the compact phone tiers. @MainActor -final class TerminalTallyKeyControl: UIControl { +final class TerminalTallyKeyControl: UIControl, UIDragInteractionDelegate { enum Face { case text(String, font: UIFont, kerning: CGFloat) case symbol(String, pointSize: CGFloat, weight: UIImage.SymbolWeight) @@ -37,7 +39,41 @@ final class TerminalTallyKeyControl: UIControl { var preferredSize: CGSize { didSet { invalidateIntrinsicContentSize() } } - + /// Arrange Keys: the face wiggles, press and hold are inert, and the key + /// is a system drag source (the tab strip's `UIDragInteraction`). It + /// stays a live, hoverable target — visionOS gaze needs one. + var isArranging = false { + didSet { + guard isArranging != oldValue else { return } + longPressRecognizer?.isEnabled = !isArranging + cancelRepeat() + isHighlighted = false + if isArranging { + startWiggle() + installArrangeDragInteraction() + observeForegroundForWiggle() + } else { + stopWiggle() + setDraggedAway(false) + setDropTarget(false) + arrangeDragInteraction?.isEnabled = false + foregroundObserver.map(NotificationCenter.default.removeObserver) + foregroundObserver = nil + } + accessibilityHint = isArranging ? String(localized: "Drag to move this key") : nil + } + } + /// The owner mints the drag item for a lift of this key; nil declines. + var arrangeDragItem: ((UIDragSession) -> UIDragItem?)? + + private static let wiggleAnimationKey = "arrange.wiggle" + private var arrangeDragInteraction: UIDragInteraction? + private var foregroundObserver: NSObjectProtocol? + var isArrangeDragSourceForTesting: Bool { arrangeDragInteraction?.isEnabled == true } + /// Lifted into a system drag: the slot reads as the hole the key left. + private(set) var isDraggedAway = false + /// The key a drop would land on, lit the way a tab cell's target is. + private(set) var isDropTarget = false private let faceView = UIKitTallyBorderedView() private let textLabel = UILabel() private let symbolView = UIImageView() @@ -116,6 +152,10 @@ final class TerminalTallyKeyControl: UIControl { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("unused") } + deinit { + foregroundObserver.map(NotificationCenter.default.removeObserver) + } + override var intrinsicContentSize: CGSize { preferredSize } override var isHighlighted: Bool { @@ -141,11 +181,97 @@ final class TerminalTallyKeyControl: UIControl { } override func accessibilityActivate() -> Bool { + guard !isArranging else { return false } primaryAction() return true } + /// Installed on first use, toggled with the mode. + private func installArrangeDragInteraction() { + if let arrangeDragInteraction { + arrangeDragInteraction.isEnabled = true + return + } + let drag = TerminalTabDragPolicy.makeDragInteraction(delegate: self) + addInteraction(drag) + arrangeDragInteraction = drag + } + + /// Backgrounding strips layer animations; a key still in the mode + /// wiggles again on return. + private func observeForegroundForWiggle() { + guard foregroundObserver == nil else { return } + foregroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.resumeWiggleIfNeeded() } + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + // Core Animation drops a layer's animations when it leaves the + // window; a key re-mounted mid-mode wiggles again. + if isArranging, window != nil { startWiggle() } + } + + /// The slot dims to the hole the key left while the system carries its preview. + func setDraggedAway(_ away: Bool) { + guard isDraggedAway != away else { return } + isDraggedAway = away + refreshAppearance() + if away { + stopWiggle() + } else if isArranging { + startWiggle() + } + } + + func setDropTarget(_ targeted: Bool) { + guard isDropTarget != targeted else { return } + isDropTarget = targeted + refreshAppearance() + } + + /// Holds the wiggle still for a landing animation. + func suspendWiggle() { + stopWiggle() + } + + /// Restarts a wiggle backgrounding dropped or a landing paused. + func resumeWiggleIfNeeded() { + guard isArranging, !isDraggedAway else { return } + startWiggle() + } + + private func startWiggle() { + guard window != nil, + layer.animation(forKey: Self.wiggleAnimationKey) == nil, + !UIAccessibility.isReduceMotionEnabled + else { return } + let amplitude = 1.5 * CGFloat.pi / 180 + let rotation = CAKeyframeAnimation(keyPath: "transform.rotation.z") + rotation.values = [-amplitude, amplitude, -amplitude] + rotation.keyTimes = [0, 0.5, 1] + rotation.duration = 0.14 + rotation.timingFunctions = [ + CAMediaTimingFunction(name: .easeInEaseOut), + CAMediaTimingFunction(name: .easeInEaseOut), + ] + rotation.repeatCount = .greatestFiniteMagnitude + // Out of phase with its neighbours, or the row rocks as one slab. + rotation.timeOffset = Double.random(in: 0.. [UIDragItem] { + guard isArranging, let item = arrangeDragItem?(session) else { return [] } + item.previewProvider = { [weak self] in + guard let self else { return nil } + return UIDragPreview(view: self, parameters: self.dragPreviewParameters()) + } + return [item] + } + + func dragInteraction( + _ interaction: UIDragInteraction, + previewForLifting item: UIDragItem, + session: UIDragSession + ) -> UITargetedDragPreview? { + UITargetedDragPreview(view: self, parameters: dragPreviewParameters()) + } + + func dragInteraction( + _ interaction: UIDragInteraction, + sessionWillBegin session: UIDragSession + ) { + setDraggedAway(true) + } + + func dragInteraction( + _ interaction: UIDragInteraction, + sessionDidEnd session: UIDragSession, + with operation: UIDropOperation + ) { + setDraggedAway(false) + } + + func dragInteraction( + _ interaction: UIDragInteraction, + sessionIsRestrictedToDraggingApplication session: UIDragSession + ) -> Bool { + true + } + + func dragInteraction( + _ interaction: UIDragInteraction, + prefersFullSizePreviewsFor session: UIDragSession + ) -> Bool { + true + } + + private func dragPreviewParameters() -> UIDragPreviewParameters { + let parameters = UIDragPreviewParameters() + let path = UIBezierPath(rect: faceView.frame) + parameters.backgroundColor = .clear + parameters.visiblePath = path + parameters.shadowPath = path + return parameters + } } /// Native C/B slab used by both CTRL implementations. iPad installs it @@ -371,6 +560,264 @@ final class TerminalCtrlComboViewController: UIViewController { } } +private let keyArrangeLog = Logger(subsystem: "app.multiplexterm.multiplex", category: "keys") + +/// A key in a row with the slot it occupies. +struct RenderedKey { + let slot: KeyBarSlot + let control: TerminalTallyKeyControl +} + +/// Local-object marker for an Arrange Keys drag. +struct KeyBarDragPayload: TerminalChromeDragPayload { + static let dragTypeTag = "application/x-multiplex-key-slot" + var surfaceID: UUID + var slot: KeyBarSlot +} + +/// What a drop surface — the rail, the cluster context — tells the +/// coordinator. +@MainActor +protocol KeyBarDropSurface: AnyObject { + /// Arrange Keys is on and the slot is on this surface. + func canArrange(_ slot: KeyBarSlot) -> Bool + /// Every key on the surface, in row order. + var dropControls: [RenderedKey] { get } + /// Lands `source` on `target`, writing the order; false when nothing moves. + func dropKey(_ source: KeyBarSlot, onto target: KeyBarSlot) -> Bool + /// Lays the surface out after a commit so the row shows the new order. + func layoutAfterDrop() +} + +/// The tab strip's drop dance for a row of keys, shared by the iPad rail +/// and the visionOS cluster: a process-local item per lift, the nearest +/// other key as the target (lit like a tab cell's), and a landing that +/// parks the displaced keys on their old centres for UIKit's drop animator +/// while the preview flies to the key's new control. +@MainActor +final class KeyBarDropCoordinator: NSObject, UIDropInteractionDelegate { + private unowned let surface: any KeyBarDropSurface + private let surfaceID = UUID() + private var targetSlot: KeyBarSlot? + private var pending: (slot: KeyBarSlot, landed: TerminalTallyKeyControl, displaced: [TerminalTallyKeyControl])? + + init(surface: any KeyBarDropSurface) { + self.surface = surface + } + + /// Idempotent: a host asked twice keeps one interaction. + func installDropTarget(on host: UIView) { + guard !host.interactions.contains(where: { ($0 as? UIDropInteraction)?.delegate === self }) + else { return } + host.addInteraction(UIDropInteraction(delegate: self)) + } + + func makeDragItem(for slot: KeyBarSlot, session: UIDragSession) -> UIDragItem? { + guard surface.canArrange(slot), surface.dropControls.count > 1 else { return nil } + keyArrangeLog.debug("arrange lift \(slot.rawValue, privacy: .public)") + return TerminalChromeDragItem.make( + KeyBarDragPayload(surfaceID: surfaceID, slot: slot), + session: session + ) + } + + func clearTarget() { + setTarget(nil) + } + + /// A rebuild mid-landing (a tier change) replaces the keys; nothing is + /// left parked. + func cancelLanding() { + finishLanding() + } + + /// The key a drop at `point` (in `view`'s coordinates) lands on: the + /// nearest other key by centre, nil over the dragged key's own slot. + func targetSlot(at point: CGPoint, in view: UIView, source: KeyBarSlot) -> KeyBarSlot? { + let row = surface.dropControls + guard let sourceIndex = row.firstIndex(where: { $0.slot == source }) else { return nil } + let frames = row.map { entry in + entry.control.superview?.convert(entry.control.frame, to: view) ?? entry.control.frame + } + return RowDropGeometry.dropTargetIndex( + x: point.x, + restingFrames: frames, + sourceIndex: sourceIndex + ).map { row[$0].slot } + } + + private func source(of session: UIDropSession) -> KeyBarSlot? { + guard let payload = TerminalChromeDragItem.payload(KeyBarDragPayload.self, from: session), + payload.surfaceID == surfaceID, + surface.canArrange(payload.slot) + else { return nil } + return payload.slot + } + + private func target(for session: UIDropSession, in interaction: UIDropInteraction) -> KeyBarSlot? { + guard let view = interaction.view, let source = source(of: session) else { return nil } + return targetSlot(at: session.location(in: view), in: view, source: source) + } + + private func setTarget(_ slot: KeyBarSlot?) { + guard targetSlot != slot else { return } + targetSlot = slot + for entry in surface.dropControls { + entry.control.setDropTarget(entry.slot == slot) + } + } + + private func center(of control: UIView, in view: UIView) -> CGPoint { + control.superview?.convert(control.center, to: view) ?? control.center + } + + private func land(_ source: KeyBarSlot, on target: KeyBarSlot, in view: UIView) { + finishLanding() + surface.layoutAfterDrop() + var oldCenters: [KeyBarSlot: CGPoint] = [:] + for entry in surface.dropControls { + oldCenters[entry.slot] = center(of: entry.control, in: view) + } + var landed = false + UIView.performWithoutAnimation { + landed = surface.dropKey(source, onto: target) + surface.layoutAfterDrop() + } + guard landed else { return } + var key: TerminalTallyKeyControl? + var displaced: [TerminalTallyKeyControl] = [] + UIView.performWithoutAnimation { + for entry in surface.dropControls { + if entry.slot == source { + key = entry.control + continue + } + guard let oldCenter = oldCenters[entry.slot] else { continue } + let dx = oldCenter.x - center(of: entry.control, in: view).x + guard abs(dx) > 0.5 else { continue } + entry.control.suspendWiggle() + entry.control.transform = CGAffineTransform(translationX: dx, y: 0) + displaced.append(entry.control) + } + } + guard let key else { return } + pending = (source, key, displaced) + keyArrangeLog.debug( + "arrange drop \(source.rawValue, privacy: .public) onto \(target.rawValue, privacy: .public)" + ) + } + + private func finishLanding(slot: KeyBarSlot? = nil) { + guard let pending, slot == nil || slot == pending.slot else { return } + UIView.performWithoutAnimation { + for control in pending.displaced { + control.transform = .identity + control.resumeWiggleIfNeeded() + } + } + self.pending = nil + } + + func dropInteraction( + _ interaction: UIDropInteraction, + canHandle session: UIDropSession + ) -> Bool { + source(of: session) != nil + } + + func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidEnter session: UIDropSession + ) { + setTarget(target(for: session, in: interaction)) + } + + func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidUpdate session: UIDropSession + ) -> UIDropProposal { + let target = target(for: session, in: interaction) + setTarget(target) + return UIDropProposal(operation: target == nil ? .forbidden : .move) + } + + func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidExit session: UIDropSession + ) { + setTarget(nil) + } + + func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidEnd session: UIDropSession + ) { + setTarget(nil) + } + + func dropInteraction( + _ interaction: UIDropInteraction, + previewForDropping item: UIDragItem, + withDefault defaultPreview: UITargetedDragPreview + ) -> UITargetedDragPreview? { + guard let payload = item.localObject as? KeyBarDragPayload, + let pending, + payload.slot == pending.slot, + let container = pending.landed.superview + else { return defaultPreview } + return defaultPreview.retargetedPreview( + with: UIDragPreviewTarget(container: container, center: pending.landed.center) + ) + } + + func dropInteraction( + _ interaction: UIDropInteraction, + performDrop session: UIDropSession + ) { + guard let view = interaction.view, let source = source(of: session) else { return } + let target = targetSlot(at: session.location(in: view), in: view, source: source) + setTarget(nil) + guard let target else { return } + land(source, on: target, in: view) + } + + func dropInteraction( + _ interaction: UIDropInteraction, + item: UIDragItem, + willAnimateDropWith animator: UIDragAnimating + ) { + guard let payload = item.localObject as? KeyBarDragPayload, + let pending, + payload.slot == pending.slot + else { return } + animator.addAnimations { [weak self] in + guard self?.pending?.slot == pending.slot else { return } + for control in pending.displaced { + control.transform = .identity + } + } + animator.addCompletion { [weak self] _ in + self?.finishLanding(slot: pending.slot) + } + } + + func dropInteraction( + _ interaction: UIDropInteraction, + concludeDrop session: UIDropSession + ) { + finishLanding() + } +} + +/// VoiceOver's road through the mode: one step left or right. +@MainActor +private func keyMoveActions(_ move: @escaping (Int) -> Bool) -> [UIAccessibilityCustomAction] { + [ + UIAccessibilityCustomAction(name: String(localized: "Move left")) { _ in move(-1) }, + UIAccessibilityCustomAction(name: String(localized: "Move right")) { _ in move(1) }, + ] +} + /// The talk key's VoiceOver name on the rail and in the visionOS cluster — /// one wording, flipped in place with the latch. private func talkbackKeyLabel(open: Bool) -> String { @@ -552,6 +999,55 @@ enum TerminalKeyBarLayout { $0.idealWidth(includesReturn: includesReturn) <= usable + 0.5 } ?? candidates[candidates.count - 1] } + + /// The resting frame of every key in a row of `keyCount`, in row order. + /// Groups are counted, never named — three keys, the tier's symbols, the + /// rest — so a custom order permutes keys across the same slots and the + /// gaps stay put. The slack between the minimum row and the usable width + /// is spread evenly over the group gaps. + static func keyFrames( + specification: Specification, + keyCount: Int, + includesReturn: Bool, + width: CGFloat, + contentSafeArea: UIEdgeInsets, + keyTop: CGFloat, + keyHeight: CGFloat + ) -> [CGRect] { + guard keyCount > 0 else { return [] } + let metric = specification.metric + let leftCount = min(3, keyCount) + let symbolCount = min(specification.symbols.count, keyCount - leftCount) + let rightCount = keyCount - leftCount - symbolCount + var counts = [leftCount] + if symbolCount > 0 { counts.append(symbolCount) } + if rightCount > 0 { counts.append(rightCount) } + let groupGap = includesReturn ? min(metric.groupGap, 8) : metric.groupGap + let internalSpacingCount = counts.reduce(0) { $0 + max(0, $1 - 1) } + let minimumContentWidth = CGFloat(keyCount) * metric.keyWidth + + CGFloat(internalSpacingCount) * metric.spacing + + CGFloat(counts.count - 1) * groupGap + let available = max( + 0, + width - contentSafeArea.left - contentSafeArea.right + - specification.edgeInset * 2 + ) + let flexibleGap = groupGap + + max(0, available - minimumContentWidth) / CGFloat(max(1, counts.count - 1)) + + var frames: [CGRect] = [] + frames.reserveCapacity(keyCount) + var x = contentSafeArea.left + specification.edgeInset + for (groupIndex, count) in counts.enumerated() { + for keyIndex in 0.. Void, finishTmuxCopyMode: @escaping () -> Void, - shortcutBackend: Host.SessionBackend? + shortcutBackend: Host.SessionBackend?, + orderStore: KeyBarOrderStore = .shared ) { self.terminal = terminal self.controller = controller self.performShortcut = performShortcut self.finishTmuxCopyMode = finishTmuxCopyMode self.shortcutBackend = shortcutBackend + self.orderStore = orderStore showsReturnKey = UIDevice.current.userInterfaceIdiom == .pad ctrlLatched = terminal.controlModifier super.init(frame: .zero) @@ -684,6 +1202,7 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { topBorder.backgroundColor = UIKitChassis.bezelHi topBorder.isAccessibilityElement = false addSubview(topBorder) + dropCoordinator.installDropTarget(on: self) HardwareKeyboardMonitor.shared.startIfNeeded() NotificationCenter.default.addObserver( @@ -733,8 +1252,15 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { rebuildRow(specification: specification, state: state) renderedSignature = signature } + // The order permutes the same keys: the row flips in place. + let arranged = orderStore.order.arrange(renderedSlots) + if arranged != renderedSlots { + let bySlot = Dictionary(uniqueKeysWithValues: rendered.map { ($0.slot, $0) }) + rendered = arranged.compactMap { bySlot[$0] } + } talkKeyControl?.isLatched = state.talkbackOpen talkKeyControl?.accessibilityLabel = talkbackKeyLabel(open: state.talkbackOpen) + applyArranging(state.arranging, offersReset: !orderStore.order.isStandard) layoutRow(specification: specification, includesReturn: includesReturn) bringSubviewToFront(topBorder) } @@ -742,7 +1268,33 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { override func willMove(toWindow newWindow: UIWindow?) { super.willMove(toWindow: newWindow) - if newWindow == nil { hideCtrlCombos() } + if newWindow == nil { + hideCtrlCombos() + // The mode is this rail's to show; a rail leaving the window (a + // tab switch, a merge, a close) takes the mode with it. + hideArrangeBar() + controller?.setKeyBarArranging(false) + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + // The bar mounts in the window; a rail that joins one mid-mode + // (rare — the mode ends on leaving) hangs it on the next pass. + if window != nil, observedState?.arranging == true { setNeedsLayout() } + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + // A key released over the terminal above the rail still lands. + if let superview { dropCoordinator.installDropTarget(on: superview) } + } + + /// Tests paint a state without waiting on the observation's turn — the + /// UMD bar's and the pane's seam. + func applyObservedState(_ state: TerminalKeyBarObservedState) { + observedState = state + setNeedsLayout() } @objc private func controlModifierDidReset() { @@ -756,7 +1308,9 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { hardwareKeyboardConnected: HardwareKeyboardMonitor.shared.isConnected, keyboardLocked: KeyboardLock.shared.isLocked, isDictating: controller?.isDictating == true, - talkbackOpen: controller?.talkbackOpen == true + talkbackOpen: controller?.talkbackOpen == true, + arranging: controller?.keyBarArranging == true, + order: orderStore.order ) } @@ -781,10 +1335,12 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { specification: TerminalKeyBarLayout.Specification, state: TerminalKeyBarObservedState ) { - for key in renderedKeys { key.removeFromSuperview() } - renderedKeys.removeAll(keepingCapacity: true) + dropCoordinator.cancelLanding() + for entry in rendered { entry.control.removeFromSuperview() } + rendered.removeAll(keepingCapacity: true) ctrlKeyControl = nil talkKeyControl = nil + shortcutKeyControl = nil let metric = specification.metric let includesReturn = showsReturnKey || state.keyboardLocked @@ -792,44 +1348,54 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { // key opens Key Commands and never toggles the latch. var control = caps( "CTRL", .ctrl, String(localized: "Control"), - identifier: "control", latched: ctrlLatched + identifier: "control", slot: .control, latched: ctrlLatched ) control.longPressKey = .keyCommands - var groups: [[RailKey]] = [[ - caps("ESC", .esc, String(localized: "Escape"), identifier: "escape"), + var descriptors: [RailKey] = [ + caps("ESC", .esc, String(localized: "Escape"), identifier: "escape", slot: .escape), control, - caps("TAB", .tab, String(localized: "Tab"), identifier: "tab"), - ]] - if !specification.symbols.isEmpty { - groups.append(specification.symbols.map { symbol in + caps("TAB", .tab, String(localized: "Tab"), identifier: "tab", slot: .tab), + ] + descriptors += specification.symbols.compactMap { symbol in + symbolSlot(symbol).map { slot in RailKey( key: .text(symbol), + slot: slot, face: .text(symbol, font: UIKitChassis.monoFont(15), kerning: 0), accessibility: symbol, - identifier: "symbol.\(symbolIdentifier(symbol))" + identifier: "symbol.\(slot.rawValue)" ) - }) + } } - - var right: [RailKey] = [] if specification.pageKeys { - right.append(arrowKey( - "arrow.up.to.line", .pageUp, String(localized: "Page up"), identifier: "pageUp" + descriptors.append(arrowKey( + "arrow.up.to.line", .pageUp, String(localized: "Page up"), + identifier: "pageUp", slot: .pageUp )) - right.append(arrowKey( + descriptors.append(arrowKey( "arrow.down.to.line", .pageDown, String(localized: "Page down"), - identifier: "pageDown" + identifier: "pageDown", slot: .pageDown )) } - right.append(contentsOf: [ - arrowKey("arrow.left", .left, String(localized: "Arrow left"), identifier: "left"), - arrowKey("arrow.up", .up, String(localized: "Arrow up"), identifier: "up"), - arrowKey("arrow.down", .down, String(localized: "Arrow down"), identifier: "down"), - arrowKey("arrow.right", .right, String(localized: "Arrow right"), identifier: "right"), + descriptors.append(contentsOf: [ + arrowKey( + "arrow.left", .left, String(localized: "Arrow left"), + identifier: "left", slot: .left + ), + arrowKey("arrow.up", .up, String(localized: "Arrow up"), identifier: "up", slot: .up), + arrowKey( + "arrow.down", .down, String(localized: "Arrow down"), + identifier: "down", slot: .down + ), + arrowKey( + "arrow.right", .right, String(localized: "Arrow right"), + identifier: "right", slot: .right + ), ]) if includesReturn { - right.append(RailKey( + descriptors.append(RailKey( key: .returnKey, + slot: .returnKey, face: .symbol("return", pointSize: 12, weight: .semibold), accessibility: String(localized: "Return"), identifier: "return" @@ -837,16 +1403,20 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { } // Talkback — the message box under the pane; RET · talk · keyboard. // Latched while the box is open, like CTRL and the keyboard lock. - right.append(RailKey( + descriptors.append(RailKey( key: .talkback, + slot: .talkback, face: .symbol("text.bubble", pointSize: 13, weight: .semibold), accessibility: talkbackKeyLabel(open: state.talkbackOpen), identifier: "talkback", latched: state.talkbackOpen )) + // One slot, two occupants — the mic beside a hardware keyboard, the + // keyboard key without one. An order names the slot, so it holds. if state.hardwareKeyboardConnected { - right.append(RailKey( + descriptors.append(RailKey( key: .dictation, + slot: .keyboard, face: .symbol( state.isDictating ? "mic.fill" : "mic", pointSize: 13, @@ -859,8 +1429,9 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { latched: state.isDictating )) } else { - right.append(RailKey( + descriptors.append(RailKey( key: .keyboard, + slot: .keyboard, face: .symbol( state.keyboardLocked ? "lock.fill" : "keyboard", pointSize: 13, @@ -879,8 +1450,9 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { if specification.tmux, let backend = shortcutBackend { // The identifier names the slot, not the occupant — debug hooks // and tests address "tmux" for either backend's key. - right.append(RailKey( + descriptors.append(RailKey( key: .showShortcutPanel, + slot: .shortcuts, face: .text( backend == .herdr ? "HRDR" : "TMUX", font: UIKitChassis.monoFont(9, weight: .semibold), @@ -892,75 +1464,173 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { identifier: "tmux" )) } - groups.append(right) - - for group in groups { - for descriptor in group { - let control = TerminalTallyKeyControl( - face: descriptor.face, - width: metric.keyWidth, - height: Self.keyHeight, - accessibilityLabel: descriptor.accessibility, - accessibilityIdentifier: "terminal.keybar.\(descriptor.identifier)", - repeats: descriptor.repeats, - latched: descriptor.latched, - faceInset: metric.faceInset, - longPressAction: descriptor.longPressKey.map { key in - { [weak self] in self?.press(key) } - }, - action: { [weak self] in self?.press(descriptor.key) } - ) - if let hold = descriptor.longPressKey { control.longPressDuration = hold.holdDuration } - control.accessibilityUserInputLabels = [descriptor.accessibility] - addSubview(control) - renderedKeys.append(control) - if descriptor.identifier == "control" { ctrlKeyControl = control } - if descriptor.identifier == "talkback" { talkKeyControl = control } + + // The tier decided WHICH keys the row carries; the order decides + // where each one sits. Groups are counted at layout, so the gaps + // stay put whatever lands in them. + let bySlot = Dictionary(uniqueKeysWithValues: descriptors.map { ($0.slot, $0) }) + for slot in orderStore.order.arrange(descriptors.map(\.slot)) { + guard let descriptor = bySlot[slot] else { continue } + let control = TerminalTallyKeyControl( + face: descriptor.face, + width: metric.keyWidth, + height: Self.keyHeight, + accessibilityLabel: descriptor.accessibility, + accessibilityIdentifier: "terminal.keybar.\(descriptor.identifier)", + repeats: descriptor.repeats, + latched: descriptor.latched, + faceInset: metric.faceInset, + longPressAction: descriptor.longPressKey.map { key in + { [weak self] in self?.press(key) } + }, + action: { [weak self] in self?.press(descriptor.key) } + ) + if let hold = descriptor.longPressKey { control.longPressDuration = hold.holdDuration } + control.accessibilityUserInputLabels = [descriptor.accessibility] + control.arrangeDragItem = { [weak self] session in + self?.dropCoordinator.makeDragItem(for: slot, session: session) + } + addSubview(control) + rendered.append(RenderedKey(slot: slot, control: control)) + switch slot { + case .control: ctrlKeyControl = control + case .talkback: talkKeyControl = control + case .shortcuts: shortcutKeyControl = control + default: break } } } + /// Every key's resting frame for the row as it stands (`rendered` order). + private func restingFrames( + specification: TerminalKeyBarLayout.Specification, + includesReturn: Bool + ) -> [CGRect] { + TerminalKeyBarLayout.keyFrames( + specification: specification, + keyCount: rendered.count, + includesReturn: includesReturn, + width: bounds.width, + contentSafeArea: contentSafeArea, + keyTop: Self.keyTopInset, + keyHeight: Self.keyHeight + ) + } + private func layoutRow( specification: TerminalKeyBarLayout.Specification, includesReturn: Bool ) { - let metric = specification.metric - let leftCount = 3 - let symbolCount = specification.symbols.count - let rightCount = renderedKeys.count - leftCount - symbolCount - let counts = symbolCount > 0 - ? [leftCount, symbolCount, rightCount] - : [leftCount, rightCount] - let groupGap = includesReturn ? min(metric.groupGap, 8) : metric.groupGap - let internalSpacingCount = counts.reduce(0) { $0 + max(0, $1 - 1) } - let minimumContentWidth = CGFloat(renderedKeys.count) * metric.keyWidth - + CGFloat(internalSpacingCount) * metric.spacing - + CGFloat(counts.count - 1) * groupGap - let available = max( - 0, - bounds.width - contentSafeArea.left - contentSafeArea.right - - specification.edgeInset * 2 - ) - let flexibleGap = groupGap - + max(0, available - minimumContentWidth) / CGFloat(max(1, counts.count - 1)) + let frames = restingFrames(specification: specification, includesReturn: includesReturn) + for (entry, frame) in zip(rendered, frames) { + entry.control.frame = frame + } + } - var index = 0 - var x = contentSafeArea.left + specification.edgeInset - for groupIndex in counts.indices { - for keyIndex in 0.. Bool { + guard let window else { return false } + slab.translatesAutoresizingMaskIntoConstraints = false + window.addSubview(slab) + centerX.priority = .defaultHigh + var constraints = [ + centerX, + slab.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 8), + slab.bottomAnchor.constraint(equalTo: topAnchor, constant: -6), + ] + if clampsTrailing { + constraints.append(slab.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8)) } + NSLayoutConstraint.activate(constraints) + return true + } + + /// One step along the row — VoiceOver's road and the headless proof's. + @discardableResult + func moveKey(_ slot: KeyBarSlot, by delta: Int) -> Bool { + let slots = renderedSlots + guard let index = slots.firstIndex(of: slot), + slots.indices.contains(index + delta) + else { return false } + let order = orderStore.order.moving(slot, toVisibleIndex: index + delta, among: slots) + guard order != orderStore.order else { return false } + orderStore.setOrder(order) + return true + } + + // MARK: KeyBarDropSurface + + func canArrange(_ slot: KeyBarSlot) -> Bool { + observedState?.arranging == true && rendered.contains { $0.slot == slot } + } + + var dropControls: [RenderedKey] { rendered } + + /// Lands `source` on `target` and writes the order; the layout pass + /// flips the row in place from the store's change. + @discardableResult + func dropKey(_ source: KeyBarSlot, onto target: KeyBarSlot) -> Bool { + let slots = renderedSlots + guard source != target, + slots.contains(source), + let targetIndex = slots.firstIndex(of: target) + else { return false } + let order = orderStore.order.moving(source, toVisibleIndex: targetIndex, among: slots) + guard order != orderStore.order else { return false } + orderStore.setOrder(order) + setNeedsLayout() + return true + } + + func layoutAfterDrop() { + layoutIfNeeded() } private func press(_ key: TerminalKey) { @@ -1091,7 +1761,9 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { controller.preferredContentSize = controller.fittingContentSize() if let popover = controller.popoverPresentationController { popover.sourceView = self - popover.sourceRect = CGRect( + // Anchored to the TMUX/HRDR key wherever the order put it; the + // trailing corner is the fallback for a rail without the key. + popover.sourceRect = shortcutKeyControl?.frame ?? CGRect( x: bounds.maxX - 44, y: bounds.minY, width: 44, @@ -1105,10 +1777,7 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { } private func showCtrlCombos() { - guard !keyCommandsPresenter.isPresented, - ctrlComboView == nil, - let window - else { return } + guard !keyCommandsPresenter.isPresented, ctrlComboView == nil, window != nil else { return } let slab = TerminalCtrlComboView( faceHeight: 34, padding: 8, @@ -1116,20 +1785,13 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { ) { [weak self] letter in self?.sendCtrlCombo(letter) } - slab.translatesAutoresizingMaskIntoConstraints = false - window.addSubview(slab) layoutIfNeeded() let anchorX = ctrlKeyControl?.frame.midX ?? 85 - let centerX = slab.centerXAnchor.constraint( - equalTo: leadingAnchor, - constant: anchorX + mountOverRail( + slab, + centerX: slab.centerXAnchor.constraint(equalTo: leadingAnchor, constant: anchorX), + clampsTrailing: false ) - centerX.priority = .defaultHigh - NSLayoutConstraint.activate([ - centerX, - slab.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 8), - slab.bottomAnchor.constraint(equalTo: topAnchor, constant: -6), - ]) ctrlComboView = slab } @@ -1167,10 +1829,12 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { _ key: TerminalKey, _ accessibility: String, identifier: String, + slot: KeyBarSlot, latched: Bool = false ) -> RailKey { RailKey( key: key, + slot: slot, face: .text( label, font: UIKitChassis.monoFont(11, weight: .semibold), @@ -1186,10 +1850,12 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { _ image: String, _ key: TerminalKey, _ accessibility: String, - identifier: String + identifier: String, + slot: KeyBarSlot ) -> RailKey { RailKey( key: key, + slot: slot, face: .symbol(image, pointSize: 12, weight: .semibold), accessibility: accessibility, identifier: identifier, @@ -1197,13 +1863,13 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { ) } - private func symbolIdentifier(_ symbol: String) -> String { + private func symbolSlot(_ symbol: String) -> KeyBarSlot? { switch symbol { - case "~": "tilde" - case "|": "pipe" - case "/": "slash" - case "-": "hyphen" - default: symbol + case "~": .tilde + case "|": .pipe + case "/": .slash + case "-": .hyphen + default: nil } } @@ -1290,11 +1956,26 @@ final class TerminalKeyBar: UIView, UIInputViewAudioFeedback { guard let terminal, TerminalFocusArbiter.current === terminal else { return } if !terminal.controlModifier { press(.ctrl) } } + + /// The `⋯` menu's Arrange Keys, headlessly (no simulator route can open + /// a native menu). Posting again is DONE. + func debugToggleArranging() { + controller?.toggleKeyBarArranging() + } + + /// A headless drop: the leftmost key moves to the row's end through the + /// order model, proving the write-back and every rail's rebuild. + func debugMoveFirstKeyToEnd() { + guard let first = renderedSlots.first else { return } + moveKey(first, by: rendered.count - 1) + } #endif } private struct RailKey { var key: TerminalKey + /// The position this key occupies in the user's order. + var slot: KeyBarSlot var face: TerminalTallyKeyControl.Face var accessibility: String var identifier: String @@ -1409,10 +2090,13 @@ enum TmuxShortcutDebugHook { ) { _ in focusedBar()?.debugShowCtrlCombos() } } - private static func focusedBar() -> TerminalKeyBar? { - guard let view = TerminalFocusArbiter.current else { return nil } - return view.superview?.subviews.compactMap { $0 as? TerminalKeyBar }.first - } +} + +/// The rail beside the focused terminal — every headless hook's target. +@MainActor +private func focusedBar() -> TerminalKeyBar? { + guard let view = TerminalFocusArbiter.current else { return nil } + return view.superview?.subviews.compactMap { $0 as? TerminalKeyBar }.first } @MainActor @@ -1425,22 +2109,22 @@ enum KeyBarDebugHook { var token: Int32 = 0 notify_register_dispatch( "app.multiplexterm.multiplex.debug.keybar", &token, .main - ) { _ in - guard let view = TerminalFocusArbiter.current, - let bar = view.superview?.subviews.compactMap({ $0 as? TerminalKeyBar }).first - else { return } - bar.debugExercise() - } + ) { _ in focusedBar()?.debugExercise() } var dictationToken: Int32 = 0 notify_register_dispatch( "app.multiplexterm.multiplex.debug.dictation", &dictationToken, .main - ) { _ in - guard let view = TerminalFocusArbiter.current, - let bar = view.superview?.subviews.compactMap({ $0 as? TerminalKeyBar }).first - else { return } - bar.debugToggleDictation() - } + ) { _ in focusedBar()?.debugToggleDictation() } + + var arrangeToken: Int32 = 0 + notify_register_dispatch( + "app.multiplexterm.multiplex.debug.arrangekeys", &arrangeToken, .main + ) { _ in focusedBar()?.debugToggleArranging() } + + var arrangeMoveToken: Int32 = 0 + notify_register_dispatch( + "app.multiplexterm.multiplex.debug.arrangekeysmove", &arrangeMoveToken, .main + ) { _ in focusedBar()?.debugMoveFirstKeyToEnd() } } } #endif @@ -1460,17 +2144,30 @@ struct TerminalKeyClusterMetric: Equatable { /// One shared owner per ornament. ViewThatFits may construct several native /// metric candidates, so terminal state and DEBUG routing live here to ensure -/// one notification still emits one proof sequence. +/// one notification still emits one proof sequence. It is also the Arrange +/// Keys drop surface: the two slabs flanking the UMD are separate views, and +/// a key dropped on the other slab crosses the UMD. @MainActor -final class TerminalKeyClusterContext { +final class TerminalKeyClusterContext: KeyBarDropSurface { + /// The keys the ornament carries, in shipped order — three leading, seven + /// trailing. The user's order permutes them across those ten slots. + static let ornamentSlots: [KeyBarSlot] = [ + .escape, .control, .tab, .left, .up, .down, .right, .returnKey, .talkback, .keyboard, + ] + static let leadingSlotCount = 3 + private static let arrowSlots: Set = [.left, .up, .down, .right] + private weak var controller: TerminalSessionController? private(set) weak var observedTerminal: TerminalView? /// The tier's Key Commands cap and paywall route, set by the window that /// holds the entitlement store; every group of this context presents /// with it. var keyCommandPlan: KeyCommandPlan = .unrestricted + private let orderStore: KeyBarOrderStore private let groups = NSHashTable.weakObjects() private var controlResetObserver: NSObjectProtocol? + private var stateObservationGeneration = 0 + private(set) lazy var dropCoordinator = KeyBarDropCoordinator(surface: self) #if DEBUG private var debugObservers: [NSObjectProtocol] = [] #endif @@ -1479,8 +2176,13 @@ final class TerminalKeyClusterContext { /// The talk key latches while the active tab's message box is open — /// read from the controller, never mirrored. var talkbackOpen: Bool { controller?.talkbackOpen == true } + /// Arrange Keys is on for the active tab. + var arranging: Bool { controller?.keyBarArranging == true } + var order: KeyBarOrder { orderStore.order } - init() { + init(orderStore: KeyBarOrderStore = .shared) { + self.orderStore = orderStore + observeOrderAndMode() #if DEBUG KeyClusterDebugHook.install() let center = NotificationCenter.default @@ -1509,6 +2211,20 @@ final class TerminalKeyClusterContext { self?.debugShowKeyCommands(mode) } }, + center.addObserver( + forName: .multiplexDebugArrangeKeys, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.debugToggleArranging() } + }, + center.addObserver( + forName: .multiplexDebugArrangeKeysMove, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.debugMoveFirstKeyToEnd() } + }, ] #endif } @@ -1531,8 +2247,19 @@ final class TerminalKeyClusterContext { func update(controller: TerminalSessionController?) { let controllerChanged = self.controller !== controller + if controllerChanged { + // The mode is the active tab's to show; a tab going off the + // ornament takes it along (the iPad rail ends it on leaving its + // window — there is no such moment for an ornament slab). + self.controller?.setKeyBarArranging(false) + dropCoordinator.clearTarget() + } self.controller = controller let terminal = controller?.terminalView + if controllerChanged { + observeOrderAndMode() + rebuildAllIfSlotsChanged() + } // Every group applies the context's state on its own update; only a // changed terminal re-registers the latch-reset observer. guard controllerChanged || observedTerminal !== terminal else { return } @@ -1555,9 +2282,119 @@ final class TerminalKeyClusterContext { func register(_ group: TerminalKeyClusterGroupView) { groups.add(group) + dropCoordinator.installDropTarget(on: group) group.applyContextState() } + /// The console row is one drop surface: the ornament enrols the UMD and + /// the ARRANGE KEYS slab between and below the key slabs, so a key + /// released there still lands beside the nearer key. + func installDropTarget(on host: UIView) { + dropCoordinator.installDropTarget(on: host) + } + + // MARK: Order + + /// The keys a role shows, in the user's order: the first three of the + /// ornament's sequence lead, the other seven trail; a standalone slab + /// shows the lot (its minimal tier drops the arrows first). + func slots(for role: TerminalKeyClusterGroupView.Role, minimal: Bool) -> [KeyBarSlot] { + let ordered = order.arrange(Self.ornamentSlots) + switch role { + case .leading: + return Array(ordered.prefix(Self.leadingSlotCount)) + case .trailing: + return Array(ordered.dropFirst(Self.leadingSlotCount)) + case .standalone: + return minimal + ? order.arrange(Self.ornamentSlots.filter { !Self.arrowSlots.contains($0) }) + : ordered + } + } + + /// The store's order and the tab's mode, watched here rather than by + /// each fitting candidate: a change rebuilds the slabs whose keys moved + /// and flips the arranging state on every registered group. + private func observeOrderAndMode() { + stateObservationGeneration &+= 1 + let generation = stateObservationGeneration + withObservationTracking { + _ = orderStore.order + _ = controller?.keyBarArranging + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self, generation == self.stateObservationGeneration else { return } + self.observeOrderAndMode() + self.rebuildAllIfSlotsChanged() + self.broadcast() + } + } + } + + /// Only slabs whose rendered keys no longer match the order rebuild. + private func rebuildAllIfSlotsChanged() { + for group in groups.allObjects { group.rebuildIfSlotsChanged() } + } + + /// One step along the sequence — VoiceOver's road and the headless + /// proof's; crossing the UMD is just an index. + @discardableResult + func moveKey(_ slot: KeyBarSlot, by delta: Int) -> Bool { + let sequence = dropControls.map(\.slot) + guard let index = sequence.firstIndex(of: slot), + sequence.indices.contains(index + delta) + else { return false } + let moved = order.moving(slot, toVisibleIndex: index + delta, among: sequence) + guard moved != order else { return false } + orderStore.setOrder(moved) + rebuildAllIfSlotsChanged() + return true + } + + // MARK: KeyBarDropSurface + + /// The slabs on screen, leading first (or the standalone one alone). + /// `ViewThatFits` may keep discarded candidates registered; only one per + /// role is in the window with real bounds. + private func visibleGroups() -> [TerminalKeyClusterGroupView] { + let onScreen = groups.allObjects.filter { + $0.window != nil && !$0.isHidden && $0.alpha > 0 && !$0.bounds.isEmpty + } + if let standalone = onScreen.first(where: { $0.role == .standalone }) { return [standalone] } + return [onScreen.first { $0.role == .leading }, onScreen.first { $0.role == .trailing }] + .compactMap { $0 } + } + + func canArrange(_ slot: KeyBarSlot) -> Bool { + arranging && dropControls.contains { $0.slot == slot } + } + + /// Both slabs' keys as one row. + var dropControls: [RenderedKey] { visibleGroups().flatMap(\.rendered) } + + /// Lands `source` on `target` and writes the order — across the UMD + /// when the target sits in the other slab; the slabs whose keys moved + /// rebuild. + @discardableResult + func dropKey(_ source: KeyBarSlot, onto target: KeyBarSlot) -> Bool { + let sequence = dropControls.map(\.slot) + guard source != target, + sequence.contains(source), + let targetIndex = sequence.firstIndex(of: target) + else { return false } + let moved = order.moving(source, toVisibleIndex: targetIndex, among: sequence) + guard moved != order else { return false } + orderStore.setOrder(moved) + rebuildAllIfSlotsChanged() + return true + } + + func layoutAfterDrop() { + for group in visibleGroups() { group.layoutIfNeeded() } + } + + // MARK: Keys + func sendEscape() { observedTerminal?.send(EscapeSequences.cmdEsc) } func sendTab() { observedTerminal?.send([0x09]) } func sendReturn() { observedTerminal?.send([0x0D]) } @@ -1661,6 +2498,25 @@ final class TerminalKeyClusterContext { else { return } visibleControlGroup?.debugShowKeyCommands(mode) } + + /// The `⋯` menu's Arrange Keys, headlessly; posting again is DONE. + private func debugToggleArranging() { + guard let terminal = observedTerminal, + TerminalFocusArbiter.current === terminal + else { return } + controller?.toggleKeyBarArranging() + } + + /// A headless drop: the leftmost key moves to the sequence's end — from + /// the leading slab across the UMD into the trailing one. + private func debugMoveFirstKeyToEnd() { + guard let terminal = observedTerminal, + TerminalFocusArbiter.current === terminal + else { return } + let sequence = dropControls.map(\.slot) + guard let first = sequence.first else { return } + moveKey(first, by: sequence.count - 1) + } #endif } @@ -1686,9 +2542,13 @@ final class TerminalKeyClusterGroupView: UIKitTallyBorderedView { private weak var talkKey: TerminalTallyKeyControl? private weak var comboPopoverController: UIViewController? private let keyCommandsPresenter = KeyCommandPanelPresenter() - private(set) var keys: [TerminalTallyKeyControl] = [] + /// The slab's keys with the slots they occupy, in row order. + private(set) var rendered: [RenderedKey] = [] + var keys: [TerminalTallyKeyControl] { rendered.map(\.control) } + var renderedSlots: [KeyBarSlot] { rendered.map(\.slot) } - var carriesControlKey: Bool { role != .trailing } + /// Whether the CTRL key landed in this slab (the user's order decides). + var carriesControlKey: Bool { ctrlKey != nil } var keyCommandsArePresented: Bool { keyCommandsPresenter.isPresented } var ctrlCombosArePresentedForTesting: Bool { comboPopoverController != nil } @@ -1724,7 +2584,7 @@ final class TerminalKeyClusterGroupView: UIKitTallyBorderedView { if variant != standaloneVariant { rebuildKeys(variant: variant) } - layoutKeys(variant: variant) + layoutKeys() } override func willMove(toWindow newWindow: UIWindow?) { @@ -1741,6 +2601,21 @@ final class TerminalKeyClusterGroupView: UIKitTallyBorderedView { ctrlKey?.isLatched = context.ctrlLatched talkKey?.isLatched = context.talkbackOpen talkKey?.accessibilityLabel = talkbackKeyLabel(open: context.talkbackOpen) + let arranging = context.arranging + for entry in rendered where entry.control.isArranging != arranging { + entry.control.isArranging = arranging + entry.control.accessibilityCustomActions = arranging + ? keyMoveActions { [weak context] in context?.moveKey(entry.slot, by: $0) ?? false } + : nil + } + } + + /// Rebuilds only when the order changed this slab's keys. + func rebuildIfSlotsChanged() { + let expected = context.slots(for: role, minimal: standaloneVariant == .minimal) + guard renderedSlots != expected else { return } + rebuildKeys(variant: standaloneVariant) + setNeedsLayout() } func fittingSize(maximumWidth: CGFloat?) -> CGSize { @@ -1839,9 +2714,71 @@ final class TerminalKeyClusterGroupView: UIKitTallyBorderedView { } #endif + // MARK: Geometry + + /// Every slot's resting frame, in row order — the slab's authored + /// rhythm, which never changes with the order. + private func slotFrames() -> [CGRect] { + let runs: [Int] + switch role { + case .leading: + runs = [rendered.count] + case .trailing: + runs = [4] + Array(repeating: 1, count: max(0, rendered.count - 4)) + case .standalone: + let arrows = standaloneVariant == .minimal ? 0 : 4 + runs = [3] + (arrows > 0 ? [arrows] : []) + + Array(repeating: 1, count: max(0, rendered.count - 3 - arrows)) + } + return Self.slotFrames( + widths: rendered.map(\.control.preferredSize.width), + runs: runs, + spacing: activeMetric.spacing, + groupGap: activeMetric.groupGap + ) + } + + /// Runs of keys `spacing` apart and a `groupGap` between runs (the + /// pre-UIKit `HStack(spacing: groupGap)`), from the slab's 12-point + /// inset. Pure: the drop geometry reads these frames. + static func slotFrames( + widths: [CGFloat], + runs: [Int], + spacing: CGFloat, + groupGap: CGFloat + ) -> [CGRect] { + var frames: [CGRect] = [] + var remaining = widths[...] + var x: CGFloat = 12 + for (runIndex, count) in runs.enumerated() { + if runIndex > 0 { x += groupGap } + for index in 0.. TerminalTallyKeyControl? { + switch slot { + case .escape: + return caps("ESC", String(localized: "Escape"), metric, identifier: "escape") { [weak context] in context?.sendEscape() - }) + } + case .control: let control = caps( "CTRL", String(localized: "Control"), - activeMetric, + metric, identifier: "control", latched: context.ctrlLatched ) { [weak self, weak context] in @@ -1876,126 +2831,74 @@ final class TerminalKeyClusterGroupView: UIKitTallyBorderedView { control.longPressAction = { [weak self] in self?.showKeyCommands() } - append(control) ctrlKey = control - append(caps( - "TAB", String(localized: "Tab"), activeMetric, identifier: "tab" - ) { [weak context] in + return control + case .tab: + return caps("TAB", String(localized: "Tab"), metric, identifier: "tab") { [weak context] in context?.sendTab() - }) - } - if role != .leading { - if !minimal { - append(arrow( - "arrow.left", String(localized: "Arrow left"), activeMetric, - identifier: "left", - app: EscapeSequences.moveLeftApp, - normal: EscapeSequences.moveLeftNormal - )) - append(arrow( - "arrow.up", String(localized: "Arrow up"), activeMetric, - identifier: "up", - app: EscapeSequences.moveUpApp, - normal: EscapeSequences.moveUpNormal - )) - append(arrow( - "arrow.down", String(localized: "Arrow down"), activeMetric, - identifier: "down", - app: EscapeSequences.moveDownApp, - normal: EscapeSequences.moveDownNormal - )) - append(arrow( - "arrow.right", String(localized: "Arrow right"), activeMetric, - identifier: "right", - app: EscapeSequences.moveRightApp, - normal: EscapeSequences.moveRightNormal - )) } - append(TerminalTallyKeyControl( + case .left: + return arrow( + "arrow.left", String(localized: "Arrow left"), metric, + identifier: "left", + app: EscapeSequences.moveLeftApp, + normal: EscapeSequences.moveLeftNormal + ) + case .up: + return arrow( + "arrow.up", String(localized: "Arrow up"), metric, + identifier: "up", + app: EscapeSequences.moveUpApp, + normal: EscapeSequences.moveUpNormal + ) + case .down: + return arrow( + "arrow.down", String(localized: "Arrow down"), metric, + identifier: "down", + app: EscapeSequences.moveDownApp, + normal: EscapeSequences.moveDownNormal + ) + case .right: + return arrow( + "arrow.right", String(localized: "Arrow right"), metric, + identifier: "right", + app: EscapeSequences.moveRightApp, + normal: EscapeSequences.moveRightNormal + ) + case .returnKey: + return TerminalTallyKeyControl( face: .symbol("return", pointSize: 12, weight: .semibold), - width: activeMetric.keyWidth, + width: metric.keyWidth, height: 26, accessibilityLabel: String(localized: "Return"), accessibilityIdentifier: "terminal.keyCluster.return", action: { [weak context] in context?.sendReturn() } - )) + ) + case .talkback: // Talkback — RET · talk · keyboard, mirroring the iPad rail; // latched while the message box is open. let talk = TerminalTallyKeyControl( face: .symbol("text.bubble", pointSize: 12, weight: .semibold), - width: activeMetric.keyWidth, + width: metric.keyWidth, height: 26, accessibilityLabel: talkbackKeyLabel(open: context.talkbackOpen), accessibilityIdentifier: "terminal.keyCluster.talkback", latched: context.talkbackOpen, action: { [weak context] in context?.toggleTalkback() } ) - append(talk) talkKey = talk - let keyboard = TerminalTallyKeyControl( + return talk + case .keyboard: + return TerminalTallyKeyControl( face: .symbol("keyboard", pointSize: 12, weight: .semibold), - width: activeMetric.keyWidth, + width: metric.keyWidth, height: 26, accessibilityLabel: String(localized: "Show or hide keyboard"), accessibilityIdentifier: "terminal.keyCluster.keyboard", action: { [weak context] in context?.toggleKeyboard() } ) - append(keyboard) - } - } - - private func layoutKeys(variant: StandaloneVariant?) { - let activeMetric = role == .standalone && variant != .regular - ? TerminalKeyClusterMetric.compact - : metric - var x: CGFloat = 12 - let y: CGFloat = 9 - // `layout(range:)` leaves the cursor on the last key's trailing edge - // with no spacing appended, so a group boundary advances by the whole - // `groupGap` — the pre-UIKit `HStack(spacing: groupGap)` gap, and what - // the reserved slab widths below already pay for. Subtracting - // `spacing` here packed the keys left and dumped the slack on the - // right edge. - switch role { - case .leading: - layout(range: keys.indices, x: &x, y: y, spacing: activeMetric.spacing) - case .trailing: - layout(range: 0..<4, x: &x, y: y, spacing: activeMetric.spacing) - x += activeMetric.groupGap - layout(range: 4.. 0 { - layout(range: 3..<(3 + arrowCount), x: &x, y: y, spacing: activeMetric.spacing) - x += activeMetric.groupGap - } - layout( - range: (3 + arrowCount).., - x: inout CGFloat, - y: CGFloat, - spacing: CGFloat - ) { - for index in range { - let key = keys[index] - key.frame = CGRect( - x: x, - y: y, - width: key.preferredSize.width, - height: key.preferredSize.height - ) - x += key.preferredSize.width - if index < range.upperBound - 1 { x += spacing } + default: + return nil } } @@ -2030,11 +2933,6 @@ final class TerminalKeyClusterGroupView: UIKitTallyBorderedView { + CGFloat(groups - 1) * metric.groupGap } - private func append(_ key: TerminalTallyKeyControl) { - addSubview(key) - keys.append(key) - } - private func caps( _ label: String, _ accessibility: String, @@ -2113,6 +3011,8 @@ extension Notification.Name { static let multiplexDebugKeyCluster = Notification.Name("MultiplexDebugKeyCluster") static let multiplexDebugCtrlCombos = Notification.Name("MultiplexDebugCtrlCombos") static let multiplexDebugKeyCommands = Notification.Name("MultiplexDebugKeyCommands") + static let multiplexDebugArrangeKeys = Notification.Name("MultiplexDebugArrangeKeys") + static let multiplexDebugArrangeKeysMove = Notification.Name("MultiplexDebugArrangeKeysMove") } @MainActor @@ -2146,6 +3046,20 @@ enum KeyClusterDebugHook { ) } } + + var arrangeToken: Int32 = 0 + notify_register_dispatch( + "app.multiplexterm.multiplex.debug.arrangekeys", &arrangeToken, .main + ) { _ in + NotificationCenter.default.post(name: .multiplexDebugArrangeKeys, object: nil) + } + + var arrangeMoveToken: Int32 = 0 + notify_register_dispatch( + "app.multiplexterm.multiplex.debug.arrangekeysmove", &arrangeMoveToken, .main + ) { _ in + NotificationCenter.default.post(name: .multiplexDebugArrangeKeysMove, object: nil) + } } } diff --git a/Multiplex/Views/Terminal/TerminalPaneUIKit.swift b/Multiplex/Views/Terminal/TerminalPaneUIKit.swift index b85cb297..6b9933fc 100644 --- a/Multiplex/Views/Terminal/TerminalPaneUIKit.swift +++ b/Multiplex/Views/Terminal/TerminalPaneUIKit.swift @@ -211,9 +211,8 @@ final class TerminalPaneViewController: UIViewController, UIDropInteractionDeleg } static func isFileDropCandidate(_ session: UIDropSession) -> Bool { - let isTabDrag = session.localDragSession?.localContext is TerminalTabDragPayload - || session.items.contains { $0.localObject is TerminalTabDragPayload } - return !isTabDrag + // The window's own chrome on the move is never a file. + return !TerminalChromeDragItem.isChromeDrag(session) && session.hasItemsConforming(toTypeIdentifiers: [UTType.item.identifier]) } @@ -790,6 +789,105 @@ final class TerminalContextBarView: UIKitTallyBorderedView { ]) } + /// Arrange Keys: the lamp, RESET off the shipped order, DONE — the + /// copy-mode bar's anatomy. No hint text: the wiggling row is the + /// instruction, and the bar must fit a 375 pt phone. + static func arrangeKeys( + canReset: Bool, + reset: @escaping () -> Void, + done: @escaping () -> Void + ) -> TerminalContextBarView { + var items: [UIView] = [ + UIKitTallyLamp(caption: "ARRANGE KEYS", color: TallyPalette.caution), + ] + if canReset { + let chip = UIKitChassisChip( + "RESET", + accessibilityLabel: String(localized: "Restore the standard key order"), + action: reset + ) + chip.accessibilityIdentifier = "terminalPane.context.arrangeKeys.reset" + items.append(chip) + } + let doneChip = UIKitChassisChip( + "DONE", + prominent: true, + accessibilityLabel: String(localized: "Done arranging keys"), + action: done + ) + doneChip.accessibilityIdentifier = "terminalPane.context.arrangeKeys.done" + items.append(doneChip) + return TerminalContextBarView(items: items) + } +} + +/// The ARRANGE KEYS bar for the visionOS ornament mount — one per tab; it +/// watches the order itself and swaps RESET in place, reporting the new +/// size through `preferredContentSize`. +@MainActor +final class ArrangeKeysBarViewController: UIViewController { + private(set) weak var controller: TerminalSessionController? + private let orderStore: KeyBarOrderStore + private var bar: TerminalContextBarView? + private(set) var canReset = false + private var observationGeneration = 0 + /// Measured once per bar: a constraint solve per SwiftUI size query + /// would run twice a layout pass. + private var fittingSize = CGSize.zero + + init(controller: TerminalSessionController, orderStore: KeyBarOrderStore = .shared) { + self.controller = controller + self.orderStore = orderStore + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unused") } + + override func loadView() { + let root = UIView() + root.backgroundColor = .clear + view = root + observeOrder() + } + + func fittingContentSize() -> CGSize { + loadViewIfNeeded() + return fittingSize + } + + private func observeOrder() { + observationGeneration &+= 1 + let generation = observationGeneration + let canReset = withObservationTracking { + !orderStore.order.isStandard + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self, generation == self.observationGeneration else { return } + self.observeOrder() + } + } + guard bar == nil || canReset != self.canReset else { return } + self.canReset = canReset + bar?.removeFromSuperview() + let bar = TerminalContextBarView.arrangeKeys( + canReset: canReset, + reset: { [orderStore] in orderStore.reset() }, + done: { [weak self] in self?.controller?.setKeyBarArranging(false) } + ) + bar.accessibilityIdentifier = "terminal.arrangeBar" + view.addSubview(bar) + bar.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + bar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bar.topAnchor.constraint(equalTo: view.topAnchor), + bar.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + self.bar = bar + fittingSize = bar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + preferredContentSize = fittingSize + } } /// Select Text mode's ONE piece of chrome — the mode lamp, its selection diff --git a/Multiplex/Views/Terminal/TerminalTabStrip.swift b/Multiplex/Views/Terminal/TerminalTabStrip.swift index adf67fa9..8a17ecf4 100644 --- a/Multiplex/Views/Terminal/TerminalTabStrip.swift +++ b/Multiplex/Views/Terminal/TerminalTabStrip.swift @@ -14,12 +14,70 @@ enum TerminalTabDragPolicy { isIOSAppOnMac: ProcessInfo.processInfo.isiOSAppOnMac ) } + + /// A drag interaction under the policy. + @MainActor + static func makeDragInteraction(delegate: any UIDragInteractionDelegate) -> UIDragInteraction { + let drag = UIDragInteraction(delegate: delegate) + #if compiler(>=6.4) + if #available(iOS 27.0, visionOS 27.0, *), allowsPointerDragBeforeLiftDelay { + drag.allowsPointerDragBeforeLiftDelay = true + } + #endif + return drag + } } -/// Local-object marker shared with the terminal's file-drop gate. The provider -/// carries only a process-local representation; this second gate keeps the -/// file surface honest even if UIKit hands nested targets only the local item. -struct TerminalTabDragPayload { +/// The window's own chrome on the move — a tab, an Arrange Keys key. Its item +/// is process-local and outside `public.item`, so no other drop surface can +/// mistake it for text or a file, and one gate refuses every kind. +protocol TerminalChromeDragPayload { + /// The item's private MIME tag. + static var dragTypeTag: String { get } +} + +@MainActor +enum TerminalChromeDragItem { + /// The payload rides the session and the item: nested targets may be + /// handed only the local item. + static func make(_ payload: some TerminalChromeDragPayload, session: UIDragSession) -> UIDragItem? { + guard let dragType = UTType( + tag: type(of: payload).dragTypeTag, + tagClass: .mimeType, + conformingTo: nil + ) else { return nil } + session.localContext = payload + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: dragType.identifier, + visibility: .ownProcess + ) { completion in + completion(Data(), nil) + return nil + } + let item = UIDragItem(itemProvider: provider) + item.localObject = payload + return item + } + + static func payload( + _ type: Payload.Type, + from session: UIDropSession + ) -> Payload? { + if let payload = session.localDragSession?.localContext as? Payload { + return payload + } + return session.items.lazy.compactMap { $0.localObject as? Payload }.first + } + + static func isChromeDrag(_ session: UIDropSession) -> Bool { + session.localDragSession?.localContext is any TerminalChromeDragPayload + || session.items.contains { $0.localObject is any TerminalChromeDragPayload } + } +} + +struct TerminalTabDragPayload: TerminalChromeDragPayload { + static let dragTypeTag = "application/x-multiplex-window-tab" var stripID: UUID var tabID: UUID } @@ -30,14 +88,6 @@ struct TerminalTabDragPayload { final class TerminalTabStripView: UIView, UIDropInteractionDelegate { static let cellSpacing: CGFloat = 4 - /// Process-local and outside `public.item`: other drop surfaces cannot - /// mistake this representation for text or a file. - private static let dragType = UTType( - tag: "application/x-multiplex-window-tab", - tagClass: .mimeType, - conformingTo: nil - ) - /// Closures deliberately stay out of the key: retained cells route every /// action through this view, whose callback properties `apply` refreshes. private struct PendingDropAnimation { @@ -231,23 +281,11 @@ final class TerminalTabStripView: UIView, UIDropInteractionDelegate { } private func dragItem(for tabID: UUID, session: UIDragSession) -> UIDragItem? { - guard items.count > 1, - items.contains(where: { $0.id == tabID }), - let dragType = Self.dragType - else { return nil } - let payload = TerminalTabDragPayload(stripID: dragScopeID, tabID: tabID) - session.localContext = payload - let provider = NSItemProvider() - provider.registerDataRepresentation( - forTypeIdentifier: dragType.identifier, - visibility: .ownProcess - ) { completion in - completion(Data(), nil) - return nil - } - let item = UIDragItem(itemProvider: provider) - item.localObject = payload - return item + guard items.count > 1, items.contains(where: { $0.id == tabID }) else { return nil } + return TerminalChromeDragItem.make( + TerminalTabDragPayload(stripID: dragScopeID, tabID: tabID), + session: session + ) } private func canHandleTabDrag(_ session: UIDropSession) -> Bool { @@ -260,21 +298,15 @@ final class TerminalTabStripView: UIView, UIDropInteractionDelegate { private func dropTarget(for session: UIDropSession) -> UUID? { guard canHandleTabDrag(session), - let sourceID = dragPayload(from: session)?.tabID + let sourceID = dragPayload(from: session)?.tabID, + let sourceIndex = cells.firstIndex(where: { $0.itemID == sourceID }) else { return nil } - - let location = session.location(in: self) - if let source = cells.first(where: { $0.itemID == sourceID }), - location.x >= source.frame.minX - Self.cellSpacing / 2, - location.x <= source.frame.maxX + Self.cellSpacing / 2 { - return nil - } - return cells - .filter { $0.itemID != sourceID } - .min { - abs(location.x - $0.frame.midX) < abs(location.x - $1.frame.midX) - }? - .itemID + return RowDropGeometry.dropTargetIndex( + x: session.location(in: self).x, + restingFrames: cells.map(\.frame), + sourceIndex: sourceIndex, + slack: Self.cellSpacing / 2 + ).map { cells[$0].itemID } } private func setDropTarget(_ id: UUID?) { @@ -285,12 +317,7 @@ final class TerminalTabStripView: UIView, UIDropInteractionDelegate { } private func dragPayload(from session: UIDropSession) -> TerminalTabDragPayload? { - if let payload = session.localDragSession?.localContext as? TerminalTabDragPayload { - return payload - } - return session.items.lazy - .compactMap { $0.localObject as? TerminalTabDragPayload } - .first + TerminalChromeDragItem.payload(TerminalTabDragPayload.self, from: session) } /// Rebuild the stack at its committed order without exposing that jump, @@ -697,14 +724,8 @@ final class TerminalTabCell: UIView, accessibilityHint = canReorder ? String(localized: "Drag to reorder within this window") : nil accessibilityCustomActions = makeAccessibilityActions() - let drag = UIDragInteraction(delegate: self) + let drag = TerminalTabDragPolicy.makeDragInteraction(delegate: self) drag.isEnabled = canReorder - #if compiler(>=6.4) - if #available(iOS 27.0, visionOS 27.0, *), - TerminalTabDragPolicy.allowsPointerDragBeforeLiftDelay { - drag.allowsPointerDragBeforeLiftDelay = true - } - #endif addInteraction(drag) tabDragInteraction = drag diff --git a/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift b/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift index 5bf99825..660fe49b 100644 --- a/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift +++ b/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift @@ -53,28 +53,35 @@ struct TerminalVisionConsoleGeometry: Equatable { var size: CGSize var helperOrigin: CGPoint? var consoleOrigin: CGPoint - /// The Talkback slab hangs below the console row, `spacing` under it. - /// It lengthens the lower half — and so, symmetrically, the reported - /// bounds — while the console's top stays the exact midpoint. + /// The ARRANGE KEYS bar hangs `spacing` below the console row, the + /// Talkback slab below that; both lengthen the lower half symmetrically + /// so the console's top stays the midpoint. + var arrangeOrigin: CGPoint? var talkbackOrigin: CGPoint? static func resolve( helperSize: CGSize?, consoleSize: CGSize, + arrangeSize: CGSize? = nil, talkbackSize: CGSize? = nil, helperLeading: Bool, spacing: CGFloat ) -> Self { let helperSize = helperSize.map(Self.normalized) let consoleSize = Self.normalized(consoleSize) + let arrangeSize = arrangeSize.map(Self.normalized) let talkbackSize = talkbackSize.map(Self.normalized) let spacing = spacing.isFinite ? max(0, spacing) : 0 let upperExtent = helperSize.map { $0.height + spacing } ?? 0 - let lowerExtent = consoleSize.height + (talkbackSize.map { spacing + $0.height } ?? 0) + let arrangeExtent = arrangeSize.map { spacing + $0.height } ?? 0 + let lowerExtent = consoleSize.height + + arrangeExtent + + (talkbackSize.map { spacing + $0.height } ?? 0) let halfHeight = max(upperExtent, lowerExtent) let width = max( helperSize?.width ?? 0, consoleSize.width, + arrangeSize?.width ?? 0, talkbackSize?.width ?? 0 ) let helperOrigin = helperSize.map { helperSize in @@ -83,10 +90,16 @@ struct TerminalVisionConsoleGeometry: Equatable { y: halfHeight - spacing - helperSize.height ) } + let arrangeOrigin = arrangeSize.map { arrangeSize in + CGPoint( + x: (width - arrangeSize.width) / 2, + y: halfHeight + consoleSize.height + spacing + ) + } let talkbackOrigin = talkbackSize.map { talkbackSize in CGPoint( x: (width - talkbackSize.width) / 2, - y: halfHeight + consoleSize.height + spacing + y: halfHeight + consoleSize.height + arrangeExtent + spacing ) } return Self( @@ -96,6 +109,7 @@ struct TerminalVisionConsoleGeometry: Equatable { x: (width - consoleSize.width) / 2, y: halfHeight ), + arrangeOrigin: arrangeOrigin, talkbackOrigin: talkbackOrigin ) } @@ -129,6 +143,9 @@ final class TerminalVisionOrnamentState { /// window: the composer reports, the window re-renders, `revision` /// bumps, and the mount re-asks the composer for its size. private(set) var talkbackController: TalkbackComposerViewController? + /// The active tab's ARRANGE KEYS bar (its own slab below the console + /// row) while it arranges; identity is the revision. + private(set) var arrangeBarController: UIViewController? /// Active terminal tab's SIDECAR mount. It is independent of the bottom /// console presentation: the active route remains a terminal while this /// controller hangs from the scene's trailing edge. @@ -179,6 +196,7 @@ final class TerminalVisionOrnamentState { umdController: UIViewController?, helperController: AgentHelperStripViewController?, talkbackController: TalkbackComposerViewController? = nil, + arrangeBarController: UIViewController? = nil, sidePanelController: SidePanelViewController? = nil, windowWidth: CGFloat, windowHeight: CGFloat = 0, @@ -188,6 +206,7 @@ final class TerminalVisionOrnamentState { self.interfaceStyle = interfaceStyle let nextHelper = isAuxiliary ? nil : helperController let nextTalkback = isAuxiliary ? nil : talkbackController + let nextArrangeBar = isAuxiliary ? nil : arrangeBarController // The ornament hosts the whole strip; the card's place inside it is // the controller's (and the store's) business, never the ornament's. let nextSidePanelSize = sidePanelController == nil @@ -209,6 +228,7 @@ final class TerminalVisionOrnamentState { || self.umdController !== umdController || self.helperController !== nextHelper || self.talkbackController !== nextTalkback + || self.arrangeBarController !== nextArrangeBar || self.sidePanelController !== sidePanelController || sidePanelSize != nextSidePanelSize || helperCollapsed != nextHelperCollapsed @@ -216,6 +236,15 @@ final class TerminalVisionOrnamentState { self.umdController = umdController self.helperController = nextHelper self.talkbackController = nextTalkback + self.arrangeBarController = nextArrangeBar + // The console row is one drop surface: a key released over the UMD + // or the ARRANGE KEYS slab still lands beside the nearer key. + if !isAuxiliary, let umdController { + keyClusterContext.installDropTarget(on: umdController.view) + } + if let nextArrangeBar { + keyClusterContext.installDropTarget(on: nextArrangeBar.view) + } self.sidePanelController = sidePanelController sidePanelSize = nextSidePanelSize helperCollapsed = nextHelperCollapsed @@ -236,11 +265,18 @@ final class TerminalVisionOrnamentState { umdContentSize = size } + /// A mounted slab reported its own size change (the ARRANGE KEYS bar's + /// RESET coming or going): the mounts re-measure on the next revision. + func refreshMountedSizes() { + revision &+= 1 + } + func clear() { activeTerminalController = nil umdController = nil helperController = nil talkbackController = nil + arrangeBarController = nil sidePanelController = nil sidePanelSize = .zero sidePanelCardFrame = .zero @@ -308,6 +344,7 @@ final class TerminalVisionOrnamentCoordinator { umdController: UIViewController?, helperController: AgentHelperStripViewController?, talkbackController: TalkbackComposerViewController? = nil, + arrangeBarController: UIViewController? = nil, sidePanelController: SidePanelViewController? = nil, windowWidth: CGFloat, windowHeight: CGFloat = 0, @@ -321,6 +358,7 @@ final class TerminalVisionOrnamentCoordinator { umdController: umdController, helperController: helperController, talkbackController: talkbackController, + arrangeBarController: arrangeBarController, sidePanelController: sidePanelController, windowWidth: windowWidth, windowHeight: windowHeight, @@ -539,6 +577,25 @@ private struct TerminalVisionBottomOrnament: View { value: .console ) + if let arrangeBar = state.arrangeBarController { + // The ARRANGE KEYS bar: its own slab below the console row. + TerminalVisionControllerMount( + controller: arrangeBar, + sizing: .arrangeBar, + revision: state.revision, + interfaceStyle: state.interfaceStyle, + onContentSizeChange: { [state] in + Task { @MainActor in state.refreshMountedSizes() } + } + ) + .fixedSize() + .modifier(GlassPrototypeSlabGround(cornerRadius: 8)) + .layoutValue( + key: TerminalVisionConsoleRoleKey.self, + value: .arrange + ) + } + if let talkback = state.talkbackController { // Content-sized like every slab (never window-width), // hung below the console row: the composer's arithmetic @@ -644,6 +701,7 @@ private struct TerminalVisionStackedDeckLayout: Layout { private enum TerminalVisionConsoleRole: Equatable { case helper case console + case arrange case talkback } @@ -667,6 +725,7 @@ private struct TerminalVisionConsoleLayout: Layout { return TerminalVisionConsoleGeometry.resolve( helperSize: subview(.helper, in: subviews)?.sizeThatFits(proposal), consoleSize: console.sizeThatFits(proposal), + arrangeSize: subview(.arrange, in: subviews)?.sizeThatFits(proposal), talkbackSize: subview(.talkback, in: subviews)?.sizeThatFits(proposal), helperLeading: helperLeading, spacing: spacing @@ -683,11 +742,14 @@ private struct TerminalVisionConsoleLayout: Layout { let helper = subview(.helper, in: subviews) let helperSize = helper?.sizeThatFits(proposal) let consoleSize = console.sizeThatFits(proposal) + let arrange = subview(.arrange, in: subviews) + let arrangeSize = arrange?.sizeThatFits(proposal) let talkback = subview(.talkback, in: subviews) let talkbackSize = talkback?.sizeThatFits(proposal) let geometry = TerminalVisionConsoleGeometry.resolve( helperSize: helperSize, consoleSize: consoleSize, + arrangeSize: arrangeSize, talkbackSize: talkbackSize, helperLeading: helperLeading, spacing: spacing @@ -696,47 +758,18 @@ private struct TerminalVisionConsoleLayout: Layout { x: bounds.midX - geometry.size.width / 2, y: bounds.midY - geometry.size.height / 2 ) - console.place( - at: CGPoint( - x: origin.x + geometry.consoleOrigin.x, - y: origin.y + geometry.consoleOrigin.y - ), - anchor: .topLeading, - proposal: ProposedViewSize( - width: consoleSize.width, - height: consoleSize.height - ) - ) - if let helper, - let helperSize, - let helperOrigin = geometry.helperOrigin { - helper.place( - at: CGPoint( - x: origin.x + helperOrigin.x, - y: origin.y + helperOrigin.y - ), + func place(_ view: LayoutSubview?, at slot: CGPoint?, size: CGSize?) { + guard let view, let slot, let size else { return } + view.place( + at: CGPoint(x: origin.x + slot.x, y: origin.y + slot.y), anchor: .topLeading, - proposal: ProposedViewSize( - width: helperSize.width, - height: helperSize.height - ) - ) - } - if let talkback, - let talkbackSize, - let talkbackOrigin = geometry.talkbackOrigin { - talkback.place( - at: CGPoint( - x: origin.x + talkbackOrigin.x, - y: origin.y + talkbackOrigin.y - ), - anchor: .topLeading, - proposal: ProposedViewSize( - width: talkbackSize.width, - height: talkbackSize.height - ) + proposal: ProposedViewSize(width: size.width, height: size.height) ) } + place(console, at: geometry.consoleOrigin, size: consoleSize) + place(helper, at: geometry.helperOrigin, size: helperSize) + place(arrange, at: geometry.arrangeOrigin, size: arrangeSize) + place(talkback, at: geometry.talkbackOrigin, size: talkbackSize) } private func subview(_ role: TerminalVisionConsoleRole, in subviews: Subviews) -> LayoutSubview? { @@ -1092,6 +1125,8 @@ private enum TerminalVisionControllerSizing: Equatable { /// The Talkback slab: the composer's own arithmetic at the width the /// window handed it. case talkback + /// The ARRANGE KEYS bar: its compressed fitting size. + case arrangeBar } private struct TerminalVisionControllerMount: UIViewControllerRepresentable { @@ -1239,6 +1274,10 @@ private final class TerminalVisionControllerHost: UIViewController { if let composer = content as? TalkbackComposerViewController { return composer.fittingContentSize() } + case .arrangeBar: + if let bar = content as? ArrangeKeysBarViewController { + return bar.fittingContentSize() + } } content.loadViewIfNeeded() return content.view.systemLayoutSizeFitting( diff --git a/Multiplex/Views/Terminal/TerminalWindowUIKit.swift b/Multiplex/Views/Terminal/TerminalWindowUIKit.swift index 5e10473c..0e4fce9b 100644 --- a/Multiplex/Views/Terminal/TerminalWindowUIKit.swift +++ b/Multiplex/Views/Terminal/TerminalWindowUIKit.swift @@ -254,6 +254,10 @@ final class TerminalWindowViewController: UIViewController, /// The active tab's open Talkback composer — one per window, re-pointed /// at whichever tab is active; nil while that tab's box is closed. private var talkbackController: TalkbackComposerViewController? + #if os(visionOS) + /// The active tab's ARRANGE KEYS bar as its own ornament slab. + private var arrangeBarController: ArrangeKeysBarViewController? + #endif /// Whether opening the box folded the helper strip to its dot (so /// closing can unfold it) — the strip stays as the user left it otherwise. private var talkbackFoldedHelper = false @@ -795,6 +799,9 @@ final class TerminalWindowViewController: UIViewController, _ = activeController?.pendingLink _ = activeController?.pendingPath _ = activeController?.talkbackOpen + #if os(visionOS) + _ = activeController?.keyBarArranging + #endif // The eyebrow's telemetry matters only while a box is open — // otherwise an attention edge on the host is not this window's. if talkbackOpen { _ = activeTabAgentState } @@ -2749,6 +2756,7 @@ extension TerminalWindowViewController { let sidePanel = mountedSidePanelHostID.flatMap { sidePanelViewControllers[$0] } + renderVisionArrangeBar() visionOrnaments.update( tabCount: route.tabs.count, isAuxiliary: activeTab?.isAuxiliaryPane == true, @@ -2756,6 +2764,7 @@ extension TerminalWindowViewController { umdController: umdController, helperController: helperController, talkbackController: talkbackController, + arrangeBarController: arrangeBarController, sidePanelController: sidePanel, windowWidth: rootView.bounds.width, windowHeight: rootView.bounds.height, @@ -2764,6 +2773,16 @@ extension TerminalWindowViewController { ) } + /// One bar per tab; it watches the order (RESET) itself. + private func renderVisionArrangeBar() { + guard let controller = activeController, controller.keyBarArranging else { + arrangeBarController = nil + return + } + if arrangeBarController?.controller === controller { return } + arrangeBarController = ArrangeKeysBarViewController(controller: controller) + } + private var visionFloatingHelperMaximumWidth: CGFloat { if let shell { return max(1, shell.availableWidth - 24) diff --git a/Multiplex/Views/Terminal/UMDBarUIKit.swift b/Multiplex/Views/Terminal/UMDBarUIKit.swift index 11751857..39c07c09 100644 --- a/Multiplex/Views/Terminal/UMDBarUIKit.swift +++ b/Multiplex/Views/Terminal/UMDBarUIKit.swift @@ -67,6 +67,9 @@ struct UMDBarObservedState: Equatable { var needsYou: Bool var keyboardLocked: Bool var hardwareKeyboardConnected: Bool + /// The tab's key rail / cluster is in Arrange Keys: the menu row reads + /// DONE instead. + var keyBarArranging = false } private struct UMDBarMergeSourceKey: Equatable { @@ -130,6 +133,7 @@ enum UMDBarAction: Equatable { case detach case closeSession case toggleKeyboardLock + case toggleKeyBarArranging case showGuide case showConnectionStats case attach(FileAttachPicker) @@ -253,6 +257,8 @@ final class UMDBarViewController: UIViewController, #if !os(visionOS) configuration.controller?.toggleKeyboardLock() #endif + case .toggleKeyBarArranging: + configuration.controller?.toggleKeyBarArranging() case .showGuide: showGuide() case .showConnectionStats: @@ -296,7 +302,8 @@ final class UMDBarViewController: UIViewController, #else HardwareKeyboardMonitor.shared.isConnected #endif - }() + }(), + keyBarArranging: configuration.controller?.keyBarArranging ?? false ) } onChange: { [weak self] in Task { @MainActor [weak self] in @@ -370,7 +377,9 @@ final class UMDBarViewController: UIViewController, if !configuration.mergeSources.isEmpty { views.append(mergeButton()) } - views.append(guideButton()) + if guideIsDirectChip(displacesDirectActions: false) { + views.append(guideButton()) + } if let overflow = overflowButtonIfNeeded(displacesDirectActions: false) { views.append(overflow) } @@ -455,7 +464,9 @@ final class UMDBarViewController: UIViewController, if !configuration.mergeSources.isEmpty { views.append(mergeButton()) } - views.append(guideButton()) + if guideIsDirectChip(displacesDirectActions: false) { + views.append(guideButton()) + } if let overflow = overflowButtonIfNeeded( displacesDirectActions: false ) { @@ -584,8 +595,17 @@ final class UMDBarViewController: UIViewController, ) } - /// GUIDE is a direct chip wherever the rail has room; only the compact - /// row — which displaces every direct action — carries it in the `⋯`. + /// GUIDE is a direct chip where an iPad/iPhone rail has room; the compact + /// row and every visionOS row carry it in the `⋯` instead. One decision + /// for the row builders and the menu. + private func guideIsDirectChip(displacesDirectActions: Bool) -> Bool { + #if os(visionOS) + false + #else + !displacesDirectActions + #endif + } + private func guideButton() -> UMDBarButton { actionButton( caption: "GUIDE", @@ -825,6 +845,21 @@ final class UMDBarViewController: UIViewController, )) } #endif + // Arrange Keys: a menu row on purpose — every key hold is spoken for + // and a held key is unfindable. Reads DONE while the mode is on. + if configuration.controller != nil { + children.append(menuAction( + title: currentObservedState.keyBarArranging + ? String(localized: "Done Arranging Keys") + : String(localized: "Arrange Keys…"), + image: UIImage( + systemName: currentObservedState.keyBarArranging + ? "checkmark" : "arrow.left.arrow.right" + ), + identifier: "umd.arrangeKeys", + action: .toggleKeyBarArranging + )) + } if configuration.showConnectionStats != nil { children.append(menuAction( title: String(localized: "Connection Stats…"), @@ -833,15 +868,15 @@ final class UMDBarViewController: UIViewController, action: .showConnectionStats )) } - if displacesDirectActions { - // The wide rows carry GUIDE as its own chip; it rides the menu - // only where the direct actions have been displaced. + if !guideIsDirectChip(displacesDirectActions: displacesDirectActions) { children.append(menuAction( title: String(localized: "Guide"), image: UIImage(systemName: "questionmark.circle"), identifier: "umd.guide.action", action: .showGuide )) + } + if displacesDirectActions { children.append(UIMenu( title: String(localized: "Text Size"), options: .displayInline, diff --git a/MultiplexTests/KeyBarOrderTests.swift b/MultiplexTests/KeyBarOrderTests.swift new file mode 100644 index 00000000..ccc48db1 --- /dev/null +++ b/MultiplexTests/KeyBarOrderTests.swift @@ -0,0 +1,175 @@ +import Foundation +import XCTest +@testable import Multiplex + +/// The key rail's order model: always a full permutation, tier-filtered +/// rendering, and the drag rule that moves only the dragged key. +final class KeyBarOrderTests: XCTestCase { + private let phoneTier: [KeyBarSlot] = [ + .escape, .control, .tab, .left, .up, .down, .right, .talkback, .keyboard, .shortcuts, + ] + + func testStandardOrderIsTheDeclaredSlotSequence() { + XCTAssertEqual(KeyBarOrder.standard.slots, KeyBarSlot.allCases) + XCTAssertTrue(KeyBarOrder.standard.isStandard) + XCTAssertEqual(KeyBarOrder.standard.tokens.count, KeyBarSlot.allCases.count) + XCTAssertEqual(KeyBarSlot.returnKey.rawValue, "return", "Persisted tokens never change") + } + + func testStoredTokensNormalizeToAFullPermutation() { + let order = KeyBarOrder(tokens: ["tab", "bogus", "escape", "tab", "return"]) + XCTAssertEqual(Array(order.slots.prefix(3)), [.tab, .escape, .returnKey]) + XCTAssertEqual(order.slots.count, KeyBarSlot.allCases.count) + XCTAssertEqual(Set(order.slots), Set(KeyBarSlot.allCases)) + XCTAssertEqual( + Array(order.slots.dropFirst(3)), + KeyBarSlot.allCases.filter { ![.tab, .escape, .returnKey].contains($0) }, + "Missing slots append in shipped order" + ) + XCTAssertFalse(order.isStandard) + XCTAssertEqual(KeyBarOrder(tokens: order.tokens), order, "Tokens round-trip") + XCTAssertEqual(KeyBarOrder(tokens: []), .standard) + } + + func testArrangeKeepsOnlyTheTierKeysInOrder() { + let order = KeyBarOrder(slots: [.shortcuts, .keyboard, .escape]) + XCTAssertEqual( + order.arrange(phoneTier), + [.shortcuts, .keyboard, .escape, .control, .tab, .left, .up, .down, .right, .talkback] + ) + XCTAssertEqual(KeyBarOrder.standard.arrange(phoneTier), phoneTier) + XCTAssertEqual(order.arrange([]), []) + } + + func testRightwardMoveLandsJustAfterTheNeighbourItPassed() { + // A phone tier hides the symbols and page keys between TAB and the + // arrows. Dragging TAB past → must not drag those along. + let moved = KeyBarOrder.standard.moving(.tab, toVisibleIndex: 6, among: phoneTier) + XCTAssertEqual( + moved.slots, + [ + .escape, .control, .tilde, .pipe, .slash, .hyphen, .pageUp, .pageDown, + .left, .up, .down, .right, .tab, .returnKey, .talkback, .keyboard, .shortcuts, + ] + ) + XCTAssertEqual( + moved.arrange(phoneTier), + [.escape, .control, .left, .up, .down, .right, .tab, .talkback, .keyboard, .shortcuts], + "The phone sees exactly the drop it made" + ) + } + + func testLeftwardMoveLandsJustBeforeTheNeighbourItPassed() { + let moved = KeyBarOrder.standard.moving(.left, toVisibleIndex: 0, among: phoneTier) + XCTAssertEqual(Array(moved.slots.prefix(4)), [.left, .escape, .control, .tab]) + XCTAssertEqual( + Array(moved.slots.dropFirst(4)), + [.tilde, .pipe, .slash, .hyphen, .pageUp, .pageDown, .up, .down, .right, + .returnKey, .talkback, .keyboard, .shortcuts] + ) + // Rightward up to a hidden run: ESC dropped after TAB (before ← on + // the phone) lands right after TAB, ahead of the symbols and page + // keys the phone never showed. + let escape = KeyBarOrder.standard.moving(.escape, toVisibleIndex: 2, among: phoneTier) + XCTAssertEqual( + escape.slots, + [ + .control, .tab, .escape, .tilde, .pipe, .slash, .hyphen, .pageUp, .pageDown, + .left, .up, .down, .right, .returnKey, .talkback, .keyboard, .shortcuts, + ] + ) + // Leftward past the same run: ← dropped before TAB lands right + // before TAB, the hidden run still after it. + let arrow = KeyBarOrder.standard.moving(.left, toVisibleIndex: 2, among: phoneTier) + XCTAssertEqual( + Array(arrow.slots.prefix(5)), + [.escape, .control, .left, .tab, .tilde] + ) + } + + func testMoveClampsIgnoresAbsentKeysAndKeepsAPermutation() { + XCTAssertEqual( + KeyBarOrder.standard.moving(.tab, toVisibleIndex: 2, among: phoneTier), + .standard, + "Dropping a key where it already sits changes nothing" + ) + XCTAssertEqual( + KeyBarOrder.standard.moving(.tilde, toVisibleIndex: 0, among: phoneTier), + .standard, + "A key the tier does not show cannot move" + ) + let clamped = KeyBarOrder.standard.moving(.escape, toVisibleIndex: 99, among: phoneTier) + XCTAssertEqual(clamped.slots.last, .escape) + XCTAssertEqual(clamped.slots.count, KeyBarSlot.allCases.count) + XCTAssertEqual(Set(clamped.slots), Set(KeyBarSlot.allCases)) + XCTAssertEqual( + KeyBarOrder.standard.moving(.escape, toVisibleIndex: 1, among: [.escape]), + .standard, + "One visible key has nowhere to go" + ) + } + + /// Whatever the move, the tier that made it sees the plain reorder — the + /// invariant the rail relies on to skip a rebuild after its own drop. + func testEveryVisibleMoveIsSeenBackAsThatMove() { + for from in phoneTier.indices { + for to in phoneTier.indices { + let slot = phoneTier[from] + var expected = phoneTier + expected.remove(at: from) + expected.insert(slot, at: to) + let moved = KeyBarOrder.standard.moving(slot, toVisibleIndex: to, among: phoneTier) + XCTAssertEqual(moved.arrange(phoneTier), expected, "\(slot) \(from) → \(to)") + XCTAssertEqual(moved.slots.count, KeyBarSlot.allCases.count) + } + } + } +} + +@MainActor +final class KeyBarOrderStoreTests: XCTestCase { + private var suite: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suite = "KeyBarOrderStoreTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suite) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suite) + super.tearDown() + } + + func testFreshStoreIsStandardAndACustomOrderPersistsAsTokens() { + let store = KeyBarOrderStore(defaults: defaults) + XCTAssertEqual(store.order, .standard) + XCTAssertNil(defaults.object(forKey: KeyBarOrderStore.key)) + + let custom = KeyBarOrder(slots: [.shortcuts, .escape]) + store.setOrder(custom) + XCTAssertEqual(store.order, custom) + XCTAssertEqual( + defaults.stringArray(forKey: KeyBarOrderStore.key)?.prefix(2).map { $0 }, + ["shortcuts", "escape"] + ) + XCTAssertEqual(KeyBarOrderStore(defaults: defaults).order, custom, "Comes back on relaunch") + + store.reset() + XCTAssertEqual(store.order, .standard) + XCTAssertNil( + defaults.object(forKey: KeyBarOrderStore.key), + "The shipped order is the absence of a record" + ) + } + + func testGarbageOnDiskFallsBackToTheShippedOrder() { + defaults.set("not an array", forKey: KeyBarOrderStore.key) + XCTAssertEqual(KeyBarOrderStore(defaults: defaults).order, .standard) + defaults.set(["bogus", "escape", "escape"], forKey: KeyBarOrderStore.key) + let store = KeyBarOrderStore(defaults: defaults) + XCTAssertEqual(store.order.slots.first, .escape) + XCTAssertEqual(store.order.slots.count, KeyBarSlot.allCases.count) + } +} diff --git a/MultiplexTests/TerminalKeyBarUIKitTests.swift b/MultiplexTests/TerminalKeyBarUIKitTests.swift index c6397d58..2b2e4a07 100644 --- a/MultiplexTests/TerminalKeyBarUIKitTests.swift +++ b/MultiplexTests/TerminalKeyBarUIKitTests.swift @@ -33,6 +33,23 @@ final class TerminalTallyKeyControlTests: XCTestCase { key.isLatched = true XCTAssertTrue(key.accessibilityTraits.contains(.selected)) + + // Arrange Keys: the key stays a live control (visionOS gaze targets + // interactive elements) but sends nothing; the system drag source + // arms in its place and disarms with the mode. + key.isArranging = true + XCTAssertTrue(key.isUserInteractionEnabled) + XCTAssertTrue(key.isArrangeDragSourceForTesting) + key.sendActions(for: .touchDown) + key.sendActions(for: .touchUpInside) + XCTAssertEqual(activations, 2, "A tap in the mode sends nothing") + XCTAssertFalse(key.accessibilityActivate()) + XCTAssertEqual(key.accessibilityHint, "Drag to move this key") + key.isArranging = false + XCTAssertFalse(key.isArrangeDragSourceForTesting) + XCTAssertNil(key.accessibilityHint) + key.sendActions(for: .touchUpInside) + XCTAssertEqual(activations, 3) } func testNativeControlComboKeepsOrderLabelsAndTypedLetters() { @@ -97,12 +114,15 @@ final class TerminalKeyBarUIKitTests: XCTestCase { let terminal = TerminalView( frame: CGRect(x: 0, y: 0, width: 420, height: 200) ) + // An isolated store: the simulator's container may carry a custom + // order from a headless proof, and this test speaks for the shipped one. let bar = TerminalKeyBar( terminal: terminal, controller: nil, performShortcut: { _ in }, finishTmuxCopyMode: {}, - shortcutBackend: .tmux + shortcutBackend: .tmux, + orderStore: KeyBarOrderStore(defaults: try isolatedDefaults()) ) bar.frame = CGRect(x: 0, y: 0, width: 420, height: TerminalKeyBar.barHeight) bar.layoutIfNeeded() @@ -194,6 +214,290 @@ final class TerminalKeyBarUIKitTests: XCTestCase { }) } + func testKeyFramesCountGroupsAndADropLandsOnTheNearestOtherKey() { + let full = specification(width: 1024, returns: true) + XCTAssertEqual(full.tier, .full) + let frames = TerminalKeyBarLayout.keyFrames( + specification: full, + keyCount: 17, + includesReturn: true, + width: 1024, + contentSafeArea: .zero, + keyTop: TerminalKeyBar.keyTopInset, + keyHeight: TerminalKeyBar.keyHeight + ) + XCTAssertEqual(frames.count, 17) + XCTAssertEqual(frames[0].minX, TerminalKeyBarLayout.regularEdgeInset) + XCTAssertEqual(frames[16].maxX, 1024 - TerminalKeyBarLayout.regularEdgeInset, accuracy: 0.5) + XCTAssertTrue(frames.allSatisfy { $0.minY == TerminalKeyBar.keyTopInset && $0.width == 46 }) + // Three · four symbols · the rest: spacing inside a group, and the + // slack split evenly over the two group gaps. + XCTAssertEqual(frames[1].minX - frames[0].maxX, 6) + XCTAssertEqual(frames[4].minX - frames[3].maxX, 6) + XCTAssertEqual( + frames[3].minX - frames[2].maxX, + frames[7].minX - frames[6].maxX, + accuracy: 0.001 + ) + XCTAssertGreaterThan(frames[3].minX - frames[2].maxX, 8) + XCTAssertEqual(TerminalKeyBarLayout.keyFrames( + specification: full, keyCount: 0, includesReturn: true, width: 1024, + contentSafeArea: .zero, keyTop: 7, keyHeight: 34 + ), []) + + // A drop lands on the nearest OTHER key by centre; the dragged key's + // own slot (three points of slack a side) is no target. + func target(_ x: CGFloat, source: Int) -> Int? { + RowDropGeometry.dropTargetIndex(x: x, restingFrames: frames, sourceIndex: source) + } + XCTAssertNil(target(frames[0].maxX + 3, source: 0)) + XCTAssertEqual(target(frames[0].maxX + 4, source: 0), 1) + XCTAssertEqual(target(frames[2].midX, source: 3), 2) + XCTAssertEqual( + target((frames[2].maxX + frames[3].minX) / 2 - 1, source: 0), 2, + "A group gap belongs to the nearer key" + ) + XCTAssertEqual(target(5000, source: 3), 16) + XCTAssertNil(RowDropGeometry.dropTargetIndex(x: 10, restingFrames: [frames[0]], sourceIndex: 0)) + } + + func testRailFollowsTheStoredOrderAndArrangeModeMovesKeysThroughIt() throws { + let suite = "TerminalKeyBarUIKitTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = KeyBarOrderStore(defaults: defaults) + store.setOrder(KeyBarOrder(slots: [.shortcuts, .keyboard, .tab])) + let host = Host(name: "devbox", hostname: "127.0.0.1", username: "dev") + let controller = TerminalSessionController( + route: TerminalRoute(hostID: host.id, mode: .attach(sessionName: "main")), + host: host + ) + let terminal = TerminalView(frame: CGRect(x: 0, y: 0, width: 1024, height: 200)) + let bar = TerminalKeyBar( + terminal: terminal, + controller: controller, + performShortcut: { _ in }, + finishTmuxCopyMode: {}, + shortcutBackend: .tmux, + orderStore: store + ) + // In a window: the ARRANGE KEYS bar mounts there, over the rail. + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1024, height: 300)) + window.addSubview(bar) + bar.frame = CGRect(x: 0, y: 200, width: 1024, height: TerminalKeyBar.barHeight) + func state(arranging: Bool) -> TerminalKeyBarObservedState { + TerminalKeyBarObservedState( + hardwareKeyboardConnected: false, + keyboardLocked: false, + isDictating: false, + talkbackOpen: false, + arranging: arranging, + order: store.order + ) + } + // Painted, not observed: the simulator reports a hardware keyboard, + // which would fill the keyboard slot with the mic. + bar.applyObservedState(state(arranging: false)) + bar.layoutIfNeeded() + + // The order permutes keys across the tier's slots; the gaps stay. + XCTAssertEqual(Array(bar.renderedSlots.prefix(4)), [.shortcuts, .keyboard, .tab, .escape]) + XCTAssertEqual( + bar.renderedKeys.prefix(3).map(\.accessibilityIdentifier), + ["terminal.keybar.tmux", "terminal.keybar.keyboard", "terminal.keybar.tab"] + ) + XCTAssertEqual(bar.renderedKeys.count, 17) + let frames = bar.renderedKeys.map(\.frame) + XCTAssertEqual(frames[1].minX - frames[0].maxX, 6) + XCTAssertGreaterThan(frames[3].minX - frames[2].maxX, 8, "The first group gap holds after slot 3") + XCTAssertTrue(bar.renderedKeys.allSatisfy(\.isUserInteractionEnabled)) + XCTAssertNil(bar.renderedKeys[0].accessibilityCustomActions) + XCTAssertFalse(bar.isArrangingForTesting) + XCTAssertNil(bar.arrangeBarForTesting) + + // Arrange Keys flips in place: same controls, now inert to touch, + // movable through VoiceOver's custom actions. + let escapeBefore = bar.renderedKeys[3] + bar.applyObservedState(state(arranging: true)) + bar.layoutIfNeeded() + window.layoutIfNeeded() + XCTAssertTrue(bar.isArrangingForTesting) + XCTAssertTrue(bar.renderedKeys[3] === escapeBefore, "Entering the mode rebuilds nothing") + // Still live controls — gaze on visionOS targets interactive + // elements — but inert: no action, no hold, a drag source instead. + XCTAssertTrue(bar.renderedKeys.allSatisfy(\.isUserInteractionEnabled)) + XCTAssertTrue(bar.renderedKeys.allSatisfy(\.isArranging)) + XCTAssertTrue(bar.renderedKeys.allSatisfy(\.isArrangeDragSourceForTesting)) + XCTAssertEqual( + bar.renderedKeys[0].accessibilityCustomActions?.map(\.name), + ["Move left", "Move right"] + ) + XCTAssertFalse(bar.renderedKeys[0].accessibilityActivate(), "A press sends nothing in the mode") + + // The bar hangs over the rail — a window subview pinned 6 pt above + // the rail's top and centred on it — with RESET (the order is + // custom) and DONE. + let arrangeBar = try XCTUnwrap(bar.arrangeBarForTesting) + XCTAssertTrue(arrangeBar.superview === window) + XCTAssertEqual(arrangeBar.frame.maxY, bar.frame.minY - 6, accuracy: 0.5) + XCTAssertEqual(arrangeBar.frame.midX, bar.frame.midX, accuracy: 0.5) + XCTAssertNotNil(chip("Restore the standard key order", in: arrangeBar)) + XCTAssertTrue(renderedText(in: arrangeBar).contains("ARRANGE KEYS")) + + XCTAssertFalse(bar.moveKey(.shortcuts, by: -1), "The leftmost key has nowhere to go") + XCTAssertFalse(bar.moveKey(.tilde, by: 99)) + XCTAssertTrue(bar.moveKey(.tab, by: 1)) + XCTAssertEqual(Array(store.order.slots.prefix(4)), [.shortcuts, .keyboard, .escape, .tab]) + // Painted here (the observation's turn in production), the row + // rebuilds in the new order, still arranging. + bar.applyObservedState(state(arranging: true)) + bar.layoutIfNeeded() + XCTAssertEqual(Array(bar.renderedSlots.prefix(4)), [.shortcuts, .keyboard, .escape, .tab]) + XCTAssertTrue(bar.renderedKeys.allSatisfy(\.isArranging)) + XCTAssertTrue(bar.arrangeBarForTesting === arrangeBar, "RESET's presence unchanged: the bar stays") + + // RESET restores the shipped order; the rebuilt bar drops the chip. + let reset = try XCTUnwrap(chip("Restore the standard key order", in: arrangeBar)) + XCTAssertTrue(reset.accessibilityActivate()) + XCTAssertEqual(store.order, .standard) + bar.applyObservedState(state(arranging: true)) + bar.layoutIfNeeded() + XCTAssertEqual(Array(bar.renderedSlots.prefix(3)), [.escape, .control, .tab]) + let standardBar = try XCTUnwrap(bar.arrangeBarForTesting) + XCTAssertFalse(standardBar === arrangeBar) + XCTAssertNil(chip("Restore the standard key order", in: standardBar)) + + // DONE ends the mode through the controller; painted back, the rail + // is live again and the bar is gone. + controller.setKeyBarArranging(true) + let done = try XCTUnwrap(chip("Done arranging keys", in: standardBar)) + XCTAssertTrue(done.isProminent) + XCTAssertTrue(done.accessibilityActivate()) + XCTAssertFalse(controller.keyBarArranging) + bar.applyObservedState(state(arranging: false)) + bar.layoutIfNeeded() + XCTAssertFalse(bar.isArrangingForTesting) + XCTAssertNil(bar.arrangeBarForTesting) + XCTAssertNil(standardBar.superview) + XCTAssertTrue(bar.renderedKeys.allSatisfy(\.isUserInteractionEnabled)) + XCTAssertTrue(bar.renderedKeys.allSatisfy { !$0.isArranging }) + XCTAssertNil(bar.renderedKeys[0].accessibilityCustomActions) + XCTAssertTrue(bar.renderedKeys[0].accessibilityActivate()) + + // A rail leaving its window takes the mode (and the bar) with it. + controller.setKeyBarArranging(true) + bar.applyObservedState(state(arranging: true)) + bar.layoutIfNeeded() + XCTAssertNotNil(bar.arrangeBarForTesting) + bar.removeFromSuperview() + XCTAssertNil(bar.arrangeBarForTesting) + XCTAssertFalse(controller.keyBarArranging) + } + + /// A fake drop session against the rail's own drop interaction: ESC + /// dropped on TAB lands after it, in place, with the displaced keys + /// parked on their old centres for the animator. + func testADropSessionOnTheRailLandsThroughTheDelegate() throws { + let store = KeyBarOrderStore(defaults: try isolatedDefaults()) + let host = Host(name: "devbox", hostname: "127.0.0.1", username: "dev") + let controller = TerminalSessionController( + route: TerminalRoute(hostID: host.id, mode: .attach(sessionName: "main")), + host: host + ) + let bar = TerminalKeyBar( + terminal: TerminalView(frame: CGRect(x: 0, y: 0, width: 1024, height: 200)), + controller: controller, + performShortcut: { _ in }, + finishTmuxCopyMode: {}, + shortcutBackend: .tmux, + orderStore: store + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1024, height: 300)) + window.addSubview(bar) + bar.frame = CGRect(x: 0, y: 200, width: 1024, height: TerminalKeyBar.barHeight) + bar.applyObservedState(TerminalKeyBarObservedState( + hardwareKeyboardConnected: false, + keyboardLocked: false, + isDictating: false, + talkbackOpen: false, + arranging: true, + order: store.order + )) + bar.layoutIfNeeded() + window.layoutIfNeeded() + let (escape, control, tab) = (bar.renderedKeys[0], bar.renderedKeys[1], bar.renderedKeys[2]) + let frames = bar.renderedKeys.map(\.frame) + func dropInteraction(on view: UIView) -> UIDropInteraction? { + view.interactions.compactMap { $0 as? UIDropInteraction } + .first { $0.delegate === bar.dropCoordinator } + } + let interaction = try XCTUnwrap(dropInteraction(on: bar)) + let coordinator = bar.dropCoordinator + XCTAssertNotNil(dropInteraction(on: window), "The container is a host too") + XCTAssertNotNil(dropInteraction(on: try XCTUnwrap(bar.arrangeBarForTesting))) + + let dragSession = FakeDragSession() + let item = try XCTUnwrap(escape.arrangeDragItem?(dragSession)) + let session = FakeDropSession( + items: [item], + localDragSession: dragSession, + windowPoint: CGPoint(x: frames[2].midX, y: 220) + ) + XCTAssertTrue(coordinator.dropInteraction(interaction, canHandle: session)) + XCTAssertEqual(coordinator.dropInteraction(interaction, sessionDidUpdate: session).operation, .move) + XCTAssertTrue(tab.isDropTarget) + session.windowPoint = CGPoint(x: frames[0].midX, y: 220) + XCTAssertEqual(coordinator.dropInteraction(interaction, sessionDidUpdate: session).operation, .forbidden) + XCTAssertFalse(tab.isDropTarget) + + session.windowPoint = CGPoint(x: frames[2].midX, y: 220) + coordinator.dropInteraction(interaction, performDrop: session) + XCTAssertEqual(Array(bar.renderedSlots.prefix(3)), [.control, .tab, .escape]) + XCTAssertEqual(Array(store.order.slots.prefix(3)), [.control, .tab, .escape]) + XCTAssertTrue(bar.renderedKeys[2] === escape, "Same control, new slot") + XCTAssertEqual(escape.center.x, frames[2].midX, accuracy: 0.5) + XCTAssertEqual(control.transform.tx, frames[1].midX - frames[0].midX, accuracy: 0.5) + XCTAssertEqual(tab.transform.tx, frames[2].midX - frames[1].midX, accuracy: 0.5) + let preview = coordinator.dropInteraction( + interaction, previewForDropping: item, withDefault: UITargetedDragPreview(view: escape) + ) + XCTAssertEqual(preview?.target.center, escape.center) + + let animator = FakeDragAnimator() + coordinator.dropInteraction(interaction, item: item, willAnimateDropWith: animator) + animator.runAnimations() + animator.runCompletions() + coordinator.dropInteraction(interaction, concludeDrop: session) + coordinator.dropInteraction(interaction, sessionDidEnd: session) + XCTAssertEqual(control.transform, .identity) + XCTAssertTrue(bar.renderedKeys.allSatisfy { !$0.isDropTarget && $0.isArranging }) + } + + private func chip(_ label: String, in root: UIView) -> UIKitChassisChip? { + if let root = root as? UIKitChassisChip, root.accessibilityLabel == label { return root } + for child in root.subviews { + if let match = chip(label, in: child) { return match } + } + return nil + } + + private func renderedText(in root: UIView) -> [String] { + var values: [String] = [] + if let label = root as? UILabel { + if let text = label.text { values.append(text) } + if let text = label.attributedText?.string { values.append(text) } + } + for child in root.subviews { values.append(contentsOf: renderedText(in: child)) } + return values + } + + private func isolatedDefaults() throws -> UserDefaults { + let suite = "TerminalKeyBarUIKitTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + addTeardownBlock { defaults.removePersistentDomain(forName: suite) } + return defaults + } + private func specification( width: CGFloat, returns: Bool, @@ -214,8 +518,8 @@ final class TerminalKeyBarUIKitTests: XCTestCase { #else @MainActor final class TerminalKeyClusterUIKitTests: XCTestCase { - func testNativeSlabsPreserveRegularAndCompactGeometry() { - let context = TerminalKeyClusterContext() + func testNativeSlabsPreserveRegularAndCompactGeometry() throws { + let context = TerminalKeyClusterContext(orderStore: try isolatedStore()) let leading = TerminalKeyClusterGroupView( role: .leading, metric: .regular, @@ -251,8 +555,10 @@ final class TerminalKeyClusterUIKitTests: XCTestCase { ) } - func testNativeSlabsKeepKeyOrderRepeatSemanticsAndAccessibility() { - let context = TerminalKeyClusterContext() + func testNativeSlabsKeepKeyOrderRepeatSemanticsAndAccessibility() throws { + // An isolated store: the simulator's container may carry a custom + // order from a headless proof, and this test speaks for the shipped one. + let context = TerminalKeyClusterContext(orderStore: try isolatedStore()) let leading = TerminalKeyClusterGroupView( role: .leading, metric: .regular, @@ -292,5 +598,281 @@ final class TerminalKeyClusterUIKitTests: XCTestCase { String(describing: type(of: $0)).contains("Hosting") }) } + + func testClustersFollowTheStoredOrderAcrossSlabsAndArrangeMovesCrossTheUMD() throws { + let suite = "TerminalKeyClusterUIKitTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = KeyBarOrderStore(defaults: defaults) + store.setOrder(KeyBarOrder(slots: [.keyboard, .escape])) + let context = TerminalKeyClusterContext(orderStore: store) + let leading = TerminalKeyClusterGroupView(role: .leading, metric: .regular, context: context) + let trailing = TerminalKeyClusterGroupView(role: .trailing, metric: .regular, context: context) + let standalone = TerminalKeyClusterGroupView( + role: .standalone, + metric: .regular, + context: context + ) + + // The order permutes keys across the ornament's ten slots: the first + // three lead, the other seven trail; the slabs keep their widths. + XCTAssertEqual(leading.renderedSlots, [.keyboard, .escape, .control]) + XCTAssertEqual( + trailing.renderedSlots, + [.tab, .left, .up, .down, .right, .returnKey, .talkback] + ) + XCTAssertEqual( + leading.keys.map(\.accessibilityIdentifier), + [ + "terminal.keyCluster.keyboard", + "terminal.keyCluster.escape", + "terminal.keyCluster.control", + ] + ) + XCTAssertEqual(leading.intrinsicContentSize, CGSize(width: 174, height: 44)) + XCTAssertEqual(trailing.intrinsicContentSize, CGSize(width: 400, height: 44)) + XCTAssertTrue(leading.carriesControlKey, "CTRL's slab follows the order") + XCTAssertFalse(trailing.carriesControlKey) + XCTAssertFalse(trailing.keys[0].repeats, "TAB took an arrow slot; repeat follows the key") + XCTAssertTrue(trailing.keys[1].repeats) + XCTAssertEqual(Array(standalone.renderedSlots.prefix(3)), [.keyboard, .escape, .control]) + XCTAssertEqual(standalone.renderedSlots.count, 10) + + // The minimal standalone tier drops the arrows and keeps the order. + standalone.frame = CGRect(x: 0, y: 0, width: 300, height: 44) + standalone.layoutIfNeeded() + XCTAssertEqual( + standalone.renderedSlots, + [.keyboard, .escape, .control, .tab, .returnKey, .talkback] + ) + + // In a window, like the ornament: the context reads the live row + // from the slabs on screen. + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 900, height: 100)) + window.addSubview(leading) + leading.frame = CGRect(x: 0, y: 0, width: 174, height: 44) + window.addSubview(trailing) + trailing.frame = CGRect(x: 480, y: 0, width: 400, height: 44) + leading.layoutIfNeeded() + trailing.layoutIfNeeded() + + // Arrange mode flows from the active tab through the context and + // flips every slab in place. + let host = Host(name: "devbox", hostname: "127.0.0.1", username: "dev") + let controller = TerminalSessionController( + route: TerminalRoute(hostID: host.id, mode: .attach(sessionName: "main")), + host: host + ) + context.update(controller: controller) + XCTAssertTrue(leading.keys.allSatisfy(\.isUserInteractionEnabled)) + controller.setKeyBarArranging(true) + leading.applyContextState() + trailing.applyContextState() + XCTAssertTrue(leading.keys.allSatisfy(\.isArranging)) + XCTAssertTrue(trailing.keys.allSatisfy(\.isArranging)) + XCTAssertTrue(trailing.keys.allSatisfy(\.isUserInteractionEnabled), "Gaze needs live targets") + XCTAssertTrue(trailing.keys.allSatisfy(\.isArrangeDragSourceForTesting)) + XCTAssertEqual( + leading.keys[0].accessibilityCustomActions?.map(\.name), + ["Move left", "Move right"] + ) + XCTAssertFalse(leading.keys[0].accessibilityActivate()) + + // A move across the UMD: the keyboard key leaves the leading slab + // for the trailing one and TAB comes the other way — one order + // write, both slabs rebuilt, still arranging. + XCTAssertFalse(context.moveKey(.keyboard, by: -1), "The leftmost key has nowhere to go") + XCTAssertTrue(context.moveKey(.keyboard, by: 3)) + XCTAssertEqual( + Array(store.order.slots.prefix(4)), + [.escape, .control, .tab, .keyboard] + ) + XCTAssertEqual(leading.renderedSlots, [.escape, .control, .tab]) + XCTAssertEqual( + trailing.renderedSlots, + [.keyboard, .left, .up, .down, .right, .returnKey, .talkback] + ) + XCTAssertTrue(leading.keys.allSatisfy(\.isArranging)) + XCTAssertTrue(trailing.keys.allSatisfy(\.isArranging)) + XCTAssertFalse(leading.carriesControlKey == trailing.carriesControlKey) + + // Another tab taking the ornament ends the mode on the one leaving. + let other = TerminalSessionController( + route: TerminalRoute(hostID: host.id, mode: .attach(sessionName: "scratch")), + host: host + ) + context.update(controller: other) + XCTAssertFalse(controller.keyBarArranging) + leading.applyContextState() + XCTAssertTrue(leading.keys.allSatisfy { !$0.isArranging }) + XCTAssertNil(leading.keys[0].accessibilityCustomActions) + } + + /// A fake drop session against the trailing slab's drop interaction + /// (the visionOS simulator cannot lift a drag): TAB dropped on ← lands + /// after it across the UMD, ← comes over parked on its old centre for + /// the animator, and a drop on CTRL brings TAB back. + func testADropSessionOverTheOtherSlabLandsThroughTheDelegate() throws { + let store = try isolatedStore() + let context = TerminalKeyClusterContext(orderStore: store) + let leading = TerminalKeyClusterGroupView(role: .leading, metric: .regular, context: context) + let trailing = TerminalKeyClusterGroupView(role: .trailing, metric: .regular, context: context) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 900, height: 100)) + window.addSubview(leading) + leading.frame = CGRect(x: 0, y: 0, width: 174, height: 44) + window.addSubview(trailing) + trailing.frame = CGRect(x: 480, y: 0, width: 400, height: 44) + leading.layoutIfNeeded() + trailing.layoutIfNeeded() + let host = Host(name: "devbox", hostname: "127.0.0.1", username: "dev") + let controller = TerminalSessionController( + route: TerminalRoute(hostID: host.id, mode: .attach(sessionName: "main")), + host: host + ) + context.update(controller: controller) + let tab = try XCTUnwrap(leading.keys.last) + let dragSession = FakeDragSession() + XCTAssertNil(tab.arrangeDragItem?(dragSession), "Nothing lifts outside the mode") + controller.setKeyBarArranging(true) + leading.applyContextState() + trailing.applyContextState() + let ornament = TerminalKeyClusterContext.ornamentSlots + + // Leading keys at x 12 · 64 · 116 (46 wide), the trailing slab's + // first at 492: the UMD between belongs to the nearer key. + let row: CGFloat = 22 + let coordinator = context.dropCoordinator + XCTAssertNil(coordinator.targetSlot(at: CGPoint(x: 139, y: row), in: window, source: .tab)) + XCTAssertEqual(coordinator.targetSlot(at: CGPoint(x: 300, y: row), in: window, source: .escape), .tab) + XCTAssertEqual(coordinator.targetSlot(at: CGPoint(x: 400, y: row), in: window, source: .escape), .left) + + func dropInteraction(on view: UIView) -> UIDropInteraction? { + view.interactions.compactMap { $0 as? UIDropInteraction } + .first { $0.delegate === context.dropCoordinator } + } + let interaction = try XCTUnwrap(dropInteraction(on: trailing), "Every slab is a drop host") + XCTAssertNotNil(dropInteraction(on: leading)) + let item = try XCTUnwrap(tab.arrangeDragItem?(dragSession)) + let session = FakeDropSession( + items: [item], + localDragSession: dragSession, + windowPoint: CGPoint(x: 515, y: row) + ) + XCTAssertTrue(coordinator.dropInteraction(interaction, canHandle: session)) + XCTAssertEqual(coordinator.dropInteraction(interaction, sessionDidUpdate: session).operation, .move) + XCTAssertTrue(trailing.keys[0].isDropTarget, "← lights") + coordinator.dropInteraction(interaction, performDrop: session) + XCTAssertEqual(leading.renderedSlots, [.escape, .control, .left]) + XCTAssertEqual(trailing.renderedSlots.first, .tab) + XCTAssertEqual(Array(store.order.arrange(ornament).prefix(4)), [.escape, .control, .left, .tab]) + let landed = try XCTUnwrap(trailing.keys.first) + let left = try XCTUnwrap(leading.keys.last) + XCTAssertEqual(left.transform.tx, 515 - 139, accuracy: 0.5, "← parked on its old centre") + XCTAssertTrue(leading.keys.allSatisfy(\.isArranging) && trailing.keys.allSatisfy(\.isArranging)) + let preview = coordinator.dropInteraction( + interaction, previewForDropping: item, withDefault: UITargetedDragPreview(view: landed) + ) + XCTAssertTrue(preview?.target.container === trailing) + XCTAssertEqual(preview?.target.center, landed.center) + + let animator = FakeDragAnimator() + coordinator.dropInteraction(interaction, item: item, willAnimateDropWith: animator) + animator.runAnimations() + animator.runCompletions() + coordinator.dropInteraction(interaction, concludeDrop: session) + coordinator.dropInteraction(interaction, sessionDidEnd: session) + XCTAssertEqual(left.transform, .identity) + XCTAssertTrue(trailing.keys.allSatisfy { !$0.isDropTarget }) + + // And back: TAB dropped on CTRL lands before it. + XCTAssertTrue(context.dropKey(.tab, onto: .control)) + XCTAssertEqual(leading.renderedSlots, [.escape, .tab, .control]) + XCTAssertEqual(trailing.renderedSlots.first, .left) + XCTAssertFalse(context.dropKey(.pipe, onto: .tab), "A rail-only slot is not on the ornament") + } + + func testSlotFramesRunKeysAtSpacingAndRunsAGroupGapApart() { + let frames = TerminalKeyClusterGroupView.slotFrames( + widths: Array(repeating: 46, count: 5), runs: [3, 1, 1], spacing: 6, groupGap: 12 + ) + XCTAssertEqual(frames.map(\.minX), [12, 64, 116, 174, 232]) + XCTAssertTrue(frames.allSatisfy { $0.minY == 9 && $0.height == 26 }) + XCTAssertEqual( + TerminalKeyClusterGroupView.slotFrames(widths: [46], runs: [3], spacing: 6, groupGap: 12).count, + 1, + "Runs stop at the keys there are" + ) + } + + private func isolatedStore() throws -> KeyBarOrderStore { + let suite = "TerminalKeyClusterUIKitTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + addTeardownBlock { defaults.removePersistentDomain(forName: suite) } + return KeyBarOrderStore(defaults: defaults) + } } #endif + +// MARK: - Drag and drop session fakes + +/// A drag session for a key's item closure. +@MainActor +private final class FakeDragSession: NSObject, UIDragSession { + var localContext: Any? + var items: [UIDragItem] = [] + var allowsMoveOperation: Bool { true } + var isRestrictedToDraggingApplication: Bool { true } + func location(in view: UIView) -> CGPoint { .zero } + func hasItemsConforming(toTypeIdentifiers typeIdentifiers: [String]) -> Bool { false } + func canLoadObjects(ofClass aClass: NSItemProviderReading.Type) -> Bool { false } +} + +/// A drop session at one window point carrying the lifted key's item. +@MainActor +private final class FakeDropSession: NSObject, UIDropSession { + let items: [UIDragItem] + let localDragSession: UIDragSession? + let progress = Progress() + var progressIndicatorStyle: UIDropSessionProgressIndicatorStyle = .none + var windowPoint: CGPoint + var allowsMoveOperation: Bool { true } + var isRestrictedToDraggingApplication: Bool { true } + + init(items: [UIDragItem], localDragSession: UIDragSession?, windowPoint: CGPoint) { + self.items = items + self.localDragSession = localDragSession + self.windowPoint = windowPoint + } + + func location(in view: UIView) -> CGPoint { view.convert(windowPoint, from: nil) } + func hasItemsConforming(toTypeIdentifiers typeIdentifiers: [String]) -> Bool { false } + func canLoadObjects(ofClass aClass: NSItemProviderReading.Type) -> Bool { false } + func loadObjects( + ofClass aClass: NSItemProviderReading.Type, + completion: @escaping ([NSItemProviderReading]) -> Void + ) -> Progress { + Progress() + } +} + +@MainActor +private final class FakeDragAnimator: NSObject, UIDragAnimating { + private var animations: [() -> Void] = [] + private var completions: [(UIViewAnimatingPosition) -> Void] = [] + + func addAnimations(_ animations: @escaping () -> Void) { + self.animations.append(animations) + } + + func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) { + completions.append(completion) + } + + func runAnimations() { + animations.forEach { $0() } + } + + func runCompletions() { + completions.forEach { $0(.end) } + } +} diff --git a/MultiplexTests/TerminalWindowUIKitTests.swift b/MultiplexTests/TerminalWindowUIKitTests.swift index 9c8c28e7..56205977 100644 --- a/MultiplexTests/TerminalWindowUIKitTests.swift +++ b/MultiplexTests/TerminalWindowUIKitTests.swift @@ -1010,6 +1010,23 @@ final class TerminalWindowUIKitTests: XCTestCase { XCTAssertEqual(collapsedHelper.size, CGSize(width: 600, height: 88)) XCTAssertEqual(collapsedHelper.helperOrigin, CGPoint(x: 0, y: 4)) XCTAssertEqual(collapsedHelper.consoleOrigin, CGPoint(x: 0, y: 44)) + + // The ARRANGE KEYS bar hangs directly below the console row, the + // Talkback slab below it; the lower half grows by both, the console + // top stays the midpoint. + let arranging = TerminalVisionConsoleGeometry.resolve( + helperSize: nil, + consoleSize: CGSize(width: 600, height: 44), + arrangeSize: CGSize(width: 240, height: 34), + talkbackSize: CGSize(width: 500, height: 120), + helperLeading: false, + spacing: 10 + ) + XCTAssertEqual(arranging.size, CGSize(width: 600, height: 436)) + XCTAssertEqual(arranging.consoleOrigin, CGPoint(x: 0, y: 218)) + XCTAssertEqual(arranging.arrangeOrigin, CGPoint(x: 180, y: 272)) + XCTAssertEqual(arranging.talkbackOrigin, CGPoint(x: 50, y: 316)) + XCTAssertNil(helperless.arrangeOrigin) } func testVisionStackedDeckGeometryHangsTheExtraRowBelowTheAnchor() { diff --git a/MultiplexTests/UMDBarUIKitTests.swift b/MultiplexTests/UMDBarUIKitTests.swift index a85e2a03..ca36818e 100644 --- a/MultiplexTests/UMDBarUIKitTests.swift +++ b/MultiplexTests/UMDBarUIKitTests.swift @@ -176,7 +176,7 @@ final class UMDBarUIKitTests: XCTestCase { XCTAssertNotNil(control("umd.tmux", in: ornament.view)) } - func testWideRailsCarryGuideAsItsOwnChipNeverInTheOverflow() throws { + func testWideRailsCarryGuideAsAChipOnIPadAndInTheOverflowOnVisionOS() throws { let terminal = terminalController(useMosh: false) let controller = UMDBarViewController(configuration: configuration( controller: terminal, @@ -193,14 +193,27 @@ final class UMDBarUIKitTests: XCTestCase { hardwareKeyboardConnected: true )) XCTAssertNotNil(view("umd.shell.wide", in: controller.view)) - let guide = try XCTUnwrap(control("umd.guide", in: controller.view)) - XCTAssertEqual(guide.accessibilityLabel, "Guide") - // With GUIDE out of the menu the keyboard lock is its only other - // resident — hidden here (a hardware keyboard is connected, and on - // visionOS the row is compiled out), so the ⋯ has nothing to carry. - XCTAssertNil(control("umd.overflow", in: controller.view)) - - #if !os(visionOS) + let overflow = try XCTUnwrap(control("umd.overflow", in: controller.view) as? UIButton) + let regular = UMDBarViewController(configuration: configuration(controller: terminal)) + regular.loadViewIfNeeded() + #if os(visionOS) + XCTAssertNil(control("umd.guide", in: controller.view)) + XCTAssertNil(control("umd.guide", in: regular.view)) + XCTAssertEqual( + actions(in: try XCTUnwrap(overflow.menu)).map(\.title), + ["Arrange Keys…", "Guide"] + ) + #else + XCTAssertEqual( + try XCTUnwrap(control("umd.guide", in: controller.view)).accessibilityLabel, + "Guide" + ) + XCTAssertNotNil(control("umd.guide", in: regular.view)) + // A hardware keyboard hides the lock row; Arrange Keys stays. + XCTAssertEqual( + actions(in: try XCTUnwrap(overflow.menu)).map(\.title), + ["Arrange Keys…"] + ) controller.applyObservedState(UMDBarObservedState( status: .live, contactLost: false, @@ -208,24 +221,12 @@ final class UMDBarUIKitTests: XCTestCase { keyboardLocked: false, hardwareKeyboardConnected: false )) - let lock = try XCTUnwrap( - control("umd.overflow", in: controller.view) as? UIButton - ) + let lock = try XCTUnwrap(control("umd.overflow", in: controller.view) as? UIButton) XCTAssertEqual( actions(in: try XCTUnwrap(lock.menu)).map(\.title), - ["Lock Keyboard Closed"] + ["Lock Keyboard Closed", "Arrange Keys…"] ) - XCTAssertNotNil(control("umd.guide", in: controller.view)) #endif - - let regular = UMDBarViewController(configuration: configuration( - controller: terminal - )) - regular.loadViewIfNeeded() - XCTAssertNotNil( - control("umd.guide", in: regular.view), - "the classic window rail carries the chip too" - ) } func testCompactOverflowContainsDisplacedActionsMergeDestructionAndFileSources() throws { @@ -317,6 +318,48 @@ final class UMDBarUIKitTests: XCTestCase { #endif } + func testOverflowCarriesArrangeKeysThatReadsDoneWhileArranging() throws { + let terminal = terminalController(useMosh: false) + let controller = UMDBarViewController(configuration: configuration( + controller: terminal, + style: .shell, + availableWidth: 375 + )) + controller.loadViewIfNeeded() + controller.applyObservedState(UMDBarObservedState( + status: .live, + contactLost: false, + needsYou: false, + keyboardLocked: false, + hardwareKeyboardConnected: false + )) + func arrangeRow() throws -> UIAction? { + let overflow = try XCTUnwrap( + control("umd.overflow", in: controller.view) as? UIButton + ) + return actions(in: try XCTUnwrap(overflow.menu)).first { + $0.identifier.rawValue == "umd.arrangeKeys" + } + } + + // Every platform: the rail arranges on iPad/iPhone, the ornament's + // cluster on visionOS. + XCTAssertEqual(try arrangeRow()?.title, "Arrange Keys…") + controller.perform(.toggleKeyBarArranging) + XCTAssertTrue(terminal.keyBarArranging) + controller.applyObservedState(UMDBarObservedState( + status: .live, + contactLost: false, + needsYou: false, + keyboardLocked: false, + hardwareKeyboardConnected: false, + keyBarArranging: true + )) + XCTAssertEqual(try arrangeRow()?.title, "Done Arranging Keys") + controller.perform(.toggleKeyBarArranging) + XCTAssertFalse(terminal.keyBarArranging) + } + func testStatusClusterPreservesMoshConnectionAttentionAndKeychainSemantics() throws { let terminal = terminalController(useMosh: true) var openedTip = false diff --git a/MultiplexUITests/ArrangeKeysUITests.swift b/MultiplexUITests/ArrangeKeysUITests.swift new file mode 100644 index 00000000..4bc89f9a --- /dev/null +++ b/MultiplexUITests/ArrangeKeysUITests.swift @@ -0,0 +1,106 @@ +import notify +import XCTest + +/// Real touches through XCUITest — the one route that drives the Arrange +/// Keys drag and drop end to end (no headless tap injection exists on Xcode +/// 27 simulators). Needs the dev sshd harness for the seeded host; run with +/// `TEST_RUNNER_MULTIPLEX_SEED_HOST=` in +/// xcodebuild's environment. Deliberately outside the unit scheme and CI. +final class ArrangeKeysUITests: XCTestCase { + private let bundleID = "app.multiplexterm.multiplex" + #if os(visionOS) + private let prefix = "terminal.keyCluster." + #else + private let prefix = "terminal.keybar." + #endif + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testPressAndDragReordersTheKeys() throws { + let app = XCUIApplication() + if let seed = ProcessInfo.processInfo.environment["MULTIPLEX_SEED_HOST"] { + app.launchEnvironment["MULTIPLEX_SEED_HOST"] = seed + } + app.launchEnvironment["MULTIPLEX_AUTO_ATTACH"] = "main" + app.launchEnvironment["MULTIPLEX_METAL"] = "0" + app.launch() + + let escape = app.buttons[prefix + "escape"] + XCTAssertTrue(escape.waitForExistence(timeout: 60), "No key rail / cluster appeared") + sleep(3) + notify_post(bundleID + ".debug.summon") + sleep(1) + notify_post(bundleID + ".debug.arrangekeys") + let done = app.buttons["terminalPane.context.arrangeKeys.done"] + XCTAssertTrue(done.waitForExistence(timeout: 10), "Arrange mode did not present its bar") + + // A previous run (or a headless proof) may have left a custom order + // in this simulator's container: start from the shipped one. + let reset = app.buttons["terminalPane.context.arrangeKeys.reset"] + if reset.exists { + reset.tap() + sleep(1) + } + XCTAssertEqual(Array(keyOrder(app).prefix(3)), ["escape", "control", "tab"]) + #if os(visionOS) + // The visionOS simulator cannot synthesize a lift for a drag + // interaction; the mode and the bar are proved above, the drop + // plumbing by the fake-session unit tests. + throw XCTSkip("visionOS simulator cannot synthesize a lift for the key's drag interaction") + #endif + + // The gesture itself — a system drag and drop, the tab strip's: + // press ESC until it lifts, carry it onto TAB, hold there so the + // target resolves, release. ESC lands after TAB. + let tab = app.buttons[prefix + "tab"].firstMatch + XCTAssertTrue(tab.exists) + escape.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + .press( + forDuration: 1.0, + thenDragTo: tab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)), + withVelocity: .default, + thenHoldForDuration: 0.8 + ) + sleep(2) + XCTAssertEqual( + Array(keyOrder(app).prefix(3)), + ["control", "tab", "escape"], + "ESC dropped on TAB should land right after it" + ) + XCTAssertTrue(reset.waitForExistence(timeout: 5), "RESET should appear off the shipped order") + + reset.tap() + sleep(1) + XCTAssertEqual(Array(keyOrder(app).prefix(3)), ["escape", "control", "tab"]) + done.tap() + XCTAssertTrue(done.waitForNonExistence(timeout: 5)) + } + + /// Every key on screen, left to right, by the part of its identifier + /// after the prefix. Identifier and frame are read from ONE snapshot + /// each, and the row is logged with its frames so a surprising order + /// can be read off the xcodebuild log. + private func keyOrder(_ app: XCUIApplication) -> [String] { + let predicate = NSPredicate(format: "identifier BEGINSWITH %@", prefix) + let snapshot = app.buttons.matching(predicate).allElementsBoundByIndex.map { element in + (identifier: element.identifier, frame: element.frame) + } + // XCUITest reports each key twice (one accessibility element, two + // paths to it); one entry per identifier is the row. + var seen = Set() + let row = snapshot + .filter { $0.frame.width > 0 && seen.insert($0.identifier).inserted } + .sorted { $0.frame.midX < $1.frame.midX } + NSLog( + "ArrangeKeysUITests row: %@", + row.map { "\($0.identifier.dropFirst(prefix.count))@\(Int($0.frame.minX))" } + .joined(separator: " ") + ) + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.lifetime = .keepAlways + add(shot) + return row.map { String($0.identifier.dropFirst(prefix.count)) } + } +} diff --git a/README.md b/README.md index 8cb14ede..a4c13c00 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A spatial SSH terminal for remote **tmux** — visionOS first, with iPadOS and i - **Fleet-wide deck:** live tmux and herdr sessions across every host, with password, OpenSSH-key, or `mpx bind` setup and iCloud Keychain sync. - **Real terminal windows:** spatial scenes on visionOS, Stage Manager on iPad, and an adaptive iPhone shell. Tabs can move or merge without reconnecting. - **Agent awareness:** detects Claude Code, Codex, Pi, Grok Build, Antigravity CLI, and Hermes; surfaces questions, permissions, and completed turns on the wall and through notifications. -- **Purpose-built input:** keyboard-focus arbitration, key rail with hold-CTRL Key Commands (saved chords and text macros), IME, dictation, remote scrolling, tmux Copy Mode, and text selection. +- **Purpose-built input:** keyboard-focus arbitration, key rail with hold-CTRL Key Commands (saved chords and text macros) and a drag-to-arrange key order, IME, dictation, remote scrolling, tmux Copy Mode, and text selection. - **More than a shell:** confirmed web links and remote paths open in a floating side panel beside the live terminal on iPad and Vision Pro (or move into a tab), alongside SFTP uploads, optional clean-room mosh, widgets, Shortcuts, deep links, and custom themes. Some features require Multiplex Pro; see [`docs/store-metadata.md`](docs/store-metadata.md). This repository contains the complete app. diff --git a/Tools/build.sh b/Tools/build.sh index ce4b95b7..733caed5 100755 --- a/Tools/build.sh +++ b/Tools/build.sh @@ -9,6 +9,8 @@ # run unit tests (default: vos) # ./Tools/build.sh verify [vos|ipad] build + install + drive end-to-end # against the local sshd/tmux harness +# ./Tools/build.sh uitest [vos|ipad] XCUITest real-touch runs (the Arrange +# Keys press-and-drag) against the harness # ./Tools/build.sh interop round-trip against real mosh-server # ./Tools/build.sh strings sync the String Catalogs from the # last visionOS build's .stringsdata @@ -160,6 +162,22 @@ verify() { echo "verify OK — inspect the screenshot to confirm output rendered." } +# Real touches through XCUITest — the one headless route that drives a +# gesture recognizer on an Xcode 27 simulator (simctl has no tap/drag, idb is +# dead). Seeds the host through the runner's environment (xcodebuild forwards +# TEST_RUNNER_* variables to the runner, which hands them to the app). +uitest() { + local plat="${1:-vos}" + shift || true + echo "== starting harness ==" + "$HARNESS" start + "$HARNESS" demo + TEST_RUNNER_MULTIPLEX_SEED_HOST="$SEED" \ + xcodebuild -project "$PROJECT" -scheme MultiplexUITests \ + -destination "id=$(require_udid "$plat")" \ + -derivedDataPath "$DERIVED" test "$@" +} + interop() { command -v swiftc >/dev/null || { echo "swiftc not found" >&2; exit 1; } command -v mosh-server >/dev/null || { @@ -189,6 +207,7 @@ case "${1:-}" in build) shift; build "$@" ;; test) shift; run_tests "$@" ;; verify) verify "${2:-vos}" ;; + uitest) shift; uitest "$@" ;; interop) interop ;; strings) strings ;; all) gen; lint; build vos; build ipad; run_tests vos ;; diff --git a/docs/agents/e2e-headless.md b/docs/agents/e2e-headless.md index f977f636..4aa57fa4 100644 --- a/docs/agents/e2e-headless.md +++ b/docs/agents/e2e-headless.md @@ -254,6 +254,27 @@ app.multiplexterm.multiplex.`: - `debug.tmuxclosepane` / `debug.tmuxclosewindow` — the already-confirmed destructive close actions (disposable sessions only). - `debug.keybar` — iPad key-bar proof: a shell prompt capture shows `~|/-^C`. +- `debug.arrangekeys` — the `⋯` menu's Arrange Keys on the focused terminal's + key rail (iPad/iPhone) or ornament cluster (visionOS); post again for DONE. + Proof is the wiggling keys and the ARRANGE KEYS bar — above the rail on + iPad, its own slab below the console row on visionOS. + `debug.arrangekeysmove` moves the leftmost key to the sequence's end + through the order model — a headless drop (no route drags a key); on + visionOS that is a crossing from the leading slab to the trailing one. + Proof is the row in the new order on every tab, the RESET chip appearing, + and the same order after a relaunch. The drag and drop itself (a system + `UIDragInteraction` → `UIDropInteraction`, the tab strip's) needs real + touches: `./Tools/build.sh uitest [vos|ipad]` runs the XCUITest + `MultiplexUITests/ArrangeKeysUITests` (harness up, seeded host; ESC pressed + until it lifts, carried onto TAB, held, released), the only headless route + that drives a drag session on an Xcode 27 simulator. Its rows land in the + xcodebuild log (`ArrangeKeysUITests row:`) and the app's lifts and drops + under `log stream --predicate 'category == "keys"' --level debug`. + ⚠ XCUITest reports each rail key twice (one element, two paths) — query + with `firstMatch`, dedupe rows by identifier. ⚠ The visionOS simulator + cannot synthesize a lift for a `UIDragInteraction` (nor for a long-press + recognizer): `uitest vos` proves the mode and the bar and skips the drag; + the drop plumbing is covered by the fake-session unit tests. - `debug.kbdlock` — toggle the software-keyboard lock headlessly. - `debug.fvselect` — the file viewer's markdown SELECT mode. - `debug.fvimage` — press the first image placeholder on the rendered diff --git a/docs/agents/input-and-windows.md b/docs/agents/input-and-windows.md index a53b741f..9bdc727f 100644 --- a/docs/agents/input-and-windows.md +++ b/docs/agents/input-and-windows.md @@ -155,6 +155,34 @@ routing, tab moves, keyboard avoidance, or secret fields. still *builds* its stock accessory on visionOS and `commitTextInput` prefers its `controlModifier` — `SwiftTermView` nils `inputAccessoryView` there; don't remove that. +- **Arrange Keys (2026-08-27)** — `⋯` → Arrange Keys… (a menu row: every + key hold is taken and a held key is unfindable); per tab, never persisted + (`TerminalSessionController.keyBarArranging`). The ORDER is a slot + permutation (`KeyBarOrder`; a slot names a position — keyboard/mic share + `.keyboard`, TMUX/HRDR `.shortcuts`), device-local in `KeyBarOrderStore`; + gaps and widths never move (rail groups are counted at layout, the + ornament's ten keys split 3 lead / 7 trail, CTRL's slab follows the key). + **The move is the tab strip's drag and drop**: each key is a + `UIDragInteraction` source (`TerminalChromeDragItem`, the item recipe tabs + share — the pane's file gate refuses any `TerminalChromeDragPayload`), one + `KeyBarDropCoordinator` is the drop delegate for the rail and the cluster + (`KeyBarDropSurface`); target = nearest other key by centre + (`RowDropGeometry`), rightward lands after it, leftward before + (`KeyBarOrder.moving` moves only the dragged key in the full order). + Never a custom recognizer drag: the keys stay live and hoverable (visionOS + gaze targets interactive elements) and the system preview is the one + thing that floats over the UMD's glass. + Rails reconcile the order in layout, never in the render signature, so + nothing re-creates a key mid-mode. iPad: the ARRANGE KEYS bar (RESET · + DONE) hangs over the rail like the C / B slab (`mountOverRail`); leaving + the window ends the mode. + visionOS: drop hosts are both slabs plus the UMD and bar slabs (a release + over the title lands beside the nearer key); the bar is ornament role + `.arrange` below the console row, one `ArrangeKeysBarViewController` per + tab that watches the store for RESET; a tab switch ends the mode; GUIDE + rides the `⋯` (`guideIsDirectChip`). Proofs: `debug.arrangekeys`, + `debug.arrangekeysmove`, `build.sh uitest ipad` (the visionOS sim lifts + nothing; fake `UIDropSession` tests cover the coordinator). - **Auxiliary tabs wear their whole bottom chrome in the ornament (2026-08-10)** — on classic visionOS windows the in-window ▤/⌗ rails are not mounted (`showsInWindowRail: false`); their end chips collided with diff --git a/fastlane/testflight-whats-new.txt b/fastlane/testflight-whats-new.txt index e64ed7e5..79552d51 100644 --- a/fastlane/testflight-whats-new.txt +++ b/fastlane/testflight-whats-new.txt @@ -1,7 +1,11 @@ NEW SINCE 1.4.1 • CTRL SHORTCUTS — holding CTRL now replaces an already-open C / B quick-key slab with Key Commands instead of leaving both open. +• Vision Pro: GUIDE moves from the console row into the three-dot menu. +• ARRANGE KEYS — the terminal's three-dot menu gains Arrange Keys: the keys wiggle, drag any key onto the key it should sit beside (the same drag and drop as the window's tabs), then DONE in the ARRANGE KEYS bar beside them (above the rail on iPad and iPhone; below the console row on Vision Pro, where a key can be dropped on the other side of the title). RESET there restores the shipped order. The order is remembered on this device and applies to every tab. PLEASE TRY 1. Tap CTRL to open C / B, then hold CTRL; only Key Commands should remain. +2. Three-dot menu → Arrange Keys, press TAB until it lifts, drop it on an arrow key, DONE. The new order should hold on other tabs and after a relaunch; rotate the phone to check narrower rows keep it too. RESET in the bar should put it back. +3. Vision Pro: Arrange Keys, then pinch and hold ESC in the left group, carry it onto a key in the right group and let go; it should land there (and come back the same way), and DONE below the console row ends the mode. Feedback: screenshot in TestFlight diff --git a/project.yml b/project.yml index 2bf3531b..67e2e931 100644 --- a/project.yml +++ b/project.yml @@ -73,6 +73,16 @@ schemes: config: Debug targets: - MultiplexTests + # Real-touch UI tests (XCUITest): the one route that drives a gesture + # recognizer end to end on an Xcode 27 simulator. Not part of CI. + MultiplexUITests: + build: + targets: + MultiplexUITests: [test] + test: + config: Debug + targets: + - MultiplexUITests targets: Multiplex: @@ -247,6 +257,18 @@ targets: IPHONEOS_DEPLOYMENT_TARGET: "17.0" XROS_DEPLOYMENT_TARGET: "26.0" + MultiplexUITests: + type: bundle.ui-testing + supportedDestinations: [iOS, visionOS] + sources: + - path: MultiplexUITests + dependencies: + - target: Multiplex + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: Multiplex + MultiplexTests: type: bundle.unit-test supportedDestinations: [iOS, visionOS]