Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1ced57b
Add configurable iOS toolbar rows
Jun 26, 2026
3209251
Update Swift file length budget
Jun 26, 2026
1d18fe1
Merge origin/main
Jun 26, 2026
5f86d83
Address iOS toolbar row review
Jun 26, 2026
c38e281
Update toolbar migration tests for v4 rows
Jun 26, 2026
062129c
Add regression test for same-row toolbar moves
Jun 26, 2026
84722cf
Avoid same-row toolbar move churn
Jun 26, 2026
5ef5ad0
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
Jun 26, 2026
2d9f701
Stabilize shell integration path test
Jun 27, 2026
34acb0d
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
Jun 27, 2026
ec39915
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
Jun 27, 2026
5be3d3f
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
austinywang Jun 29, 2026
ded38f8
Document intentional within-row reorder for Shared Shortcuts
austinywang Jun 30, 2026
2dd9033
Merge origin/main into issue-6090-ios-multiple-configurable-toolbar-rows
austinywang Jul 2, 2026
b3ae54c
Merge origin/main into issue-6090-ios-multiple-configurable-toolbar-rows
austinywang Jul 2, 2026
9af23e5
Document intentional top-anchored toolbar row growth
austinywang Jul 2, 2026
62a3e27
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
austinywang Jul 2, 2026
02a707a
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
austinywang Jul 3, 2026
b250a77
Address iOS toolbar row review feedback
austinywang Jul 3, 2026
69a1f22
Satisfy toolbar row policy review
austinywang Jul 3, 2026
b27b3a8
Keep shared shortcuts row reordering local
austinywang Jul 3, 2026
4aef6c4
Move toolbar height metrics to value type
austinywang Jul 3, 2026
c6984ca
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
austinywang Jul 4, 2026
d59b949
Regenerate Swift file length budget
austinywang Jul 4, 2026
0aee117
Fix shared shortcut cross-row reordering
austinywang Jul 4, 2026
9825ace
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
austinywang Jul 4, 2026
625d33a
Refresh Swift file length budget
austinywang Jul 4, 2026
00bc0ce
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
austinywang Jul 4, 2026
f900bc8
Merge remote-tracking branch 'origin/main' into issue-6090-ios-multip…
austinywang Jul 4, 2026
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
4 changes: 2 additions & 2 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#if os(iOS)
import CmuxMobileTerminal

struct TerminalShortcutRowSection: Identifiable {
let id: String
let index: Int
let items: [ResolvedToolbarItem]
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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..<configuration.rowCount, id: \.self) { index in
Text(rowTitle(index)).tag(index)
}
}
.labelsHidden()
.pickerStyle(.menu)
.accessibilityIdentifier("TerminalShortcutRowPicker.\(item.id.storageKey)")
}
}
.accessibilityIdentifier("TerminalShortcutToggle.\(item.id.storageKey)")
Expand Down Expand Up @@ -120,26 +177,80 @@ struct TerminalShortcutsSettingsView: View {
)
}

private var rowCountBinding: Binding<Int> {
Binding(
get: { configuration.rowCount },
set: { configuration.setRowCount($0) }
)
}

private func rowBinding(for id: ToolbarItemID, fallback: Int) -> Binding<Int> {
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)
}
Comment thread
austinywang marked this conversation as resolved.

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
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -1731,6 +1742,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
}

deinit {
NotificationCenter.default.removeObserver(self)
stopKeyboardHeightAnimation()
disposeSurface()
}
Expand Down
Loading
Loading