Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/skills/build-and-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
included:
- Multiplex
- MultiplexTests
- MultiplexUITests
- MultiplexWidgets
- Tools

Expand Down
112 changes: 112 additions & 0 deletions Multiplex/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2328,6 +2328,22 @@
}
}
},
"Arrange Keys…" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "キーを並べ替え…"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "排列按鍵…"
}
}
}
},
"Arrow down" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down
122 changes: 122 additions & 0 deletions Multiplex/Models/KeyBarOrder.swift
Original file line number Diff line number Diff line change
@@ -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<KeyBarSlot>()
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) }
}
}
37 changes: 37 additions & 0 deletions Multiplex/Services/KeyBarOrderStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions Multiplex/Services/TerminalSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading