From df9ba6dcd92c520ea9cc463e81b2fa5a6b810fb1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:17:00 -0800 Subject: [PATCH] Fix #155: remap-aware bonsplit tooltips + browser split shortcuts (#200) * Issue #155: remap bonsplit tooltips and add browser split shortcuts * Fix split button mousedown feedback regression * Match split button sizing with main --- Sources/AppDelegate.swift | 44 ++++++++++++ Sources/KeyboardShortcutSettings.swift | 12 ++++ Sources/TabManager.swift | 37 +++++++++- Sources/Workspace.swift | 16 +++++ Sources/cmuxApp.swift | 31 +++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 68 +++++++++++++++++++ vendor/bonsplit | 2 +- web/app/keyboard-shortcuts.tsx | 14 +++- 8 files changed, 219 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d353ee7d..77645d1f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -187,6 +187,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var workspaceObserver: NSObjectProtocol? private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? + private var shortcutDefaultsObserver: NSObjectProtocol? private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -336,6 +337,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installWindowKeyEquivalentSwizzle() installBrowserAddressBarFocusObservers() installShortcutMonitor() + installShortcutDefaultsObserver() NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) @@ -1460,6 +1462,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func installShortcutDefaultsObserver() { + guard shortcutDefaultsObserver == nil else { return } + shortcutDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshSplitButtonTooltipsAcrossWorkspaces() + } + } + + private func refreshSplitButtonTooltipsAcrossWorkspaces() { + var refreshedManagers: Set = [] + if let manager = tabManager { + manager.refreshSplitButtonTooltips() + refreshedManagers.insert(ObjectIdentifier(manager)) + } + for context in mainWindowContexts.values { + let manager = context.tabManager + let identifier = ObjectIdentifier(manager) + guard refreshedManagers.insert(identifier).inserted else { continue } + manager.refreshSplitButtonTooltips() + } + } + private func installGhosttyConfigObserver() { guard ghosttyConfigObserver == nil else { return } ghosttyConfigObserver = NotificationCenter.default.addObserver( @@ -1861,6 +1888,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) { + _ = performBrowserSplitShortcut(direction: .right) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) { + _ = performBrowserSplitShortcut(direction: .down) + return true + } + // Surface navigation (legacy Ctrl+Tab support) if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { tabManager?.selectNextSurface() @@ -2041,6 +2078,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + @discardableResult + func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } + _ = focusBrowserAddressBar(panelId: panelId) + return true + } + /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts /// through the same app-level shortcut handler used by the local key monitor. @discardableResult diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6ecc0e6c..689b1161 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -26,6 +26,8 @@ enum KeyboardShortcutSettings { case focusDown case splitRight case splitDown + case splitBrowserRight + case splitBrowserDown // Panels case openBrowser @@ -51,6 +53,8 @@ enum KeyboardShortcutSettings { case .focusDown: return "Focus Pane Down" case .splitRight: return "Split Right" case .splitDown: return "Split Down" + case .splitBrowserRight: return "Split Browser Right" + case .splitBrowserDown: return "Split Browser Down" case .openBrowser: return "Open Browser" } } @@ -71,6 +75,8 @@ enum KeyboardShortcutSettings { case .focusDown: return "shortcut.focusDown" case .splitRight: return "shortcut.splitRight" case .splitDown: return "shortcut.splitDown" + case .splitBrowserRight: return "shortcut.splitBrowserRight" + case .splitBrowserDown: return "shortcut.splitBrowserDown" case .nextSurface: return "shortcut.nextSurface" case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" @@ -108,6 +114,10 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) + case .splitBrowserRight: + return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false) + case .splitBrowserDown: + return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false) case .nextSurface: return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false) case .prevSurface: @@ -176,6 +186,8 @@ enum KeyboardShortcutSettings { static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) } static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) } + static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) } + static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) } static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) } static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f7585f2b..5dd3cfc3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1253,6 +1253,28 @@ class TabManager: ObservableObject { _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } + /// Create a new browser split from the currently focused panel. + @discardableResult + func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? { + guard let selectedTabId, + let tab = tabs.first(where: { $0.id == selectedTabId }), + let focusedPanelId = tab.focusedPanelId else { return nil } + return newBrowserSplit( + tabId: selectedTabId, + fromPanelId: focusedPanelId, + orientation: direction.orientation, + insertFirst: direction.insertFirst, + url: url + ) + } + + /// Refresh Bonsplit right-side action button tooltips for all workspaces. + func refreshSplitButtonTooltips() { + for workspace in tabs { + workspace.refreshSplitButtonTooltips() + } + } + // MARK: - Pane Focus Navigation /// Move focus to an adjacent pane in the specified direction @@ -1393,9 +1415,20 @@ class TabManager: ObservableObject { // MARK: - Browser Panel Operations /// Create a new browser panel in a split - func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? { + func newBrowserSplit( + tabId: UUID, + fromPanelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil + ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id + return tab.newBrowserSplit( + from: fromPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url + )?.id } /// Create a new browser surface in a pane diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7ed6e468..71d28ded 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -105,12 +105,22 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Initialization + private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { + BonsplitConfiguration.SplitButtonTooltips( + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") + ) + } + private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { bonsplitAppearance(from: config.backgroundColor) } private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( + splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init(backgroundHex: backgroundColor.hexString()) ) @@ -208,6 +218,12 @@ final class Workspace: Identifiable, ObservableObject { } } + func refreshSplitButtonTooltips() { + var configuration = bonsplitController.configuration + configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips() + bonsplitController.configuration = configuration + } + // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3a2e5264..daf9ecd6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,6 +15,8 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -463,6 +465,14 @@ struct cmuxApp: App { performSplitFromMenu(direction: .down) } + splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) { + performBrowserSplitFromMenu(direction: .right) + } + + splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) { + performBrowserSplitFromMenu(direction: .down) + } + Divider() // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) @@ -545,6 +555,20 @@ struct cmuxApp: App { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } + private var splitBrowserRightMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserRightShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut + ) + } + + private var splitBrowserDownMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserDownShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -577,6 +601,13 @@ struct cmuxApp: App { tabManager.createSplit(direction: direction) } + private func performBrowserSplitFromMenu(direction: SplitDirection) { + if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true { + return + } + _ = tabManager.createBrowserSplit(direction: direction) + } + @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { if let key = keyEquivalent(for: shortcut) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0060171f..98f6914a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -225,6 +225,74 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { } } +final class KeyboardShortcutSettingsTests: XCTestCase { + func testBrowserSplitShortcutDefaults() { + let keys = [ + KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey, + KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey + ] + let defaults = UserDefaults.standard + let previousValues = keys.map { key in (key, defaults.data(forKey: key)) } + defer { + for (key, value) in previousValues { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + keys.forEach { defaults.removeObject(forKey: $0) } + + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserRight).displayString, "⌥⌘D") + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserDown).displayString, "⌥⇧⌘D") + } + + @MainActor + func testWorkspaceConfiguresSplitButtonTooltipsWithEffectiveShortcuts() throws { + let keys = [ + KeyboardShortcutSettings.Action.newSurface.defaultsKey, + KeyboardShortcutSettings.Action.openBrowser.defaultsKey, + KeyboardShortcutSettings.Action.splitRight.defaultsKey, + KeyboardShortcutSettings.Action.splitDown.defaultsKey + ] + let defaults = UserDefaults.standard + let previousValues = keys.map { key in (key, defaults.data(forKey: key)) } + defer { + for (key, value) in previousValues { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + + let customPairs: [(KeyboardShortcutSettings.Action, StoredShortcut)] = [ + (.newSurface, StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)), + (.openBrowser, StoredShortcut(key: "2", command: true, shift: false, option: false, control: false)), + (.splitRight, StoredShortcut(key: "3", command: true, shift: false, option: false, control: false)), + (.splitDown, StoredShortcut(key: "4", command: true, shift: false, option: false, control: false)), + ] + + for (action, shortcut) in customPairs { + guard let data = try? JSONEncoder().encode(shortcut) else { + XCTFail("Failed to encode shortcut for \(action.rawValue)") + return + } + defaults.set(data, forKey: action.defaultsKey) + } + + let workspace = Workspace(title: "Tooltip Test") + let tooltips = workspace.bonsplitController.configuration.appearance.splitButtonTooltips + + XCTAssertEqual(tooltips.newTerminal, "New Terminal (⌘1)") + XCTAssertEqual(tooltips.newBrowser, "New Browser (⌘2)") + XCTAssertEqual(tooltips.splitRight, "Split Right (⌘3)") + XCTAssertEqual(tooltips.splitDown, "Split Down (⌘4)") + } +} + final class ShortcutHintLanePlannerTests: XCTestCase { func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { let intervals: [ClosedRange] = [0...20, 28...40, 48...64] diff --git a/vendor/bonsplit b/vendor/bonsplit index ae234a22..6ac667d3 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87 +Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745 diff --git a/web/app/keyboard-shortcuts.tsx b/web/app/keyboard-shortcuts.tsx index e70d174a..f4c483c0 100644 --- a/web/app/keyboard-shortcuts.tsx +++ b/web/app/keyboard-shortcuts.tsx @@ -80,6 +80,16 @@ const CATEGORIES: ShortcutCategory[] = [ combos: [["⌥", "⌘", "←/→/↑/↓"]], description: "Focus pane directionally", }, + { + id: "sp-browser-right", + combos: [["⌥", "⌘", "D"]], + description: "Split browser right", + }, + { + id: "sp-browser-down", + combos: [["⌥", "⌘", "⇧", "D"]], + description: "Split browser down", + }, ], }, { @@ -88,8 +98,8 @@ const CATEGORIES: ShortcutCategory[] = [ shortcuts: [ { id: "br-open", - combos: [["⌘", "⇧", "B"]], - description: "Open browser in split", + combos: [["⌘", "⇧", "L"]], + description: "Open browser surface", }, { id: "br-addr", combos: [["⌘", "L"]], description: "Focus address bar" }, { id: "br-forward", combos: [["⌘", "]"]], description: "Forward" },