Skip to content
Merged
17 changes: 8 additions & 9 deletions App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
16 changes: 9 additions & 7 deletions App/NookCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down
169 changes: 132 additions & 37 deletions App/Window/WindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ struct WindowView: View {

SidebarWebViewStack()

// Hover-reveal Sidebar overlay (slides in over web content)
SidebarHoverOverlayView()
.environmentObject(hoverSidebarManager)
.environment(windowState)

CommandPaletteView()
DialogView()

Expand Down Expand Up @@ -112,6 +107,7 @@ struct WindowView: View {
hoverSidebarManager.windowRegistry = windowRegistry
hoverSidebarManager.nookSettings = nookSettings
hoverSidebarManager.start()
windowState.hoverSidebarManager = hoverSidebarManager
}
.onDisappear {
hoverSidebarManager.stop()
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Navigation/Sidebar/SidebarHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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())
Expand Down
6 changes: 5 additions & 1 deletion Navigation/Sidebar/SpacesSideBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 9 additions & 0 deletions Nook/Components/Dialog/DialogView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import WebKit

struct DialogView: View {
@EnvironmentObject var browserManager: BrowserManager
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions Nook/Components/Settings/Tabs/Appearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 17 additions & 12 deletions Nook/Components/Sidebar/Menu/SidebarMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion Nook/Components/Sidebar/NavButtonsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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())
Expand Down
Loading