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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,83 @@ extension ExternalTreeNode {
)
}

/// Plans an equalize pass over the run of same-orientation splits that
/// directly contains `paneId`: the pane's parent split plus every
/// ancestor reached without crossing a split of another orientation,
/// and the same-orientation splits nested inside that run. Splits
/// elsewhere in the tree, including same-orientation splits behind a
/// cross-orientation subtree, are left alone. Returns an empty plan when
/// `paneId` is absent or has no parent split.
public func equalizeDividerPlan(forSplitRunContainingPaneId paneId: String) -> SplitEqualizePlan {
guard let path = splitPath(toPaneId: paneId), let parent = path.last else {
return SplitEqualizePlan(adjustments: [], foundSplit: false, hadInvalidSplitIds: false)
}
var runRoot = parent
for ancestor in path.dropLast().reversed() {
guard ancestor.orientation == parent.orientation else { break }
runRoot = ancestor
}
var adjustments: [SplitDividerAdjustment] = []
var foundSplit = false
var hadInvalidSplitIds = false
ExternalTreeNode.split(runRoot).appendRunEqualizeAdjustments(
orientation: parent.orientation,
adjustments: &adjustments,
foundSplit: &foundSplit,
hadInvalidSplitIds: &hadInvalidSplitIds
)
return SplitEqualizePlan(
adjustments: adjustments,
foundSplit: foundSplit,
hadInvalidSplitIds: hadInvalidSplitIds
)
}

/// The splits from this node down to `paneId`'s parent, or `nil` when
/// the pane is not in this subtree.
private func splitPath(toPaneId paneId: String) -> [ExternalSplitNode]? {
switch self {
case .pane(let pane):
return pane.id == paneId ? [] : nil
case .split(let splitNode):
guard let rest = splitNode.first.splitPath(toPaneId: paneId)
?? splitNode.second.splitPath(toPaneId: paneId) else { return nil }
return [splitNode] + rest
}
}

/// Post-order equalize over a same-orientation run: recurses only into
/// children that continue the run.
private func appendRunEqualizeAdjustments(
orientation: String,
adjustments: inout [SplitDividerAdjustment],
foundSplit: inout Bool,
hadInvalidSplitIds: inout Bool
) {
guard case .split(let splitNode) = self, splitNode.orientation == orientation else { return }
splitNode.first.appendRunEqualizeAdjustments(
orientation: orientation,
adjustments: &adjustments,
foundSplit: &foundSplit,
hadInvalidSplitIds: &hadInvalidSplitIds
)
splitNode.second.appendRunEqualizeAdjustments(
orientation: orientation,
adjustments: &adjustments,
foundSplit: &foundSplit,
hadInvalidSplitIds: &hadInvalidSplitIds
)
foundSplit = true
guard let splitId = UUID(uuidString: splitNode.id) else {
hadInvalidSplitIds = true
return
}
let firstSpanCount = splitNode.first.spanCount(along: orientation)
let secondSpanCount = splitNode.second.spanCount(along: orientation)
let position = CGFloat(firstSpanCount) / CGFloat(firstSpanCount + secondSpanCount)
adjustments.append(SplitDividerAdjustment(splitId: splitId, position: position))
}

private func appendEqualizeAdjustments(
orientationFilter: String?,
adjustments: inout [SplitDividerAdjustment],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,24 @@ public struct PaneLayoutService {
controller: BonsplitController,
orientationFilter: String? = nil
) -> SplitEqualizeResult {
let plan = node.equalizeDividerPlan(orientationFilter: orientationFilter)
apply(node.equalizeDividerPlan(orientationFilter: orientationFilter), to: controller)
}

/// Equalizes only the run of same-orientation splits that directly
/// contains `paneId` (see
/// ``ExternalTreeNode/equalizeDividerPlan(forSplitRunContainingPaneId:)``),
/// so a new split rebalances its own row or column without resetting
/// dividers elsewhere in the tree.
@discardableResult
public func equalizeSplitRun(
containingPaneId paneId: String,
in node: ExternalTreeNode,
controller: BonsplitController
) -> SplitEqualizeResult {
apply(node.equalizeDividerPlan(forSplitRunContainingPaneId: paneId), to: controller)
}

private func apply(_ plan: SplitEqualizePlan, to controller: BonsplitController) -> SplitEqualizeResult {
var allSucceeded = !plan.hadInvalidSplitIds
for adjustment in plan.adjustments {
if !controller.setDividerPosition(adjustment.position, forSplit: adjustment.splitId, fromExternal: true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,48 @@ struct SplitGeometryTests {
#expect(plan.adjustments.isEmpty)
}

@Test func splitRunPlanRebalancesTheNewPanesRow() {
let outerId = UUID()
let innerId = UUID()
let tree = split(
outerId,
orientation: "horizontal",
first: pane("a"),
second: split(innerId, orientation: "horizontal", first: pane("b"), second: pane("c"))
)

let plan = tree.equalizeDividerPlan(forSplitRunContainingPaneId: "c")

#expect(plan.foundSplit)
#expect(plan.adjustments.map(\.splitId) == [innerId, outerId])
#expect(plan.adjustments[0].position == 0.5)
#expect(abs(plan.adjustments[1].position - (1.0 / 3.0)) < 0.0001)
}

@Test func splitRunPlanLeavesOtherRowsAndOrientationsAlone() {
let rootId = UUID()
let topRowId = UUID()
let bottomRowId = UUID()
let tree = split(
rootId,
orientation: "vertical",
dividerPosition: 0.3,
first: split(topRowId, orientation: "horizontal", dividerPosition: 0.2, first: pane("a"), second: pane("b")),
second: split(bottomRowId, orientation: "horizontal", dividerPosition: 0.5, first: pane("c"), second: pane("d"))
)

let plan = tree.equalizeDividerPlan(forSplitRunContainingPaneId: "d")

#expect(plan.adjustments.map(\.splitId) == [bottomRowId])
#expect(plan.adjustments[0].position == 0.5)
}

@Test func splitRunPlanIsEmptyWithoutAParentSplit() {
#expect(!pane("solo").equalizeDividerPlan(forSplitRunContainingPaneId: "solo").foundSplit)
let tree = split(UUID(), orientation: "horizontal", first: pane("a"), second: pane("b"))
#expect(tree.equalizeDividerPlan(forSplitRunContainingPaneId: "missing").adjustments.isEmpty)
}

// MARK: Resize planning

@Test func resizeMovesControllingDividerByPixelDelta() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ public struct AppCatalogSection: SettingCatalogSection {
userDefaultsKey: "focusHistoryIncludesPanesAndTabs"
)

/// Whether creating a split rebalances the panes along the new split's
/// axis so they share the space evenly. Defaults to off, which keeps the
/// halve-the-source-pane behavior.
public let equalizeSplitsOnCreate = DefaultsKey<Bool>(
id: "app.equalizeSplitsOnCreate",
defaultValue: false,
userDefaultsKey: "equalizeSplitsOnCreate"
)

public let preferredEditor = DefaultsKey<String>(
id: "app.preferredEditor",
defaultValue: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ struct SettingCatalogTests {
#expect(ids.contains("browser.defaultZoomLevel"))
}

@Test func equalizeSplitsOnCreateDefaultsOff() {
#expect(!SettingCatalog().app.equalizeSplitsOnCreate.defaultValue)
}

@Test func focusHistoryDefaultsToWorkspacesOnly() {
#expect(!SettingCatalog().app.focusHistoryIncludesPanesAndTabs.defaultValue)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ extension Array where Element == CuratedSettingEntry {
title: String(localized: "settings.app.focusHistoryIncludesPanesAndTabs", defaultValue: "Include Panes and Tabs in Focus History"),
synonyms: "app.focusHistoryIncludesPanesAndTabs focus history back forward panes tabs workspaces only navigation"
),
.init(
section: .app,
id: "equalize-splits-on-create",
title: String(localized: "settings.app.equalizeSplitsOnCreate", defaultValue: "Equalize Splits on Create"),
synonyms: "app.equalizeSplitsOnCreate equalize balance distribute even equal split pane size new split"
),
.init(section: .app, id: "file-drops", title: String(localized: "settings.app.fileDrop.defaultBehavior", defaultValue: "File Drops"), synonyms: "File Drops drag drop files finder path text terminal editor split preview shift"),
.init(section: .app, id: "preferred-editor", title: String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), synonyms: "Open Files With app.preferredEditor editor open file code vscode visual studio zed sublime subl cursor"),
.init(section: .app, id: "supported-file-previews", title: String(localized: "settings.app.openSupportedFilesInCmux", defaultValue: "Open Supported Files in cmux"), synonyms: "Open Supported Files in cmux app.openSupportedFilesInCmux cmd click file preview pdf image video audio quicklook quick look editor external"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public struct AppSection: View {
@State private var keepWorkspaceOpen: DefaultsValueModel<Bool>
@State private var firstClick: DefaultsValueModel<Bool>
@State private var focusHistoryIncludesPanesAndTabs: DefaultsValueModel<Bool>
@State private var equalizeSplitsOnCreate: DefaultsValueModel<Bool>
@State private var fileDrop: DefaultsValueModel<FileDropDefaultBehavior>
@State private var preferredEditor: DefaultsValueModel<String>
@State private var openSupported: DefaultsValueModel<Bool>
Expand Down Expand Up @@ -96,6 +97,7 @@ public struct AppSection: View {
_keepWorkspaceOpen = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.keepWorkspaceOpenWhenClosingLastSurface))
_firstClick = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.focusPaneOnFirstClick))
_focusHistoryIncludesPanesAndTabs = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.focusHistoryIncludesPanesAndTabs))
_equalizeSplitsOnCreate = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.equalizeSplitsOnCreate))
_fileDrop = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.fileDropDefaultBehavior))
_preferredEditor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.preferredEditor))
_openSupported = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.openSupportedFilesInCmux))
Expand Down Expand Up @@ -158,7 +160,7 @@ public struct AppSection: View {
mainCard
}
.task {
startSettingsObservation([language, appearance, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, focusHistoryIncludesPanesAndTabs, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, fileEditorSyntaxHighlighting, fileEditorLineNumbers, fileEditorIndentGuides, fileEditorCurrentLineHighlight, fileEditorTabWidth, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, desktopNotifications, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundCommand, customSoundFile, soundOverrides, telemetry, confirmQuit, warnCloseTab, warnCloseX, hideCloseButton, renameSelects, paletteAllSurfaces])
startSettingsObservation([language, appearance, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, focusHistoryIncludesPanesAndTabs, equalizeSplitsOnCreate, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, fileEditorSyntaxHighlighting, fileEditorLineNumbers, fileEditorIndentGuides, fileEditorCurrentLineHighlight, fileEditorTabWidth, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, desktopNotifications, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundCommand, customSoundFile, soundOverrides, telemetry, confirmQuit, warnCloseTab, warnCloseX, hideCloseButton, renameSelects, paletteAllSurfaces])
if soundAgents.isEmpty {
soundAgents = await hostActions.notificationSoundAgentOptions()
}
Expand Down Expand Up @@ -344,6 +346,24 @@ public struct AppSection: View {
}
SettingsCardDivider()

// Equalize Splits on Create
SettingsCardRow(
configurationReview: .json("app.equalizeSplitsOnCreate"),
String(localized: "settings.app.equalizeSplitsOnCreate", defaultValue: "Equalize Splits on Create"),
subtitle: equalizeSplitsOnCreate.current
? String(localized: "settings.app.equalizeSplitsOnCreate.subtitleOn", defaultValue: "New splits resize the panes in that direction to equal sizes.")
: String(localized: "settings.app.equalizeSplitsOnCreate.subtitleOff", defaultValue: "New splits take half of the pane they were created from.")
) {
Toggle("", isOn: Binding(
get: { equalizeSplitsOnCreate.current },
set: { equalizeSplitsOnCreate.set($0) }
))
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsEqualizeSplitsOnCreateToggle")
}
SettingsCardDivider()

// File Drops
SettingsCardRow(
configurationReview: .settingsOnly,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct SettingsRowAnchorResolutionTests {
static let rowConfigPaths: [String] = [
"app.commandPaletteSearchesAllSurfaces",
"app.confirmQuit",
"app.equalizeSplitsOnCreate",
"app.focusPaneOnFirstClick",
"app.focusHistoryIncludesPanesAndTabs",
"app.globalFontMagnification",
Expand Down
Loading
Loading