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
29 changes: 29 additions & 0 deletions Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,35 @@ public final class CanvasModel {
revision &+= 1
}

/// Reorders a panel within its current pane by a relative offset.
///
/// The destination is clamped to the pane's tab bounds. Reordering at an
/// edge succeeds without changing the layout, matching adjacent-move
/// semantics used by the split layout.
///
/// - Parameters:
/// - panelId: The panel to move.
/// - offset: The relative final-position offset.
/// - Returns: Whether the panel belongs to a canvas pane.
@discardableResult
public func reorderPanel(_ panelId: UUID, by offset: Int) -> Bool {
let panel = CanvasPanelID(rawValue: panelId)
guard let paneID = layout.pane(containing: panel),
let panelIds = layout.panelIds(in: paneID),
let currentIndex = panelIds.firstIndex(of: panel),
!panelIds.isEmpty else { return false }
let destinationIndex = min(
max(currentIndex + offset, panelIds.startIndex),
panelIds.index(before: panelIds.endIndex)
)
guard destinationIndex != currentIndex else { return true }

layout.removePanel(panel)
layout.addPanel(panel, toPane: paneID, at: destinationIndex, select: true)
revision &+= 1
return true
}

/// Moves `panelId` into the pane hosting `targetPanelId` (a join). The
/// source pane disappears when it loses its last tab.
/// - Returns: Whether the join happened.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ struct CanvasModelTests {
#expect(model.revision != before)
}

@Test func reorderPanelClampsExtremeOffsetsWithoutOverflow() {
let model = makeModel()
let a = UUID()
let b = UUID()
let c = UUID()
model.restoreFrames([
(id: a, frame: CGRect(x: 0, y: 0, width: 300, height: 200)),
(id: b, frame: CGRect(x: 400, y: 0, width: 300, height: 200)),
(id: c, frame: CGRect(x: 800, y: 0, width: 300, height: 200)),
])
#expect(model.joinPanel(b, withPaneContaining: a))
#expect(model.joinPanel(c, withPaneContaining: a))
let paneID = model.paneID(containing: a)!

#expect(model.reorderPanel(b, by: .max))
#expect(model.layout.panelIds(in: paneID)?.map(\.rawValue) == [a, c, b])

#expect(model.reorderPanel(b, by: .min))
#expect(model.layout.panelIds(in: paneID)?.map(\.rawValue) == [b, a, c])
}

@Test func joinAndBreakRoundTrip() {
let model = makeModel()
let a = UUID()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,12 @@ extension ShortcutAction {
return nil
case .nextSurface: return ShortcutStroke(key: "]", command: true, shift: true)
case .prevSurface: return ShortcutStroke(key: "[", command: true, shift: true)
case .moveSurfaceLeft: return ShortcutStroke(key: "[", command: true, shift: true, option: true)
case .moveSurfaceRight: return ShortcutStroke(key: "]", command: true, shift: true, option: true)
case .selectSurfaceByNumber: return ShortcutStroke(key: "1", control: true)
case .selectWorkspaceByNumber: return ShortcutStroke(key: "1", command: true)
case .moveWorkspaceUp: return ShortcutStroke(key: "[", command: true, option: true, control: true)
case .moveWorkspaceDown: return ShortcutStroke(key: "]", command: true, option: true, control: true)
case .newSurface: return ShortcutStroke(key: "t", command: true)
case .toggleTerminalCopyMode: return ShortcutStroke(key: "m", command: true, shift: true)
case .focusTextBoxInput: return ShortcutStroke(key: "a", command: true, shift: true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
extension ShortcutAction {
/// Logical grouping used for sectioning the shortcuts pane.
public enum Group: String, CaseIterable, Sendable, Hashable {
/// Application-wide actions.
case app
/// Workspace lifecycle and notification actions.
case workspace
/// Workspace and surface navigation actions.
case navigation
/// Pane layout and focus actions.
case panes
/// Browser, viewer, and find actions.
case browser

/// The English section title used by shortcut catalog consumers.
public var title: String {
switch self {
case .app: return "App"
case .workspace: return "Workspace"
case .navigation: return "Navigation"
case .panes: return "Panes"
case .browser: return "Browser & Find"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,17 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
// MARK: Navigation
case nextSurface
case prevSurface
/// Moves the selected surface one position left.
case moveSurfaceLeft
/// Moves the selected surface one position right.
case moveSurfaceRight
case selectSurfaceByNumber
case nextSidebarTab
case prevSidebarTab
/// Moves the selected workspace one position up within its pin tier.
case moveWorkspaceUp
/// Moves the selected workspace one position down within its pin tier.
case moveWorkspaceDown
case focusHistoryBack
case focusHistoryForward
case selectWorkspaceByNumber
Expand Down Expand Up @@ -165,25 +173,6 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
}

extension ShortcutAction {
/// Logical grouping used for sectioning the shortcuts pane.
public enum Group: String, CaseIterable, Sendable, Hashable {
case app
case workspace
case navigation
case panes
case browser

public var title: String {
switch self {
case .app: return "App"
case .workspace: return "Workspace"
case .navigation: return "Navigation"
case .panes: return "Panes"
case .browser: return "Browser & Find"
}
}
}

/// Which group this action belongs to in the settings pane.
public var group: Group {
switch self {
Expand All @@ -197,8 +186,8 @@ extension ShortcutAction {
.switchRightSidebarToSessions, .switchRightSidebarToFeed,
.switchRightSidebarToDock, .triggerFlash:
return .workspace
case .nextSurface, .prevSurface, .selectSurfaceByNumber, .nextSidebarTab,
.prevSidebarTab, .focusHistoryBack, .focusHistoryForward,
case .nextSurface, .prevSurface, .moveSurfaceLeft, .moveSurfaceRight, .selectSurfaceByNumber,
.nextSidebarTab, .prevSidebarTab, .moveWorkspaceUp, .moveWorkspaceDown, .focusHistoryBack, .focusHistoryForward,
.selectWorkspaceByNumber, .renameTab, .renameWorkspace,
.editWorkspaceDescription, .markWorkspaceDone, .cycleWorkspaceStatus, .toggleChecklistItemComplete, .closeTab, .closeOtherTabsInPane, .closeWorkspace,
.newWorkspaceGroup, .groupSelectedWorkspaces, .toggleFocusedWorkspaceGroupCollapsed,
Expand Down Expand Up @@ -371,9 +360,13 @@ extension ShortcutAction {
case .triggerFlash: return "Flash Focused Panel"
case .nextSurface: return "Next Surface"
case .prevSurface: return "Previous Surface"
case .moveSurfaceLeft: return String(localized: "shortcut.moveSurfaceLeft.label", defaultValue: "Move Surface Left")
case .moveSurfaceRight: return String(localized: "shortcut.moveSurfaceRight.label", defaultValue: "Move Surface Right")
case .selectSurfaceByNumber: return "Select Surface 1…9"
case .nextSidebarTab: return "Next Workspace"
case .prevSidebarTab: return "Previous Workspace"
case .moveWorkspaceUp: return String(localized: "shortcut.moveWorkspaceUp.label", defaultValue: "Move Workspace Up")
case .moveWorkspaceDown: return String(localized: "shortcut.moveWorkspaceDown.label", defaultValue: "Move Workspace Down")
case .focusHistoryBack: return "Focus Back"
case .focusHistoryForward: return "Focus Forward"
case .selectWorkspaceByNumber: return "Select Workspace 1…9"
Expand Down
68 changes: 68 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -191942,6 +191942,74 @@
}
}
},
"shortcut.moveSurfaceLeft.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Move Surface Left"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サーフェスを左へ移動"
}
}
}
},
"shortcut.moveSurfaceRight.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Move Surface Right"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サーフェスを右へ移動"
}
}
}
},
"shortcut.moveWorkspaceDown.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Move Workspace Down"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペースを下へ移動"
}
}
}
},
"shortcut.moveWorkspaceUp.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Move Workspace Up"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペースを上へ移動"
}
}
}
},
"shortcut.newBrowserWorkspace.label": {
"extractionState": "manual",
"localizations": {
Expand Down
33 changes: 33 additions & 0 deletions Sources/AppDelegate+AdjacentNavigationShortcut.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import AppKit

extension AppDelegate {
/// Routes adjacent surface navigation and surface/workspace reordering through
/// the main-window context selected for the key event.
func handleAdjacentNavigationShortcut(event: NSEvent) -> Bool {
if matchConfiguredShortcut(event: event, action: .nextSurface) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectNextSurface()
return true
}
if matchConfiguredShortcut(event: event, action: .prevSurface) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectPreviousSurface()
return true
}
if matchConfiguredShortcut(event: event, action: .moveSurfaceLeft) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectedWorkspace?.moveSelectedSurface(by: -1)
return true
}
if matchConfiguredShortcut(event: event, action: .moveSurfaceRight) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectedWorkspace?.moveSelectedSurface(by: 1)
return true
}
if matchConfiguredShortcut(event: event, action: .moveWorkspaceUp) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: -1)
return true
}
if matchConfiguredShortcut(event: event, action: .moveWorkspaceDown) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: 1)
return true
}
return false
}
}
10 changes: 1 addition & 9 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13550,15 +13550,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}

// Surface navigation: Cmd+Shift+] / Cmd+Shift+[
if matchConfiguredShortcut(event: event, action: .nextSurface) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectNextSurface()
return true
}
if matchConfiguredShortcut(event: event, action: .prevSurface) {
(preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectPreviousSurface()
return true
}
if handleAdjacentNavigationShortcut(event: event) { return true }

if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) {
let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false
Expand Down
4 changes: 4 additions & 0 deletions Sources/ContentView+RightSidebarCommandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ extension ContentView {
return .nextSidebarTab
case "palette.previousWorkspace":
return .prevSidebarTab
case "palette.moveWorkspaceUp":
return .moveWorkspaceUp
case "palette.moveWorkspaceDown":
return .moveWorkspaceDown
case "palette.nextTabInPane":
return .nextSurface
case "palette.previousTabInPane":
Expand Down
17 changes: 3 additions & 14 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7758,10 +7758,10 @@ struct ContentView: View {
tabManager.selectPreviousTab()
}
registry.register(commandId: "palette.moveWorkspaceUp") {
moveSelectedWorkspace(by: -1)
tabManager.moveSelectedWorkspace(by: -1)
}
registry.register(commandId: "palette.moveWorkspaceDown") {
moveSelectedWorkspace(by: 1)
tabManager.moveSelectedWorkspace(by: 1)
}
registry.register(commandId: "palette.moveWorkspaceToTop") {
guard let workspace = tabManager.selectedWorkspace else {
Expand Down Expand Up @@ -9069,15 +9069,6 @@ struct ContentView: View {
return tabManager.tabs.firstIndex { $0.id == workspace.id }
}

private func moveSelectedWorkspace(by delta: Int) {
guard let workspace = tabManager.selectedWorkspace,
let currentIndex = selectedWorkspaceIndex() else { return }
let targetIndex = currentIndex + delta
guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return }
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex)
tabManager.selectWorkspace(workspace)
}

private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) {
tabManager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned)
}
Expand Down Expand Up @@ -13982,9 +13973,7 @@ struct TabItemView: View, Equatable {
}

func moveBy(_ delta: Int) {
let targetIndex = index + delta
guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return }
guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return }
guard tabManager.reorderWorkspace(tabId: tab.id, by: delta) else { return }
selectedTabIds = [tab.id]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id }
tabManager.selectTab(tab)
Expand Down
15 changes: 15 additions & 0 deletions Sources/KeyboardShortcutSettings+ActionVisibility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
extension KeyboardShortcutSettings.Action {
/// Whether this action is part of the public, user-configurable shortcut catalog.
var isPublicShortcutAction: Bool {
switch self {
case .switchRightSidebarToFiles,
.switchRightSidebarToFind,
.switchRightSidebarToSessions,
.switchRightSidebarToFeed,
.switchRightSidebarToDock:
return false
default:
return true
}
}
}
Loading
Loading