diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..8eebb87d6904 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -24,8 +24,8 @@ 4483 Sources/cmuxApp.swift 4482 Sources/Panels/FilePreviewPanel.swift 4367 cmuxTests/BrowserPanelTests.swift -4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 4121 Sources/BrowserWindowPortal.swift +3976 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift @@ -58,7 +58,7 @@ 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1649 cmuxTests/TerminalControllerSocketSecurityTests.swift -1604 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1634 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutRowSection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutRowSection.swift new file mode 100644 index 000000000000..76b0262ee8a6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutRowSection.swift @@ -0,0 +1,9 @@ +#if os(iOS) +import CmuxMobileTerminal + +struct TerminalShortcutRowSection: Identifiable { + let id: String + let index: Int + let items: [ResolvedToolbarItem] +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift index f9d29fcb8d1e..103590480a6a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift @@ -28,15 +28,56 @@ struct TerminalShortcutsSettingsView: View { var body: some View { NavigationStack { List { - Section { - ForEach(displayedItems) { item in - row(for: item) + if scope == .terminal { + Section { + Stepper( + value: rowCountBinding, + in: TerminalAccessoryConfiguration.minimumRowCount...TerminalAccessoryConfiguration.maximumRowCount + ) { + HStack { + Text(L10n.string("mobile.shortcuts.rows.label", defaultValue: "Rows")) + Spacer() + Text("\(configuration.rowCount)") + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("TerminalShortcutsRowCountStepper") + } header: { + Text(L10n.string("mobile.shortcuts.rows.header", defaultValue: "Toolbar Rows")) + } footer: { + Text(L10n.string( + "mobile.shortcuts.rows.footer", + defaultValue: "Add rows to keep more buttons visible above the keyboard." + )) + } + + ForEach(displayedRowSections) { rowSection in + Section { + ForEach(rowSection.items) { item in + row(for: item, rowIndex: rowSection.index) + } + .onMove { offsets, destination in + moveDisplayedItems(from: offsets, to: destination, inRow: rowSection.index) + } + } header: { + Text(rowTitle(rowSection.index)) + } footer: { + if rowSection.index == displayedRowSections.count - 1 { + Text(scope.footer) + } + } + } + } else { + Section { + ForEach(displayedItems) { item in + row(for: item, rowIndex: nil) + } + .onMove(perform: moveDisplayedItems) + } header: { + Text(L10n.string("mobile.shortcuts.header", defaultValue: "Shortcut Buttons")) + } footer: { + Text(scope.footer) } - .onMove(perform: moveDisplayedItems) - } header: { - Text(L10n.string("mobile.shortcuts.header", defaultValue: "Shortcut Buttons")) - } footer: { - Text(scope.footer) } Section { @@ -84,12 +125,28 @@ struct TerminalShortcutsSettingsView: View { } @ViewBuilder - private func row(for item: ResolvedToolbarItem) -> some View { - Toggle(isOn: binding(for: item.id)) { - if item.isCustom { - Label(item.settingsDisplayName, systemImage: "character.cursor.ibeam") - } else { - Text(item.settingsDisplayName) + private func row(for item: ResolvedToolbarItem, rowIndex: Int?) -> some View { + HStack { + Toggle(isOn: binding(for: item.id)) { + if item.isCustom { + Label(item.settingsDisplayName, systemImage: "character.cursor.ibeam") + } else { + Text(item.settingsDisplayName) + } + } + + if scope == .terminal, configuration.rowCount > 1 { + Picker( + L10n.string("mobile.shortcuts.rows.movePicker", defaultValue: "Move to Row"), + selection: rowBinding(for: item.id, fallback: rowIndex ?? 0) + ) { + ForEach(0.. { + Binding( + get: { configuration.rowCount }, + set: { configuration.setRowCount($0) } + ) + } + + private func rowBinding(for id: ToolbarItemID, fallback: Int) -> Binding { + Binding( + get: { rowIndex(for: id) ?? fallback }, + set: { configuration.moveItem(id, toRow: $0) } + ) + } + private var displayedItems: [ResolvedToolbarItem] { configuration.displayItems.filter(scope.includes) } - private func moveDisplayedItems(from offsets: IndexSet, to destination: Int) { - guard scope != .terminal else { - configuration.moveItems(from: offsets, to: destination) - return + private var displayedItemRows: [[ResolvedToolbarItem]] { + configuration.displayItemRows.map { row in row.filter(scope.includes) } + } + + private var displayedRowSections: [TerminalShortcutRowSection] { + displayedItemRows.enumerated().map { index, items in + TerminalShortcutRowSection( + id: "terminal-shortcuts-row-\(index)", + index: index, + items: items + ) } + } + private func rowIndex(for id: ToolbarItemID) -> Int? { + configuration.displayRows.firstIndex { row in row.contains(id) } + } + + private func rowTitle(_ rowIndex: Int) -> String { + String( + format: L10n.string("mobile.shortcuts.rows.rowTitleFormat", defaultValue: "Row %d"), + rowIndex + 1 + ) + } + + private func moveDisplayedItems(from offsets: IndexSet, to destination: Int) { let visibleIDs = displayedItems.map(\.id) let visibleSet = Set(visibleIDs) var reorderedVisibleIDs = visibleIDs reorderedVisibleIDs.move(fromOffsets: offsets, toOffset: destination) - var visibleIterator = reorderedVisibleIDs.makeIterator() - let reorderedFullIDs = configuration.displayOrder.map { id in - guard visibleSet.contains(id) else { return id } - return visibleIterator.next() ?? id + configuration.reorderItemsAcrossRows(reorderedVisibleIDs, limitedTo: visibleSet) + } + + private func moveDisplayedItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { + guard displayedItemRows.indices.contains(rowIndex) else { return } + let displayedRowIDs = displayedItemRows[rowIndex].map(\.id) + guard !displayedRowIDs.isEmpty else { return } + + if scope == .terminal { + // SwiftUI supplies offsets in displayed-row coordinates, while the + // configuration mutates raw row coordinates. The terminal scope is + // expected to be unfiltered; if that ever changes, this path must + // translate indices. + guard configuration.displayRows.indices.contains(rowIndex), + displayedRowIDs == configuration.displayRows[rowIndex] + else { + assertionFailure("Terminal row reordering requires unfiltered row indices") + return + } + configuration.moveItems(from: offsets, to: destination, inRow: rowIndex) + return } - configuration.reorderItems(reorderedFullIDs) + + var reorderedIDs = displayedRowIDs + reorderedIDs.move(fromOffsets: offsets, toOffset: destination) + configuration.reorderItems(reorderedIDs, limitedTo: Set(displayedRowIDs)) } } #endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index ce538903ced0..10cf733176b5 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -624,6 +624,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { name: UIResponder.keyboardWillChangeFrameNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAccessoryConfigurationChanged), + name: TerminalAccessoryConfiguration.didChangeNotification, + object: nil + ) } @objc private func handleAppWillResignActive() { @@ -697,14 +703,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// still lands even if the toolbar UI is absent. private var reservedToolbarHeight: CGFloat = 0 /// Height of the docked accessory bar reserved in the grid geometry so the - /// bottom TUI rows stay visible above it. Locked to the bar's actual button-row - /// height (`TerminalInputTextView.dockedButtonRowHeight`) so the grid reserves - /// EXACTLY the strip the buttons occupy — no taller. Round 3 reserved 44 while - /// the strip was only 34, so the extra 10pt rendered as bar background below - /// the buttons (the "gap below" Lawrence kept seeing). Matching them keeps the - /// toolbar's live top edge equal to the viewport edge; any whole-cell render - /// remainder stays inside the terminal viewport instead of becoming toolbar fill. - private static let persistentToolbarHeight: CGFloat = TerminalInputTextView.dockedButtonRowHeight + /// bottom TUI rows stay visible above it. Locked to the bar's actual configured + /// row-stack height so the grid reserves exactly the strip the buttons occupy. + private var persistentToolbarHeight: CGFloat { + TerminalAccessoryDockMetrics(rowCount: TerminalAccessoryConfiguration.shared.rowCount).buttonRowHeight + } /// The docked accessory bar. Positioned by ``bottomDockFrames()`` with the /// SAME bottom-occupancy math as the grid reservation, so its top is always /// flush with the grid bottom (no gap) and its bottom rests on the keyboard @@ -985,7 +988,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { keyboardHeight: keyboardHeight, composerBandHeight: composerBandHeight, reservedToolbarHeight: reservedToolbarHeight, - toolbarFrameHeight: Self.persistentToolbarHeight, + toolbarFrameHeight: persistentToolbarHeight, bottomSafeAreaInset: safeAreaInsetsBottom, chromeHidden: chromeHidden, chromeVisible: dockedToolbarShouldBeVisible && dockedToolbar?.isHidden == false, @@ -1042,10 +1045,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// the current keyboard + composer state. Hiding the bar releases its reserved /// height so the terminal grid reclaims that space; showing it reserves the /// height again. Idempotent: a no-op when already in the target state. - private func updateDockedToolbarVisibility() { + @discardableResult + private func updateDockedToolbarVisibility() -> Bool { let shouldShow = dockedToolbarShouldBeVisible - let reserved: CGFloat = shouldShow ? Self.persistentToolbarHeight : 0 - guard dockedToolbar?.isHidden != !shouldShow || reservedToolbarHeight != reserved else { return } + let reserved: CGFloat = shouldShow ? persistentToolbarHeight : 0 + guard dockedToolbar?.isHidden != !shouldShow || reservedToolbarHeight != reserved else { return false } dockedToolbar?.isHidden = !shouldShow // The composer band rides with the toolbar: hide it when the chrome is // suppressed, show it again when the chrome returns and a field is mounted. @@ -1056,6 +1060,13 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { layoutRenderedTerminalForCurrentViewport() setNeedsGeometrySync() setNeedsLayout() + return true + } + + @objc private func handleAccessoryConfigurationChanged() { + guard updateDockedToolbarVisibility() else { return } + layoutBottomDock() + layoutZoomOverlay() } /// Temporarily hide (or re-show) the bottom chrome — the always-visible toolbar @@ -1731,6 +1742,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } deinit { + NotificationCenter.default.removeObserver(self) stopKeyboardHeightAnimation() disposeSurface() } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift index 80ffd63d4e93..ad314442cbba 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift @@ -3,7 +3,8 @@ import Foundation import Observation /// User-editable configuration of the terminal input-accessory bar: which -/// buttons appear, in what order, and any user-defined ``CustomToolbarAction``s. +/// buttons appear, which row they occupy, in what order, and any user-defined +/// ``CustomToolbarAction``s. /// /// Every button on the bar is configurable: the modifier keys (⌃ ⌥ ⌘ ⇧), the zoom /// controls, paste, the shipped insertable shortcuts (Esc, Tab, arrows, the agent @@ -30,14 +31,23 @@ public final class TerminalAccessoryConfiguration { /// UIKit input-accessory bar can rebuild its configurable buttons. public static let didChangeNotification = Notification.Name("cmux.terminal.accessoryConfigurationDidChange") + /// Minimum number of configurable toolbar rows. + public static let minimumRowCount = 1 + /// Maximum number of configurable toolbar rows. + public static let maximumRowCount = 3 + + // v4 schema, keyed by rows of ``ToolbarItemID`` storage keys + JSON custom + // actions. Empty rows are persisted so an explicit row count survives. + private static let rowsDefaultsKey = "cmux.terminal.toolbar.rows.v4" + private static let enabledDefaultsKey = "cmux.terminal.toolbar.enabled.v4" // v3 schema, keyed by ``ToolbarItemID`` storage keys + JSON custom actions. // v3 widened the configurable region to include the modifier/zoom/paste // built-ins that v1/v2 pinned, so its enabled set is authoritative (it knows // those built-ins are hideable); presence of these keys means "skip the // force-enable widening migration". - private static let orderDefaultsKey = "cmux.terminal.toolbar.order.v3" - private static let enabledDefaultsKey = "cmux.terminal.toolbar.enabled.v3" - // Custom actions are schema-stable across v2 and v3, so the v2 key is reused. + private static let legacyV3OrderDefaultsKey = "cmux.terminal.toolbar.order.v3" + private static let legacyV3EnabledDefaultsKey = "cmux.terminal.toolbar.enabled.v3" + // Custom actions are schema-stable across v2-v4, so the v2 key is reused. private static let customDefaultsKey = "cmux.terminal.toolbar.custom.v2" // v2 schema (ToolbarItemID storage keys, configurable region = trailing // shortcuts only), read once to forward-migrate an upgrading user. @@ -48,9 +58,14 @@ public final class TerminalAccessoryConfiguration { private static let legacyV1OrderDefaultsKey = "cmux.terminal.accessory.displayOrder.v1" private static let legacyV1EnabledDefaultsKey = "cmux.terminal.accessory.enabled.v1" - /// The configurable items in the order the user has arranged them, as unified - /// identifiers (built-ins and custom actions together). - public private(set) var displayOrder: [ToolbarItemID] + /// The configurable items arranged into the toolbar rows the user configured, + /// as unified identifiers (built-ins and custom actions together). + public private(set) var displayRows: [[ToolbarItemID]] + + /// The configurable items in row-major order. + public var displayOrder: [ToolbarItemID] { + displayRows.flatMap { $0 } + } /// The subset of ``displayOrder`` currently shown on the bar. public private(set) var enabledSet: Set @@ -75,9 +90,21 @@ public final class TerminalAccessoryConfiguration { public init(defaults: UserDefaults = .standard) { self.defaults = defaults - let loadedCustoms = Self.loadCustomActions(from: defaults) + let loadedCustoms: [CustomToolbarAction] + if let data = defaults.data(forKey: Self.customDefaultsKey), + let decoded = try? JSONDecoder().decode([CustomToolbarAction].self, from: data) { + loadedCustoms = decoded + } else { + loadedCustoms = [] + } self.customActions = loadedCustoms - self.reducer = Self.makeReducer(customActions: loadedCustoms) + let builtinIDs = TerminalInputAccessoryAction.configurableActions.map(\.itemID) + let customIDs = loadedCustoms.map(\.itemID) + let defaultOrder = TerminalInputAccessoryAction.defaultConfigurableOrder.map(\.itemID) + customIDs + self.reducer = TerminalAccessoryLayoutReducer( + configurable: builtinIDs + customIDs, + defaultOrder: defaultOrder + ) // Resolve the persisted layout across schema generations: // v3 present → authoritative; load as-is (modifiers can stay hidden). @@ -86,11 +113,23 @@ public final class TerminalAccessoryConfiguration { // else v1 present → relabel v1→ids, then widen to v3 the same way. // else fresh install → empty saved + nil enabled ⇒ default layout, // which already includes modifiers/zoom/paste. - let savedOrder: [ToolbarItemID] + let savedRows: [[ToolbarItemID]]? + var savedOrder: [ToolbarItemID] = [] let savedEnabled: [ToolbarItemID]? - if let v3Order = defaults.array(forKey: Self.orderDefaultsKey) as? [String] { + let rowCount: Int + let v4Rows = defaults.array(forKey: Self.rowsDefaultsKey)?.map { row in + guard let storageKeys = row as? [String] else { return [] } + return storageKeys.compactMap(ToolbarItemID.init(storageKey:)) + } + if let v4Rows { + savedRows = v4Rows + savedEnabled = (defaults.array(forKey: Self.enabledDefaultsKey) as? [String])? + .compactMap(ToolbarItemID.init(storageKey:)) + rowCount = min(max(v4Rows.count, Self.minimumRowCount), Self.maximumRowCount) + } else if let v3Order = defaults.array(forKey: Self.legacyV3OrderDefaultsKey) as? [String] { + savedRows = nil let order = v3Order.compactMap(ToolbarItemID.init(storageKey:)) - let enabled = (defaults.array(forKey: Self.enabledDefaultsKey) as? [String])? + let enabled = (defaults.array(forKey: Self.legacyV3EnabledDefaultsKey) as? [String])? .compactMap(ToolbarItemID.init(storageKey:)) // ⇧ and Return each became user-configurable after the v3 schema // shipped, so a layout persisted under the v3 keys has no record of @@ -103,7 +142,9 @@ public final class TerminalAccessoryConfiguration { let folded = Self.foldNewlyConfigurableV3(order: order, enabled: enabled, migration: migration) savedOrder = folded.order savedEnabled = folded.enabled + rowCount = Self.minimumRowCount } else if let v2Order = defaults.array(forKey: Self.legacyV2OrderDefaultsKey) as? [String] { + savedRows = nil let widened = migration.widenedToV3( order: v2Order.compactMap(ToolbarItemID.init(storageKey:)), enabled: (defaults.array(forKey: Self.legacyV2EnabledDefaultsKey) as? [String])? @@ -116,7 +157,9 @@ public final class TerminalAccessoryConfiguration { let folded = Self.foldNewlyConfigurableV3(order: widened.order, enabled: widened.enabled, migration: migration) savedOrder = folded.order savedEnabled = folded.enabled + rowCount = Self.minimumRowCount } else if let v1Order = defaults.array(forKey: Self.legacyV1OrderDefaultsKey) as? [Int] { + savedRows = nil let widened = migration.widenedToV3( order: migration.migratedOrder(legacy: v1Order), enabled: migration.migratedEnabled( @@ -130,15 +173,22 @@ public final class TerminalAccessoryConfiguration { let folded = Self.foldNewlyConfigurableV3(order: widened.order, enabled: widened.enabled, migration: migration) savedOrder = folded.order savedEnabled = folded.enabled + rowCount = Self.minimumRowCount } else { - savedOrder = [] + savedRows = nil savedEnabled = nil + rowCount = Self.minimumRowCount } - let layout = reducer.load(savedOrder: savedOrder, savedEnabled: savedEnabled) - self.displayOrder = layout.order + let layout = reducer.load( + savedRows: savedRows, + savedOrder: savedOrder, + savedEnabled: savedEnabled, + rowCount: rowCount + ) + self.displayRows = layout.rows self.enabledSet = layout.enabled - // Persist the normalized (and possibly migrated) layout under the v3 keys + // Persist the normalized (and possibly migrated) layout under the v4 keys // so the migration path runs at most once. persist() } @@ -211,6 +261,25 @@ public final class TerminalAccessoryConfiguration { displayOrder.filter { enabledSet.contains($0) }.compactMap(resolve) } + /// Every configurable item arranged by toolbar row, regardless of + /// shown/hidden state. + public var displayItemRows: [[ResolvedToolbarItem]] { + displayRows.map { row in row.compactMap(resolve) } + } + + /// The shown items arranged by toolbar row — exactly what the toolbar renders, + /// ahead of the fixed trailing "customize" control. + public var enabledItemRows: [[ResolvedToolbarItem]] { + displayRows.map { row in + row.filter { enabledSet.contains($0) }.compactMap(resolve) + } + } + + /// The number of persisted toolbar rows. + public var rowCount: Int { + displayRows.count + } + /// Whether `id` is currently shown on the bar. public func isEnabled(_ id: ToolbarItemID) -> Bool { enabledSet.contains(id) @@ -231,22 +300,68 @@ public final class TerminalAccessoryConfiguration { persistAndNotify() } - /// Reorder the configurable items using a complete desired order. + /// Reorder the configurable items within a specific toolbar row. + public func moveItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { + apply(reducer.move(from: offsets, to: destination, inRow: rowIndex, in: currentLayout)) + persistAndNotify() + } + + /// Move one configurable item to the end of another toolbar row. + /// + /// Selecting the row the item already occupies is a no-op. + public func moveItem(_ id: ToolbarItemID, toRow rowIndex: Int) { + guard displayRows.indices.contains(rowIndex), + !displayRows[rowIndex].contains(id) + else { return } + apply(reducer.move(id, toRow: rowIndex, in: currentLayout)) + persistAndNotify() + } + + /// Change the number of visible toolbar rows. + public func setRowCount(_ rowCount: Int) { + let clampedRowCount = min(max(rowCount, Self.minimumRowCount), Self.maximumRowCount) + apply(reducer.setRowCount(clampedRowCount, in: currentLayout)) + persistAndNotify() + } + + /// Reorder the configurable items inside their current toolbar rows. /// /// Unknown identifiers are dropped and any omitted current/configurable ids - /// are appended by the reducer load path, matching launch-time normalization. + /// keep their current relative order. Moving an item between rows must go + /// through ``moveItem(_:toRow:)`` so row assignment never changes implicitly. public func reorderItems(_ orderedIDs: [ToolbarItemID]) { - apply(reducer.load(savedOrder: orderedIDs, savedEnabled: Array(enabledSet))) + apply(reducer.reorder(orderedIDs, limitedTo: Set(displayOrder), in: currentLayout)) + persistAndNotify() + } + + /// Reorder a scoped subset of configurable items inside their current rows. + /// + /// This keeps every non-scoped item in its exact row position and only reorders + /// `scopedIDs` among the scoped slots in the row each item already occupies. + public func reorderItems(_ orderedIDs: [ToolbarItemID], limitedTo scopedIDs: Set) { + apply(reducer.reorder(orderedIDs, limitedTo: scopedIDs, in: currentLayout)) + persistAndNotify() + } + + /// Reorder a scoped subset of configurable items across row-major slots. + /// + /// Non-scoped items keep their exact row positions, while scoped items can + /// trade places across the slots occupied by that scope. This keeps flat + /// settings surfaces aligned with flat runtime consumers without moving + /// terminal-only items. + public func reorderItemsAcrossRows(_ orderedIDs: [ToolbarItemID], limitedTo scopedIDs: Set) { + apply(reducer.reorderAcrossRows(orderedIDs, limitedTo: scopedIDs, in: currentLayout)) persistAndNotify() } /// Append a new custom action, shown at the end of the configurable region. public func addCustomAction(_ action: CustomToolbarAction) { customActions.append(action) - reducer = Self.makeReducer(customActions: customActions) + reducer = makeReducer() apply(reducer.load( - savedOrder: displayOrder, - savedEnabled: Array(enabledSet) + [action.itemID] + savedRows: displayRows, + savedEnabled: Array(enabledSet) + [action.itemID], + rowCount: rowCount )) persistAndNotify() } @@ -256,7 +371,7 @@ public final class TerminalAccessoryConfiguration { public func updateCustomAction(_ action: CustomToolbarAction) { guard let index = customActions.firstIndex(where: { $0.id == action.id }) else { return } customActions[index] = action - reducer = Self.makeReducer(customActions: customActions) + reducer = makeReducer() persistAndNotify() } @@ -264,8 +379,8 @@ public final class TerminalAccessoryConfiguration { public func removeCustomAction(id: UUID) { guard customActions.contains(where: { $0.id == id }) else { return } customActions.removeAll { $0.id == id } - reducer = Self.makeReducer(customActions: customActions) - apply(reducer.load(savedOrder: displayOrder, savedEnabled: Array(enabledSet))) + reducer = makeReducer() + apply(reducer.load(savedRows: displayRows, savedEnabled: Array(enabledSet), rowCount: rowCount)) persistAndNotify() } @@ -288,17 +403,15 @@ public final class TerminalAccessoryConfiguration { } private var currentLayout: TerminalAccessoryLayoutReducer.Layout { - .init(order: displayOrder, enabled: enabledSet) + .init(rows: displayRows, enabled: enabledSet) } private func apply(_ layout: TerminalAccessoryLayoutReducer.Layout) { - displayOrder = layout.order + displayRows = layout.rows enabledSet = layout.enabled } - private static func makeReducer( - customActions: [CustomToolbarAction] - ) -> TerminalAccessoryLayoutReducer { + private func makeReducer() -> TerminalAccessoryLayoutReducer { let builtin = TerminalInputAccessoryAction.configurableActions.map(\.itemID) let custom = customActions.map(\.itemID) // The redesigned bar's curated built-in arrangement first, then customs, @@ -307,16 +420,8 @@ public final class TerminalAccessoryConfiguration { return TerminalAccessoryLayoutReducer(configurable: builtin + custom, defaultOrder: defaultOrder) } - private static func loadCustomActions(from defaults: UserDefaults) -> [CustomToolbarAction] { - guard let data = defaults.data(forKey: Self.customDefaultsKey), - let decoded = try? JSONDecoder().decode([CustomToolbarAction].self, from: data) else { - return [] - } - return decoded - } - private func persist() { - defaults.set(displayOrder.map(\.storageKey), forKey: Self.orderDefaultsKey) + defaults.set(displayRows.map { row in row.map(\.storageKey) }, forKey: Self.rowsDefaultsKey) defaults.set( displayOrder.filter { enabledSet.contains($0) }.map(\.storageKey), forKey: Self.enabledDefaultsKey diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryDockMetrics.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryDockMetrics.swift new file mode 100644 index 000000000000..d73dfec52752 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryDockMetrics.swift @@ -0,0 +1,24 @@ +#if os(iOS) +import CoreGraphics + +struct TerminalAccessoryDockMetrics { + static let nubSize: CGFloat = 28 + static let bottomPadding: CGFloat = 8 + static let rowSpacing: CGFloat = 4 + + let rowCount: Int + + var rowsHeight: CGFloat { + let clampedRowCount = min( + max(rowCount, TerminalAccessoryConfiguration.minimumRowCount), + TerminalAccessoryConfiguration.maximumRowCount + ) + let rows = CGFloat(clampedRowCount) + return rows * Self.nubSize + max(0, rows - 1) * Self.rowSpacing + } + + var buttonRowHeight: CGFloat { + rowsHeight + Self.bottomPadding + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index 64864b7aa138..1eeccc1a8461 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -267,27 +267,18 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// with ``accessoryButtonHeight`` (the floor below which the buttons would clip /// the strip) so the bar — and the reserved grid band above the keyboard — is as /// short as it can be while every control stays a comfortable tap target. - static let dockedNubSize: CGFloat = 28 + static let dockedNubSize: CGFloat = TerminalAccessoryDockMetrics.nubSize /// Breathing room below the control row, between the buttons and the keyboard /// top (or the home indicator when the keyboard is down), so the bar is not /// flush-tight at its bottom while the TOP stays snug to the terminal's last - /// row. It is part of ``dockedButtonRowHeight`` so the grid reservation, the - /// surface frame, and the composer host all reserve the same total band; the - /// button row itself is pinned to the BOTTOM of that band minus this padding + /// row. It is part of the docked button-row height so the grid reservation, + /// the surface frame, and the composer host all reserve the same total band; the + /// row stack itself is pinned to the BOTTOM of that band minus this padding /// (see the docked bar's constraints), so the extra space lands below the /// controls. - static let dockedBottomPadding: CGFloat = 8 - /// Fixed height of the docked bar's button row band, reserved by the grid and - /// the composer host. It is the tallest control (the arrow nub, - /// ``dockedNubSize``) plus ``dockedBottomPadding`` below it. The controls are - /// pinned to the BOTTOM of this band (minus the padding) instead of the top: - /// when the surface-hosted container grows taller than this band (a - /// letterbox/resize pushes the rendered terminal's bottom up), the buttons stay - /// glued to the keyboard top and only the slack ABOVE them grows, so the - /// control row never rides up off the keyboard. In the composer host the frame - /// is exactly this height (no slack), so bottom-pinning is identical to - /// top-pinning there. - static let dockedButtonRowHeight: CGFloat = dockedNubSize + dockedBottomPadding + static let dockedBottomPadding: CGFloat = TerminalAccessoryDockMetrics.bottomPadding + /// Vertical gap between configurable toolbar rows. + private static let dockedRowSpacing: CGFloat = TerminalAccessoryDockMetrics.rowSpacing /// Minimum (not fixed) button width. Text buttons (Tab, Esc, ^C, ^D) size to /// their intrinsic content width and only floor here so they hug their label /// plus the comfortable inset; single-glyph modifiers/icons (⌃ ⌥ ⌘, the arrow @@ -317,7 +308,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // Placeholder height until the host positions the bar via // `GhosttySurfaceView.bottomDockFrames()`; sized to the button-row strip so // the pre-layout frame matches the reserved grid height. - container.frame = CGRect(x: 0, y: 0, width: 0, height: Self.dockedButtonRowHeight) + container.frame = CGRect( + x: 0, + y: 0, + width: 0, + height: TerminalAccessoryDockMetrics(rowCount: TerminalAccessoryConfiguration.shared.rowCount).buttonRowHeight + ) let backgroundView = UIView() backgroundView.backgroundColor = Self.themeBarColor @@ -334,23 +330,16 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { dismissButton.translatesAutoresizingMaskIntoConstraints = false self.dismissButton = dismissButton - // Scrollable action buttons - let scrollView = UIScrollView() - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.alwaysBounceHorizontal = true - scrollView.translatesAutoresizingMaskIntoConstraints = false - - let stack = UIStackView() - stack.axis = .horizontal - // Tighter inter-button spacing so the keys read as a compact row. - stack.spacing = 4 - stack.alignment = .center - - stack.translatesAutoresizingMaskIntoConstraints = false - accessoryStackView = stack + // Configurable action rows. Each row scrolls horizontally; the rows + // stack vertically so more buttons can stay visible above the keyboard. + let rowsStack = UIStackView() + rowsStack.axis = .vertical + rowsStack.spacing = Self.dockedRowSpacing + rowsStack.alignment = .fill + rowsStack.distribution = .fill + rowsStack.translatesAutoresizingMaskIntoConstraints = false + accessoryRowsStackView = rowsStack populateAccessoryActions() - scrollView.addSubview(stack) // Arrow nub for directional pad let nub = TerminalArrowNubView() @@ -377,7 +366,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { container.addSubview(dismissButton) container.addSubview(nub) container.addSubview(composerButton) - container.addSubview(scrollView) + container.addSubview(rowsStack) let backgroundLeadingConstraint = backgroundView.leadingAnchor.constraint(equalTo: container.leadingAnchor) let backgroundTrailingConstraint = backgroundView.trailingAnchor.constraint(equalTo: container.trailingAnchor) @@ -388,13 +377,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // The bar scrolls horizontally, so the right edge runs flush to the // screen (zero trailing inset). `updateAccessoryLayoutInsets` only adds a // safe-area inset when the surface itself does not reach the window edge. - let scrollTrailingConstraint = scrollView.trailingAnchor.constraint( + let scrollTrailingConstraint = rowsStack.trailingAnchor.constraint( equalTo: container.safeAreaLayoutGuide.trailingAnchor, constant: 0 ) // A short fixed-height strip pinned to the container's BOTTOM (minus - // ``dockedBottomPadding``) that holds the button row. The docked container + // ``dockedBottomPadding``) that holds the configured row stack. The docked container // can be TALLER than this strip, because the host // (`GhosttySurfaceView.bottomDockFrames`) anchors the bar's TOP to the // rendered terminal's bottom and its BOTTOM to the keyboard top, so a @@ -404,8 +393,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // top-pin would let the controls ride UP off the keyboard whenever the // terminal was letterboxed. `dockedBottomPadding` lifts the strip off the // very bottom edge so the controls have breathing room. - let buttonRow = UILayoutGuide() - container.addLayoutGuide(buttonRow) + let buttonRows = UILayoutGuide() + let bottomButtonRow = UILayoutGuide() + container.addLayoutGuide(buttonRows) + container.addLayoutGuide(bottomButtonRow) + let buttonRowsHeightConstraint = buttonRows.heightAnchor.constraint( + equalToConstant: TerminalAccessoryDockMetrics(rowCount: TerminalAccessoryConfiguration.shared.rowCount).rowsHeight + ) NSLayoutConstraint.activate([ backgroundLeadingConstraint, @@ -416,24 +410,26 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // Bottom-pinned (minus the bottom padding) so the controls hug the // keyboard top no matter how tall the container grows; the strip itself // stays exactly the nub height. - buttonRow.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Self.dockedBottomPadding), - buttonRow.leadingAnchor.constraint(equalTo: container.leadingAnchor), - buttonRow.trailingAnchor.constraint(equalTo: container.trailingAnchor), - buttonRow.heightAnchor.constraint(equalToConstant: Self.dockedNubSize), + buttonRows.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Self.dockedBottomPadding), + buttonRows.leadingAnchor.constraint(equalTo: container.leadingAnchor), + buttonRows.trailingAnchor.constraint(equalTo: container.trailingAnchor), + buttonRowsHeightConstraint, - // Every control shares the strip's single centerline. The strip is sized + bottomButtonRow.bottomAnchor.constraint(equalTo: buttonRows.bottomAnchor), + bottomButtonRow.leadingAnchor.constraint(equalTo: buttonRows.leadingAnchor), + bottomButtonRow.trailingAnchor.constraint(equalTo: buttonRows.trailingAnchor), + bottomButtonRow.heightAnchor.constraint(equalToConstant: Self.dockedNubSize), + + // The fixed controls share the bottom row's centerline. That row is sized // to the tallest control (the ``dockedNubSize`` nub), so centering keeps - // all three groups — keyboard button, nub, and the scrollable Ctrl/Esc/Tab - // row — on ONE horizontal line, hugging the keyboard top (the strip is - // bottom-pinned). (Top-pinning the directly-anchored controls instead - // would float them above the scroll row, which is centered inside its own - // scroll view.) + // the keyboard button, nub, composer, and bottom shortcut row aligned + // while any extra rows stack above them. dismissLeadingConstraint, - dismissButton.centerYAnchor.constraint(equalTo: buttonRow.centerYAnchor), + dismissButton.centerYAnchor.constraint(equalTo: bottomButtonRow.centerYAnchor), dismissButton.widthAnchor.constraint(equalToConstant: 32), nub.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 6), - nub.centerYAnchor.constraint(equalTo: buttonRow.centerYAnchor), + nub.centerYAnchor.constraint(equalTo: bottomButtonRow.centerYAnchor), nub.widthAnchor.constraint(equalToConstant: Self.dockedNubSize), nub.heightAnchor.constraint(equalToConstant: Self.dockedNubSize), @@ -443,30 +439,19 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // stack uses, so the bar reads identically to before — only now the // composer can never scroll away. composerButton.leadingAnchor.constraint(equalTo: nub.trailingAnchor, constant: 6), - composerButton.centerYAnchor.constraint(equalTo: buttonRow.centerYAnchor), + composerButton.centerYAnchor.constraint(equalTo: bottomButtonRow.centerYAnchor), - scrollView.leadingAnchor.constraint(equalTo: composerButton.trailingAnchor, constant: 4), + rowsStack.leadingAnchor.constraint(equalTo: composerButton.trailingAnchor, constant: 4), scrollTrailingConstraint, - scrollView.topAnchor.constraint(equalTo: buttonRow.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: buttonRow.bottomAnchor), - - // No vertical margin inside the scroll view: the stack fills the strip - // height so the glass capsules grow to the full section height. The - // bar's breathing room lives BELOW the strip (`dockedBottomPadding`), - // not as a margin that shrinks the buttons. - stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - // Zero trailing content padding so the last button runs to the screen - // edge when scrolled to the end (the bar scrolls horizontally). - stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + rowsStack.topAnchor.constraint(equalTo: buttonRows.topAnchor), + rowsStack.bottomAnchor.constraint(equalTo: buttonRows.bottomAnchor), ]) accessoryBackgroundLeadingConstraint = backgroundLeadingConstraint accessoryBackgroundTrailingConstraint = backgroundTrailingConstraint accessoryDismissLeadingConstraint = dismissLeadingConstraint accessoryScrollTrailingConstraint = scrollTrailingConstraint + accessoryRowsHeightConstraint = buttonRowsHeightConstraint // The cmux iOS app always drives a macOS cmux surface, so default the // accessory to Mac modifiers: retitle Ctrl/Alt to ⌃/⌥ and insert the ⌘ // button. `updateModifierLabels(isMacRemote:)` can still switch this if a @@ -484,7 +469,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// regardless of where the view is hosted. var toolbarView: UIView { terminalAccessoryToolbar } - private weak var accessoryStackView: UIStackView? + private weak var accessoryRowsStackView: UIStackView? + private var accessoryActionStackViews: [UIStackView] = [] + private var accessoryRowsHeightConstraint: NSLayoutConstraint? private var isMacRemote = false #if DEBUG @@ -497,7 +484,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// ``GhosttySurfaceView.composerDockProbeValue`` so it lands in the XCUITest /// failure message with no log plumbing. var accessoryLayoutDiagnostics: String { - let scroll = accessoryStackView?.superview as? UIScrollView + let scroll = accessoryActionStackViews.first?.superview as? UIScrollView let win = window let scrollOffsetX = scroll.map { Int($0.contentOffset.x) } ?? -1 let scrollContentW = scroll.map { Int($0.contentSize.width) } ?? -1 @@ -528,25 +515,30 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // bar's leading inset, so the rightmost button hugs the screen edge. accessoryScrollTrailingConstraint?.constant = -rightInset - if accessoryStackView != nil { + if accessoryRowsStackView != nil { terminalAccessoryToolbar.setNeedsLayout() terminalAccessoryToolbar.layoutIfNeeded() } } - /// Build (or rebuild) the SCROLLABLE button row: the user's configured order + /// Build (or rebuild) the scrollable button rows: the user's configured rows /// (modifiers, zoom, paste, shortcuts, and custom actions all reorderable - /// together), followed by the fixed trailing HIDE and "customize" controls. + /// together), followed on the bottom row by the fixed trailing HIDE and + /// "customize" controls. /// The composer toggle is NOT here — it is pinned in the container outside /// the scroll view (see ``terminalAccessoryToolbar``). The ⌘ item is rendered /// only when driving a Mac remote. Safe to call repeatedly; it clears the - /// stack first. + /// row stack first. private func populateAccessoryActions() { - guard let stack = accessoryStackView else { return } - for view in stack.arrangedSubviews { - stack.removeArrangedSubview(view) + guard let rowsStack = accessoryRowsStackView else { return } + for view in rowsStack.arrangedSubviews { + rowsStack.removeArrangedSubview(view) view.removeFromSuperview() } + accessoryActionStackViews.removeAll() + + let configuration = TerminalAccessoryConfiguration.shared + updateAccessoryRowsHeight(rowCount: configuration.rowCount) // The composer toggle is NOT added here: it is pinned directly in the // container (see ``terminalAccessoryToolbar``), OUTSIDE this scrollable @@ -555,24 +547,35 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // dependent), so a repopulate must not re-add or rebuild it. // // The user-configurable region: built-in shortcuts/modifiers/zoom/paste - // and custom actions, all in the user's saved order. - for item in TerminalAccessoryConfiguration.shared.enabledItems { - switch item { - case let .builtin(action): - // ⌘ only makes sense against a Mac remote; skip it otherwise - // (it stays in the saved order, just unrendered, so flipping the - // remote re-shows it in place). - if action == .command && !isMacRemote { continue } - stack.addArrangedSubview(makeAccessoryButton(for: action)) - case let .custom(custom): - stack.addArrangedSubview(makeCustomAccessoryButton(for: custom)) + // and custom actions, all in the user's saved rows and order. + let rowCount = max(1, configuration.rowCount) + let itemRows = configuration.enabledItemRows + for rowIndex in 0.. (UIScrollView, UIStackView) { + let scrollView = UIScrollView() + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.heightAnchor.constraint(equalToConstant: Self.dockedNubSize).isActive = true + + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 4 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + return (scrollView, stack) + } + + private func updateAccessoryRowsHeight(rowCount: Int) { + accessoryRowsHeightConstraint?.constant = TerminalAccessoryDockMetrics(rowCount: rowCount).rowsHeight + } + /// Disarm the active modifier if its bar button is no longer rendered, so a /// hidden (or non-Mac-remote ⌘) modifier can never stay invisibly armed. private func reconcileArmedModifierVisibility() { guard let armed = modifierState.armedModifier, let action = Self.accessoryAction(for: armed) else { return } - let renderedActions = (accessoryStackView?.arrangedSubviews ?? []).compactMap { view -> TerminalInputAccessoryAction? in + let renderedActions = accessoryActionStackViews.flatMap(\.arrangedSubviews).compactMap { view -> TerminalInputAccessoryAction? in guard let button = view as? AccessoryActionButton, case let .builtin(builtinAction) = button.item else { return nil } return builtinAction @@ -610,7 +642,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { @objc private func handleAccessoryConfigurationChanged() { // Only rebuild once the bar exists; otherwise the lazy build picks up // the new configuration on first use. - guard accessoryStackView != nil else { return } + guard accessoryRowsStackView != nil else { return } populateAccessoryActions() applyModifierPresentation() terminalAccessoryToolbar.setNeedsLayout() @@ -631,11 +663,10 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// button's armed/sticky style. Split out of ``updateModifierLabels(isMacRemote:)`` /// so a configuration-driven rebuild can re-apply it without toggling the flag. private func applyModifierPresentation() { - guard let stack = accessoryStackView else { return } // Restyle every visible button for the current remote (built-in titles // depend on `isMacRemote`) and its armed/sticky state. Custom actions // never arm. - for case let button as AccessoryActionButton in stack.arrangedSubviews { + for case let button as AccessoryActionButton in accessoryActionStackViews.flatMap(\.arrangedSubviews) { let armed: Bool let sticky: Bool if case let .builtin(action) = button.item { @@ -1202,8 +1233,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } private func refreshAccessoryButtonStyles() { - guard let stack = accessoryStackView else { return } - for case let button as AccessoryActionButton in stack.arrangedSubviews { + for case let button as AccessoryActionButton in accessoryActionStackViews.flatMap(\.arrangedSubviews) { // Only built-in modifier keys arm; custom actions always render normal. let armed: Bool let sticky: Bool diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift index 2f0a9e9d30a3..3670ed553444 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift @@ -1,16 +1,14 @@ public import Foundation /// Pure, `Sendable` reducer for the terminal input-accessory bar's configurable -/// region: which insertable shortcuts are shown and in what order. +/// region: which shortcuts are shown, which row they occupy, and their order. /// -/// The terminal accessory bar has two regions. The leading region (modifier and -/// zoom controls) is structural and pinned, so it is never modeled here. The -/// trailing region is the user-configurable list of insertable shortcuts (Esc, -/// Tab, arrows, `$`, `/`, `@`, `^C`, the agent launchers, …). This reducer owns -/// the *logic* for that trailing region — load/merge/forward-compat, enable -/// toggling, reordering, and reset — as pure transformations over the raw `Int` -/// identifiers of those actions, so it stays decoupled from the UIKit-gated -/// `TerminalInputAccessoryAction` enum and is testable from `swift test`. +/// The terminal accessory bar exposes every shortcut/modifier/paste/zoom action +/// as user-configurable. This reducer owns the logic for that region — +/// load/merge/forward-compat, enable toggling, row-count changes, reordering, +/// row assignment, and reset — as pure transformations over opaque identifiers, +/// so it stays decoupled from the UIKit-gated `TerminalInputAccessoryAction` +/// enum and is testable from `swift test`. /// /// Identifiers are opaque, `Hashable` values supplied by the caller. The reducer /// never invents identifiers: every value it returns is drawn from the @@ -22,11 +20,11 @@ public import Foundation /// /// ```swift /// let reducer = TerminalAccessoryLayoutReducer(configurable: [0, 1, 2, 3]) -/// var layout = reducer.load(savedOrder: [2, 0], savedEnabled: nil) -/// // layout.order == [2, 0, 1, 3] (saved first, then forward-compat append) +/// var layout = reducer.load(savedRows: [[2, 0]], savedEnabled: nil, rowCount: 2) +/// // layout.rows == [[2, 0], [1, 3]] (saved first, then forward-compat append) /// // layout.enabled == [0, 1, 2, 3] (nil enabled ⇒ everything on first launch) /// layout = reducer.setEnabled(1, false, in: layout) -/// // layout.visibleOrder == [2, 0, 3] +/// // layout.visibleRows == [[2, 0], [3]] /// ``` public struct TerminalAccessoryLayoutReducer: Sendable { /// The configurable action identifiers in canonical order. This is the @@ -74,8 +72,11 @@ public struct TerminalAccessoryLayoutReducer: Sendable /// An immutable snapshot of the configurable region's state. public struct Layout: Equatable, Sendable { - /// Every configurable identifier in the user's arranged order. - public let order: [ID] + /// Every configurable identifier in the user's arranged rows. + /// + /// Each identifier appears at most once. Empty rows are preserved so the + /// user's configured row count survives even when a row has no actions. + public let rows: [[ID]] /// The subset of ``order`` currently shown on the bar. public let enabled: Set @@ -85,14 +86,33 @@ public struct TerminalAccessoryLayoutReducer: Sendable /// - order: The configurable identifiers in display order. /// - enabled: The identifiers currently shown. public init(order: [ID], enabled: Set) { - self.order = order + self.rows = [order] self.enabled = enabled } - /// The enabled identifiers in display order — exactly what the toolbar's - /// configurable region renders, after the pinned leading buttons. + /// Creates a row-aware layout snapshot. + /// + /// - Parameters: + /// - rows: The configurable identifiers arranged into toolbar rows. + /// - enabled: The identifiers currently shown. + public init(rows: [[ID]], enabled: Set) { + self.rows = rows.isEmpty ? [[]] : rows + self.enabled = enabled + } + + /// Every configurable identifier in row-major display order. + public var order: [ID] { + rows.flatMap { $0 } + } + + /// The enabled identifiers in row-major display order. public var visibleOrder: [ID] { - order.filter { enabled.contains($0) } + visibleRows.flatMap { $0 } + } + + /// The enabled identifiers arranged by toolbar row. + public var visibleRows: [[ID]] { + rows.map { row in row.filter { enabled.contains($0) } } } } @@ -109,11 +129,42 @@ public struct TerminalAccessoryLayoutReducer: Sendable /// - Returns: A normalized ``Layout`` containing exactly the configurable /// identifiers. public func load(savedOrder: [ID], savedEnabled: [ID]?) -> Layout { - var order = savedOrder.filter { configurableSet.contains($0) } - var seen = Set(order) - for identifier in defaultOrder where !seen.contains(identifier) { - order.append(identifier) - seen.insert(identifier) + load(savedRows: [savedOrder], savedEnabled: savedEnabled, rowCount: 1) + } + + /// Builds a row-aware layout from persisted values, dropping unknown + /// identifiers, de-duplicating across rows, preserving empty rows, and + /// appending any configurable action not yet persisted. + /// + /// Forward-compatible identifiers are appended to the last row so an existing + /// custom row arrangement remains stable while still surfacing new actions. + /// If `rowCount` is smaller than the saved row count, overflow rows are merged + /// into the last retained row. If it is larger, empty rows are appended. + /// + /// - Parameters: + /// - savedRows: The persisted row arrangement. + /// - savedEnabled: The persisted enabled set, or `nil` on first launch. + /// `nil` means "show everything"; an empty array means the user hid every + /// shortcut. + /// - rowCount: The requested number of toolbar rows. Values below 1 are + /// clamped to 1. + /// - Returns: A normalized ``Layout`` containing exactly the configurable + /// identifiers. + public func load(savedRows: [[ID]], savedEnabled: [ID]?, rowCount: Int) -> Layout { + let desiredRowCount = max(1, rowCount) + let persistedRows = savedRows.isEmpty ? [defaultOrder] : savedRows + var rows = Array(repeating: [ID](), count: desiredRowCount) + var seen = Set() + for (sourceIndex, sourceRow) in persistedRows.enumerated() { + let rowIndex = min(sourceIndex, desiredRowCount - 1) + for identifier in sourceRow + where configurableSet.contains(identifier) && seen.insert(identifier).inserted { + rows[rowIndex].append(identifier) + } + } + + for identifier in defaultOrder where seen.insert(identifier).inserted { + rows[desiredRowCount - 1].append(identifier) } let enabled: Set @@ -122,7 +173,30 @@ public struct TerminalAccessoryLayoutReducer: Sendable } else { enabled = configurableSet } - return Layout(order: order, enabled: enabled) + return Layout(rows: rows, enabled: enabled) + } + + /// Builds a row-aware layout from persisted rows, using `defaultOrder` as the + /// fresh-install arrangement when no rows were saved. + /// + /// - Parameters: + /// - savedRows: The persisted row arrangement, or `nil` when not present. + /// - savedOrder: The legacy flat saved order to migrate when `savedRows` is + /// absent. + /// - savedEnabled: The persisted enabled set, or `nil` on first launch. + /// - rowCount: The requested number of toolbar rows. + /// - Returns: A normalized ``Layout`` containing exactly the configurable + /// identifiers. + public func load( + savedRows: [[ID]]?, + savedOrder: [ID], + savedEnabled: [ID]?, + rowCount: Int + ) -> Layout { + if let savedRows { + return load(savedRows: savedRows, savedEnabled: savedEnabled, rowCount: rowCount) + } + return load(savedRows: savedOrder.isEmpty ? [defaultOrder] : [savedOrder], savedEnabled: savedEnabled, rowCount: rowCount) } /// Returns `layout` with `identifier` shown or hidden. A no-op for @@ -137,7 +211,7 @@ public struct TerminalAccessoryLayoutReducer: Sendable guard configurableSet.contains(identifier) else { return layout } var enabled = layout.enabled if isEnabled { enabled.insert(identifier) } else { enabled.remove(identifier) } - return Layout(order: layout.order, enabled: enabled) + return Layout(rows: layout.rows, enabled: enabled) } /// Returns `layout` with the configurable actions reordered. @@ -163,11 +237,191 @@ public struct TerminalAccessoryLayoutReducer: Sendable } let insertionIndex = destination - movedIndices.filter { $0 < destination }.count order.insert(contentsOf: moved, at: max(0, min(insertionIndex, order.count))) - return Layout(order: order, enabled: layout.enabled) + return Layout(rows: split(order, matchingRowLengthsOf: layout.rows), enabled: layout.enabled) + } + + /// Returns `layout` with a scoped set of identifiers reordered inside their current rows. + /// + /// Use this when a settings surface shows only part of the toolbar. Identifiers + /// outside `scopedIDs` keep their exact row positions, and scoped identifiers are + /// reordered only among the scoped slots in the row they already occupy. Moving + /// an item between rows must go through ``move(_:toRow:in:)``. + /// + /// - Parameters: + /// - orderedIDs: The desired row-major order for the scoped identifiers. + /// - scopedIDs: The identifiers the caller's current surface is allowed to move. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func reorder(_ orderedIDs: [ID], limitedTo scopedIDs: Set, in layout: Layout) -> Layout { + let validScopedIDs = scopedIDs.intersection(configurableSet) + guard !validScopedIDs.isEmpty else { return layout } + + var seen = Set() + let desiredScopedOrder = orderedIDs.filter { identifier in + configurableSet.contains(identifier) + && validScopedIDs.contains(identifier) + && seen.insert(identifier).inserted + } + guard !desiredScopedOrder.isEmpty else { return layout } + + var rows = layout.rows + for rowIndex in rows.indices { + let row = rows[rowIndex] + let rowScopedIDs = Set(row.filter { validScopedIDs.contains($0) }) + guard !rowScopedIDs.isEmpty else { continue } + + var rowSeen = Set() + var rowScopedOrder = desiredScopedOrder.filter { identifier in + rowScopedIDs.contains(identifier) && rowSeen.insert(identifier).inserted + } + for identifier in row where rowScopedIDs.contains(identifier) && rowSeen.insert(identifier).inserted { + rowScopedOrder.append(identifier) + } + + var iterator = rowScopedOrder.makeIterator() + rows[rowIndex] = row.map { identifier in + guard rowScopedIDs.contains(identifier) else { return identifier } + return iterator.next() ?? identifier + } + } + return Layout(rows: rows, enabled: layout.enabled) } - /// The default layout: ``defaultOrder`` with every shortcut shown. + /// Returns `layout` with a scoped set of identifiers reordered across row-major slots. + /// + /// Use this when a settings surface renders a flat scoped list while the + /// underlying toolbar may have multiple rows. Identifiers outside `scopedIDs` + /// keep their exact row positions, row lengths stay unchanged, and scoped + /// identifiers can move between the row-major slots occupied by that scope. + /// + /// - Parameters: + /// - orderedIDs: The desired row-major order for the scoped identifiers. + /// - scopedIDs: The identifiers the caller's current surface is allowed to move. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func reorderAcrossRows(_ orderedIDs: [ID], limitedTo scopedIDs: Set, in layout: Layout) -> Layout { + let validScopedIDs = scopedIDs.intersection(configurableSet).intersection(Set(layout.order)) + guard !validScopedIDs.isEmpty else { return layout } + + var seen = Set() + var scopedOrder = orderedIDs.filter { identifier in + validScopedIDs.contains(identifier) && seen.insert(identifier).inserted + } + for identifier in layout.order where validScopedIDs.contains(identifier) && seen.insert(identifier).inserted { + scopedOrder.append(identifier) + } + guard !scopedOrder.isEmpty else { return layout } + + var iterator = scopedOrder.makeIterator() + let rows = layout.rows.map { row in + row.map { identifier in + guard validScopedIDs.contains(identifier) else { return identifier } + return iterator.next() ?? identifier + } + } + return Layout(rows: rows, enabled: layout.enabled) + } + + /// Returns `layout` with items moved within one toolbar row. + /// + /// `offsets`/`destination` follow the SwiftUI `onMove` contract: indices into + /// the selected row. + /// + /// - Parameters: + /// - offsets: The row-local indices being moved. + /// - destination: The row-local insertion index. + /// - rowIndex: The row to reorder. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func move(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int, in layout: Layout) -> Layout { + guard layout.rows.indices.contains(rowIndex) else { return layout } + var rows = layout.rows + var row = rows[rowIndex] + var validOffsets = IndexSet() + for offset in offsets where row.indices.contains(offset) { + validOffsets.insert(offset) + } + guard !validOffsets.isEmpty else { return layout } + let movedIndices = validOffsets.sorted() + let moved = movedIndices.map { row[$0] } + for index in movedIndices.reversed() { + row.remove(at: index) + } + let insertionIndex = destination - movedIndices.filter { $0 < destination }.count + row.insert(contentsOf: moved, at: max(0, min(insertionIndex, row.count))) + rows[rowIndex] = row + return Layout(rows: rows, enabled: layout.enabled) + } + + /// Returns `layout` with `identifier` moved to the end of another row. + /// + /// A no-op for unknown identifiers or invalid row indices. + /// + /// - Parameters: + /// - identifier: The action identifier to move. + /// - rowIndex: The destination row. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func move(_ identifier: ID, toRow rowIndex: Int, in layout: Layout) -> Layout { + guard configurableSet.contains(identifier), layout.rows.indices.contains(rowIndex) else { return layout } + var rows = layout.rows.map { row in row.filter { $0 != identifier } } + rows[rowIndex].append(identifier) + return Layout(rows: rows, enabled: layout.enabled) + } + + /// Returns `layout` with its toolbar row count changed. + /// + /// Reducing the count preserves every action by merging overflow rows into + /// the last retained row. Increasing the count appends empty rows. + /// + /// Rows are numbered top-to-bottom: "Row 1"…"Row N" in the settings UI, and + /// in the toolbar "Row 1" renders at the top down to "Row N" nearest the + /// keyboard (the last row also carries the fixed HIDE/customize controls). + /// Growth deliberately appends the new empty rows *after* the existing ones + /// so each existing row keeps its number and position instead of being + /// renumbered/shifted on every count change; the added rows start empty and + /// the user fills them via the per-item "Move to Row" picker (`move(_:toRow:in:)`) + /// and within-row drag. Prepending/bottom-anchoring instead would push the + /// user's existing shortcuts to a higher-numbered row on every increase, + /// contradicting the stable "Row 1…N" numbering. This is intentional and + /// pinned by `TerminalAccessoryLayoutReducerTests`. + /// + /// - Parameters: + /// - rowCount: The requested row count. Values below 1 are clamped to 1. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func setRowCount(_ rowCount: Int, in layout: Layout) -> Layout { + let desiredRowCount = max(1, rowCount) + var rows = layout.rows.isEmpty ? [[]] : layout.rows + if rows.count > desiredRowCount { + let keptPrefix = rows.prefix(desiredRowCount - 1) + let mergedTail = rows.dropFirst(desiredRowCount - 1).flatMap { $0 } + rows = Array(keptPrefix) + [mergedTail] + } else if rows.count < desiredRowCount { + // Append (not prepend) so existing rows keep their "Row 1…k" numbers; + // see the doc comment above for why growth is intentionally top-anchored. + rows.append(contentsOf: Array(repeating: [], count: desiredRowCount - rows.count)) + } + return Layout(rows: rows, enabled: layout.enabled) + } + + /// The default layout: ``defaultOrder`` in one row, with every shortcut shown. public func defaultLayout() -> Layout { Layout(order: defaultOrder, enabled: configurableSet) } + + private func split(_ order: [ID], matchingRowLengthsOf rows: [[ID]]) -> [[ID]] { + guard !rows.isEmpty else { return [order] } + var result: [[ID]] = [] + var cursor = order.startIndex + for row in rows { + let end = order.index(cursor, offsetBy: min(row.count, order.distance(from: cursor, to: order.endIndex))) + result.append(Array(order[cursor...Layout( + rows: [[0], [1, 2], [3]], + enabled: Set([0, 1, 2, 3]) + ) + let reduced = reducer.setRowCount(2, in: layout) + #expect(reduced.rows == [[0], [1, 2, 3]]) + } + + @Test("row count increase appends empty rows") + func increasingRowCountAppendsEmptyRows() { + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1, 2, 3]], + enabled: Set([0, 1, 2, 3]) + ) + let expanded = reducer.setRowCount(3, in: layout) + #expect(expanded.rows == [[0, 1, 2, 3], [], []]) + } + + @Test("row-local move reorders only that row") + func rowLocalMove() { + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1], [2, 3]], + enabled: Set([0, 1, 2, 3]) + ) + let moved = reducer.move(from: IndexSet(integer: 0), to: 2, inRow: 1, in: layout) + #expect(moved.rows == [[0, 1], [3, 2]]) + } + + @Test("scoped reorder preserves terminal rows and non-scoped positions") + func scopedReorderPreservesRows() { + let scopedReducer = TerminalAccessoryLayoutReducer(configurable: [0, 1, 2, 3, 4, 5]) + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1, 2], [3, 4, 5]], + enabled: Set([0, 1, 2, 3, 4, 5]) + ) + + let moved = scopedReducer.reorder([5, 3, 0, 2], limitedTo: Set([0, 2, 3, 5]), in: layout) + + #expect(moved.rows == [[0, 1, 2], [5, 4, 3]]) + #expect(moved.enabled == layout.enabled) + } + + @Test("flat scoped reorder can move scoped items across row slots") + func scopedReorderAcrossRowsPreservesNonScopedSlots() { + let scopedReducer = TerminalAccessoryLayoutReducer(configurable: [0, 1, 2, 3, 4, 5]) + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1, 2], [3, 4, 5]], + enabled: Set([0, 1, 2, 3, 4, 5]) + ) + + let moved = scopedReducer.reorderAcrossRows([5, 3, 0, 2], limitedTo: Set([0, 2, 3, 5]), in: layout) + + #expect(moved.rows == [[5, 1, 3], [0, 4, 2]]) + #expect(moved.enabled == layout.enabled) + } + + @Test("moving an item to another row preserves enabled state") + func moveItemToRow() { + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1], [2, 3]], + enabled: Set([0, 2]) + ) + let moved = reducer.move(1, toRow: 1, in: layout) + #expect(moved.rows == [[0], [2, 3, 1]]) + #expect(moved.enabled == Set([0, 2])) + } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ef80c1446033..7d58a6426762 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4744,6 +4744,91 @@ } } }, + "mobile.shortcuts.rows.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add rows to keep more buttons visible above the keyboard." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行を追加すると、キーボード上に表示できるボタンが増えます。" + } + } + } + }, + "mobile.shortcuts.rows.header": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toolbar Rows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツールバー行" + } + } + } + }, + "mobile.shortcuts.rows.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行" + } + } + } + }, + "mobile.shortcuts.rows.movePicker": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Row" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行へ移動" + } + } + } + }, + "mobile.shortcuts.rows.rowTitleFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Row %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d行目" + } + } + } + }, "mobile.shortcuts.title": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift index cb04c8a51745..f89a161fd7f3 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift @@ -24,6 +24,13 @@ struct TerminalAccessoryConfigurationTests { private func id(_ action: TerminalInputAccessoryAction) -> ToolbarItemID { action.itemID } + private func persistedV4Rows(in defaults: UserDefaults) -> [[String]]? { + guard let rows = defaults.array(forKey: "cmux.terminal.toolbar.rows.v4") else { return nil } + return rows.map { row in + (row as? [String]) ?? [] + } + } + // MARK: - Gating test #1: fresh-install default order @Test("fresh install puts modifiers (incl. ⇧) at the front and zoom at the back, all shown") @@ -100,6 +107,19 @@ struct TerminalAccessoryConfigurationTests { #expect(reloaded.displayOrder.last == id(.control)) } + @Test("selecting an item's current toolbar row preserves row order") + func movingItemToCurrentRowIsNoOp() { + let config = TerminalAccessoryConfiguration(defaults: freshDefaults()) + config.setRowCount(2) + config.moveItem(id(.tab), toRow: 1) + config.moveItem(id(.escape), toRow: 1) + let rowsBeforeSameRowMove = config.displayRows + + config.moveItem(id(.tab), toRow: 1) + + #expect(config.displayRows == rowsBeforeSameRowMove) + } + @Test("hiding a modifier persists across reload and keeps it in the order") func hideModifierPersists() { let defaults = freshDefaults() @@ -176,24 +196,23 @@ struct TerminalAccessoryConfigurationTests { #expect(config.isEnabled(id(.zoomIn))) } - @Test("an upgraded config re-persists under the v3 keys so the migration runs once") - func migrationPersistsUnderV3Keys() { + @Test("an upgraded config re-persists under the v4 rows so the migration runs once") + func migrationPersistsUnderV4Rows() throws { let defaults = freshDefaults() defaults.set([id(.tab).storageKey], forKey: "cmux.terminal.toolbar.order.v2") defaults.set([id(.tab).storageKey], forKey: "cmux.terminal.toolbar.enabled.v2") _ = TerminalAccessoryConfiguration(defaults: defaults) - // After init, v3 keys exist; a second load takes the v3 path (no second + // After init, v4 rows exist; a second load takes the v4 path (no second // force-enable), so hiding a modifier then would persist. - let v3Order = defaults.array(forKey: "cmux.terminal.toolbar.order.v3") as? [String] - #expect(v3Order != nil) - #expect(v3Order?.contains(id(.control).storageKey) == true) + let v4Rows = try #require(persistedV4Rows(in: defaults)) + #expect(v4Rows.flatMap { $0 }.contains(id(.control).storageKey)) let reloaded = TerminalAccessoryConfiguration(defaults: defaults) reloaded.setEnabled(id(.control), false) let reloadedAgain = TerminalAccessoryConfiguration(defaults: defaults) - // The v3 path honored the hidden modifier rather than re-forcing it on. + // The v4 path honored the hidden modifier rather than re-forcing it on. #expect(!reloadedAgain.isEnabled(id(.control))) } @@ -381,8 +400,8 @@ struct TerminalAccessoryConfigurationTests { #expect(!config.isEnabled(id(.returnKey))) } - @Test("the Return fold re-persists under v3 keys so it runs once") - func returnFoldPersistsUnderV3Keys() { + @Test("the Return fold re-persists under v4 rows so it runs once") + func returnFoldPersistsUnderV4Rows() throws { let defaults = freshDefaults() let preReturn: [TerminalInputAccessoryAction] = [.control, .alternate, .command, .shift, .paste, .tab, .escape] defaults.set(preReturn.map { id($0).storageKey }, forKey: "cmux.terminal.toolbar.order.v3") @@ -390,10 +409,10 @@ struct TerminalAccessoryConfigurationTests { _ = TerminalAccessoryConfiguration(defaults: defaults) - // After init, Return lives in the persisted v3 order, so the next load + // After init, Return lives in the persisted v4 rows, so the next load // takes the no-op path and a later hide would persist. - let v3Order = defaults.array(forKey: "cmux.terminal.toolbar.order.v3") as? [String] - #expect(v3Order?.contains(id(.returnKey).storageKey) == true) + let v4Rows = try #require(persistedV4Rows(in: defaults)) + #expect(v4Rows.flatMap { $0 }.contains(id(.returnKey).storageKey)) } @Test("a v2 upgrade also surfaces Return force-enabled")