Show sidebar/notification/new-tab controls in fullscreen without hovering titlebar (#55)
In fullscreen mode, the NSTitlebarAccessoryViewController buttons are hidden with the system titlebar. This adds SwiftUI-based fullscreen controls that appear in the sidebar area (when visible) or inline in the custom titlebar (when sidebar is hidden), reusing the existing TitlebarControlsView component. - Track fullscreen state via window notifications and toggle controls visibility - Hide original titlebar accessory (isHidden + alphaValue=0) in fullscreen - Route notification popover anchoring through fullscreen controls view model so both button clicks and keyboard shortcuts (Cmd+Shift+I) position correctly - Add debug titlebar spacing slider for fine-tuning leading inset
This commit is contained in:
parent
4220c3808f
commit
f0e4ccdc1d
4 changed files with 117 additions and 14 deletions
|
|
@ -182,6 +182,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
weak var tabManager: TabManager?
|
||||
weak var notificationStore: TerminalNotificationStore?
|
||||
weak var sidebarState: SidebarState?
|
||||
weak var fullscreenControlsViewModel: TitlebarControlsViewModel?
|
||||
weak var sidebarSelectionState: SidebarSelectionState?
|
||||
private var workspaceObserver: NSObjectProtocol?
|
||||
private var windowKeyObserver: NSObjectProtocol?
|
||||
|
|
@ -1385,8 +1386,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
windowDecorationsController.apply(to: window)
|
||||
}
|
||||
|
||||
func toggleNotificationsPopover(animated: Bool = true) {
|
||||
titlebarAccessoryController.toggleNotificationsPopover(animated: animated)
|
||||
func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) {
|
||||
titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView)
|
||||
}
|
||||
|
||||
func jumpToLatestUnread() {
|
||||
|
|
@ -1697,7 +1698,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
// Check Show Notifications shortcut
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showNotifications)) {
|
||||
toggleNotificationsPopover(animated: false)
|
||||
toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,9 @@ struct ContentView: View {
|
|||
@State private var selectedTabIds: Set<UUID> = []
|
||||
@State private var lastSidebarSelectionIndex: Int? = nil
|
||||
@State private var titlebarText: String = ""
|
||||
@State private var isFullScreen: Bool = false
|
||||
@State private var observedWindow: NSWindow?
|
||||
@StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel()
|
||||
|
||||
private var sidebarView: some View {
|
||||
VerticalTabsSidebar(
|
||||
|
|
@ -276,6 +279,7 @@ struct ContentView: View {
|
|||
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
|
||||
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03
|
||||
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
|
||||
@AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0
|
||||
|
||||
@State private var titlebarLeadingInset: CGFloat = 12
|
||||
private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" }
|
||||
|
|
@ -294,6 +298,21 @@ struct ContentView: View {
|
|||
Color(nsColor: .separatorColor).opacity(colorScheme == .light ? 0.68 : 0.34)
|
||||
}
|
||||
|
||||
private var fullscreenControls: some View {
|
||||
TitlebarControlsView(
|
||||
notificationStore: TerminalNotificationStore.shared,
|
||||
viewModel: fullscreenControlsViewModel,
|
||||
onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() },
|
||||
onToggleNotifications: { [fullscreenControlsViewModel] in
|
||||
AppDelegate.shared?.toggleNotificationsPopover(
|
||||
animated: true,
|
||||
anchorView: fullscreenControlsViewModel.notificationsAnchorView
|
||||
)
|
||||
},
|
||||
onNewTab: { tabManager.addTab() }
|
||||
)
|
||||
}
|
||||
|
||||
private var customTitlebar: some View {
|
||||
ZStack {
|
||||
// Enable window dragging from the titlebar strip without making the entire content
|
||||
|
|
@ -303,6 +322,10 @@ struct ContentView: View {
|
|||
TitlebarLeadingInsetReader(inset: $titlebarLeadingInset)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if isFullScreen && !sidebarState.isVisible {
|
||||
fullscreenControls
|
||||
}
|
||||
|
||||
// Draggable folder icon + focused command name
|
||||
if let directory = focusedDirectory {
|
||||
DraggableFolderIcon(directory: directory)
|
||||
|
|
@ -318,7 +341,7 @@ struct ContentView: View {
|
|||
}
|
||||
.frame(height: 28)
|
||||
.padding(.top, 2)
|
||||
.padding(.leading, sidebarState.isVisible ? 12 : titlebarLeadingInset)
|
||||
.padding(.leading, (isFullScreen && !sidebarState.isVisible) ? 8 : (sidebarState.isVisible ? 12 : titlebarLeadingInset + CGFloat(debugTitlebarLeadingExtra)))
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
.frame(height: titlebarPadding)
|
||||
|
|
@ -386,6 +409,13 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if isFullScreen && sidebarState.isVisible {
|
||||
fullscreenControls
|
||||
.padding(.leading, 10)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.background(Color.clear)
|
||||
.onAppear {
|
||||
|
|
@ -441,6 +471,20 @@ struct ContentView: View {
|
|||
}
|
||||
.onChange(of: bgGlassTintOpacity) { _ in
|
||||
updateWindowGlassTint()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { notification in
|
||||
guard let window = notification.object as? NSWindow,
|
||||
window === observedWindow else { return }
|
||||
isFullScreen = true
|
||||
setTitlebarControlsHidden(true, in: window)
|
||||
AppDelegate.shared?.fullscreenControlsViewModel = fullscreenControlsViewModel
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { notification in
|
||||
guard let window = notification.object as? NSWindow,
|
||||
window === observedWindow else { return }
|
||||
isFullScreen = false
|
||||
setTitlebarControlsHidden(false, in: window)
|
||||
AppDelegate.shared?.fullscreenControlsViewModel = nil
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
|
||||
|
|
@ -451,6 +495,14 @@ struct ContentView: View {
|
|||
window.isMovableByWindowBackground = false
|
||||
window.styleMask.insert(.fullSizeContentView)
|
||||
|
||||
// Track this window for fullscreen notifications
|
||||
if observedWindow !== window {
|
||||
DispatchQueue.main.async {
|
||||
observedWindow = window
|
||||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep content below the titlebar so drags on Bonsplit's tab bar don't
|
||||
// get interpreted as window drags.
|
||||
let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height
|
||||
|
|
@ -512,6 +564,16 @@ struct ContentView: View {
|
|||
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
|
||||
WindowGlassEffect.updateTint(to: window, color: tintColor)
|
||||
}
|
||||
|
||||
private func setTitlebarControlsHidden(_ hidden: Bool, in window: NSWindow) {
|
||||
let controlsId = NSUserInterfaceItemIdentifier("cmux.titlebarControls")
|
||||
for accessory in window.titlebarAccessoryViewControllers {
|
||||
if accessory.view.identifier == controlsId {
|
||||
accessory.isHidden = hidden
|
||||
accessory.view.alphaValue = hidden ? 0 : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VerticalTabsSidebar: View {
|
||||
|
|
@ -534,7 +596,7 @@ struct VerticalTabsSidebar: View {
|
|||
GeometryReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Space for traffic lights
|
||||
// Space for traffic lights / fullscreen controls
|
||||
Spacer()
|
||||
.frame(height: trafficLightPadding)
|
||||
|
||||
|
|
@ -2288,7 +2350,7 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable {
|
|||
where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left {
|
||||
leading += accessory.view.frame.width
|
||||
}
|
||||
leading += 16
|
||||
leading += 0
|
||||
if leading != inset {
|
||||
inset = leading
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ final class TitlebarControlsViewModel: ObservableObject {
|
|||
weak var notificationsAnchorView: NSView?
|
||||
}
|
||||
|
||||
private struct NotificationsAnchorView: NSViewRepresentable {
|
||||
struct NotificationsAnchorView: NSViewRepresentable {
|
||||
let onResolve: (NSView) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
|
|
@ -134,7 +134,7 @@ private struct NotificationsAnchorView: NSViewRepresentable {
|
|||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
private final class AnchorNSView: NSView {
|
||||
final class AnchorNSView: NSView {
|
||||
var onLayout: (() -> Void)?
|
||||
|
||||
override func layout() {
|
||||
|
|
@ -193,7 +193,7 @@ struct ShortcutHintHorizontalPlanner {
|
|||
}
|
||||
}
|
||||
|
||||
private struct TitlebarControlButton<Content: View>: View {
|
||||
struct TitlebarControlButton<Content: View>: View {
|
||||
let config: TitlebarControlsStyleConfig
|
||||
let action: () -> Void
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
|
@ -221,7 +221,7 @@ private struct TitlebarControlButton<Content: View>: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct TitlebarControlsView: View {
|
||||
struct TitlebarControlsView: View {
|
||||
@ObservedObject var notificationStore: TerminalNotificationStore
|
||||
@ObservedObject var viewModel: TitlebarControlsViewModel
|
||||
let onToggleSidebar: () -> Void
|
||||
|
|
@ -666,7 +666,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height)
|
||||
}
|
||||
|
||||
func toggleNotificationsPopover(animated: Bool = true) {
|
||||
func toggleNotificationsPopover(animated: Bool = true, externalAnchor: NSView? = nil) {
|
||||
if notificationsPopover.isShown {
|
||||
notificationsPopover.performClose(nil)
|
||||
return
|
||||
|
|
@ -684,7 +684,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
hostingController.view.layer?.backgroundColor = .clear
|
||||
notificationsPopover.contentViewController = hostingController
|
||||
|
||||
guard let window = view.window ?? hostingView.window ?? NSApp.keyWindow,
|
||||
guard let window = externalAnchor?.window ?? view.window ?? hostingView.window ?? NSApp.keyWindow,
|
||||
let contentView = window.contentView else {
|
||||
return
|
||||
}
|
||||
|
|
@ -692,7 +692,18 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
// Force layout to ensure geometry is current.
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
|
||||
if let anchorView = viewModel.notificationsAnchorView, anchorView.window != nil {
|
||||
// Use external anchor (e.g. fullscreen sidebar controls) if provided.
|
||||
if let externalAnchor, externalAnchor.window != nil {
|
||||
externalAnchor.superview?.layoutSubtreeIfNeeded()
|
||||
let anchorRect = externalAnchor.convert(externalAnchor.bounds, to: contentView)
|
||||
if !anchorRect.isEmpty {
|
||||
notificationsPopover.animates = animated
|
||||
notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let anchorView = viewModel.notificationsAnchorView, anchorView.window != nil, !isHidden {
|
||||
anchorView.superview?.layoutSubtreeIfNeeded()
|
||||
let anchorRect = anchorView.convert(anchorView.bounds, to: contentView)
|
||||
if !anchorRect.isEmpty {
|
||||
|
|
@ -1034,10 +1045,21 @@ final class UpdateTitlebarAccessoryController {
|
|||
return controllers.first
|
||||
}
|
||||
|
||||
func toggleNotificationsPopover(animated: Bool = true) {
|
||||
func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) {
|
||||
let controllers = controlsControllers.allObjects
|
||||
guard !controllers.isEmpty else { return }
|
||||
|
||||
// If an external anchor is provided (e.g. fullscreen sidebar controls),
|
||||
// use it for popover positioning instead of the hidden titlebar accessory.
|
||||
if let anchorView, anchorView.window != nil {
|
||||
let target = preferredNotificationsController(from: controllers, preferShownPopover: true)
|
||||
for controller in controllers where controller !== target {
|
||||
controller.dismissNotificationsPopover()
|
||||
}
|
||||
target?.toggleNotificationsPopover(animated: animated, externalAnchor: anchorView)
|
||||
return
|
||||
}
|
||||
|
||||
let target = preferredNotificationsController(from: controllers, preferShownPopover: true)
|
||||
for controller in controllers {
|
||||
if controller !== target {
|
||||
|
|
|
|||
|
|
@ -1195,6 +1195,7 @@ private struct DebugWindowControlsView: View {
|
|||
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
||||
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
|
|
@ -1261,6 +1262,23 @@ private struct DebugWindowControlsView: View {
|
|||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
GroupBox("Titlebar Spacing") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Leading extra")
|
||||
Slider(value: $titlebarLeadingExtra, in: 0...40)
|
||||
Text(String(format: "%.0f", titlebarLeadingExtra))
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.frame(width: 30, alignment: .trailing)
|
||||
}
|
||||
Button("Reset (0)") {
|
||||
titlebarLeadingExtra = 0
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
GroupBox("Copy") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button("Copy All Debug Config") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue