From a4876c96957b2cd0679b24e4d3dfc18f7a928c66 Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea Date: Tue, 3 Mar 2026 20:53:05 +0000 Subject: [PATCH 1/9] refactor: unify sidebar into single view instance to eliminate redraw flash Replace the two separate SpacesSideBarView containers (HStack layout and SidebarHoverOverlayView) with a single instance rendered as an overlay. A Color.clear spacer animates width to push web content when pinned. The floating sidebar slides in/out via offset instead of being conditionally created/destroyed, preserving view identity and state. - Delete SidebarHoverOverlayView.swift (logic moved to WindowView) - Increase toggleSidebar animation from 0.1s to 0.2s for smoother transition - Animate hover dismissal when sidebar becomes pinned Co-Authored-By: Claude Opus 4.6 --- App/Window/WindowView.swift | 112 ++++++++++++++---- .../Sidebar/SidebarHoverOverlayView.swift | 74 ------------ .../BrowserManager/BrowserManager.swift | 2 +- .../HoverSidebarManager.swift | 4 +- 4 files changed, 94 insertions(+), 98 deletions(-) delete mode 100644 Nook/Components/Sidebar/SidebarHoverOverlayView.swift diff --git a/App/Window/WindowView.swift b/App/Window/WindowView.swift index a674063d..559207b6 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() @@ -172,10 +167,10 @@ struct WindowView: View { let sidebarVisible = windowState.isSidebarVisible let sidebarOnRight = nookSettings.sidebarPosition == .right let sidebarOnLeft = nookSettings.sidebarPosition == .left - + HStack(spacing: 0) { if aiAppearsOnTrailingEdge { - SpacesSidebar() + SidebarLayoutSpacer() WebContent() if aiVisible { AISidebar() @@ -185,33 +180,106 @@ struct WindowView: View { AISidebar() } WebContent() - SpacesSidebar() + SidebarLayoutSpacer() } } // 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) + .overlay(alignment: nookSettings.sidebarPosition == .left ? .leading : .trailing) { + UnifiedSidebar() + } + } + + /// Invisible spacer that pushes web content aside when the sidebar is pinned. + @ViewBuilder + private func SidebarLayoutSpacer() -> some View { + Color.clear + .frame(width: windowState.isSidebarVisible ? windowState.sidebarWidth : 0) + } + + /// 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) } + /// 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/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/Managers/BrowserManager/BrowserManager.swift b/Nook/Managers/BrowserManager/BrowserManager.swift index e01cd99d..de7f038c 100644 --- a/Nook/Managers/BrowserManager/BrowserManager.swift +++ b/Nook/Managers/BrowserManager/BrowserManager.swift @@ -819,7 +819,7 @@ class BrowserManager: ObservableObject { } func toggleSidebar(for windowState: BrowserWindowState) { - withAnimation(.easeInOut(duration: 0.1)) { + withAnimation(.easeInOut(duration: 0.2)) { windowState.isSidebarVisible.toggle() // Width stays the same whether visible or hidden } diff --git a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift index c3a8e337..2961f561 100644 --- a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift +++ b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift @@ -82,7 +82,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 } From 5c6935a6ef6e58c275857e245e7f894ba428f99d Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea Date: Tue, 3 Mar 2026 21:34:08 +0000 Subject: [PATCH 2/9] feat: smooth animated sidebar position swap Replace branching if/else HStack with fixed-order ZStack layout so WebContent keeps stable view identity when swapping sidebar sides. Sidebar and AI panels slide under web content (zIndex layering) during the swap animation. When unpinned, sidebar floats above as before. - Add peekOverlay to HoverSidebarManager so the floating sidebar briefly appears on its new side for 2s after a position swap - Use layoutDirection environment to flip SidebarMenu tab placement - Wrap all position setters (context menu, settings) in withAnimation Co-Authored-By: Claude Opus 4.6 --- App/Window/WindowView.swift | 80 ++++++++++++------- Navigation/Sidebar/SpacesSideBarView.swift | 6 +- .../Components/Settings/Tabs/Appearance.swift | 10 ++- .../Components/Sidebar/Menu/SidebarMenu.swift | 8 +- .../HoverSidebarManager.swift | 35 ++++++++ 5 files changed, 103 insertions(+), 36 deletions(-) diff --git a/App/Window/WindowView.swift b/App/Window/WindowView.swift index 559207b6..b354baeb 100644 --- a/App/Window/WindowView.swift +++ b/App/Window/WindowView.swift @@ -163,40 +163,60 @@ 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 { - SidebarLayoutSpacer() - 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() - } - WebContent() - SidebarLayoutSpacer() + return aiVisible ? windowState.aiSidebarWidth : 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) - .overlay(alignment: nookSettings.sidebarPosition == .left ? .leading : .trailing) { + }() + + 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 { + // Sidebar sits below web content when pinned (slides under during swap), + // but above when floating (hovers over web content from the edge) UnifiedSidebar() - } - } + .zIndex(windowState.isSidebarVisible ? 0 : 2) - /// Invisible spacer that pushes web content aside when the sidebar is pinned. - @ViewBuilder - private func SidebarLayoutSpacer() -> some View { - Color.clear - .frame(width: windowState.isSidebarVisible ? windowState.sidebarWidth : 0) + if aiVisible { + AISidebar() + .frame(maxWidth: .infinity, maxHeight: .infinity, + alignment: sidebarOnLeft ? .trailing : .leading) + .zIndex(0) + } + + // Web content column renders above sidebars so they slide under it + HStack(spacing: 0) { + Color.clear + .frame(width: leftWidth) + .allowsHitTesting(false) + WebContent() + 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. @@ -240,6 +260,12 @@ struct WindowView: View { .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) + } } /// Wraps `SpacesSideBarView` with mode-dependent styling. 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/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..195238f3 100644 --- a/Nook/Components/Sidebar/Menu/SidebarMenu.swift +++ b/Nook/Components/Sidebar/Menu/SidebarMenu.swift @@ -40,9 +40,7 @@ struct SidebarMenu: View { var body: some View { HStack(alignment: .center, spacing: 0) { - if nookSettings.sidebarPosition == .left{ - tabs - } + tabs VStack { switch selectedTab { case .history: @@ -52,10 +50,8 @@ struct SidebarMenu: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - if nookSettings.sidebarPosition == .right{ - tabs - } } + .environment(\.layoutDirection, nookSettings.sidebarPosition == .left ? .leftToRight : .rightToLeft) .frame(maxWidth: .infinity) .ignoresSafeArea() } diff --git a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift index 2961f561..68fbee0a 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? @@ -133,6 +160,14 @@ final class HoverSidebarManager: ObservableObject { inSidebarContentZone = (mouse.x >= rightEdge - overlayWidth) && (mouse.x <= rightEdge) } + isMouseInsideSidebar = inSidebarContentZone && verticalOK + + // 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 { From 99679374550dbfe30d510f6edc3c0fac5e42349a Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea Date: Tue, 3 Mar 2026 22:36:04 +0000 Subject: [PATCH 3/9] fix: remove animation when pinning sidebar, keep it when unpinning Matches Arc-style instant snap when pinning the sidebar while preserving the smooth ease-out animation when unpinning. Co-Authored-By: Claude Opus 4.6 --- .../BrowserManager/BrowserManager.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Nook/Managers/BrowserManager/BrowserManager.swift b/Nook/Managers/BrowserManager/BrowserManager.swift index de7f038c..ed3064e0 100644 --- a/Nook/Managers/BrowserManager/BrowserManager.swift +++ b/Nook/Managers/BrowserManager/BrowserManager.swift @@ -810,18 +810,26 @@ class BrowserManager: ObservableObject { if let windowState = windowRegistry?.activeWindow { toggleSidebar(for: windowState) } else { - withAnimation(.easeInOut(duration: 0.1)) { - isSidebarVisible.toggle() - // Width stays the same whether visible or hidden + let unpinning = isSidebarVisible + if unpinning { + withAnimation(.easeInOut(duration: 0.2)) { + isSidebarVisible = false + } + } else { + isSidebarVisible = true } saveSidebarSettings() } } func toggleSidebar(for windowState: BrowserWindowState) { - withAnimation(.easeInOut(duration: 0.2)) { - windowState.isSidebarVisible.toggle() - // Width stays the same whether visible or hidden + let unpinning = windowState.isSidebarVisible + if unpinning { + withAnimation(.easeInOut(duration: 0.2)) { + windowState.isSidebarVisible = false + } + } else { + windowState.isSidebarVisible = true } if windowRegistry?.activeWindow?.id == windowState.id { isSidebarVisible = windowState.isSidebarVisible From e153154fac19734a9d92f3a5577f786cf5e11f69 Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea Date: Wed, 4 Mar 2026 20:26:15 +0000 Subject: [PATCH 4/9] fix: restore pinning animation, skip when floating sidebar already visible Animate sidebar toggle in both directions (pin and unpin). When the floating sidebar is already showing via hover, pinning is instant since the sidebar is already visually present. Co-Authored-By: Claude Opus 4.6 --- App/Window/WindowView.swift | 7 ++++--- Navigation/Sidebar/SidebarHeader.swift | 4 +++- Nook/Components/Sidebar/NavButtonsView.swift | 3 ++- .../BrowserManager/BrowserManager.swift | 20 +++++++++---------- Nook/Models/BrowserWindowState.swift | 3 +++ 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/App/Window/WindowView.swift b/App/Window/WindowView.swift index b354baeb..6306fa2b 100644 --- a/App/Window/WindowView.swift +++ b/App/Window/WindowView.swift @@ -107,6 +107,7 @@ struct WindowView: View { hoverSidebarManager.windowRegistry = windowRegistry hoverSidebarManager.nookSettings = nookSettings hoverSidebarManager.start() + windowState.hoverSidebarManager = hoverSidebarManager } .onDisappear { hoverSidebarManager.stop() @@ -190,8 +191,8 @@ struct WindowView: View { let hasRightContent = (!sidebarOnLeft && sidebarVisible) || (sidebarOnLeft && aiVisible) ZStack { - // Sidebar sits below web content when pinned (slides under during swap), - // but above when floating (hovers over web content from the edge) + // 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) @@ -202,7 +203,7 @@ struct WindowView: View { .zIndex(0) } - // Web content column renders above sidebars so they slide under it + // Web content column — above pinned sidebars so they slide under it HStack(spacing: 0) { Color.clear .frame(width: leftWidth) 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/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/Managers/BrowserManager/BrowserManager.swift b/Nook/Managers/BrowserManager/BrowserManager.swift index ed3064e0..f657e3e4 100644 --- a/Nook/Managers/BrowserManager/BrowserManager.swift +++ b/Nook/Managers/BrowserManager/BrowserManager.swift @@ -808,25 +808,23 @@ 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 { - let unpinning = isSidebarVisible - if unpinning { - withAnimation(.easeInOut(duration: 0.2)) { - isSidebarVisible = false - } - } else { - isSidebarVisible = true + withAnimation(.easeInOut(duration: 0.2)) { + isSidebarVisible.toggle() } saveSidebarSettings() } } - func toggleSidebar(for windowState: BrowserWindowState) { + func toggleSidebar(for windowState: BrowserWindowState, floatingVisible: Bool = false) { let unpinning = windowState.isSidebarVisible - if unpinning { + // Skip animation when pinning while the floating sidebar is already showing + let shouldAnimate = unpinning || !floatingVisible + if shouldAnimate { withAnimation(.easeInOut(duration: 0.2)) { - windowState.isSidebarVisible = false + windowState.isSidebarVisible = !unpinning } } else { windowState.isSidebarVisible = true 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 From 2134f7c07c3d2078f196b65e71b133d904683afa Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea Date: Thu, 5 Mar 2026 16:20:25 +0000 Subject: [PATCH 5/9] fix: use page background color for webview containers and speed up sidebar animation Use each tab's pageBackgroundColor instead of windowBackgroundColor for webview and split pane backgrounds. Switch sidebar pin/unpin animation to .smooth(duration: 0.1) for a snappier feel. Co-Authored-By: Claude Opus 4.6 --- Nook/Components/WebsiteView/WebsiteView.swift | 16 ++++++++-------- .../Managers/BrowserManager/BrowserManager.swift | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Nook/Components/WebsiteView/WebsiteView.swift b/Nook/Components/WebsiteView/WebsiteView.swift index 68367fd2..95d5c0b9 100644 --- a/Nook/Components/WebsiteView/WebsiteView.swift +++ b/Nook/Components/WebsiteView/WebsiteView.swift @@ -214,7 +214,7 @@ 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) @@ -645,19 +645,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 +706,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 f657e3e4..d927b55c 100644 --- a/Nook/Managers/BrowserManager/BrowserManager.swift +++ b/Nook/Managers/BrowserManager/BrowserManager.swift @@ -811,7 +811,7 @@ class BrowserManager: ObservableObject { let floatingVisible = windowState.hoverSidebarManager?.isOverlayVisible ?? false toggleSidebar(for: windowState, floatingVisible: floatingVisible) } else { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.smooth(duration: 0.1)) { isSidebarVisible.toggle() } saveSidebarSettings() @@ -823,7 +823,7 @@ class BrowserManager: ObservableObject { // Skip animation when pinning while the floating sidebar is already showing let shouldAnimate = unpinning || !floatingVisible if shouldAnimate { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.smooth(duration: 0.1)) { windowState.isSidebarVisible = !unpinning } } else { From 3df0eb64d7b7c7ce73c12cf150ee9dfb0d548f63 Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea Date: Thu, 5 Mar 2026 17:00:57 +0000 Subject: [PATCH 6/9] fix: avoid layoutDirection mirroring in SidebarMenu and reduce unnecessary hover publishes Replace .environment(\.layoutDirection) override with explicit child ordering to prevent SF Symbols and text from being mirrored. Guard isMouseInsideSidebar updates to only publish when the value changes. --- .../Components/Sidebar/Menu/SidebarMenu.swift | 29 ++++++++++++------- .../HoverSidebarManager.swift | 5 +++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Nook/Components/Sidebar/Menu/SidebarMenu.swift b/Nook/Components/Sidebar/Menu/SidebarMenu.swift index 195238f3..6d89ebb5 100644 --- a/Nook/Components/Sidebar/Menu/SidebarMenu.swift +++ b/Nook/Components/Sidebar/Menu/SidebarMenu.swift @@ -40,21 +40,30 @@ struct SidebarMenu: View { var body: some View { HStack(alignment: .center, spacing: 0) { - tabs - VStack { - switch selectedTab { - case .history: - SidebarMenuHistoryTab() - case .downloads: - SidebarMenuDownloadsTab() - } + if nookSettings.sidebarPosition == .left { + tabs + content + } else { + content + tabs } - .frame(maxWidth: .infinity, maxHeight: .infinity) } - .environment(\.layoutDirection, nookSettings.sidebarPosition == .left ? .leftToRight : .rightToLeft) .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/Managers/HoverSidebarManager/HoverSidebarManager.swift b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift index 68fbee0a..b75091cc 100644 --- a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift +++ b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift @@ -160,7 +160,10 @@ final class HoverSidebarManager: ObservableObject { inSidebarContentZone = (mouse.x >= rightEdge - overlayWidth) && (mouse.x <= rightEdge) } - isMouseInsideSidebar = inSidebarContentZone && verticalOK + 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 { From 127a7bd09f804f8e672d71177af999986eca78f8 Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea Date: Tue, 3 Mar 2026 16:16:59 +0000 Subject: [PATCH 7/9] Quit Fixes: correct quitting behaviour when 'warn' is false, improved keyboard input, closer visual design (#329) * fix: allow quitting with Cmd+Q when "warn before quitting" is disabled Replace the "Force Quit App" menu item with a standard Cmd+Q that routes through showQuitDialog(). When "warn before quitting" is off, return .terminateNow immediately to avoid a deadlock in applicationShouldTerminate. * fix: prevent keyboard/mouse interaction with WebView while dialog is open and improve quit dialog design Resign WebView as first responder when a dialog appears so Enter/Escape reach dialog buttons. Disable hit testing on the WebView during dialog display. Add an NSEvent monitor to block Tab key and play the system beep. Update quit dialog copy and icon size. Add customIcon support to DialogButton with a KeycapLabel view for text-based shortcut hints. Simplify duplicate left-button branch in DialogFooter. * chore: trim verbose comments to match project style * fix: address review feedback on dialog focus and actor isolation Remove overly narrow WKWebView type check when resigning first responder. Drop unnecessary nonisolated(unsafe) from tabKeyMonitor. --------- Co-authored-by: AllDaGearNoIdea --- App/AppDelegate.swift | 17 ++- App/NookCommands.swift | 16 ++- Nook/Components/Dialog/DialogView.swift | 9 ++ Nook/Components/WebsiteView/WebsiteView.swift | 3 +- .../DialogManager/DialogManager.swift | 134 ++++++++++-------- 5 files changed, 105 insertions(+), 74 deletions(-) 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/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/WebsiteView/WebsiteView.swift b/Nook/Components/WebsiteView/WebsiteView.swift index 95d5c0b9..b1d4990a 100644 --- a/Nook/Components/WebsiteView/WebsiteView.swift +++ b/Nook/Components/WebsiteView/WebsiteView.swift @@ -220,7 +220,8 @@ struct WebsiteView: View { .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 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)) + } +} From 777f29cbab3716a410744bc378f13a5cc181230f Mon Sep 17 00:00:00 2001 From: Maciej Baginski Date: Fri, 27 Mar 2026 22:40:08 +0100 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?cursor=20reset=20on=20floating=20panel,=20@MainActor=20isolatio?= =?UTF-8?q?n,=20minor=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .alwaysArrowCursor(when:) to floating sidebar panel so web views underneath don't override the cursor - Mark HoverSidebarManager as @MainActor for compiler-enforced isolation - Simplify dead cornerRadius availability check in WebContent - Extract magic number 14 into named floatingInset constant - Add comment noting dual animation paths for sidebar position Co-Authored-By: Claude Opus 4.6 --- App/Window/WindowView.swift | 15 ++++++-------- Navigation/Sidebar/SpacesSideBarView.swift | 3 +++ .../HoverSidebarManager.swift | 20 +++++++++++-------- Nook/Utils/ForceArrowCursorView.swift | 11 ++++++++++ 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/App/Window/WindowView.swift b/App/Window/WindowView.swift index 6306fa2b..2daec2e0 100644 --- a/App/Window/WindowView.swift +++ b/App/Window/WindowView.swift @@ -229,10 +229,12 @@ struct WindowView: View { let shouldShow = isPinned || isFloatingVisible let onLeft = nookSettings.sidebarPosition == .left // Slide offset: push sidebar fully off-screen in the appropriate direction + // Total floating inset = 7pt padding × 2 sides (horizontal padding around the floating panel) + let floatingInset: CGFloat = 14 let slideOffset: CGFloat = { if isPinned || isFloatingVisible { return 0 } - // Slide out to the left or right edge - return onLeft ? -(windowState.sidebarWidth + 14) : (windowState.sidebarWidth + 14) + // Slide out to the left or right edge (sidebar width + both sides of floating padding) + return onLeft ? -(windowState.sidebarWidth + floatingInset) : (windowState.sidebarWidth + floatingInset) }() ZStack(alignment: onLeft ? .leading : .trailing) { @@ -279,6 +281,7 @@ struct WindowView: View { SpacesSideBarView() .frame(width: windowState.sidebarWidth) .frame(maxHeight: .infinity) + .alwaysArrowCursor(when: !isPinned) .overlay(alignment: resizeHandleAlignment) { SidebarResizeView() .frame(maxHeight: .infinity) @@ -311,13 +314,7 @@ struct WindowView: View { @ViewBuilder private func WebContent() -> some View { - let cornerRadius: CGFloat = { - if #available(macOS 26.0, *) { - return 8 - } else { - return 8 - } - }() + let cornerRadius: CGFloat = 8 let hasTopBar = nookSettings.topBarAddressView diff --git a/Navigation/Sidebar/SpacesSideBarView.swift b/Navigation/Sidebar/SpacesSideBarView.swift index ec23ede9..7aed360e 100644 --- a/Navigation/Sidebar/SpacesSideBarView.swift +++ b/Navigation/Sidebar/SpacesSideBarView.swift @@ -260,6 +260,9 @@ struct SpacesSideBarView: View { Divider() + // Note: The same .smooth(duration: 0.3) animation is also applied in + // Appearance.swift's sidebar position picker — both entry points animate + // consistently. Menu { ForEach(SidebarPosition.allCases) { position in Toggle(isOn: Binding( diff --git a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift index b75091cc..8763ef46 100644 --- a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift +++ b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift @@ -11,6 +11,11 @@ import AppKit /// Manages reveal/hide of the overlay sidebar when the real sidebar is collapsed. /// Uses a global mouse-move monitor to handle edge hover, including slight overshoot /// beyond the window's left boundary. +/// +/// All state reads/writes (isOverlayVisible, peekUntil, isMouseInsideSidebar) happen on +/// the main actor — either from SwiftUI onChange callbacks or DispatchQueue.main callbacks. +/// Marking the class @MainActor makes this guarantee compiler-enforced. +@MainActor final class HoverSidebarManager: ObservableObject { // MARK: - Published State @Published var isOverlayVisible: Bool = false @@ -46,13 +51,14 @@ final class HoverSidebarManager: ObservableObject { withAnimation(.easeInOut(duration: 0.15)) { isOverlayVisible = true } - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) 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() + self.handleMouseMovementOnMain() } } } @@ -87,20 +93,18 @@ final class HoverSidebarManager: ObservableObject { isActive = false if let token = localMonitor { NSEvent.removeMonitor(token); localMonitor = nil } if let token = globalMonitor { NSEvent.removeMonitor(token); globalMonitor = nil } - DispatchQueue.main.async { [weak self] in self?.isOverlayVisible = false } + isOverlayVisible = false } - deinit { stop() } - // MARK: - Mouse Logic - private func scheduleHandleMouseMovement() { - // Ensure main-actor work since we touch NSApp/window and main-actor BrowserManager + private nonisolated func scheduleHandleMouseMovement() { + // Event monitors fire on the main thread but aren't formally MainActor-isolated, + // so dispatch back to MainActor explicitly. DispatchQueue.main.async { [weak self] in self?.handleMouseMovementOnMain() } } - @MainActor private func handleMouseMovementOnMain() { guard let bm = browserManager, let registry = windowRegistry, diff --git a/Nook/Utils/ForceArrowCursorView.swift b/Nook/Utils/ForceArrowCursorView.swift index 1edac1ff..a2969c30 100644 --- a/Nook/Utils/ForceArrowCursorView.swift +++ b/Nook/Utils/ForceArrowCursorView.swift @@ -66,4 +66,15 @@ extension View { func alwaysArrowCursor() -> some View { self.overlay(ForceArrowCursorView().allowsHitTesting(false)) } + + /// Conditionally ensures the arrow cursor while hovering this view's visual bounds. + /// Useful for the floating sidebar where a web view underneath may set a different cursor. + @ViewBuilder + func alwaysArrowCursor(when condition: Bool) -> some View { + if condition { + self.overlay(ForceArrowCursorView().allowsHitTesting(false)) + } else { + self + } + } } From 9eb48a8a4b7debdf046d27ae22deafc8034450f2 Mon Sep 17 00:00:00 2001 From: Maciej Baginski Date: Fri, 27 Mar 2026 22:47:07 +0100 Subject: [PATCH 9/9] =?UTF-8?q?Revert=20"fix:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20cursor=20reset=20on=20floating=20panel,=20@MainActo?= =?UTF-8?q?r=20isolation,=20minor=20nits"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 777f29cbab3716a410744bc378f13a5cc181230f. --- App/Window/WindowView.swift | 15 ++++++++------ Navigation/Sidebar/SpacesSideBarView.swift | 3 --- .../HoverSidebarManager.swift | 20 ++++++++----------- Nook/Utils/ForceArrowCursorView.swift | 11 ---------- 4 files changed, 17 insertions(+), 32 deletions(-) diff --git a/App/Window/WindowView.swift b/App/Window/WindowView.swift index 2daec2e0..6306fa2b 100644 --- a/App/Window/WindowView.swift +++ b/App/Window/WindowView.swift @@ -229,12 +229,10 @@ struct WindowView: View { let shouldShow = isPinned || isFloatingVisible let onLeft = nookSettings.sidebarPosition == .left // Slide offset: push sidebar fully off-screen in the appropriate direction - // Total floating inset = 7pt padding × 2 sides (horizontal padding around the floating panel) - let floatingInset: CGFloat = 14 let slideOffset: CGFloat = { if isPinned || isFloatingVisible { return 0 } - // Slide out to the left or right edge (sidebar width + both sides of floating padding) - return onLeft ? -(windowState.sidebarWidth + floatingInset) : (windowState.sidebarWidth + floatingInset) + // Slide out to the left or right edge + return onLeft ? -(windowState.sidebarWidth + 14) : (windowState.sidebarWidth + 14) }() ZStack(alignment: onLeft ? .leading : .trailing) { @@ -281,7 +279,6 @@ struct WindowView: View { SpacesSideBarView() .frame(width: windowState.sidebarWidth) .frame(maxHeight: .infinity) - .alwaysArrowCursor(when: !isPinned) .overlay(alignment: resizeHandleAlignment) { SidebarResizeView() .frame(maxHeight: .infinity) @@ -314,7 +311,13 @@ struct WindowView: View { @ViewBuilder private func WebContent() -> some View { - let cornerRadius: CGFloat = 8 + let cornerRadius: CGFloat = { + if #available(macOS 26.0, *) { + return 8 + } else { + return 8 + } + }() let hasTopBar = nookSettings.topBarAddressView diff --git a/Navigation/Sidebar/SpacesSideBarView.swift b/Navigation/Sidebar/SpacesSideBarView.swift index 7aed360e..ec23ede9 100644 --- a/Navigation/Sidebar/SpacesSideBarView.swift +++ b/Navigation/Sidebar/SpacesSideBarView.swift @@ -260,9 +260,6 @@ struct SpacesSideBarView: View { Divider() - // Note: The same .smooth(duration: 0.3) animation is also applied in - // Appearance.swift's sidebar position picker — both entry points animate - // consistently. Menu { ForEach(SidebarPosition.allCases) { position in Toggle(isOn: Binding( diff --git a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift index 8763ef46..b75091cc 100644 --- a/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift +++ b/Nook/Managers/HoverSidebarManager/HoverSidebarManager.swift @@ -11,11 +11,6 @@ import AppKit /// Manages reveal/hide of the overlay sidebar when the real sidebar is collapsed. /// Uses a global mouse-move monitor to handle edge hover, including slight overshoot /// beyond the window's left boundary. -/// -/// All state reads/writes (isOverlayVisible, peekUntil, isMouseInsideSidebar) happen on -/// the main actor — either from SwiftUI onChange callbacks or DispatchQueue.main callbacks. -/// Marking the class @MainActor makes this guarantee compiler-enforced. -@MainActor final class HoverSidebarManager: ObservableObject { // MARK: - Published State @Published var isOverlayVisible: Bool = false @@ -51,14 +46,13 @@ final class HoverSidebarManager: ObservableObject { withAnimation(.easeInOut(duration: 0.15)) { isOverlayVisible = true } - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + 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.handleMouseMovementOnMain() + self.scheduleHandleMouseMovement() } } } @@ -93,18 +87,20 @@ final class HoverSidebarManager: ObservableObject { isActive = false if let token = localMonitor { NSEvent.removeMonitor(token); localMonitor = nil } if let token = globalMonitor { NSEvent.removeMonitor(token); globalMonitor = nil } - isOverlayVisible = false + DispatchQueue.main.async { [weak self] in self?.isOverlayVisible = false } } + deinit { stop() } + // MARK: - Mouse Logic - private nonisolated func scheduleHandleMouseMovement() { - // Event monitors fire on the main thread but aren't formally MainActor-isolated, - // so dispatch back to MainActor explicitly. + private func scheduleHandleMouseMovement() { + // Ensure main-actor work since we touch NSApp/window and main-actor BrowserManager DispatchQueue.main.async { [weak self] in self?.handleMouseMovementOnMain() } } + @MainActor private func handleMouseMovementOnMain() { guard let bm = browserManager, let registry = windowRegistry, diff --git a/Nook/Utils/ForceArrowCursorView.swift b/Nook/Utils/ForceArrowCursorView.swift index a2969c30..1edac1ff 100644 --- a/Nook/Utils/ForceArrowCursorView.swift +++ b/Nook/Utils/ForceArrowCursorView.swift @@ -66,15 +66,4 @@ extension View { func alwaysArrowCursor() -> some View { self.overlay(ForceArrowCursorView().allowsHitTesting(false)) } - - /// Conditionally ensures the arrow cursor while hovering this view's visual bounds. - /// Useful for the floating sidebar where a web view underneath may set a different cursor. - @ViewBuilder - func alwaysArrowCursor(when condition: Bool) -> some View { - if condition { - self.overlay(ForceArrowCursorView().allowsHitTesting(false)) - } else { - self - } - } }