From 70ec1a0915bbf8bcd7ed8bd5a120ad5d9ab2c68a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 21:31:41 -0700 Subject: [PATCH] Split fade buttons from titlebar visibility --- Resources/Localizable.xcstrings | 55 ++++++- Sources/Update/UpdateTitlebarAccessory.swift | 76 +++++----- Sources/cmuxApp.swift | 145 +++++++++++++++++-- vendor/bonsplit | 2 +- 4 files changed, 224 insertions(+), 54 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b10c4e69..af2b4691 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -43658,13 +43658,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Hide the workspace title bar and show sidebar or pane actions only on hover." + "value": "Hide the folder and active title above pane tabs." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ワークスペースのタイトルバーを隠し、サイドバーやペインタブの操作はホバー時のみ表示します。" + "value": "ペインタブの上にあるフォルダ名と現在のタイトルを隠します。" } } } @@ -43686,6 +43686,57 @@ } } }, + "settings.app.fadeButtons": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fade Buttons" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボタンをフェード表示" + } + } + } + }, + "settings.app.fadeButtons.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep action buttons always visible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作ボタンを常に表示します。" + } + } + } + }, + "settings.app.fadeButtons.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show action buttons only on hover." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作ボタンはホバー時のみ表示します。" + } + } + } + }, "settings.app.showPullRequests": { "extractionState": "manual", "localizations": { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 0b9c956c..191cf68e 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -257,10 +257,14 @@ struct TitlebarControlsView: View { let onToggleNotifications: () -> Void let onNewTab: () -> Void @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue + @AppStorage(WorkspaceButtonFadeSettings.modeKey) + private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @State private var shortcutRefreshTick = 0 + @State private var isHoveringControls = false + @State private var isNotificationsPopoverShown = false @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 @@ -295,6 +299,17 @@ struct TitlebarControlsView: View { alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed } + private var fadeButtonsEnabled: Bool { + WorkspaceButtonFadeSettings.mode(for: workspaceButtonsFadeMode) == .enabled + } + + private var shouldShowControls: Bool { + if !fadeButtonsEnabled { + return true + } + return isHoveringControls || isNotificationsPopoverShown || shouldShowTitlebarShortcutHints + } + var body: some View { // Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings. // (The titlebar controls don't otherwise re-render on UserDefaults changes.) @@ -304,15 +319,28 @@ struct TitlebarControlsView: View { controlsGroup(config: config) .padding(.leading, 4) .padding(.trailing, titlebarHintTrailingInset) + .contentShape(Rectangle()) + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(shouldShowControls) + .animation(.easeInOut(duration: 0.14), value: shouldShowControls) .background( WindowAccessor { window in modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) + .onHover { hovering in + isHoveringControls = hovering + } .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } + .onAppear { + isNotificationsPopoverShown = AppDelegate.shared?.isNotificationsPopoverShown() ?? false + } + .onReceive(NotificationCenter.default.publisher(for: .cmuxNotificationsPopoverVisibilityDidChange)) { notification in + isNotificationsPopoverShown = (notification.userInfo?[NotificationsPopoverVisibilityUserInfoKey.isShown] as? Bool) ?? false + } .onAppear { modifierKeyMonitor.start() } @@ -527,48 +555,24 @@ struct TitlebarControlsView: View { struct HiddenTitlebarSidebarControlsView: View { @ObservedObject var notificationStore: TerminalNotificationStore @StateObject private var viewModel = TitlebarControlsViewModel() - @State private var isHoveringControls = false - @State private var isNotificationsPopoverShown = false private let hostWidth: CGFloat = 124 private let hostHeight: CGFloat = 28 - private var shouldShowControls: Bool { - isHoveringControls || isNotificationsPopoverShown - } - var body: some View { - ZStack(alignment: .leading) { - Color.clear - .frame(width: hostWidth, height: hostHeight) - - TitlebarControlsView( - notificationStore: notificationStore, - viewModel: viewModel, - onToggleSidebar: { _ = AppDelegate.shared?.sidebarState?.toggle() }, - onToggleNotifications: { [viewModel] in - AppDelegate.shared?.toggleNotificationsPopover( - animated: true, - anchorView: viewModel.notificationsAnchorView - ) - }, - onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } - ) - .opacity(shouldShowControls ? 1 : 0) - .allowsHitTesting(shouldShowControls) - .animation(.easeInOut(duration: 0.12), value: shouldShowControls) - } + TitlebarControlsView( + notificationStore: notificationStore, + viewModel: viewModel, + onToggleSidebar: { _ = AppDelegate.shared?.sidebarState?.toggle() }, + onToggleNotifications: { [viewModel] in + AppDelegate.shared?.toggleNotificationsPopover( + animated: true, + anchorView: viewModel.notificationsAnchorView + ) + }, + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } + ) .frame(width: hostWidth, height: hostHeight, alignment: .leading) - .contentShape(Rectangle()) - .onHover { hovering in - isHoveringControls = hovering - } - .onAppear { - isNotificationsPopoverShown = AppDelegate.shared?.isNotificationsPopoverShown() ?? false - } - .onReceive(NotificationCenter.default.publisher(for: .cmuxNotificationsPopoverVisibilityDidChange)) { notification in - isNotificationsPopoverShown = (notification.userInfo?[NotificationsPopoverVisibilityUserInfoKey.isShown] as? Bool) ?? false - } } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ea6c3e35..9e9c5222 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -16,6 +16,54 @@ enum WorkspaceTitlebarSettings { } } +enum WorkspaceButtonFadeSettings { + static let modeKey = "workspaceButtonsFadeMode" + static let legacyTitlebarControlsVisibilityModeKey = "titlebarControlsVisibilityMode" + static let legacyPaneTabBarControlsVisibilityModeKey = "paneTabBarControlsVisibilityMode" + + enum Mode: String { + case enabled + case disabled + } + + static let defaultMode: Mode = .disabled + + static func mode(for rawValue: String?) -> Mode { + Mode(rawValue: rawValue ?? "") ?? defaultMode + } + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + mode(for: defaults.string(forKey: modeKey)) == .enabled + } + + static func initializeStoredModeIfNeeded(defaults: UserDefaults = .standard) { + guard defaults.string(forKey: modeKey) == nil else { return } + + if let migratedMode = migratedLegacyMode(defaults: defaults) { + defaults.set(migratedMode.rawValue, forKey: modeKey) + return + } + + let initialMode: Mode = WorkspaceTitlebarSettings.isVisible(defaults: defaults) ? .disabled : .enabled + defaults.set(initialMode.rawValue, forKey: modeKey) + } + + private static func migratedLegacyMode(defaults: UserDefaults) -> Mode? { + let legacyValues = [ + defaults.string(forKey: legacyTitlebarControlsVisibilityModeKey), + defaults.string(forKey: legacyPaneTabBarControlsVisibilityModeKey), + ] + + if legacyValues.contains(where: { $0 == "onHover" || $0 == "hover" || $0 == "enabled" }) { + return .enabled + } + if legacyValues.contains(where: { $0 == "always" || $0 == "disabled" }) { + return .disabled + } + return nil + } +} + @main struct cmuxApp: App { @StateObject private var tabManager: TabManager @@ -66,6 +114,7 @@ struct cmuxApp: App { _tabManager = StateObject(wrappedValue: TabManager()) // Migrate legacy and old-format socket mode values to the new enum. let defaults = UserDefaults.standard + WorkspaceButtonFadeSettings.initializeStoredModeIfNeeded(defaults: defaults) if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) { let migrated = SocketControlSettings.migrateMode(stored) if migrated.rawValue != stored { @@ -1994,6 +2043,7 @@ private struct AcknowledgmentsView: View { final class SettingsWindowController: NSWindowController, NSWindowDelegate { static let shared = SettingsWindowController() + private var pendingFocusRestoreWorkItems: [DispatchWorkItem] = [] private init() { let window = NSWindow( @@ -2036,6 +2086,37 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") #endif } + + func preserveFocusAfterPreferenceMutation() { + guard let window, window.isVisible, window.isKeyWindow else { return } + cancelPendingFocusRestore() + + let delays: [TimeInterval] = [0, 0.05, 0.12, 0.24, 0.4] + for delay in delays { + let workItem = DispatchWorkItem { [weak window] in + guard let window, window.isVisible else { return } + guard !window.isKeyWindow else { return } + NSApp.activate(ignoringOtherApps: true) + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + pendingFocusRestoreWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + + func windowWillClose(_ notification: Notification) { + cancelPendingFocusRestore() + } + + func windowDidBecomeKey(_ notification: Notification) { + cancelPendingFocusRestore() + } + + private func cancelPendingFocusRestore() { + pendingFocusRestoreWorkItems.forEach { $0.cancel() } + pendingFocusRestoreWorkItems.removeAll() + } } enum SettingsNavigationTarget: String { @@ -3099,6 +3180,8 @@ struct SettingsView: View { @AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + @AppStorage(WorkspaceButtonFadeSettings.modeKey) + private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @@ -3190,7 +3273,24 @@ struct SettingsView: View { } return String( localized: "settings.app.showWorkspaceTitlebar.subtitleOff", - defaultValue: "Hide the workspace title bar and show sidebar or pane actions only on hover." + defaultValue: "Hide the folder and active title above pane tabs." + ) + } + + private var fadeButtonsEnabled: Bool { + WorkspaceButtonFadeSettings.mode(for: workspaceButtonsFadeMode) == .enabled + } + + private var workspaceButtonFadeSubtitle: String { + if fadeButtonsEnabled { + return String( + localized: "settings.app.fadeButtons.subtitleOn", + defaultValue: "Show action buttons only on hover." + ) + } + return String( + localized: "settings.app.fadeButtons.subtitleOff", + defaultValue: "Keep action buttons always visible." ) } @@ -3246,7 +3346,19 @@ struct SettingsView: View { get: { showWorkspaceTitlebar }, set: { newValue in showWorkspaceTitlebar = newValue - reassertSettingsWindowFocusIfNeeded() + SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() + } + ) + } + + private var fadeButtonsBinding: Binding { + Binding( + get: { fadeButtonsEnabled }, + set: { newValue in + workspaceButtonsFadeMode = newValue + ? WorkspaceButtonFadeSettings.Mode.enabled.rawValue + : WorkspaceButtonFadeSettings.Mode.disabled.rawValue + SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() } ) } @@ -3611,6 +3723,21 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.fadeButtons", defaultValue: "Fade Buttons"), + subtitle: workspaceButtonFadeSubtitle + ) { + Toggle("", isOn: fadeButtonsBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsFadeButtonsToggle") + .accessibilityLabel( + String(localized: "settings.app.fadeButtons", defaultValue: "Fade Buttons") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -4636,19 +4763,6 @@ struct SettingsView: View { NSApplication.shared.terminate(nil) } - private func reassertSettingsWindowFocusIfNeeded() { - DispatchQueue.main.async { - guard let window = SettingsWindowController.shared.window, window.isVisible else { return } - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - DispatchQueue.main.async { - guard window.isVisible else { return } - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - } - } - } - private func resetAllSettings() { isResettingSettings = true appLanguage = LanguageSettings.defaultLanguage.rawValue @@ -4689,6 +4803,7 @@ struct SettingsView: View { alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage diff --git a/vendor/bonsplit b/vendor/bonsplit index 743de85d..1ae8ee43 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 743de85dfbdd138a7b07c2b91fc9e60c88fa2cf5 +Subproject commit 1ae8ee43d6813e6ce7ee67611afa1af42c0762af