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
32 changes: 22 additions & 10 deletions Packages/macOS/CmuxCanvas/Sources/CmuxCanvas/CanvasPlacer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import Foundation
/// Chooses where a new pane goes on the canvas.
///
/// Placement never moves existing panes: the placer tries the canonical-gap
/// position to the right of the anchor, then below, left, above, then scans
/// outward, and finally falls back to the area right of all existing content
/// (which is free by construction).
/// position in the requested direction when provided, then the remaining
/// cardinal neighbors, then scans outward, and finally falls back to the area
/// right of all existing content (which is free by construction).
public struct CanvasPlacer: Sendable {
/// The metrics supplying the canonical gap.
public let metrics: CanvasMetrics
Expand All @@ -28,12 +28,15 @@ public struct CanvasPlacer: Sendable {
/// - anchor: The focused pane's frame, when one exists. New panes appear
/// near it at the canonical gap.
/// - existing: Frames of every pane already on the canvas.
/// - preferredDirection: First cardinal direction to try, when a shortcut
/// implies one.
/// - Returns: A frame at least ``CanvasMetrics/gap`` away from every
/// existing pane.
public func frameForNewPane(
size: CanvasSize,
near anchor: CanvasRect?,
avoiding existing: [CanvasRect]
avoiding existing: [CanvasRect],
preferredDirection: CanvasDirection? = nil
) -> CanvasRect {
guard !existing.isEmpty else {
let origin = anchor?.origin ?? .zero
Expand All @@ -43,13 +46,22 @@ public struct CanvasPlacer: Sendable {
return frameRightOfContent(size: size, existing: existing)
}

let neighbors: [CanvasRect] = [
CanvasRect(x: anchor.maxX + metrics.gap, y: anchor.minY, width: size.width, height: size.height),
CanvasRect(x: anchor.minX, y: anchor.maxY + metrics.gap, width: size.width, height: size.height),
CanvasRect(x: anchor.minX - metrics.gap - size.width, y: anchor.minY, width: size.width, height: size.height),
CanvasRect(x: anchor.minX, y: anchor.minY - metrics.gap - size.height, width: size.width, height: size.height),
let neighbors: [(direction: CanvasDirection, frame: CanvasRect)] = [
(.right, CanvasRect(x: anchor.maxX + metrics.gap, y: anchor.minY, width: size.width, height: size.height)),
(.down, CanvasRect(x: anchor.minX, y: anchor.maxY + metrics.gap, width: size.width, height: size.height)),
(.left, CanvasRect(x: anchor.minX - metrics.gap - size.width, y: anchor.minY, width: size.width, height: size.height)),
(.up, CanvasRect(x: anchor.minX, y: anchor.minY - metrics.gap - size.height, width: size.width, height: size.height)),
]
for candidate in neighbors where isFree(candidate, avoiding: existing) {
let orderedNeighbors: [CanvasRect]
if let preferredDirection,
let preferred = neighbors.first(where: { $0.direction == preferredDirection }) {
orderedNeighbors = [preferred.frame] + neighbors
.filter { $0.direction != preferredDirection }
.map(\.frame)
} else {
orderedNeighbors = neighbors.map(\.frame)
}
for candidate in orderedNeighbors where isFree(candidate, avoiding: existing) {
return candidate
}

Expand Down
13 changes: 10 additions & 3 deletions Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ public final class CanvasModel {
/// panels that no longer exist leave the canvas. Returns the IDs of
/// panes that were newly added, so the caller can reveal them.
@discardableResult
public func syncPanes(panelIds: [UUID], focusedPanelId: UUID?) -> [UUID] {
public func syncPanes(
panelIds: [UUID],
focusedPanelId: UUID?,
preferredDirection: CanvasDirection? = nil,
preferredNewPaneSize: CanvasSize? = nil
) -> [UUID] {
var changed = false
let idSet = Set(panelIds.map(CanvasPanelID.init(rawValue:)))
for panelId in layout.allPanelIds where !idSet.contains(panelId) {
Expand All @@ -62,10 +67,12 @@ public final class CanvasModel {
.flatMap { layout.pane(containing: CanvasPanelID(rawValue: $0)) }
.flatMap { layout.frame(of: $0) }
?? layout.panes.last?.frame
let size = preferredNewPaneSize ?? anchor?.size ?? Self.defaultPaneSize
let frame = placer.frameForNewPane(
size: Self.defaultPaneSize,
size: size,
near: anchor,
avoiding: occupiedFrames
avoiding: occupiedFrames,
preferredDirection: preferredDirection
)
let pane = CanvasPane(id: CanvasPaneID(rawValue: panelId), frame: frame)
layout.add(pane)
Expand Down
4 changes: 2 additions & 2 deletions Packages/macOS/CmuxSettings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,11 @@ needed. Build a context by hand and assert evaluation:

```swift
var context = ShortcutContext()
context.setBool(ShortcutContextKnownKey.commandPaletteVisible.rawValue, true)
context.setBool(ShortcutContextKnownKey.workspaceCanvasLayout.rawValue, true)
context.setString(ShortcutContextKnownKey.sidebarMode.rawValue, "find")
context.setInt(ShortcutContextKnownKey.paneCount.rawValue, 2)

let clause = ShortcutWhenClause.parse("commandPaletteVisible && paneCount > 1")
let clause = ShortcutWhenClause.parse("workspaceCanvasLayout && paneCount > 1")
#expect(clause?.evaluate(context) == true)
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ extension ShortcutAction {
case .canvasOverview: return ShortcutStroke(key: "o", command: true, control: true)
case .canvasZoomIn: return ShortcutStroke(key: "=", command: true, option: true)
case .canvasZoomOut: return ShortcutStroke(key: "-", command: true, option: true)
case .canvasZoomReset: return ShortcutStroke(key: "0", command: true, option: true)
case .canvasZoomReset: return ShortcutStroke(key: "0", command: true)
case .canvasTidy: return ShortcutStroke(key: "t", command: true, control: true)
case .canvasAlignLeft, .canvasAlignRight, .canvasAlignTop, .canvasAlignBottom,
.canvasEqualizeWidths, .canvasEqualizeHeights,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,17 @@ extension ShortcutAction {
return .atom(.browserFocus)
case .markdownZoomIn, .markdownZoomOut, .markdownZoomReset:
return .atom(.markdownFocus)
case .canvasZoomReset:
return .and(
.key(ShortcutContextKnownKey.workspaceCanvasLayout.rawValue),
.and(.not(.atom(.browserFocus)), .not(.atom(.markdownFocus)))
)
case .canvasRevealFocusedPane, .canvasOverview,
.canvasZoomIn, .canvasZoomOut, .canvasTidy,
.canvasAlignLeft, .canvasAlignRight, .canvasAlignTop, .canvasAlignBottom,
.canvasEqualizeWidths, .canvasEqualizeHeights,
.canvasDistributeHorizontally, .canvasDistributeVertically:
return .key(ShortcutContextKnownKey.workspaceCanvasLayout.rawValue)
default:
return .always
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public enum ShortcutContextKnownKey: String, CaseIterable, Sendable {
case commandPaletteVisible
/// The focused terminal's find overlay is open.
case terminalFindVisible
/// The focused workspace is using the freeform canvas layout.
case workspaceCanvasLayout
/// The right sidebar's active mode (`files`, `find`, `sessions`, `feed`, `dock`).
case sidebarMode
/// The number of panes in the focused workspace.
Expand All @@ -40,7 +42,7 @@ public enum ShortcutContextKnownKey: String, CaseIterable, Sendable {
public var valueType: ShortcutContextValueType {
switch self {
case .sidebarFocus, .browserFocus, .markdownFocus, .terminalFocus,
.commandPaletteVisible, .terminalFindVisible:
.commandPaletteVisible, .terminalFindVisible, .workspaceCanvasLayout:
return .bool
case .sidebarMode:
return .string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,7 @@ public struct KeyboardShortcutsSection: View {
for other in ShortcutAction.allCases where other != action {
// Two bindings on the same keystroke only collide when some focus
// state activates both effective `when` clauses AND router priority
// cannot decide the overlap. Context-disjoint clauses (e.g.
// `!sidebarFocus` workspace digits vs the sidebar's own digits)
// coexist, and a pre-routed action (sidebar modes) wins its context
// cannot decide the overlap. Context-disjoint clauses coexist.
// outright so the factory Select Surface ⌃1…9 coexists with the
// sidebar's ⌃1…5 — matching the app target's authoritative check.
guard ShortcutWhenClause.bindingsCollide(
Expand Down
83 changes: 83 additions & 0 deletions Sources/AppDelegate+CanvasShortcutRouting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import CmuxCanvas
import CmuxPanes
import CmuxSettings

extension AppDelegate {
@discardableResult
func performBrowserSplitShortcut(direction: SplitDirection) -> Bool {
guard BrowserAvailabilitySettings.isEnabled() else {
#if DEBUG
cmuxDebugLog("split.browser.shortcut blocked reason=browser_disabled")
#endif
return false
}

_ = synchronizeActiveMainWindowContext(preferredWindow: shortcutRoutingActiveWindow)

if let workspace = tabManager?.selectedWorkspace, workspace.layoutMode == .canvas {
guard let panelId = workspace.openNewCanvasPane(
type: .browser,
focus: true,
direction: direction.canvasDirection
) else {
return false
}
_ = focusBrowserAddressBar(panelId: panelId)
return true
}

#if DEBUG
let directionLabel: String
switch direction {
case .left: directionLabel = "left"
case .right: directionLabel = "right"
case .up: directionLabel = "up"
case .down: directionLabel = "down"
}
let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil"
let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil"
cmuxDebugLog(
"split.browser.shortcut pre dir=\(directionLabel) " +
"tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)"
)
#endif

guard let panelId = tabManager?.createBrowserSplit(direction: direction) else {
#if DEBUG
cmuxDebugLog("split.browser.shortcut failed dir=\(directionLabel)")
#endif
return false
}

#if DEBUG
let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil"
let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil"
cmuxDebugLog(
"split.browser.shortcut post dir=\(directionLabel) " +
"created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)"
)
#endif

_ = focusBrowserAddressBar(panelId: panelId)
return true
}

func performToggleSplitZoomShortcut(tabManager routedManager: TabManager?) {
if let workspace = routedManager?.selectedWorkspace, workspace.layoutMode == .canvas {
_ = CanvasActionExecutor(workspace: workspace).perform(.toggleOverview)
} else {
_ = routedManager?.toggleFocusedSplitZoom()
}
}
}

extension SplitDirection {
var canvasDirection: CanvasDirection {
switch self {
case .left: return .left
case .right: return .right
case .up: return .up
case .down: return .down
}
}
}
11 changes: 11 additions & 0 deletions Sources/AppDelegate+EqualizeSplitsShortcut.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ extension AppDelegate {
#if DEBUG
cmuxDebugLog("shortcut.action name=equalizeSplits workspaceId=\(workspace.id)")
#endif
if workspace.layoutMode == .canvas {
let executor = CanvasActionExecutor(workspace: workspace)
let didEqualizeWidths = executor.perform(.alignment(.equalizeWidths))
let didEqualizeHeights = executor.perform(.alignment(.equalizeHeights))
#if DEBUG
if !didEqualizeWidths && !didEqualizeHeights {
cmuxDebugLog("shortcut.action name=equalizeSplits result=noCanvasChange workspaceId=\(workspace.id)")
}
#endif
return
}
if shouldSuppressSplitShortcutForTransientTerminalFocusState(tabManager: tabManager) {
return
}
Expand Down
Loading
Loading