Split fade buttons from titlebar visibility

This commit is contained in:
Lawrence Chen 2026-03-15 21:31:41 -07:00
parent f883299152
commit 70ec1a0915
No known key found for this signature in database
4 changed files with 224 additions and 54 deletions

View file

@ -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": {

View file

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

View file

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

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 743de85dfbdd138a7b07c2b91fc9e60c88fa2cf5
Subproject commit 1ae8ee43d6813e6ce7ee67611afa1af42c0762af