diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index 41165c10..aabada80 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -149,17 +149,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { /// /// - Returns: Always returns `.terminateLater` to handle termination asynchronously func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - let reason = NSAppleEventManager.shared() - .currentAppleEvent? - .attributeDescriptor(forKeyword: kAEQuitReason) - - switch reason?.enumCodeValue { - case nil: - handleTermination(sender: sender, shouldTerminate: true) - default: - handleTermination(sender: sender, shouldTerminate: true) + // When "warn before quitting" is disabled, terminate(nil) is called directly + // from the SwiftUI CommandGroup button action. Returning .terminateLater in that + // context deadlocks because the async Task can't execute during the termination + // run loop mode. Since the user opted out of the warning, just quit immediately. + let askBeforeQuit = userDefaults.bool(forKey: "settings.askBeforeQuit") + if !askBeforeQuit { + return .terminateNow } + handleTermination(sender: sender, shouldTerminate: true) return .terminateLater } diff --git a/App/NookCommands.swift b/App/NookCommands.swift index 9f7a75fb..4fb2a209 100644 --- a/App/NookCommands.swift +++ b/App/NookCommands.swift @@ -80,6 +80,15 @@ struct NookCommands: Commands { CommandGroup(replacing: .newItem) {} CommandGroup(replacing: .windowList) {} + // Replace the standard Quit menu item to route through showQuitDialog(), + // which respects the "warn before quitting" setting + CommandGroup(replacing: .appTermination) { + Button("Quit Nook") { + browserManager.showQuitDialog() + } + .keyboardShortcut("q", modifiers: .command) + } + // App Menu Section (under Nook) CommandGroup(after: .appInfo) { Divider() @@ -220,13 +229,6 @@ struct NookCommands: Commands { Divider() - Button("Force Quit App") { - browserManager.showQuitDialog() - } - .modifier(dynamicShortcut(.closeBrowser)) - - Divider() - Button(browserManager.currentTabIsMuted() ? "Unmute Audio" : "Mute Audio") { browserManager.toggleMuteCurrentTabInActiveWindow() } diff --git a/App/Window/WindowView.swift b/App/Window/WindowView.swift index a674063d..6306fa2b 100644 --- a/App/Window/WindowView.swift +++ b/App/Window/WindowView.swift @@ -32,11 +32,6 @@ struct WindowView: View { SidebarWebViewStack() - // Hover-reveal Sidebar overlay (slides in over web content) - SidebarHoverOverlayView() - .environmentObject(hoverSidebarManager) - .environment(windowState) - CommandPaletteView() DialogView() @@ -112,6 +107,7 @@ struct WindowView: View { hoverSidebarManager.windowRegistry = windowRegistry hoverSidebarManager.nookSettings = nookSettings hoverSidebarManager.start() + windowState.hoverSidebarManager = hoverSidebarManager } .onDisappear { hoverSidebarManager.stop() @@ -168,50 +164,149 @@ struct WindowView: View { @ViewBuilder private func SidebarWebViewStack() -> some View { let aiVisible = windowState.isSidebarAIChatVisible - let aiAppearsOnTrailingEdge = nookSettings.sidebarPosition == .left let sidebarVisible = windowState.isSidebarVisible - let sidebarOnRight = nookSettings.sidebarPosition == .right let sidebarOnLeft = nookSettings.sidebarPosition == .left - - HStack(spacing: 0) { - if aiAppearsOnTrailingEdge { - SpacesSidebar() - WebContent() - if aiVisible { - AISidebar() - } + + // Fixed-order layout: [LeftSpacer] [WebContent] [RightSpacer] + // WebContent always stays in the middle with stable view identity. + // Spacer widths push content based on what's on each side. + let leftWidth: CGFloat = { + if sidebarOnLeft { + return sidebarVisible ? windowState.sidebarWidth : 0 } else { - if aiVisible { - AISidebar() - } + return aiVisible ? windowState.aiSidebarWidth : 0 + } + }() + + let rightWidth: CGFloat = { + if sidebarOnLeft { + return aiVisible ? windowState.aiSidebarWidth : 0 + } else { + return sidebarVisible ? windowState.sidebarWidth : 0 + } + }() + + // Determine edge padding: remove padding when sidebar/AI is visible on that side + let hasLeftContent = (sidebarOnLeft && sidebarVisible) || (!sidebarOnLeft && aiVisible) + let hasRightContent = (!sidebarOnLeft && sidebarVisible) || (sidebarOnLeft && aiVisible) + + ZStack { + // When pinned: sidebar sits below web content (zIndex 0) so position + // swaps slide it under. When floating: above (zIndex 2) so it hovers. + UnifiedSidebar() + .zIndex(windowState.isSidebarVisible ? 0 : 2) + + if aiVisible { + AISidebar() + .frame(maxWidth: .infinity, maxHeight: .infinity, + alignment: sidebarOnLeft ? .trailing : .leading) + .zIndex(0) + } + + // Web content column — above pinned sidebars so they slide under it + HStack(spacing: 0) { + Color.clear + .frame(width: leftWidth) + .allowsHitTesting(false) WebContent() - SpacesSidebar() + Color.clear + .frame(width: rightWidth) + .allowsHitTesting(false) + } + .padding(.leading, hasLeftContent ? 0 : 8) + .padding(.trailing, hasRightContent ? 0 : 8) + .zIndex(1) + } + .animation(.smooth(duration: 0.3), value: nookSettings.sidebarPosition) + } + + /// Single sidebar instance rendered as an overlay — always the same view identity. + /// When floating, uses offset to slide in/out (preserving view identity without removal). + @ViewBuilder + private func UnifiedSidebar() -> some View { + let isPinned = windowState.isSidebarVisible + let isFloatingVisible = hoverSidebarManager.isOverlayVisible && !isPinned + let shouldShow = isPinned || isFloatingVisible + let onLeft = nookSettings.sidebarPosition == .left + // Slide offset: push sidebar fully off-screen in the appropriate direction + let slideOffset: CGFloat = { + if isPinned || isFloatingVisible { return 0 } + // Slide out to the left or right edge + return onLeft ? -(windowState.sidebarWidth + 14) : (windowState.sidebarWidth + 14) + }() + + ZStack(alignment: onLeft ? .leading : .trailing) { + // Edge hover trigger zone — always present when sidebar is unpinned + if !isPinned { + Color.clear + .frame(width: hoverSidebarManager.triggerWidth) + .contentShape(Rectangle()) + .onHover { isIn in + if isIn && !windowState.isSidebarVisible { + withAnimation(.easeInOut(duration: 0.12)) { + hoverSidebarManager.isOverlayVisible = true + } + } + NSCursor.arrow.set() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, + alignment: onLeft ? .leading : .trailing) } + + // The single sidebar panel — slides in/out when floating, always visible when pinned + sidebarPanel(isPinned: isPinned) + .offset(x: isPinned ? 0 : slideOffset) + .allowsHitTesting(shouldShow) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, + alignment: onLeft ? .leading : .trailing) + .animation(.easeInOut(duration: 0.15), value: isFloatingVisible) + .animation(.smooth(duration: 0.3), value: nookSettings.sidebarPosition) + // Briefly flash the floating sidebar on its new side after a position swap + .onChange(of: nookSettings.sidebarPosition) { _, _ in + guard !isPinned else { return } + hoverSidebarManager.peekOverlay(for: 2.0) } - // Apply padding similar to regular sidebar: remove padding when sidebar/AI is visible on that side - // When sidebar is on left, AI appears on right (trailing); when sidebar is on right, AI appears on left (leading) - .padding(.trailing, (sidebarVisible && sidebarOnRight) || (aiVisible && sidebarOnLeft) ? 0 : 8) - .padding(.leading, (sidebarVisible && sidebarOnLeft) || (aiVisible && sidebarOnRight) ? 0 : 8) } + /// Wraps `SpacesSideBarView` with mode-dependent styling. @ViewBuilder - private func SpacesSidebar() -> some View { - if windowState.isSidebarVisible { - SpacesSideBarView() - .frame(width: windowState.sidebarWidth) - .overlay(alignment: nookSettings.sidebarPosition == .left ? .trailing : .leading) { - SidebarResizeView() - .frame(maxHeight: .infinity) + private func sidebarPanel(isPinned: Bool) -> some View { + let cornerRadius: CGFloat = isPinned ? 0 : 12 + let inset: CGFloat = isPinned ? 0 : 7 + let resizeHandleAlignment: Alignment = nookSettings.sidebarPosition == .left ? .trailing : .leading + + SpacesSideBarView() + .frame(width: windowState.sidebarWidth) + .frame(maxHeight: .infinity) + .overlay(alignment: resizeHandleAlignment) { + SidebarResizeView() + .frame(maxHeight: .infinity) + .environmentObject(browserManager) + .environment(windowState) + .zIndex(2000) + .opacity(isPinned ? 1 : 0) + .allowsHitTesting(isPinned) + } + .background { + if !isPinned { + SpaceGradientBackgroundView() .environmentObject(browserManager) + .environmentObject(browserManager.gradientColorManager) .environment(windowState) - .zIndex(2000) - .environment(windowState) + .clipShape(.rect(cornerRadius: cornerRadius)) + + Rectangle() + .fill(Color.clear) + .universalGlassEffect(.regular.tint(Color(.windowBackgroundColor).opacity(0.35)), in: .rect(cornerRadius: cornerRadius)) } - .environmentObject(browserManager) - .environment(windowState) - .environment(commandPalette) - .environmentObject(browserManager.gradientColorManager) - } + } + .padding(nookSettings.sidebarPosition == .left ? .leading : .trailing, inset) + .padding(.vertical, inset) + .environmentObject(browserManager) + .environment(windowState) + .environment(commandPalette) + .environmentObject(browserManager.gradientColorManager) } @ViewBuilder diff --git a/Navigation/Sidebar/SidebarHeader.swift b/Navigation/Sidebar/SidebarHeader.swift index 914ea5c5..06c85b9b 100644 --- a/Navigation/Sidebar/SidebarHeader.swift +++ b/Navigation/Sidebar/SidebarHeader.swift @@ -10,6 +10,7 @@ import SwiftUI /// Header section of the sidebar (window controls, navigation buttons, URL bar) struct SidebarHeader: View { @EnvironmentObject var browserManager: BrowserManager + @EnvironmentObject var hoverSidebarManager: HoverSidebarManager @Environment(BrowserWindowState.self) private var windowState @Environment(\.nookSettings) var nookSettings let isSidebarHovered: Bool @@ -57,6 +58,7 @@ struct SidebarHeader: View { // MARK: - Sidebar Window Controls (Top Bar Mode) struct SidebarWindowControlsView: View { @EnvironmentObject var browserManager: BrowserManager + @EnvironmentObject var hoverSidebarManager: HoverSidebarManager @Environment(BrowserWindowState.self) private var windowState @Environment(\.nookSettings) var nookSettings @@ -66,7 +68,7 @@ struct SidebarWindowControlsView: View { .frame(width: 70) Button("Toggle Sidebar", systemImage: nookSettings.sidebarPosition == .left ? "sidebar.left" : "sidebar.right") { - browserManager.toggleSidebar(for: windowState) + browserManager.toggleSidebar(for: windowState, floatingVisible: hoverSidebarManager.isOverlayVisible) } .labelStyle(.iconOnly) .buttonStyle(NavButtonStyle()) diff --git a/Navigation/Sidebar/SpacesSideBarView.swift b/Navigation/Sidebar/SpacesSideBarView.swift index c67eb52a..ec23ede9 100644 --- a/Navigation/Sidebar/SpacesSideBarView.swift +++ b/Navigation/Sidebar/SpacesSideBarView.swift @@ -264,7 +264,11 @@ struct SpacesSideBarView: View { ForEach(SidebarPosition.allCases) { position in Toggle(isOn: Binding( get: { nookSettings.sidebarPosition == position }, - set: { _ in nookSettings.sidebarPosition = position } + set: { _ in + withAnimation(.smooth(duration: 0.3)) { + nookSettings.sidebarPosition = position + } + } )) { Label(position.displayName, systemImage: position.icon) } diff --git a/Nook/Components/Dialog/DialogView.swift b/Nook/Components/Dialog/DialogView.swift index 97727010..e6dd930e 100644 --- a/Nook/Components/Dialog/DialogView.swift +++ b/Nook/Components/Dialog/DialogView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import WebKit struct DialogView: View { @EnvironmentObject var browserManager: BrowserManager @@ -24,6 +25,14 @@ struct DialogView: View { } } .animation(.bouncy(duration: 0.2, extraBounce: -0.1), value: browserManager.dialogManager.isVisible) + .onChange(of: browserManager.dialogManager.isVisible) { _, isVisible in + if isVisible { + // Resign WebView first responder so keyboard events reach the dialog + if let window = NSApp.keyWindow { + window.makeFirstResponder(nil) + } + } + } } @ViewBuilder diff --git a/Nook/Components/Settings/Tabs/Appearance.swift b/Nook/Components/Settings/Tabs/Appearance.swift index d15eb23b..1169b5d8 100644 --- a/Nook/Components/Settings/Tabs/Appearance.swift +++ b/Nook/Components/Settings/Tabs/Appearance.swift @@ -29,8 +29,14 @@ struct SettingsAppearanceTab: View { Toggle("Liquid Glass", isOn: .constant(true)) Picker( "Sidebar Position", - selection: $settings - .sidebarPosition + selection: Binding( + get: { settings.sidebarPosition }, + set: { newValue in + withAnimation(.smooth(duration: 0.3)) { + settings.sidebarPosition = newValue + } + } + ) ) { ForEach(SidebarPosition.allCases) { provider in Text(provider.displayName).tag(provider) diff --git a/Nook/Components/Sidebar/Menu/SidebarMenu.swift b/Nook/Components/Sidebar/Menu/SidebarMenu.swift index 73a2a2f1..6d89ebb5 100644 --- a/Nook/Components/Sidebar/Menu/SidebarMenu.swift +++ b/Nook/Components/Sidebar/Menu/SidebarMenu.swift @@ -40,25 +40,30 @@ struct SidebarMenu: View { var body: some View { HStack(alignment: .center, spacing: 0) { - if nookSettings.sidebarPosition == .left{ + if nookSettings.sidebarPosition == .left { tabs - } - VStack { - switch selectedTab { - case .history: - SidebarMenuHistoryTab() - case .downloads: - SidebarMenuDownloadsTab() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - if nookSettings.sidebarPosition == .right{ + content + } else { + content tabs } } .frame(maxWidth: .infinity) .ignoresSafeArea() } + + @ViewBuilder + private var content: some View { + VStack { + switch selectedTab { + case .history: + SidebarMenuHistoryTab() + case .downloads: + SidebarMenuDownloadsTab() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } var tabs: some View{ VStack { diff --git a/Nook/Components/Sidebar/NavButtonsView.swift b/Nook/Components/Sidebar/NavButtonsView.swift index 54a71b91..f56e8167 100644 --- a/Nook/Components/Sidebar/NavButtonsView.swift +++ b/Nook/Components/Sidebar/NavButtonsView.swift @@ -45,6 +45,7 @@ class ObservableTabWrapper: ObservableObject { struct NavButtonsView: View { @EnvironmentObject var browserManager: BrowserManager + @EnvironmentObject var hoverSidebarManager: HoverSidebarManager @Environment(BrowserWindowState.self) private var windowState @Environment(\.nookSettings) var nookSettings var effectiveSidebarWidth: CGFloat? @@ -72,7 +73,7 @@ struct NavButtonsView: View { } Button("Toggle Sidebar", systemImage: sidebarOnLeft ? "sidebar.left" : "sidebar.right") { - browserManager.toggleSidebar(for: windowState) + browserManager.toggleSidebar(for: windowState, floatingVisible: hoverSidebarManager.isOverlayVisible) } .labelStyle(.iconOnly) .buttonStyle(NavButtonStyle()) diff --git a/Nook/Components/Sidebar/SidebarHoverOverlayView.swift b/Nook/Components/Sidebar/SidebarHoverOverlayView.swift deleted file mode 100644 index 092e9926..00000000 --- a/Nook/Components/Sidebar/SidebarHoverOverlayView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// SidebarHoverOverlayView.swift -// Nook -// -// Created by Jonathan Caudill on 2025-09-13. -// - -import SwiftUI -import UniversalGlass -import AppKit - -struct SidebarHoverOverlayView: View { - @EnvironmentObject var browserManager: BrowserManager - @EnvironmentObject var hoverManager: HoverSidebarManager - @Environment(BrowserWindowState.self) private var windowState - @Environment(CommandPalette.self) private var commandPalette - @Environment(\.nookSettings) var nookSettings - - private let cornerRadius: CGFloat = 12 - private let horizontalInset: CGFloat = 7 - private let verticalInset: CGFloat = 7 - - var body: some View { - // Only render overlay plumbing when the real sidebar is collapsed - if !windowState.isSidebarVisible { - ZStack(alignment: nookSettings.sidebarPosition == .left ? .leading : .trailing) { - // Edge hover hotspot - Color.clear - .frame(width: hoverManager.triggerWidth) - .contentShape(Rectangle()) - .onHover { isIn in - if isIn && !windowState.isSidebarVisible { - withAnimation(.easeInOut(duration: 0.12)) { - hoverManager.isOverlayVisible = true - } - } - NSCursor.arrow.set() - } - - if hoverManager.isOverlayVisible { - SpacesSideBarView() - .frame(width: windowState.sidebarWidth) - .environmentObject(browserManager) - .environment(windowState) - .environment(commandPalette) - .environmentObject(browserManager.gradientColorManager) - .frame(maxHeight: .infinity) - .background{ - - - SpaceGradientBackgroundView() - .environmentObject(browserManager) - .environmentObject(browserManager.gradientColorManager) - .environment(windowState) - .clipShape(.rect(cornerRadius: cornerRadius)) - - Rectangle() - .fill(Color.clear) - .universalGlassEffect(.regular.tint(Color(.windowBackgroundColor).opacity(0.35)), in: .rect(cornerRadius: cornerRadius)) - } - .alwaysArrowCursor() - .padding(nookSettings.sidebarPosition == .left ? .leading : .trailing, horizontalInset) - .padding(.vertical, verticalInset) - .transition( - .move(edge: nookSettings.sidebarPosition == .left ? .leading : .trailing) - .combined(with: .opacity) - ) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: nookSettings.sidebarPosition == .left ? .topLeading : .topTrailing) - // Container remains passive; only overlay/hotspot intercept - } - } -} diff --git a/Nook/Components/WebsiteView/WebsiteView.swift b/Nook/Components/WebsiteView/WebsiteView.swift index 68367fd2..b1d4990a 100644 --- a/Nook/Components/WebsiteView/WebsiteView.swift +++ b/Nook/Components/WebsiteView/WebsiteView.swift @@ -214,13 +214,14 @@ struct WebsiteView: View { windowState: windowState ) .coordinateSpace(name: dragCoordinateSpace) - .background(shouldShowSplit ? Color.clear : Color(nsColor: .windowBackgroundColor)) + .background(shouldShowSplit ? Color.clear : Color(nsColor: browserManager.currentTab(for: windowState)?.pageBackgroundColor ?? .windowBackgroundColor)) .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(webViewClipShape) .shadow(color: Color.black.opacity(0.3), radius: 4, x: 0, y: 0) // Critical: Use allowsHitTesting to prevent SwiftUI from intercepting mouse events // This allows right-clicks to pass through to the underlying NSView (WKWebView) - .allowsHitTesting(true) + .allowsHitTesting(!browserManager.dialogManager.isVisible) + .contentShape(Rectangle()) } // Removed SwiftUI contextMenu - it intercepts ALL right-clicks // WKWebView's willOpenMenu will handle context menus for images @@ -645,19 +646,19 @@ struct TabCompositorWrapper: NSViewRepresentable { if let lId = leftId, let leftTab = allKnownTabs.first(where: { $0.id == lId }) { // Force-create/ensure loaded when visible in split let lWeb = webView(for: leftTab, windowId: windowState.id) - let pane = makePaneContainer(frame: leftRect, isActive: (activeSide == .left), accent: accent, side: .left) + let pane = makePaneContainer(frame: leftRect, isActive: (activeSide == .left), accent: accent, side: .left, pageBackground: leftTab.pageBackgroundColor) containerView.addSubview(pane) lWeb.frame = pane.bounds lWeb.autoresizingMask = [NSView.AutoresizingMask.width, NSView.AutoresizingMask.height] lWeb.isHidden = false pane.addSubview(lWeb) - + } if let rId = rightId, let rightTab = allKnownTabs.first(where: { $0.id == rId }) { // Force-create/ensure loaded when visible in split let rWeb = webView(for: rightTab, windowId: windowState.id) - let pane = makePaneContainer(frame: rightRect, isActive: (activeSide == .right), accent: accent, side: .right) + let pane = makePaneContainer(frame: rightRect, isActive: (activeSide == .right), accent: accent, side: .right, pageBackground: rightTab.pageBackgroundColor) containerView.addSubview(pane) rWeb.frame = pane.bounds rWeb.autoresizingMask = [NSView.AutoresizingMask.width, NSView.AutoresizingMask.height] @@ -706,16 +707,16 @@ struct TabCompositorWrapper: NSViewRepresentable { print("🔍 [MEMDEBUG] updateCompositor() COMPLETE - Window: \(windowState.id.uuidString.prefix(8)), WebViews in container: \(webViewCount), Total subviews: \(totalSubviews)") } - private func makePaneContainer(frame: NSRect, isActive: Bool, accent: NSColor, side: SplitViewManager.Side) -> NSView { + private func makePaneContainer(frame: NSRect, isActive: Bool, accent: NSColor, side: SplitViewManager.Side, pageBackground: NSColor? = nil) -> NSView { let cornerRadius: CGFloat = { if #available(macOS 26.0, *) { return 8 } else { return 8 } }() - + let v = NSView(frame: frame) v.wantsLayer = true - + if let layer = v.layer { - layer.backgroundColor = NSColor.windowBackgroundColor.cgColor + layer.backgroundColor = (pageBackground ?? .windowBackgroundColor).cgColor // Create mask layer for uneven rounded corners let maskLayer = CAShapeLayer() diff --git a/Nook/Managers/BrowserManager/BrowserManager.swift b/Nook/Managers/BrowserManager/BrowserManager.swift index e01cd99d..d927b55c 100644 --- a/Nook/Managers/BrowserManager/BrowserManager.swift +++ b/Nook/Managers/BrowserManager/BrowserManager.swift @@ -808,20 +808,26 @@ class BrowserManager: ObservableObject { func toggleSidebar() { if let windowState = windowRegistry?.activeWindow { - toggleSidebar(for: windowState) + let floatingVisible = windowState.hoverSidebarManager?.isOverlayVisible ?? false + toggleSidebar(for: windowState, floatingVisible: floatingVisible) } else { - withAnimation(.easeInOut(duration: 0.1)) { + withAnimation(.smooth(duration: 0.1)) { isSidebarVisible.toggle() - // Width stays the same whether visible or hidden } saveSidebarSettings() } } - func toggleSidebar(for windowState: BrowserWindowState) { - withAnimation(.easeInOut(duration: 0.1)) { - windowState.isSidebarVisible.toggle() - // Width stays the same whether visible or hidden + func toggleSidebar(for windowState: BrowserWindowState, floatingVisible: Bool = false) { + let unpinning = windowState.isSidebarVisible + // Skip animation when pinning while the floating sidebar is already showing + let shouldAnimate = unpinning || !floatingVisible + if shouldAnimate { + withAnimation(.smooth(duration: 0.1)) { + windowState.isSidebarVisible = !unpinning + } + } else { + windowState.isSidebarVisible = true } if windowRegistry?.activeWindow?.id == windowState.id { isSidebarVisible = windowState.isSidebarVisible diff --git a/Nook/Managers/DialogManager/DialogManager.swift b/Nook/Managers/DialogManager/DialogManager.swift index 7763e0d3..38001480 100644 --- a/Nook/Managers/DialogManager/DialogManager.swift +++ b/Nook/Managers/DialogManager/DialogManager.swift @@ -15,11 +15,14 @@ class DialogManager { var isVisible: Bool = false var activeDialog: AnyView? + private var tabKeyMonitor: Any? + // MARK: - Presentation func showDialog(_ dialog: Content) { activeDialog = AnyView(dialog) isVisible = true + installTabKeyMonitor() } func showDialog(@ViewBuilder builder: () -> Content) { @@ -33,11 +36,32 @@ class DialogManager { } isVisible = false + removeTabKeyMonitor() DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in self?.activeDialog = nil } } + // MARK: - Tab Key Blocking + + private func installTabKeyMonitor() { + guard tabKeyMonitor == nil else { return } + tabKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if event.keyCode == 48 { + NSSound.beep() + return nil + } + return event + } + } + + private func removeTabKeyMonitor() { + if let monitor = tabKeyMonitor { + NSEvent.removeMonitor(monitor) + tabKeyMonitor = nil + } + } + // MARK: - Convenience Dialogs func showQuitDialog( @@ -54,13 +78,13 @@ class DialogManager { Image("nook-logo-1024") .resizable() .scaledToFit() - .frame(width: 26, height: 26) + .frame(width: 50, height: 50) .shadow( color: AppColors.textPrimary.opacity(0.3), radius: 0.5, y: 1 ) - Text("Are you sure you want to quit Nook?") + Text("Quit Nook?") .font(.system(size: 18, weight: .bold)) .foregroundStyle(AppColors.textPrimary) Text("You may lose unsaved work in your tabs.") @@ -79,13 +103,16 @@ class DialogManager { rightButtons: [ DialogButton( text: "Cancel", + customIcon: AnyView(KeycapLabel("esc")), variant: .secondary, + keyboardShortcut: .escape, action: closeDialog ), DialogButton( text: "Quit", iconName: "return", variant: .primary, + keyboardShortcut: .return, action: onQuit ), ] @@ -261,62 +288,30 @@ struct DialogFooter: View { var body: some View { HStack { if let leftButton = leftButton { - if let iconName = leftButton.iconName { - Button(leftButton.text, action: leftButton.action) - .buttonStyle( - DialogButtonStyle( - variant: leftButton.variant, - icon: leftButton.iconName.map { - AnyView(Image(systemName: $0)) - }, - iconPosition: .trailing - ) + Button(leftButton.text, action: leftButton.action) + .buttonStyle( + DialogButtonStyle( + variant: leftButton.variant, + icon: leftButton.resolvedIcon, + iconPosition: .trailing ) - .conditionally(if: OSVersion.supportsGlassEffect) { - View in - View - .tint( - Color("plainBackgroundColor").opacity( - colorScheme == .light ? 0.8 : 0.4 - ) - ) - } - .controlSize(.extraLarge) - - .disabled(!leftButton.isEnabled) - .modifier( - OptionalKeyboardShortcut( - shortcut: leftButton.keyboardShortcut - ) - ) - } else { - Button(leftButton.text, action: leftButton.action) - .buttonStyle( - DialogButtonStyle( - variant: leftButton.variant, - icon: leftButton.iconName.map { - AnyView(Image(systemName: $0)) - }, - iconPosition: .trailing - ) - ) - .conditionally(if: OSVersion.supportsGlassEffect) { - View in - View - .tint( - Color("plainBackgroundColor").opacity( - colorScheme == .light ? 0.8 : 0.4 - ) + ) + .conditionally(if: OSVersion.supportsGlassEffect) { + View in + View + .tint( + Color("plainBackgroundColor").opacity( + colorScheme == .light ? 0.8 : 0.4 ) - } - .controlSize(.extraLarge) - .disabled(!leftButton.isEnabled) - .modifier( - OptionalKeyboardShortcut( - shortcut: leftButton.keyboardShortcut ) + } + .controlSize(.extraLarge) + .disabled(!leftButton.isEnabled) + .modifier( + OptionalKeyboardShortcut( + shortcut: leftButton.keyboardShortcut ) - } + ) } Spacer() @@ -329,9 +324,7 @@ struct DialogFooter: View { .buttonStyle( DialogButtonStyle( variant: button.variant, - icon: button.iconName.map { - AnyView(Image(systemName: $0)) - }, + icon: button.resolvedIcon, iconPosition: .trailing ) ) @@ -351,15 +344,23 @@ struct DialogFooter: View { struct DialogButton { let text: String let iconName: String? + let customIcon: AnyView? let variant: DialogButtonStyleVariant let action: () -> Void let keyboardShortcut: KeyEquivalent? let shadowStyle: NookButtonStyle.ShadowStyle let isEnabled: Bool + /// The resolved icon view: customIcon takes priority, then iconName as SF Symbol + var resolvedIcon: AnyView? { + if let customIcon { return customIcon } + return iconName.map { AnyView(Image(systemName: $0)) } + } + init( text: String, iconName: String? = nil, + customIcon: AnyView? = nil, variant: DialogButtonStyleVariant = .secondary, keyboardShortcut: KeyEquivalent? = nil, shadowStyle: NookButtonStyle.ShadowStyle = .subtle, @@ -368,6 +369,7 @@ struct DialogButton { ) { self.text = text self.iconName = iconName + self.customIcon = customIcon self.variant = variant self.action = action self.keyboardShortcut = keyboardShortcut @@ -449,3 +451,21 @@ struct DialogButtonStyle: ButtonStyle { .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) } } + +/// A small rounded badge for displaying keyboard shortcut hints (e.g. "esc", "tab") +struct KeycapLabel: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text.uppercased()) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} diff --git a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift index c3a8e337..b75091cc 100644 --- a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift +++ b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift @@ -30,6 +30,33 @@ final class HoverSidebarManager: ObservableObject { weak var windowRegistry: WindowRegistry? weak var nookSettings: NookSettingsService? + /// Whether the mouse is currently inside the sidebar content area. + /// Updated on every mouse move; used to avoid dismissing the peek overlay while the user hovers. + @Published var isMouseInsideSidebar: Bool = false + + /// When set, the mouse monitor won't auto-hide the overlay until this time has passed. + /// Used to keep the sidebar visible briefly after a position swap. + var peekUntil: Date? + + /// Show the floating sidebar and keep it visible for `duration` seconds, + /// regardless of mouse position. After the duration, normal mouse tracking resumes. + func peekOverlay(for duration: TimeInterval = 2.0) { + let deadline = Date().addingTimeInterval(duration) + peekUntil = deadline + withAnimation(.easeInOut(duration: 0.15)) { + isOverlayVisible = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self else { return } + // Only expire if this is still the same peek (not a newer one) + if self.peekUntil == deadline { + self.peekUntil = nil + // Let the next mouse move naturally evaluate whether to hide + self.scheduleHandleMouseMovement() + } + } + } + // MARK: - Monitors private var globalMonitor: Any? private var localMonitor: Any? @@ -82,7 +109,9 @@ final class HoverSidebarManager: ObservableObject { // Never show overlay while the real sidebar is visible if activeState.isSidebarVisible { if isOverlayVisible { - isOverlayVisible = false + withAnimation(.easeInOut(duration: 0.15)) { + isOverlayVisible = false + } } return } @@ -131,6 +160,17 @@ final class HoverSidebarManager: ObservableObject { inSidebarContentZone = (mouse.x >= rightEdge - overlayWidth) && (mouse.x <= rightEdge) } + let newIsInsideSidebar = inSidebarContentZone && verticalOK + if isMouseInsideSidebar != newIsInsideSidebar { + isMouseInsideSidebar = newIsInsideSidebar + } + + // During a peek (e.g. after position swap), don't auto-hide + if let peekUntil, Date() < peekUntil { + // Still update isMouseInsideSidebar but don't change visibility + return + } + // Show sidebar if: in trigger zone, OR (sidebar visible AND (in keep-open zone OR over sidebar content)) let shouldShow = inTriggerZone || (isOverlayVisible && (inKeepOpenZone || inSidebarContentZone)) if shouldShow != isOverlayVisible { diff --git a/Nook/Models/BrowserWindowState.swift b/Nook/Models/BrowserWindowState.swift index f49d6b2f..870268bc 100644 --- a/Nook/Models/BrowserWindowState.swift +++ b/Nook/Models/BrowserWindowState.swift @@ -86,6 +86,9 @@ class BrowserWindowState { /// Reference to this window's CommandPalette for global shortcuts weak var commandPalette: CommandPalette? + /// Reference to this window's HoverSidebarManager for checking floating visibility + weak var hoverSidebarManager: HoverSidebarManager? + // MARK: - Incognito/Ephemeral State /// Whether this window is an incognito/private browsing window