Implement hidden-titlebar minimalism mode

This commit is contained in:
Lawrence Chen 2026-03-15 16:43:26 -07:00
parent 0109731bca
commit e4ef98aca1
No known key found for this signature in database
7 changed files with 215 additions and 6 deletions

View file

@ -43635,6 +43635,57 @@
}
}
},
"settings.app.showWorkspaceTitlebar": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show Workspace Title Bar"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペースのタイトルバーを表示"
}
}
}
},
"settings.app.showWorkspaceTitlebar.subtitleOff": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Hide the workspace title bar and show sidebar or pane actions only on hover."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペースのタイトルバーを隠し、サイドバーやペインタブの操作はホバー時のみ表示します。"
}
}
}
},
"settings.app.showWorkspaceTitlebar.subtitleOn": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show the folder and active title above pane tabs."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ペインタブの上にフォルダ名と現在のタイトルを表示します。"
}
}
}
},
"settings.app.showPullRequests": {
"extractionState": "manual",
"localizations": {

View file

@ -7663,6 +7663,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
titlebarAccessoryController.dismissNotificationsPopoverIfShown()
}
func isNotificationsPopoverShown() -> Bool {
titlebarAccessoryController.isNotificationsPopoverShown()
}
func jumpToLatestUnread() {
guard let notificationStore else { return }
#if DEBUG

View file

@ -1951,6 +1951,12 @@ struct ContentView: View {
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
/// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags.
@State private var titlebarPadding: CGFloat = 32
@AppStorage(WorkspaceTitlebarSettings.showTitlebarKey)
private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar
private var effectiveTitlebarPadding: CGFloat {
showWorkspaceTitlebar ? titlebarPadding : 0
}
private var terminalContent: some View {
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
@ -2004,10 +2010,12 @@ struct ContentView: View {
.allowsHitTesting(sidebarSelectionState.selection == .notifications)
.accessibilityHidden(sidebarSelectionState.selection != .notifications)
}
.padding(.top, titlebarPadding)
.padding(.top, effectiveTitlebarPadding)
.overlay(alignment: .top) {
// Titlebar overlay is only over terminal content, not the sidebar.
customTitlebar
if showWorkspaceTitlebar {
// Titlebar overlay is only over terminal content, not the sidebar.
customTitlebar
}
}
}
@ -2224,7 +2232,7 @@ struct ContentView: View {
contentAndSidebarLayout
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .topLeading) {
if isFullScreen && sidebarState.isVisible {
if isFullScreen && sidebarState.isVisible && showWorkspaceTitlebar {
fullscreenControls
.padding(.leading, 10)
.padding(.top, 4)
@ -7765,10 +7773,13 @@ struct VerticalTabsSidebar: View {
private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
@AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey)
private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage
@AppStorage(WorkspaceTitlebarSettings.showTitlebarKey)
private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar
/// Space at top of sidebar for traffic light buttons
private let trafficLightPadding: CGFloat = 28
private let tabRowSpacing: CGFloat = 2
private let hiddenTitlebarControlsLeadingInset: CGFloat = 72
private var showsSidebarNotificationMessage: Bool {
SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility(
@ -7856,6 +7867,13 @@ struct VerticalTabsSidebar: View {
WindowDragHandleView()
.frame(height: trafficLightPadding)
}
.overlay(alignment: .topLeading) {
if !showWorkspaceTitlebar {
HiddenTitlebarSidebarControlsView(notificationStore: notificationStore)
.padding(.leading, hiddenTitlebarControlsLeadingInset)
.padding(.top, 2)
}
}
.background(Color.clear)
.modifier(ClearScrollBackground())
}

View file

@ -119,6 +119,22 @@ final class TitlebarControlsViewModel: ObservableObject {
weak var notificationsAnchorView: NSView?
}
extension Notification.Name {
static let cmuxNotificationsPopoverVisibilityDidChange = Notification.Name("cmux.notificationsPopoverVisibilityDidChange")
}
private enum NotificationsPopoverVisibilityUserInfoKey {
static let isShown = "isShown"
}
private func postNotificationsPopoverVisibilityDidChange(isShown: Bool) {
NotificationCenter.default.post(
name: .cmuxNotificationsPopoverVisibilityDidChange,
object: nil,
userInfo: [NotificationsPopoverVisibilityUserInfoKey.isShown: isShown]
)
}
struct NotificationsAnchorView: NSViewRepresentable {
let onResolve: (NSView) -> Void
@ -508,6 +524,54 @@ 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)
}
.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
}
}
}
@MainActor
private final class TitlebarShortcutHintModifierMonitor: ObservableObject {
@Published private(set) var isModifierPressed = false
@ -714,6 +778,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
private let viewModel = TitlebarControlsViewModel()
private var userDefaultsObserver: NSObjectProtocol?
var popoverIsShownForTesting: Bool { notificationsPopover.isShown }
private var showWorkspaceTitlebar: Bool { WorkspaceTitlebarSettings.isVisible() }
init(notificationStore: TerminalNotificationStore) {
self.notificationStore = notificationStore
@ -749,9 +814,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
object: nil,
queue: .main
) { [weak self] _ in
self?.applyWorkspaceTitlebarVisibility()
self?.scheduleSizeUpdate(invalidateFittingSize: true)
}
applyWorkspaceTitlebarVisibility()
scheduleSizeUpdate(invalidateFittingSize: true)
}
@ -796,6 +863,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
}
private func updateSize() {
applyWorkspaceTitlebarVisibility()
guard showWorkspaceTitlebar else { return }
let contentSize: NSSize
if fittingSizeNeedsRefresh || cachedFittingSize == nil {
hostingView.invalidateIntrinsicContentSize()
@ -828,6 +897,16 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height)
}
private func applyWorkspaceTitlebarVisibility() {
let shouldShow = showWorkspaceTitlebar
view.isHidden = !shouldShow
if !shouldShow {
preferredContentSize = .zero
containerView.frame = .zero
hostingView.frame = .zero
}
}
func toggleNotificationsPopover(animated: Bool = true, externalAnchor: NSView? = nil) {
if notificationsPopover.isShown {
notificationsPopover.performClose(nil)
@ -861,6 +940,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
if !anchorRect.isEmpty {
notificationsPopover.animates = animated
notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY)
postNotificationsPopoverVisibilityDidChange(isShown: true)
return
}
}
@ -871,6 +951,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
if !anchorRect.isEmpty {
notificationsPopover.animates = animated
notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY)
postNotificationsPopoverVisibilityDidChange(isShown: true)
return
}
}
@ -880,6 +961,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
let anchorRect = NSRect(x: 12, y: bounds.maxY - 8, width: 1, height: 1)
notificationsPopover.animates = animated
notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY)
postNotificationsPopoverVisibilityDidChange(isShown: true)
}
func dismissNotificationsPopover() {
@ -902,6 +984,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
func popoverDidClose(_ notification: Notification) {
// Clear the content view controller to stop SwiftUI observers when popover is hidden
notificationsPopover.contentViewController = nil
postNotificationsPopoverVisibilityDidChange(isShown: false)
}
}

View file

@ -16,6 +16,8 @@ struct WorkspaceContentView: View {
_ notificationPayloadHex: String?
) -> Void)?
@State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit")
@AppStorage(WorkspaceTitlebarSettings.showTitlebarKey)
private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var notificationStore: TerminalNotificationStore
@ -52,7 +54,7 @@ struct WorkspaceContentView: View {
}
}()
BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
let bonsplitView = BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
// Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
@ -147,6 +149,15 @@ struct WorkspaceContentView: View {
notificationPayloadHex: payloadHex
)
}
Group {
if showWorkspaceTitlebar {
bonsplitView
} else {
bonsplitView
.ignoresSafeArea(.container, edges: .top)
}
}
}
private func syncBonsplitNotificationBadges() {

View file

@ -4,6 +4,18 @@ import Darwin
import Bonsplit
import UniformTypeIdentifiers
enum WorkspaceTitlebarSettings {
static let showTitlebarKey = "workspaceTitlebarVisible"
static let defaultShowTitlebar = true
static func isVisible(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: showTitlebarKey) == nil {
return defaultShowTitlebar
}
return defaults.bool(forKey: showTitlebarKey)
}
}
@main
struct cmuxApp: App {
@StateObject private var tabManager: TabManager
@ -3085,6 +3097,8 @@ struct SettingsView: View {
@AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
@AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue
@AppStorage(WorkspaceTitlebarSettings.showTitlebarKey)
private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey)
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
@ -3167,6 +3181,19 @@ struct SettingsView: View {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
}
private var workspaceTitlebarSubtitle: String {
if showWorkspaceTitlebar {
return String(
localized: "settings.app.showWorkspaceTitlebar.subtitleOn",
defaultValue: "Show the folder and active title above pane tabs."
)
}
return String(
localized: "settings.app.showWorkspaceTitlebar.subtitleOff",
defaultValue: "Hide the workspace title bar and show sidebar or pane actions only on hover."
)
}
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
@ -3559,6 +3586,20 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar"),
subtitle: workspaceTitlebarSubtitle
) {
Toggle("", isOn: $showWorkspaceTitlebar)
.labelsHidden()
.controlSize(.small)
.accessibilityLabel(
String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar")
)
}
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.")
@ -4623,6 +4664,7 @@ struct SettingsView: View {
ShortcutHintDebugSettings.resetVisibilityDefaults()
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826
Subproject commit a55981319828bd832981c9be2275d199c266da41