diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 6e032d607468..9dccab834e48 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -269,6 +269,7 @@ D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; }; + D1F0C5E2A0B1C2D3E4F50001 /* FocusResizeSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0C5E2A0B1C2D3E4F50000 /* FocusResizeSettingsTests.swift */; }; D9FEC58D5BACCF76459F1BBE /* AuthEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; @@ -642,6 +643,7 @@ D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CmuxDockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/open; sourceTree = SOURCE_ROOT; }; + D1F0C5E2A0B1C2D3E4F50000 /* FocusResizeSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusResizeSettingsTests.swift; sourceTree = ""; }; D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; @@ -1048,6 +1050,7 @@ F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */, + D1F0C5E2A0B1C2D3E4F50000 /* FocusResizeSettingsTests.swift */, FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */, @@ -1593,6 +1596,7 @@ F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */, + D1F0C5E2A0B1C2D3E4F50001 /* FocusResizeSettingsTests.swift in Sources */, FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 314676a0ad0d..eef713be00c8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -52627,6 +52627,75 @@ } } }, + "settings.app.focusResize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resize Focused Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカスペインのリサイズ" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Змінити розмір активної панелі" + } + } + } + }, + "settings.app.focusResize.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically resizes the focused pane to take more space when switching between split panes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割ペインの切り替え時にフォーカスしたペインを自動的にリサイズしてスペースを広げます" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Автоматично змінює розмір активної панелі для виділення більше місця при перемиканні між розділеними панелями" + } + } + } + }, + "settings.app.focusResizeRatio": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Resize Amount" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカスリサイズ量" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Масштаб зміни розміру" + } + } + } + }, "settings.app.paneFirstClickFocus": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index b332ccd82399..e296625aab2b 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -109,6 +109,22 @@ enum TerminalScrollBarSettings { } } +enum FocusResizeSettings { + static let enabledKey = "focusResize.enabled" + static let ratioKey = "focusResize.ratio" + static let defaultEnabled = false + static let defaultRatio: Double = 0.75 + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled + } + + static func ratio(defaults: UserDefaults = .standard) -> Double { + let value = defaults.object(forKey: ratioKey) != nil ? defaults.double(forKey: ratioKey) : defaultRatio + return min(max(value, 0.5), 0.9) + } +} + enum UITestLaunchManifest { static let argumentName = "-cmuxUITestLaunchManifest" diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 0b4422b477c8..80a273e339b1 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -39,6 +39,8 @@ final class CmuxSettingsFileStore { "app.minimalMode", "app.keepWorkspaceOpenWhenClosingLastSurface", "app.focusPaneOnFirstClick", + "app.focusResize", + "app.focusResizeRatio", "app.preferredEditor", "app.openMarkdownInCmuxViewer", "app.iMessageMode", @@ -435,6 +437,13 @@ final class CmuxSettingsFileStore { if let value = jsonBool(section["focusPaneOnFirstClick"]) { snapshot.managedUserDefaults[PaneFirstClickFocusSettings.enabledKey] = .bool(value) } + if let value = jsonBool(section["focusResize"]) { + snapshot.managedUserDefaults[FocusResizeSettings.enabledKey] = .bool(value) + } + if let value = jsonDouble(section["focusResizeRatio"]) { + let clamped = min(max(value, 0.5), 0.9) + snapshot.managedUserDefaults[FocusResizeSettings.ratioKey] = .double(clamped) + } if let value = jsonString(section["preferredEditor"]) { snapshot.managedUserDefaults[PreferredEditorSettings.key] = .string(value) } @@ -1242,6 +1251,8 @@ final class CmuxSettingsFileStore { "minimalMode": false, "keepWorkspaceOpenWhenClosingLastSurface": !LastSurfaceCloseShortcutSettings.defaultValue, "focusPaneOnFirstClick": PaneFirstClickFocusSettings.defaultEnabled, + "focusResize": FocusResizeSettings.defaultEnabled, + "focusResizeRatio": FocusResizeSettings.defaultRatio, "preferredEditor": "", "openMarkdownInCmuxViewer": CmdClickMarkdownRouteSettings.defaultValue, "reorderOnNotification": WorkspaceAutoReorderSettings.defaultValue, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9cf769417925..099517887484 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7389,6 +7389,45 @@ final class Workspace: Identifiable, ObservableObject { } } + // MARK: - Focus Resize + + /// Split IDs where the user has manually dragged the divider (suppresses auto-resize until focus changes) + private var focusResizeDragOverrideSplits: Set = [] + + /// The last pane that triggered a focus-resize, used to detect focus changes for clearing drag overrides + private var focusResizeLastPaneId: UUID? + + /// Active timer for focus-resize animation + private var focusResizeTimer: Timer? + + /// Whether a focus-resize animation is currently running (suppresses drag-override detection) + private var focusResizeIsAnimating = false + + /// Animation state for the in-flight focus-resize + private var focusResizeAnimation: FocusResizeAnimationState? + + /// Debounced work item for live-preview when the ratio slider changes + private var focusResizeRatioDebounceWorkItem: DispatchWorkItem? + + /// Observer for ratio UserDefaults changes + private var focusResizeRatioObserver: NSObjectProtocol? + + /// Cached settings state to avoid reacting to unrelated UserDefaults changes + private var focusResizeLastEnabled: Bool = FocusResizeSettings.defaultEnabled + private var focusResizeLastRatio: Double = FocusResizeSettings.defaultRatio + + private struct FocusResizeSplitTarget { + let splitId: UUID + let startPosition: CGFloat + let targetPosition: CGFloat + } + + private struct FocusResizeAnimationState { + let splits: [FocusResizeSplitTarget] + let startTime: CFTimeInterval + let duration: CFTimeInterval + } + // MARK: - Initialization private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { @@ -7810,6 +7849,7 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(initialTabId) } tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + installFocusResizeSettingsObserver() } deinit { @@ -7820,6 +7860,12 @@ final class Workspace: Identifiable, ObservableObject { } } } + focusResizeTimer?.invalidate() + focusResizeTimer = nil + focusResizeRatioDebounceWorkItem?.cancel() + if let observer = focusResizeRatioObserver { + NotificationCenter.default.removeObserver(observer) + } activeRemoteSessionControllerID = nil remoteSessionController?.stop() } @@ -13444,6 +13490,27 @@ extension Workspace: BonsplitDelegate { let terminalPanel = panels[panelId] as? TerminalPanel { terminalPanel.applyWindowBackgroundIfActive() } + + // Focus-resize: animate parent split divider to give focused pane more space + if FocusResizeSettings.isEnabled() { + let paneUUID = pane.id + let focusChanged = focusResizeLastPaneId != paneUUID + + if focusChanged { + focusResizeDragOverrideSplits.removeAll() + focusResizeLastPaneId = paneUUID + } + + let ratio = CGFloat(FocusResizeSettings.ratio()) + let tree = bonsplitController.treeSnapshot() + let targets = focusResizeBuildTargets( + forPane: paneUUID.uuidString, ratio: ratio, tree: tree + ) + + if !targets.isEmpty { + focusResizeStartAnimation(targets: targets) + } + } } func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { @@ -13893,6 +13960,215 @@ extension Workspace: BonsplitDelegate { if !isDetachingCloseTransaction { scheduleFocusReconcile() } + + // Track manual divider drags to suppress focus-resize for that split + if !focusResizeIsAnimating, FocusResizeSettings.isEnabled() { + if let lastPaneId = focusResizeLastPaneId { + let tree = bonsplitController.treeSnapshot() + let ratio = CGFloat(FocusResizeSettings.ratio()) + let ancestors = focusResizeFindAncestorSplits(ofPane: lastPaneId.uuidString, in: tree) + let eligible = ancestors.filter { !focusResizeDragOverrideSplits.contains($0.splitId) } + let hCount = eligible.filter { $0.orientation == "horizontal" }.count + let vCount = eligible.filter { $0.orientation == "vertical" }.count + + for (splitId, isFirst, orientation) in ancestors { + guard !focusResizeDragOverrideSplits.contains(splitId) else { continue } + let count = orientation == "horizontal" ? hCount : vCount + let factor = count > 1 ? pow(ratio, 1.0 / CGFloat(count)) : ratio + let expectedTarget = isFirst ? factor : (1.0 - factor) + if let currentPos = focusResizeFindDividerPosition(forSplit: splitId.uuidString, in: tree) { + if abs(currentPos - expectedTarget) > 0.02 { + focusResizeDragOverrideSplits.insert(splitId) + } + } + } + } + } + } + + // MARK: - Focus Resize Settings Observer + + private func installFocusResizeSettingsObserver() { + focusResizeLastEnabled = FocusResizeSettings.isEnabled() + focusResizeLastRatio = FocusResizeSettings.ratio() + + focusResizeRatioObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.focusResizeSettingsDidChange() + } + } + + /// Only reacts when focus-resize settings actually changed, ignoring unrelated UserDefaults writes. + private func focusResizeSettingsDidChange() { + let enabled = FocusResizeSettings.isEnabled() + let ratio = FocusResizeSettings.ratio() + + guard enabled != focusResizeLastEnabled || abs(ratio - focusResizeLastRatio) > 0.001 else { return } + focusResizeLastEnabled = enabled + focusResizeLastRatio = ratio + + // Disabled → cancel any pending work and stop any in-flight animation + if !enabled { + focusResizeRatioDebounceWorkItem?.cancel() + focusResizeRatioDebounceWorkItem = nil + focusResizeStopAnimation(snapToFinal: false) + return + } + + guard focusResizeLastPaneId != nil else { return } + + focusResizeRatioDebounceWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.focusResizeApplyCurrentSettings() + } + focusResizeRatioDebounceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: workItem) + } + + /// Re-apply focus-resize with current settings for the already-focused pane. + private func focusResizeApplyCurrentSettings() { + guard FocusResizeSettings.isEnabled(), + let paneId = focusResizeLastPaneId else { return } + + let ratio = CGFloat(FocusResizeSettings.ratio()) + let tree = bonsplitController.treeSnapshot() + let targets = focusResizeBuildTargets( + forPane: paneId.uuidString, ratio: ratio, tree: tree + ) + + if !targets.isEmpty { + focusResizeStartAnimation(targets: targets) + } + } + + // MARK: - Focus Resize Helpers + + /// Find all ancestor splits from root down to the pane, with which side the pane is on at each level. + /// Build animation targets for all ancestor splits, distributing the ratio per-orientation + /// so same-orientation ancestors don't compound (e.g., two horizontal splits each get sqrt(ratio) + /// instead of ratio, making their product equal to ratio). + private func focusResizeBuildTargets( + forPane paneIdString: String, ratio: CGFloat, tree: ExternalTreeNode + ) -> [FocusResizeSplitTarget] { + let ancestors = focusResizeFindAncestorSplits(ofPane: paneIdString, in: tree) + let eligible = ancestors.filter { !focusResizeDragOverrideSplits.contains($0.splitId) } + guard !eligible.isEmpty else { return [] } + + let hCount = eligible.filter { $0.orientation == "horizontal" }.count + let vCount = eligible.filter { $0.orientation == "vertical" }.count + + var targets: [FocusResizeSplitTarget] = [] + let threshold: CGFloat = 0.01 + + for (splitId, isFirst, orientation) in eligible { + let count = orientation == "horizontal" ? hCount : vCount + let factor = count > 1 ? pow(ratio, 1.0 / CGFloat(count)) : ratio + let targetPosition = isFirst ? factor : (1.0 - factor) + if let currentPosition = focusResizeFindDividerPosition(forSplit: splitId.uuidString, in: tree) { + if abs(currentPosition - targetPosition) > threshold { + targets.append(FocusResizeSplitTarget(splitId: splitId, startPosition: currentPosition, targetPosition: targetPosition)) + } + } + } + + return targets + } + + /// Returns an array ordered from root to immediate parent. + private func focusResizeFindAncestorSplits(ofPane paneIdString: String, in node: ExternalTreeNode) -> [(splitId: UUID, isFirst: Bool, orientation: String)] { + guard case .split(let splitNode) = node else { return [] } + guard let splitUUID = UUID(uuidString: splitNode.id) else { return [] } + let orient = splitNode.orientation + + // Check if the pane is a direct child on either side + if case .pane(let firstPane) = splitNode.first, firstPane.id == paneIdString { + return [(splitUUID, true, orient)] + } + if case .pane(let secondPane) = splitNode.second, secondPane.id == paneIdString { + return [(splitUUID, false, orient)] + } + + // Recurse into children — prepend this split if the pane is found in a subtree + let firstResult = focusResizeFindAncestorSplits(ofPane: paneIdString, in: splitNode.first) + if !firstResult.isEmpty { + return [(splitUUID, true, orient)] + firstResult + } + let secondResult = focusResizeFindAncestorSplits(ofPane: paneIdString, in: splitNode.second) + if !secondResult.isEmpty { + return [(splitUUID, false, orient)] + secondResult + } + + return [] + } + + private func focusResizeFindDividerPosition(forSplit splitIdString: String, in node: ExternalTreeNode) -> CGFloat? { + guard case .split(let splitNode) = node else { return nil } + if splitNode.id == splitIdString { + return CGFloat(splitNode.dividerPosition) + } + return focusResizeFindDividerPosition(forSplit: splitIdString, in: splitNode.first) + ?? focusResizeFindDividerPosition(forSplit: splitIdString, in: splitNode.second) + } + + // MARK: - Focus Resize Animation + + private func focusResizeStartAnimation(targets: [FocusResizeSplitTarget]) { + // Cancel without snapping to the old animation's final positions — + // the new animation will smoothly continue from wherever dividers are now. + focusResizeStopAnimation(snapToFinal: false) + + let state = FocusResizeAnimationState( + splits: targets, + startTime: CACurrentMediaTime(), + duration: 0.2 + ) + focusResizeAnimation = state + focusResizeIsAnimating = true + + let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in + self?.focusResizeTickAnimation() + } + // .common includes .eventTracking so the animation continues during mouse drags + RunLoop.current.add(timer, forMode: .common) + focusResizeTimer = timer + } + + private func focusResizeTickAnimation() { + guard let anim = focusResizeAnimation else { + focusResizeStopAnimation(snapToFinal: false) + return + } + + let elapsed = CACurrentMediaTime() - anim.startTime + let progress = min(elapsed / anim.duration, 1.0) + // Exponential ease-out (matches Bonsplit's SplitAnimator curve) + let eased = 1.0 - pow(2.0, -10.0 * progress) + + for split in anim.splits { + let current = split.startPosition + (split.targetPosition - split.startPosition) * CGFloat(eased) + _ = bonsplitController.setDividerPosition(current, forSplit: split.splitId, fromExternal: true) + } + + if progress >= 1.0 { + // Clear animation state before stopping to avoid double setDividerPosition + focusResizeAnimation = nil + focusResizeStopAnimation(snapToFinal: false) + } + } + + private func focusResizeStopAnimation(snapToFinal: Bool = true) { + focusResizeTimer?.invalidate() + focusResizeTimer = nil + if snapToFinal, let anim = focusResizeAnimation { + for split in anim.splits { + _ = bonsplitController.setDividerPosition(split.targetPosition, forSplit: split.splitId, fromExternal: true) + } + } + focusResizeAnimation = nil + focusResizeIsAnimating = false } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0984ef11c222..3523061a8058 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3,6 +3,7 @@ import SwiftUI import Darwin import Bonsplit import UniformTypeIdentifiers + @main struct cmuxApp: App { @StateObject private var tabManager: TabManager @@ -5238,6 +5239,10 @@ struct SettingsView: View { private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled @AppStorage(TerminalScrollBarSettings.showScrollBarKey) private var showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar + @AppStorage(FocusResizeSettings.enabledKey) + private var focusResizeEnabled = FocusResizeSettings.defaultEnabled + @AppStorage(FocusResizeSettings.ratioKey) + private var focusResizeRatio = FocusResizeSettings.defaultRatio @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(IMessageModeSettings.key) private var iMessageMode = IMessageModeSettings.defaultValue @AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey) @@ -5942,6 +5947,37 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("app.focusResize"), + String(localized: "settings.app.focusResize", defaultValue: "Resize Focused Pane"), + subtitle: String(localized: "settings.app.focusResize.subtitle", defaultValue: "Automatically resizes the focused pane to take more space when switching between split panes") + ) { + Toggle("", isOn: $focusResizeEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.focusResize", defaultValue: "Resize Focused Pane") + ) + } + + if focusResizeEnabled { + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("app.focusResizeRatio"), + String(localized: "settings.app.focusResizeRatio", defaultValue: "Focus Resize Amount"), + subtitle: focusResizeRatio.formatted(.percent.precision(.fractionLength(0))) + ) { + Slider(value: $focusResizeRatio, in: 0.5...0.9, step: 0.05) + .frame(width: 150) + .accessibilityLabel( + String(localized: "settings.app.focusResizeRatio", defaultValue: "Focus Resize Amount") + ) + } + } + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("app.preferredEditor"), String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), @@ -7396,6 +7432,8 @@ struct SettingsView: View { if previousShowTerminalScrollBar != showTerminalScrollBar { TerminalScrollBarSettings.notifyDidChange() } + focusResizeEnabled = FocusResizeSettings.defaultEnabled + focusResizeRatio = FocusResizeSettings.defaultRatio workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue iMessageMode = IMessageModeSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails diff --git a/cmuxTests/FocusResizeSettingsTests.swift b/cmuxTests/FocusResizeSettingsTests.swift new file mode 100644 index 000000000000..f92d3c399514 --- /dev/null +++ b/cmuxTests/FocusResizeSettingsTests.swift @@ -0,0 +1,88 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class FocusResizeSettingsTests: XCTestCase { + private let enabledKey = FocusResizeSettings.enabledKey + private let ratioKey = FocusResizeSettings.ratioKey + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: enabledKey) + UserDefaults.standard.removeObject(forKey: ratioKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: enabledKey) + UserDefaults.standard.removeObject(forKey: ratioKey) + super.tearDown() + } + + // MARK: - isEnabled() + + func testIsEnabledDefaultsToFalse() { + XCTAssertFalse(FocusResizeSettings.isEnabled()) + } + + func testIsEnabledReflectsStoredValue() { + UserDefaults.standard.set(true, forKey: enabledKey) + XCTAssertTrue(FocusResizeSettings.isEnabled()) + + UserDefaults.standard.set(false, forKey: enabledKey) + XCTAssertFalse(FocusResizeSettings.isEnabled()) + } + + // MARK: - ratio() + + func testRatioDefaultsTo075() { + XCTAssertEqual(FocusResizeSettings.ratio(), 0.75, accuracy: 0.001) + } + + func testRatioReflectsStoredValue() { + UserDefaults.standard.set(0.6, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.6, accuracy: 0.001) + } + + func testRatioClampsValueBelowMinimum() { + UserDefaults.standard.set(0.3, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.5, accuracy: 0.001) + + UserDefaults.standard.set(0.0, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.5, accuracy: 0.001) + + UserDefaults.standard.set(-1.0, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.5, accuracy: 0.001) + } + + func testRatioClampsValueAboveMaximum() { + UserDefaults.standard.set(0.95, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.9, accuracy: 0.001) + + UserDefaults.standard.set(1.0, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.9, accuracy: 0.001) + + UserDefaults.standard.set(5.0, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.9, accuracy: 0.001) + } + + func testRatioAcceptsBoundaryValues() { + UserDefaults.standard.set(0.5, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.5, accuracy: 0.001) + + UserDefaults.standard.set(0.9, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.9, accuracy: 0.001) + } + + func testRatioReadsFloatBackedNSNumber() { + // Simulates what `defaults write ... -float 0.8` produces: a Float-backed NSNumber. + // The old `object(forKey:) as? Double` would fail this cast and return the default. + let floatValue: Float = 0.8 + UserDefaults.standard.set(floatValue, forKey: ratioKey) + XCTAssertEqual(FocusResizeSettings.ratio(), 0.8, accuracy: 0.001) + } +} diff --git a/tests_v2/test_focus_resize.py b/tests_v2/test_focus_resize.py new file mode 100644 index 000000000000..5607a965903d --- /dev/null +++ b/tests_v2/test_focus_resize.py @@ -0,0 +1,427 @@ +"""Focus-resize: focused pane gets ~75% of parent split space. + +When focus-resize is enabled, switching focus between split panes +should animate the dividers so the focused pane occupies the +configured ratio (default 75%) of each ancestor split's space. +""" + +from __future__ import annotations + +import os +import subprocess +import time + +from cmux import cmux, cmuxError +from pane_resize_test_support import ( + layout_panes, + must, + pane_extent, + wait_for, + wait_for_surface_command_roundtrip, + workspace_panes, + focused_pane_id, +) + + +BUNDLE_ID = os.environ.get("CMUX_BUNDLE_ID", "com.cmuxterm.app.debug") +# Use CMUX_SOCKET env to target a tagged socket (e.g., /tmp/cmux-debug-.sock). +# No untagged fallbacks — this test writes UserDefaults and must not hit the wrong instance. +DEFAULT_SOCKET_PATHS = [ + os.environ.get("CMUX_SOCKET", ""), +] +DEFAULT_SOCKET_PATHS = [p for p in DEFAULT_SOCKET_PATHS if p] + +ENABLED_KEY = "focusResize.enabled" +RATIO_KEY = "focusResize.ratio" + + +def _defaults_write(key: str, value_type: str, value: str) -> None: + """Write a single key to the app's UserDefaults via the ``defaults`` CLI.""" + subprocess.run( + ["defaults", "write", BUNDLE_ID, key, f"-{value_type}", value], + check=True, + capture_output=True, + ) + + +def _defaults_delete(key: str) -> None: + """Delete a single key from the app's UserDefaults. No-op if absent.""" + subprocess.run( + ["defaults", "delete", BUNDLE_ID, key], + check=False, + capture_output=True, + ) + + +# Saved prior values for restore on cleanup (None = key was originally absent) +PREV_DEFAULTS: dict[str, str | None] = {} + + +def _defaults_read(key: str) -> str | None: + """Read a single key from the app's defaults domain. Returns None if absent.""" + proc = subprocess.run( + ["defaults", "read", BUNDLE_ID, key], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + return None + return proc.stdout.strip() + + +def _enable_focus_resize(ratio: float = 0.75) -> None: + """Save existing UserDefaults values, then enable focus-resize with the given ratio.""" + PREV_DEFAULTS[ENABLED_KEY] = _defaults_read(ENABLED_KEY) + PREV_DEFAULTS[RATIO_KEY] = _defaults_read(RATIO_KEY) + _defaults_write(ENABLED_KEY, "bool", "true") + _defaults_write(RATIO_KEY, "float", str(ratio)) + + +def _disable_focus_resize() -> None: + """Restore previous UserDefaults values, or delete keys if originally absent.""" + for key in (ENABLED_KEY, RATIO_KEY): + prev = PREV_DEFAULTS.get(key) + if prev is None: + _defaults_delete(key) + else: + # `defaults read` returns "1"/"0" for bools, floats as strings + _defaults_write(key, "string", prev) + PREV_DEFAULTS.clear() + + +def _pane_ratio(client: cmux, pane_id: str, other_pane_id: str, axis: str) -> float: + """Compute the ratio of pane's extent vs total of pane + other_pane along axis.""" + pane_val = pane_extent(client, pane_id, axis) + other_val = pane_extent(client, other_pane_id, axis) + total = pane_val + other_val + if total <= 0: + return 0.0 + return pane_val / total + + +def _wait_for_ratio( + client: cmux, pane_id: str, other_pane_id: str, axis: str, + min_ratio: float = 0.65, max_ratio: float = 0.85, timeout_s: float = 5.0, +) -> float: + """Poll until the pane's ratio is within the expected range. Returns the final ratio.""" + deadline = time.time() + timeout_s + ratio = 0.0 + while time.time() < deadline: + ratio = _pane_ratio(client, pane_id, other_pane_id, axis) + if min_ratio < ratio < max_ratio: + return ratio + time.sleep(0.1) + return ratio + + +def _run_once(socket_path: str) -> int: + """Run the full focus-resize test suite against a single cmux socket.""" + workspace_id = "" + try: + # Note: defaults write from outside the process may not propagate to the + # app's in-memory UserDefaults reliably. If this test fails with 50/50 + # ratios, enable "Resize Focused Pane" manually in Settings before running. + _enable_focus_resize(0.75) + time.sleep(2.0) + + with cmux(socket_path) as client: + # --- Setup: create workspace with a horizontal split (A | B) --- + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + surfaces = client.list_surfaces(workspace_id) + must(bool(surfaces), "workspace should have at least one surface") + surface_a = surfaces[0][1] + + wait_for_surface_command_roundtrip(client, workspace_id, surface_a) + + surface_b = client.new_split("right") + wait_for( + lambda: len(workspace_panes(client, workspace_id)) >= 2, + timeout_s=4.0, + ) + time.sleep(0.5) + + panes = workspace_panes(client, workspace_id) + must(len(panes) >= 2, f"expected 2 panes, got {len(panes)}") + + # Identify pane IDs — after split, B is auto-focused + pane_b_id = focused_pane_id(client, workspace_id) + pane_a_id = next(pid for pid, _, _ in panes if pid != pane_b_id) + + # Focus A first to establish a baseline, so the next focus_surface(B) + # is a real focus change that triggers didFocusPane. + client.focus_surface(surface_a) + time.sleep(0.5) + + # --- Test 1: Focus pane B, verify it gets ~75% width --- + client.focus_surface(surface_b) + b_ratio = _wait_for_ratio(client, pane_b_id, pane_a_id, "width") + must( + 0.65 < b_ratio < 0.85, + f"Test 1 FAIL: focused pane B should be ~75% width, got {b_ratio:.2%} " + f"(B={pane_extent(client, pane_b_id, 'width'):.0f}, " + f"A={pane_extent(client, pane_a_id, 'width'):.0f})", + ) + print(f" Test 1 PASS: pane B is {b_ratio:.1%} of width after focus") + + # --- Test 2: Focus pane A, verify it gets ~75% width --- + client.focus_surface(surface_a) + a_ratio = _wait_for_ratio(client, pane_a_id, pane_b_id, "width") + must( + 0.65 < a_ratio < 0.85, + f"Test 2 FAIL: focused pane A should be ~75% width, got {a_ratio:.2%} " + f"(A={pane_extent(client, pane_a_id, 'width'):.0f}, " + f"B={pane_extent(client, pane_b_id, 'width'):.0f})", + ) + print(f" Test 2 PASS: pane A is {a_ratio:.1%} of width after focus") + + # --- Test 3: 3-pane layout with full tree propagation --- + # Create a third pane by splitting B downward: A | (B top / C bottom) + client.focus_surface(surface_b) + time.sleep(0.5) + surface_c = client.new_split("down") + wait_for( + lambda: len(workspace_panes(client, workspace_id)) >= 3, + timeout_s=4.0, + ) + time.sleep(0.5) + + # After split, C is auto-focused. Focus B first so the next + # focus_surface(C) is a real change that triggers didFocusPane. + client.focus_surface(surface_b) + time.sleep(0.5) + + # Focus C — should get ~75% in both dimensions + client.focus_surface(surface_c) + time.sleep(0.3) + pane_c_id = focused_pane_id(client, workspace_id) + + # Wait for width ratio (C column vs A) + c_width_ratio = _wait_for_ratio(client, pane_c_id, pane_a_id, "width", min_ratio=0.60, max_ratio=0.90) + must( + 0.60 < c_width_ratio < 0.90, + f"Test 3a FAIL: pane C column should be ~75% width, got {c_width_ratio:.2%} " + f"(C_width={pane_extent(client, pane_c_id, 'width'):.0f}, " + f"A_width={pane_extent(client, pane_a_id, 'width'):.0f})", + ) + print(f" Test 3a PASS: pane C column is {c_width_ratio:.1%} of width") + + # Wait for height ratio (C vs B in same column) + pane_b_id_3 = [pid for pid, _, _ in workspace_panes(client, workspace_id) + if pid != pane_a_id and pid != pane_c_id] + must( + len(pane_b_id_3) == 1, + f"Test 3b FAIL: expected exactly one sibling pane B, got {len(pane_b_id_3)}: " + f"{[pid for pid, _, _ in workspace_panes(client, workspace_id)]}", + ) + c_height_ratio = _wait_for_ratio(client, pane_c_id, pane_b_id_3[0], "height", min_ratio=0.60, max_ratio=0.90) + must( + 0.60 < c_height_ratio < 0.90, + f"Test 3b FAIL: pane C should be ~75% height, got {c_height_ratio:.2%} " + f"(C_height={pane_extent(client, pane_c_id, 'height'):.0f}, " + f"B_height={pane_extent(client, pane_b_id_3[0], 'height'):.0f})", + ) + print(f" Test 3b PASS: pane C is {c_height_ratio:.1%} of height") + + client.close_workspace(workspace_id) + workspace_id = "" + + # --- Test 4: Same-orientation nesting (per-orientation factor) --- + # Create a new workspace with A | (B | C) — two nested horizontal splits. + # Without per-orientation correction, C would get 0.75 * 0.75 = 56% of + # total width. With correction, each split gets sqrt(0.75) ≈ 0.866, so + # C gets ~75% of total width. + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + surfaces_4 = client.list_surfaces(workspace_id) + must(bool(surfaces_4), "workspace should have at least one surface") + surface_4a = surfaces_4[0][1] + + wait_for_surface_command_roundtrip(client, workspace_id, surface_4a) + + # First split: A | B + surface_4b = client.new_split("right") + wait_for( + lambda: len(workspace_panes(client, workspace_id)) >= 2, + timeout_s=4.0, + ) + time.sleep(0.5) + + # Second split: A | (B | C) — split B to the right again + client.focus_surface(surface_4b) + time.sleep(0.3) + surface_4c = client.new_split("right") + wait_for( + lambda: len(workspace_panes(client, workspace_id)) >= 3, + timeout_s=4.0, + ) + time.sleep(0.5) + + # Identify pane A by finding which pane contains surface_4a + pane_4a_id = None + for pid, _, _ in workspace_panes(client, workspace_id): + client.focus_pane(pid) + time.sleep(0.1) + surfs = client.list_pane_surfaces(pid) + for _, sid, _, _ in surfs: + if sid == surface_4a: + pane_4a_id = pid + break + if pane_4a_id: + break + must(pane_4a_id is not None, "Could not find pane A in test 4") + + # Focus A first, then focus C to trigger resize + client.focus_surface(surface_4a) + time.sleep(0.5) + client.focus_surface(surface_4c) + time.sleep(0.3) + pane_4c_id = focused_pane_id(client, workspace_id) + + # C should get ~75% of total width (not 56% from compounding). + # Measure C against the full container, not just C+A, since B also + # takes width and C/(C+A) would mask the compounding bug. + def _c_share_of_total() -> float: + panes_4 = layout_panes(client) + total_w = sum( + float((p.get("frame") or {}).get("width") or 0) + for p in panes_4 + ) + c_w = pane_extent(client, pane_4c_id, "width") + return c_w / total_w if total_w > 0 else 0.0 + + wait_for( + lambda: _c_share_of_total() > 0.60, + timeout_s=5.0, + ) + c_total_ratio = _c_share_of_total() + must( + 0.60 < c_total_ratio < 0.90, + f"Test 4 FAIL: pane C should be ~75% of total width (per-orientation), " + f"got {c_total_ratio:.2%} " + f"(C={pane_extent(client, pane_4c_id, 'width'):.0f})", + ) + print(f" Test 4 PASS: pane C is {c_total_ratio:.1%} of total width " + f"(same-orientation nesting, per-orientation factor)") + + client.close_workspace(workspace_id) + workspace_id = "" + + # --- Test 4: Same-orientation nesting (per-orientation factor) --- + # Create a new workspace with A | (B | C) — two nested horizontal splits. + # Without per-orientation correction, C would get 0.75 * 0.75 = 56% of + # total width. With correction, each split gets sqrt(0.75) ≈ 0.866, so + # C gets ~75% of total width. + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + surfaces_4 = client.list_surfaces(workspace_id) + must(bool(surfaces_4), "workspace should have at least one surface") + surface_4a = surfaces_4[0][1] + + wait_for_surface_command_roundtrip(client, workspace_id, surface_4a) + + # First split: A | B + surface_4b = client.new_split("right") + wait_for( + lambda: len(workspace_panes(client, workspace_id)) >= 2, + timeout_s=4.0, + ) + time.sleep(0.5) + + # Second split: A | (B | C) — split B to the right again + client.focus_surface(surface_4b) + time.sleep(0.3) + surface_4c = client.new_split("right") + wait_for( + lambda: len(workspace_panes(client, workspace_id)) >= 3, + timeout_s=4.0, + ) + time.sleep(0.5) + + # Identify pane A by finding which pane contains surface_4a + pane_4a_id = None + for pid, _, _ in workspace_panes(client, workspace_id): + client.focus_pane(pid) + time.sleep(0.1) + surfs = client.list_pane_surfaces(pid) + for _, sid, _, _ in surfs: + if sid == surface_4a: + pane_4a_id = pid + break + if pane_4a_id: + break + must(pane_4a_id is not None, "Could not find pane A in test 4") + + # Focus A first, then focus C to trigger resize + client.focus_surface(surface_4a) + time.sleep(0.5) + client.focus_surface(surface_4c) + time.sleep(0.3) + pane_4c_id = focused_pane_id(client, workspace_id) + + # C should get ~75% of total width (not 56% from compounding). + # Measure C against the full container, not just C+A, since B also + # takes width and C/(C+A) would mask the compounding bug. + def _c_share_of_total() -> float: + panes_4 = layout_panes(client) + total_w = sum( + float((p.get("frame") or {}).get("width") or 0) + for p in panes_4 + ) + c_w = pane_extent(client, pane_4c_id, "width") + return c_w / total_w if total_w > 0 else 0.0 + + wait_for( + lambda: _c_share_of_total() > 0.60, + timeout_s=5.0, + ) + c_total_ratio = _c_share_of_total() + must( + 0.60 < c_total_ratio < 0.90, + f"Test 4 FAIL: pane C should be ~75% of total width (per-orientation), " + f"got {c_total_ratio:.2%} " + f"(C={pane_extent(client, pane_4c_id, 'width'):.0f})", + ) + print(f" Test 4 PASS: pane C is {c_total_ratio:.1%} of total width " + f"(same-orientation nesting, per-orientation factor)") + + client.close_workspace(workspace_id) + workspace_id = "" + + print("PASS: focus-resize correctly adjusts pane geometry on focus change") + return 0 + + finally: + _disable_focus_resize() + if workspace_id: + try: + with cmux(socket_path) as cleanup: + cleanup.close_workspace(workspace_id) + except Exception as e: + print(f"Cleanup failed for workspace {workspace_id}: {e}") + + +def main() -> int: + """Entry point: connect to cmux via CMUX_SOCKET or default paths and run tests.""" + env_socket = os.environ.get("CMUX_SOCKET") + if env_socket: + return _run_once(env_socket) + + last_error: Exception | None = None + for socket_path in DEFAULT_SOCKET_PATHS: + try: + return _run_once(socket_path) + except cmuxError as exc: + text = str(exc) + if not any(token in text for token in ("Failed to connect", "Socket not found")): + raise + last_error = exc + continue + + if last_error is not None: + raise last_error + raise cmuxError("No socket candidates configured") + + +if __name__ == "__main__": + raise SystemExit(main())