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
This commit is contained in:
Lawrence Chen 2026-02-20 15:17:00 -08:00 committed by GitHub
parent 573cec4a75
commit df9ba6dcd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 219 additions and 5 deletions

View file

@ -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<ObjectIdentifier> = []
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

View file

@ -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) }

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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<CGFloat>] = [0...20, 28...40, 48...64]

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87
Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745

View file

@ -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" },