```
├── .github/
├── workflows/
├── enforce-pr-base.yml (100 tokens)
├── macos-notarize.yml (900 tokens)
├── .gitignore (500 tokens)
├── CODE_OF_CONDUCT.md (400 tokens)
├── CONTRIBUTING.md (800 tokens)
├── LICENSE (omitted)
├── Nook.xcodeproj/
├── project.pbxproj (3.3k tokens)
├── project.xcworkspace/
├── contents.xcworkspacedata
├── xcshareddata/
├── WorkspaceSettings.xcsettings
├── swiftpm/
├── Package.resolved (200 tokens)
├── xcshareddata/
├── xcschemes/
├── Nook.xcscheme (700 tokens)
├── Nook/
├── Adapters/
├── TabListAdapter.swift (2.9k tokens)
├── Assets.xcassets/
├── AccentColor.colorset/
├── Contents.json
├── Contents.json
├── noise_texture.imageset/
├── Contents.json (100 tokens)
├── noise_texture.png
├── noise_texture@2x.png
├── noise_texture@3x.png
├── Components/
├── Browser/
├── Window/
├── SpaceGradientBackgroundView.swift (500 tokens)
├── SplitDropCaptureView.swift (700 tokens)
├── TabCompositorView.swift (1200 tokens)
├── WindowBackgroundView.swift (400 tokens)
├── WindowView.swift (3k tokens)
├── ColorPicker/
├── AngleDial.swift (400 tokens)
├── ColorPickerView.swift (800 tokens)
├── ColorSwatchRowView.swift (700 tokens)
├── GradientCanvasEditor.swift (5.3k tokens)
├── GradientEditorView.swift (700 tokens)
├── GradientNodePicker.swift (1200 tokens)
├── GradientPreview.swift (300 tokens)
├── GrainDial.swift (400 tokens)
├── GrainSlider.swift (600 tokens)
├── TransparencySlider.swift (800 tokens)
├── CommandPalette/
├── CommandPaletteSuggestionView.swift (900 tokens)
├── CommandPaletteView.swift (3.7k tokens)
├── GenericSuggestionItem.swift (200 tokens)
├── HistorySuggestionItem.swift (800 tokens)
├── MiniCommandPaletteView.swift (2.2k tokens)
├── TabSuggestionItem.swift (500 tokens)
├── Dialog/
├── DialogView.swift (300 tokens)
├── DragDrop/
├── DragDropPreview.swift (5k tokens)
├── DragEnabledSidebarView.swift (200 tokens)
├── DraggableTabView.swift (1000 tokens)
├── InsertionLineView.swift (500 tokens)
├── SidebarDropSupport.swift (3.9k tokens)
├── SimpleDnD.swift (2000 tokens)
├── SimpleDragPreview.swift (7.5k tokens)
├── TabDragContainerView.swift (1100 tokens)
├── EmojiPicker/
├── EmojiPicker.swift (1700 tokens)
├── Extensions/
├── ExtensionActionView.swift (600 tokens)
├── ExtensionPermissionView.swift (2000 tokens)
├── PersistentPopover.swift (600 tokens)
├── PopupConsoleWindow.swift (1000 tokens)
├── FindBar/
├── FindBarView.swift (800 tokens)
├── MiniWindow/
├── MiniWindowButtonStyle.swift (900 tokens)
├── MiniWindowToolbar.swift (2.4k tokens)
├── MiniWindowWebView.swift (3.5k tokens)
├── Navigation/
├── HoldGestureButton.swift (400 tokens)
├── NavigationHistoryContextMenu.swift (1100 tokens)
├── NavigationHistoryMenu.swift (1300 tokens)
├── NavigationHistoryOverlay.swift (500 tokens)
├── Peek/
├── PeekOverlayView.swift (2.6k tokens)
├── PulseTextField/
├── PulseTextField.swift (600 tokens)
├── Settings/
├── CacheDetailsView.swift (1800 tokens)
├── CacheManagementView.swift (2.9k tokens)
├── CookieDetailsView.swift (1400 tokens)
├── CookieManagementView.swift (2.6k tokens)
├── PrivacySettingsView.swift (3k tokens)
├── ProfilePickerView.swift (900 tokens)
├── ProfileRowView.swift (800 tokens)
├── SettingsTabBar.swift (100 tokens)
├── SettingsUtils.swift (200 tokens)
├── SettingsView.swift (12.3k tokens)
├── ShortcutRecorderView.swift (1100 tokens)
├── Sidebar/
├── AIChat/
├── AISidebarResizeView.swift (800 tokens)
├── SidebarAIChat.swift (14.4k tokens)
├── FallbackDropBelowEssentialsModifier.swift (300 tokens)
├── MediaControls/
├── MediaControlsView.swift (2000 tokens)
├── Menu/
├── DownloadIndicator.swift (400 tokens)
├── SidebarMenu.swift (700 tokens)
├── SidebarMenuDownloadsHover.swift (1800 tokens)
├── SidebarMenuDownloadsTab.swift (1800 tokens)
├── SidebarMenuHistoryTab.swift (4.9k tokens)
├── SidebarMenuTab.swift (300 tokens)
├── NavButtonsView.swift (1700 tokens)
├── NewTabButton.swift (400 tokens)
├── PinnedButtons/
├── EssentialTabsScrollView.swift (600 tokens)
├── EssentialsGridLayout.swift (100 tokens)
├── PinnedGrid.swift (1600 tokens)
├── PinnedTabView.swift (900 tokens)
├── SidebarHoverOverlayView.swift (600 tokens)
├── SidebarResizeView.swift (800 tokens)
├── SidebarView.swift (4.3k tokens)
├── SpaceSection/
├── SpaceProfileBadge.swift (500 tokens)
├── SpaceProfileDropdown.swift (400 tokens)
├── SpaceSeparator.swift (200 tokens)
├── SpaceTab.swift (1900 tokens)
├── SpaceTitle.swift (2.3k tokens)
├── SpaceView.swift (6.6k tokens)
├── SpacesList/
├── SpacesList.swift (600 tokens)
├── SpacesListItem.swift (1100 tokens)
├── SplitTabRow.swift (1100 tokens)
├── TabFolderView.swift (3.3k tokens)
├── TabClosureToast/
├── TabClosureToast.swift (500 tokens)
├── TopBar/
├── TopBarCommandPalette.swift (1800 tokens)
├── TopBarView.swift (2.2k tokens)
├── URLBarFramePreferenceKey.swift
├── URLBarView.swift (900 tokens)
├── UpdateNotification/
├── SidebarUpdateNotification.swift (1000 tokens)
├── SidebarUpdateNotificationPreview.swift (600 tokens)
├── WebsitePopup/
├── OAuthAssistBanner.swift (400 tokens)
├── WebsitePopup.swift (200 tokens)
├── WebsiteView/
├── EmptyWebsiteView.swift (300 tokens)
├── WebView.swift (2000 tokens)
├── WebsiteLoadingIndicator.swift (300 tokens)
├── WebsiteView.swift (4.7k tokens)
├── Window/
├── DoubleClickView.swift (300 tokens)
├── MacButtons.swift (1100 tokens)
├── ZoomControls/
├── ZoomPopupView.swift (900 tokens)
├── ContentView.swift (600 tokens)
├── Info.plist (200 tokens)
├── Info.plist.bak
├── Managers/
├── AuthenticationManager/
├── AuthenticationManager.swift (1700 tokens)
├── BasicAuthCredentialStore.swift (800 tokens)
├── BrowserManager/
├── BrowserManager.swift (23k tokens)
├── CacheManager/
├── CacheManager.swift (2.9k tokens)
├── CookieManager/
├── CookieManager.swift (2.1k tokens)
├── DialogManager/
├── DialogManager.swift (2.4k tokens)
├── Dialogs/
├── BasicAuthDialog.swift (700 tokens)
├── BrowserImportDialog.swift (300 tokens)
├── ProfileCreationDialog.swift (1100 tokens)
├── ProfileDeleteConfirmationDialog.swift (500 tokens)
├── ProfileRenameDialog.swift (700 tokens)
├── SettingsDialog.swift (600 tokens)
├── SpaceCreationDialog.swift (800 tokens)
├── SpaceEditDialog.swift (1200 tokens)
├── DownloadManager/
├── DownloadManager.swift (4.3k tokens)
├── DragManager/
├── DragLockManager.swift (500 tokens)
├── TabDragManager.swift (1900 tokens)
├── ExtensionManager/
├── ExtensionBridge.swift (1400 tokens)
├── ExtensionManager.swift (19.8k tokens)
├── README-URLSchemeHandler.md
├── ExternalMiniWindowManager/
├── ExternalMiniWindowManager.swift (1500 tokens)
├── MiniBrowserWindowView.swift (600 tokens)
├── FindManager/
├── FindManager.swift (800 tokens)
├── GradientColorManager/
├── GradientColorManager.swift (500 tokens)
├── HistoryManager/
├── HistoryManager.swift (2.3k tokens)
├── HoverSidebarManager/
├── HoverSidebarManager.swift (1000 tokens)
├── ImportManager/
├── Arc.swift (2.8k tokens)
├── ImportManager.swift (200 tokens)
├── KeyboardShortcutManager/
├── KeyboardShortcutManager.swift (2.1k tokens)
├── MediaControlsManager/
├── MediaControlsManager.swift (1900 tokens)
├── PeekManager/
├── PeekManager.swift (800 tokens)
├── PeekSession.swift (300 tokens)
├── PeekWebView.swift (1700 tokens)
├── PiPManager.swift (1000 tokens)
├── PrivacyManager/
├── TrackingProtectionManager.swift (2.2k tokens)
├── ProfileManager/
├── ProfileManager.swift (800 tokens)
├── SearchManager/
├── SearchManager.swift (2.3k tokens)
├── Utils.swift (700 tokens)
├── SettingsManager/
├── SettingsManager.swift (2.4k tokens)
├── SettingsManagerUtils.swift (200 tokens)
├── SplitViewManager/
├── SplitViewManager.swift (2.6k tokens)
├── TabManager/
├── TabManager.swift (20.3k tokens)
├── ZoomManager/
├── ZoomManager.swift (1200 tokens)
├── Models/
├── BrowserConfig/
├── BrowserConfig.swift (1500 tokens)
├── BrowserWindowState.swift (800 tokens)
├── Cache/
├── CacheModels.swift (2.4k tokens)
├── Cookie/
├── CookieModels.swift (1300 tokens)
├── Extension/
├── ExtensionModels.swift (500 tokens)
├── History/
├── HistoryEntity.swift (200 tokens)
├── KeyboardShortcut/
├── KeyboardShortcut.swift (2.2k tokens)
├── Profile/
├── Profile.swift (900 tokens)
├── ProfileEntity.swift (100 tokens)
├── Settings/
├── NewDocumentTarget.swift (100 tokens)
├── Space/
├── GradientNode.swift (200 tokens)
├── Space.swift (200 tokens)
├── SpaceGradient.swift (2000 tokens)
├── SpaceModels.swift (200 tokens)
├── Tab/
├── Tab.swift (24.9k tokens)
├── TabFolder.swift (200 tokens)
├── TabsModel.swift (500 tokens)
├── Nook.entitlements (100 tokens)
├── NookApp.swift (4.4k tokens)
├── Protocols/
├── TabListDataSource.swift (200 tokens)
├── Supporting Files/
├── Nook-Bridging-Header.h
├── ThirdParty/
├── BigUIPaging/
├── Examples/
├── CardDeckExample.swift (300 tokens)
├── CustomPageViewExample.swift (300 tokens)
├── ExampleGridToPageView.swift (1200 tokens)
├── PageIndicatorExample.swift (800 tokens)
├── PageViewBasicExample.swift (400 tokens)
├── PageViewForEachExample.swift (100 tokens)
├── PageViewNavigationStackExample.swift (900 tokens)
├── Implementations/
├── PageIndicator/
├── PageIndicator+Environment.swift (600 tokens)
├── PageIndicator+iOS.swift (800 tokens)
├── PageIndicator.swift (1100 tokens)
├── PageView/
├── Platform/
├── PlatformPageView+iOS.swift (1900 tokens)
├── PlatformPageView+macOS.swift (1900 tokens)
├── PlatformPageView.swift (100 tokens)
├── Styles/
├── BookPageViewStyle.swift (300 tokens)
├── BookStackPageViewStyle.swift (200 tokens)
├── CardDeckPageViewStyle.swift (1400 tokens)
├── HistoryStackPageViewStyle.swift (200 tokens)
├── PlainPageViewStyle.swift (200 tokens)
├── PlatformPageViewStyle.swift (100 tokens)
├── ScrollPageViewStyle.swift (200 tokens)
├── Types/
├── PageViewDirection.swift (100 tokens)
├── PageViewNavigateAction.swift (200 tokens)
├── PageViewStyle.swift (600 tokens)
├── PageViewStyleConfiguration.swift (500 tokens)
├── View/
├── PageView+Environment.swift (600 tokens)
├── PageView+Preferences.swift (100 tokens)
├── PageView.swift (2.3k tokens)
├── PageViewEnvironmentModifier.swift (200 tokens)
├── PageViewNavigationButton.swift (500 tokens)
├── Utils/
├── View+Inspect.swift (800 tokens)
├── View+Measure.swift (100 tokens)
├── README.md (2000 tokens)
├── HTSymbolHook/
├── HTSymbolHook.h (200 tokens)
├── HTSymbolHook.m (1200 tokens)
├── README.md (200 tokens)
├── mach_override/
├── .gitignore
├── README.markdown (800 tokens)
├── Rakefile (100 tokens)
├── libudis86/
├── decode.c (5.4k tokens)
├── decode.h (1100 tokens)
├── extern.h (700 tokens)
├── input.c (1100 tokens)
├── input.h (600 tokens)
├── itab.c (63.4k tokens)
├── itab.h (2.1k tokens)
├── syn-att.c (1200 tokens)
├── syn-intel.c (1300 tokens)
├── syn.c (1200 tokens)
├── syn.h (400 tokens)
├── types.h (1500 tokens)
├── udint.h (500 tokens)
├── udis86.c (1900 tokens)
├── mach_override.c (5k tokens)
├── mach_override.h (600 tokens)
├── udis86.h (300 tokens)
├── MuteableWKWebView/
├── Info.plist (200 tokens)
├── MethodSwizzler.h
├── MethodSwizzler.m (200 tokens)
├── MuteableWKWebView.h (100 tokens)
├── MuteableWKWebView.m (500 tokens)
├── MuteableWKWebViewPrivate.h (100 tokens)
├── README.md (200 tokens)
├── SearchSymbol.h (100 tokens)
├── SearchSymbol.m (300 tokens)
├── getPage.c (300 tokens)
├── getPage.h (100 tokens)
├── Utils/
├── BarycentricGradientView.swift (1400 tokens)
├── BlurEffectView.swift (100 tokens)
├── BlurModifier.swift (200 tokens)
├── Colors.swift (900 tokens)
├── Debug/
├── NavigationRootCauseAnalysis.md (1900 tokens)
├── DitheringUtils.swift (4.2k tokens)
├── DragWindowView.swift (100 tokens)
├── ExtensionUtils.swift (500 tokens)
├── ForceArrowCursorView.swift (400 tokens)
├── GradientInterpolation.swift (500 tokens)
├── PasteboardTypes.swift
├── Shaders/
├── BarycentricShaders.metal (800 tokens)
├── WebKit/
├── FocusableWKWebView.swift (1700 tokens)
├── WebStoreDownloader.swift (1300 tokens)
├── WebStoreInjector.js (1300 tokens)
├── WebStoreScriptHandler.swift (700 tokens)
├── WebViewThemeColorExtension.swift (800 tokens)
├── WindowUtils.swift (700 tokens)
├── logo-dev.icon/
├── Assets/
├── Grid.png
├── Light Mode.svg (1100 tokens)
├── icon.json (400 tokens)
├── logo.icon/
├── Assets/
├── Light Mode.svg (1100 tokens)
├── icon.json (300 tokens)
├── README.md (1200 tokens)
├── UI/
├── Buttons/
├── NavButtons/
├── NavButton.swift (700 tokens)
├── NavMenuStyle.swift (800 tokens)
├── NookButton/
├── NookButtonStyle.swift (1900 tokens)
├── assets/
├── icon.png
```
## /.github/workflows/enforce-pr-base.yml
```yml path="/.github/workflows/enforce-pr-base.yml"
name: Enforce PR base branch
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
check-branch:
runs-on: ubuntu-latest
steps:
- name: Fail if PR targets main
if: github.event.pull_request.base.ref == 'main'
run: |
echo "PRs must target dev, not main."
exit 1
```
## /.github/workflows/macos-notarize.yml
```yml path="/.github/workflows/macos-notarize.yml"
name: macOS Build, Sign, & Notarize
on:
push:
tags:
- 'v*'
release:
types: [published]
jobs:
build-sign-notarize:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Import Developer ID certificate
id: import-cert
run: |
printf "%s" "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > signing_certificate.p12
security create-keychain -p "" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
security import signing_certificate.p12 \
-k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security list-keychains -d user -s build.keychain login.keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" build.keychain
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -n1 | awk '{print $2}')
echo "SIGNING_IDENTITY=$IDENTITY" >> $GITHUB_ENV
echo "Imported certificate and set up keychain successfully."
- name: Build app
run: |
set -e
mkdir -p build
echo "Attempting universal build (arm64 + x86_64)..."
if ! xcodebuild -scheme Nook -configuration Release -arch arm64 -arch x86_64 -derivedDataPath build; then
echo "Universal build failed, retrying Apple Silicon only..."
xcodebuild -scheme Nook -configuration Release -arch arm64 -derivedDataPath build
fi
cp -R "build/Build/Products/Release/Nook.app" ./Nook.app
- name: Codesign app
run: |
codesign --deep --force --verify --verbose \
--options runtime \
--entitlements Nook/entitlements.plist \
--sign "$SIGNING_IDENTITY" \
"Nook.app"
- name: Verify code signature
run: |
codesign --verify --deep --strict --verbose=2 "Nook.app"
spctl --assess --type execute --verbose "Nook.app"
- name: Notarize app
env:
NOTARY_PROFILE: "nook-notary"
run: |
xcrun notarytool store-credentials "$NOTARY_PROFILE" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD"
zip -r Nook.zip "Nook.app"
xcrun notarytool submit "Nook.zip" \
--keychain-profile "$NOTARY_PROFILE" \
--wait
- name: Staple notarization ticket
run: xcrun stapler staple "Nook.app"
- name: Create DMG
run: |
VERSION=${GITHUB_REF#refs/tags/}
hdiutil create -volname "Nook ${VERSION}" \
-srcfolder "Nook.app" \
-ov -format UDZO "Nook-${VERSION}.dmg"
- name: Upload DMG to release assets
uses: softprops/action-gh-release@v2
with:
files: Nook-*.dmg
fail_on_unmatched_files: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update Sparkle appcast
if: github.event_name == 'release'
run: |
if [ ! -f appcast.xml ]; then
echo "appcast.xml not found, skipping Sparkle update"
exit 0
fi
VERSION=${GITHUB_REF#refs/tags/}
DMG_URL="https://github.com/${{ github.repository }}/releases/download/${VERSION}/Nook-${VERSION}.dmg"
DATE=$(date -R)
SHORT_VERSION=${VERSION#v}
ENTRY=$(cat <<EOF
<item>
<title>Version ${SHORT_VERSION}</title>
<sparkle:releaseNotesLink>https://github.com/${{ github.repository }}/releases/tag/${VERSION}</sparkle:releaseNotesLink>
<pubDate>${DATE}</pubDate>
<enclosure url="${DMG_URL}" sparkle:version="${SHORT_VERSION}" length="$(stat -f%z Nook-${VERSION}.dmg)" type="application/octet-stream"/>
</item>
EOF
)
git fetch origin gh-pages
git checkout gh-pages
sed -i '' "s|</channel>|${ENTRY}\n</channel>|" appcast.xml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add appcast.xml
git commit -m "Add release ${VERSION} to appcast"
git push origin gh-pages
```
## /.gitignore
```gitignore path="/.gitignore"
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
sessions-config.json
.claude
Nook/.claude
.crush
/sessions
## User settings
xcuserdata/
# Xcode nonsense
# *.pbxproj
# macOS garbage
.DS_Store
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
'timeline.xctimeline'
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
# IDE
.vscode/
# Xcode build junk
DerivedData/
*.xcresult
*.log
*.profraw
```
## /CODE_OF_CONDUCT.md
# Code of Conduct
## Our Commitment
We are committed to providing a welcoming and inclusive environment for all contributors to Nook, regardless of experience level, background, or identity.
Nook is built by volunteers who contribute their time and expertise out of passion for creating great software. We appreciate everyone who takes time to contribute to making Nook better.
## Expected Behavior
- Be respectful, welcoming, and considerate in all interactions
- Discuss ideas, give/receive constructive criticism gracefully
- Focus on what's best for the community and project
- Show empathy toward other community members
- Help create a positive environment for learning and collaboration
## Unacceptable Behavior
- Harassment, discrimination, or intimidation of any kind
- Offensive, derogatory, or discriminatory comments
- Personal attacks or trolling
- Publishing others' private information without permission
- Spam or off-topic discussions
- Any conduct that would be inappropriate in a professional setting
## Scope
This Code of Conduct applies to all project spaces, including:
- GitHub repository (issues, PRs, discussions)
- Discord community
- Any other official Nook communication channels
## Enforcement
Project maintainers are responsible for clarifying standards and will take appropriate action in response to violations. This may include:
- Warning the individual
- Temporary or permanent ban from project spaces (GitHub, Discord, etc.)
- Reporting to appropriate platforms (GitHub, Discord, etc.)
## Reporting
If you experience or witness unacceptable behavior, please report it by:
- Opening a GitHub issue (for public matters)
- Contacting maintainers directly on Discord
All reports will be handled confidentially and reviewed promptly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
---
By participating in the Nook community, you agree to abide by this Code of Conduct.
## /CONTRIBUTING.md
# Contributing to Nook
Thank you for your interest in contributing to Nook! This document provides guidelines and instructions for contributing to the project.
## AI Assistance Notice
If you are using any kind of AI assistance to contribute, this must be disclosed in your pull request, commit, or issue, including the extent to which AI assistance was used (e.g. docs only vs. code generation). If PR responses, commit messages, or comments are being generated by an AI, please disclose that as well.
As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases.
When using AI assistance, we expect contributors to understand the code that is produced and be able to answer critical questions about it. It isn't a maintainer's job to review a PR so broken that it requires significant rework to be acceptable.
Please be respectful to maintainers and disclose AI assistance.
## 🚀 Development Setup
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/Nook.git
cd Nook
```
2. **Switch to the dev branch**
```bash
git checkout dev
```
3. **Open in Xcode**
```bash
open Nook.xcodeproj
```
4. **Build and run**
- Select your target device/simulator
- Press `Cmd + R` to build and run
## 📝 Code Style & Standards
### Conventions
- Follow existing SwiftUI/AppKit patterns in the codebase
- Use the established file organization structure
- Prefer modern Swift/SwiftUI APIs when possible
### Compatibility & OS Versions
- **Maintain backward compatibility**: The current minimum deployment target is macOS 15.5
- **New features requiring newer OS versions** should use `@available` or `#if available` checks to preserve compatibility
- **Discuss before raising minimum version**: If a feature would significantly benefit from raising the minimum OS version, open an issue to discuss the trade-offs before implementation
## 📋 Pull Request Process
### Before You Start
- **Check for duplicates**: Search existing issues and PRs to ensure no one is already working on the same feature or bug
- **Avoid duplicate work**: Comment on relevant issues to indicate you're working on them
- **For major changes**, consider opening an issue first to discuss the approach
### Creating Pull Requests
**IMPORTANT**: All development work must be done on the `dev` branch:
1. **Create your branch from `dev`**, not `main`:
```bash
git checkout dev
git pull origin dev
git checkout -b feature/your-feature-name
```
2. **Make your changes** and commit them with clear, descriptive messages
3. **Push to your fork**:
```bash
git push origin feature/your-feature-name
```
4. **Open a Pull Request to the `dev` branch**, not `main`
- Provide a clear description of the changes
- Reference any related issues
- Include screenshots or videos for UI changes
- Disclose any AI assistance used (see AI Assistance Notice above)
5. **Respond to review feedback** promptly and constructively
## 🐛 Issue Reporting
### Bug Reports
Be descriptive and thorough. Include:
- **macOS version**
- **Nook version**
- **Clear steps to reproduce the issue**
- **Expected vs actual behavior**
- **Screenshots if applicable**
- **Relevant console/error messages**
### Feature Requests
Provide detailed context. Include:
- **Clear description of the feature and its use case**
- **Explain how it fits with Nook's goals** (fast, secure, beautiful)
- **Consider implementation complexity and user impact**
- **Check that similar functionality doesn't already exist**
## 💬 Community Guidelines
- Be respectful and constructive
- Help maintain a welcoming environment for all contributors
- Focus on the code and ideas, not individuals
## License
By contributing to Nook, you agree that your contributions will be licensed under the GPL-3.0 License.
## /Nook.xcodeproj/project.pbxproj
```pbxproj path="/Nook.xcodeproj/project.pbxproj"
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
2C16A0262E87430B0070894B /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 2C16A0252E87430B0070894B /* Sparkle */; };
564997D82E9B24CC00D89F78 /* Garnish in Frameworks */ = {isa = PBXBuildFile; productRef = 564997D72E9B24CC00D89F78 /* Garnish */; };
7FAFC5DA2E3ADDCD009D7DC4 /* FaviconFinder in Frameworks */ = {isa = PBXBuildFile; productRef = 7FAFC5D92E3ADDCD009D7DC4 /* FaviconFinder */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
7F8340FC2E37F39400674A5D /* Nook.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nook.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
2CAC4DD12E82457B00870189 /* Exceptions for "Nook" folder in "Nook" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
ThirdParty/BigUIPaging/README.md,
ThirdParty/HTSymbolHook/README.md,
ThirdParty/MuteableWKWebView/README.md,
);
target = 7F8340FB2E37F39400674A5D /* Nook */;
};
564997D22E9B125200D89F78 /* Exceptions for "UI" folder in "Nook" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Buttons/NavButtons/NavMenuStyle.swift,
);
target = 7F8340FB2E37F39400674A5D /* Nook */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
2CAC4D282E82457B00870189 /* Nook */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
2CAC4DD12E82457B00870189 /* Exceptions for "Nook" folder in "Nook" target */,
);
path = Nook;
sourceTree = "<group>";
};
564997D02E9B125200D89F78 /* UI */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
564997D22E9B125200D89F78 /* Exceptions for "UI" folder in "Nook" target */,
);
path = UI;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
7F8340F92E37F39400674A5D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
564997D82E9B24CC00D89F78 /* Garnish in Frameworks */,
7FAFC5DA2E3ADDCD009D7DC4 /* FaviconFinder in Frameworks */,
2C16A0262E87430B0070894B /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
7F8340F32E37F39400674A5D = {
isa = PBXGroup;
children = (
7F8340FD2E37F39400674A5D /* Products */,
564997D02E9B125200D89F78 /* UI */,
2CAC4D282E82457B00870189 /* Nook */,
);
sourceTree = "<group>";
};
7F8340FD2E37F39400674A5D /* Products */ = {
isa = PBXGroup;
children = (
7F8340FC2E37F39400674A5D /* Nook.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
7F8340FB2E37F39400674A5D /* Nook */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7F8341082E37F39500674A5D /* Build configuration list for PBXNativeTarget "Nook" */;
buildPhases = (
7F8340F82E37F39400674A5D /* Sources */,
7F8340F92E37F39400674A5D /* Frameworks */,
7F8340FA2E37F39400674A5D /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
2CAC4D282E82457B00870189 /* Nook */,
564997D02E9B125200D89F78 /* UI */,
);
name = Nook;
packageProductDependencies = (
7FAFC5D92E3ADDCD009D7DC4 /* FaviconFinder */,
2C16A0252E87430B0070894B /* Sparkle */,
564997D72E9B24CC00D89F78 /* Garnish */,
);
productName = Pulse;
productReference = 7F8340FC2E37F39400674A5D /* Nook.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
7F8340F42E37F39400674A5D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 1640;
TargetAttributes = {
7F8340FB2E37F39400674A5D = {
CreatedOnToolsVersion = 16.4;
};
};
};
buildConfigurationList = 7F8340F72E37F39400674A5D /* Build configuration list for PBXProject "Nook" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 7F8340F32E37F39400674A5D;
minimizedProjectReferenceProxies = 1;
packageReferences = (
7FAFC5D82E3ADDCD009D7DC4 /* XCRemoteSwiftPackageReference "FaviconFinder" */,
2C16A0242E87430B0070894B /* XCRemoteSwiftPackageReference "Sparkle" */,
564997D62E9B24CC00D89F78 /* XCRemoteSwiftPackageReference "Garnish" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 7F8340FD2E37F39400674A5D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
7F8340FB2E37F39400674A5D /* Nook */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
7F8340FA2E37F39400674A5D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
7F8340F82E37F39400674A5D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
7F8341062E37F39500674A5D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 96M8ZZRJK6;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
7F8341072E37F39500674A5D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 96M8ZZRJK6;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
7F8341092E37F39500674A5D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "logo-dev";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Nook/Nook.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 103;
DEVELOPMENT_TEAM = 8JTS5XWJJN;
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9DLM793N9T;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Nook/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Nook;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0.3.2;
PRODUCT_BUNDLE_IDENTIFIER = io.browsewithnook.nook;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = YES;
RUNTIME_EXCEPTION_ALLOW_JIT = YES;
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;
RUNTIME_EXCEPTION_DEBUGGING_TOOL = YES;
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = YES;
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/Nook/Supporting Files/Nook-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
7F83410A2E37F39500674A5D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Nook/Nook.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 103;
DEVELOPMENT_TEAM = 8JTS5XWJJN;
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9DLM793N9T;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Nook/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Nook;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0.3.2;
PRODUCT_BUNDLE_IDENTIFIER = io.browsewithnook.nook;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = YES;
RUNTIME_EXCEPTION_ALLOW_JIT = YES;
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/Nook/Supporting Files/Nook-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
7F8340F72E37F39400674A5D /* Build configuration list for PBXProject "Nook" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7F8341062E37F39500674A5D /* Debug */,
7F8341072E37F39500674A5D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7F8341082E37F39500674A5D /* Build configuration list for PBXNativeTarget "Nook" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7F8341092E37F39500674A5D /* Debug */,
7F83410A2E37F39500674A5D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
2C16A0242E87430B0070894B /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.8.0;
};
};
564997D62E9B24CC00D89F78 /* XCRemoteSwiftPackageReference "Garnish" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Aeastr/Garnish";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.0.4;
};
};
7FAFC5D82E3ADDCD009D7DC4 /* XCRemoteSwiftPackageReference "FaviconFinder" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/will-lumley/FaviconFinder";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.1.4;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
2C16A0252E87430B0070894B /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 2C16A0242E87430B0070894B /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
564997D72E9B24CC00D89F78 /* Garnish */ = {
isa = XCSwiftPackageProductDependency;
package = 564997D62E9B24CC00D89F78 /* XCRemoteSwiftPackageReference "Garnish" */;
productName = Garnish;
};
7FAFC5D92E3ADDCD009D7DC4 /* FaviconFinder */ = {
isa = XCSwiftPackageProductDependency;
package = 7FAFC5D82E3ADDCD009D7DC4 /* XCRemoteSwiftPackageReference "FaviconFinder" */;
productName = FaviconFinder;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 7F8340F42E37F39400674A5D /* Project object */;
}
```
## /Nook.xcodeproj/project.xcworkspace/contents.xcworkspacedata
```xcworkspacedata path="/Nook.xcodeproj/project.xcworkspace/contents.xcworkspacedata"
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
```
## /Nook.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
```xcsettings path="/Nook.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
```
## /Nook.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
```resolved path="/Nook.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
{
"originHash" : "5742fa0d6f2df74b52078520f8ba6f8cafd469cf8227a012762a476c2a2f21d0",
"pins" : [
{
"identity" : "faviconfinder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/will-lumley/FaviconFinder",
"state" : {
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
"version" : "5.1.4"
}
},
{
"identity" : "garnish",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Aeastr/Garnish",
"state" : {
"revision" : "4c49a3f163473fcb1a90f6730999cbd2e3931a18",
"version" : "0.0.4"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "9a1d2a19d3595fcf8d9c447173f9a1687b3dcadb",
"version" : "2.8.0"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "a81b1a5ac933dee8c6cfccf05d5ffcc5eb8c7ec4",
"version" : "2.10.0"
}
}
],
"version" : 3
}
```
## /Nook.xcodeproj/xcshareddata/xcschemes/Nook.xcscheme
```xcscheme path="/Nook.xcodeproj/xcshareddata/xcschemes/Nook.xcscheme"
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7F8340FB2E37F39400674A5D"
BuildableName = "Nook.app"
BlueprintName = "Nook"
ReferencedContainer = "container:Nook.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2C8925302E88DFF9002236DC"
BuildableName = "NookUITests.xctest"
BlueprintName = "NookUITests"
ReferencedContainer = "container:Nook.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7F8340FB2E37F39400674A5D"
BuildableName = "Nook.app"
BlueprintName = "Nook"
ReferencedContainer = "container:Nook.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7F8340FB2E37F39400674A5D"
BuildableName = "Nook.app"
BlueprintName = "Nook"
ReferencedContainer = "container:Nook.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Release">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
customArchiveName = "Nook Browser"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
```
## /Nook/Adapters/TabListAdapter.swift
```swift path="/Nook/Adapters/TabListAdapter.swift"
import Foundation
import SwiftUI
import AppKit
import Combine
/// Adapter for regular tabs in a space
@MainActor
class SpaceRegularTabListAdapter: TabListDataSource, ObservableObject {
private let tabManager: TabManager
let spaceId: UUID
private var cancellable: AnyCancellable?
init(tabManager: TabManager, spaceId: UUID) {
self.tabManager = tabManager
self.spaceId = spaceId
// Relay TabManager changes to table consumers
self.cancellable = tabManager.objectWillChange
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
}
var tabs: [Tab] {
guard let space = tabManager.spaces.first(where: { $0.id == spaceId }) else { return [] }
return tabManager.tabs(in: space)
}
func moveTab(from sourceIndex: Int, to targetIndex: Int) {
objectWillChange.send()
guard sourceIndex < tabs.count else { return }
let tab = tabs[sourceIndex]
tabManager.reorderRegular(tab, in: spaceId, to: targetIndex)
}
func selectTab(at index: Int) {
guard index < tabs.count else { return }
tabManager.browserManager?.selectTab(tabs[index])
}
func closeTab(at index: Int) {
objectWillChange.send()
guard index < tabs.count else { return }
tabManager.removeTab(tabs[index].id)
}
func toggleMuteTab(at index: Int) {
objectWillChange.send()
guard index < tabs.count else { return }
let tab = tabs[index]
if tab.hasAudioContent {
tab.toggleMute()
}
}
func contextMenuForTab(at index: Int) -> NSMenu? {
guard index < tabs.count else { return nil }
let tab = tabs[index]
let menu = NSMenu()
// Move Up
let upItem = NSMenuItem(title: "Move Up", action: #selector(moveTabUp(_:)), keyEquivalent: "")
upItem.target = self
upItem.representedObject = tab
upItem.isEnabled = !isFirstTab(tab)
menu.addItem(upItem)
// Move Down
let downItem = NSMenuItem(title: "Move Down", action: #selector(moveTabDown(_:)), keyEquivalent: "")
downItem.target = self
downItem.representedObject = tab
downItem.isEnabled = !isLastTab(tab)
menu.addItem(downItem)
menu.addItem(NSMenuItem.separator())
// Pin to Space
let pinToSpaceItem = NSMenuItem(title: "Pin to Space", action: #selector(pinToSpace(_:)), keyEquivalent: "")
pinToSpaceItem.target = self
pinToSpaceItem.representedObject = tab
menu.addItem(pinToSpaceItem)
// Pin Globally
let pinGlobalItem = NSMenuItem(title: "Pin Globally", action: #selector(pinGlobally(_:)), keyEquivalent: "")
pinGlobalItem.target = self
pinGlobalItem.representedObject = tab
menu.addItem(pinGlobalItem)
// Audio toggle if relevant
if tab.hasAudioContent || tab.isAudioMuted {
let title = tab.isAudioMuted ? "Unmute Audio" : "Mute Audio"
let audioItem = NSMenuItem(title: title, action: #selector(toggleAudio(_:)), keyEquivalent: "")
audioItem.target = self
audioItem.representedObject = tab
menu.addItem(audioItem)
}
// Unload operations
let unloadItem = NSMenuItem(title: "Unload Tab", action: #selector(unloadTab(_:)), keyEquivalent: "")
unloadItem.target = self
unloadItem.representedObject = tab
unloadItem.isEnabled = !tab.isUnloaded
menu.addItem(unloadItem)
let unloadAllItem = NSMenuItem(title: "Unload All Inactive Tabs", action: #selector(unloadAllInactive(_:)), keyEquivalent: "")
unloadAllItem.target = self
unloadAllItem.representedObject = tab
menu.addItem(unloadAllItem)
menu.addItem(NSMenuItem.separator())
// Close
let closeItem = NSMenuItem(title: "Close tab", action: #selector(closeTab(_:)), keyEquivalent: "")
closeItem.target = self
closeItem.representedObject = tab
menu.addItem(closeItem)
return menu
}
@objc private func moveTabUp(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.moveTabUp(tab.id)
}
@objc private func moveTabDown(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.moveTabDown(tab.id)
}
@objc private func pinToSpace(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.pinTabToSpace(tab, spaceId: spaceId)
}
@objc private func pinGlobally(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.addToEssentials(tab)
}
@objc private func toggleAudio(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tab.toggleMute()
}
@objc private func unloadTab(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.unloadTab(tab)
}
@objc private func unloadAllInactive(_ sender: NSMenuItem) {
tabManager.unloadAllInactiveTabs()
}
@objc private func closeTab(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.removeTab(tab.id)
}
private func isFirstTab(_ tab: Tab) -> Bool { tabs.first?.id == tab.id }
private func isLastTab(_ tab: Tab) -> Bool { tabs.last?.id == tab.id }
}
/// Adapter for pinned tabs in a space
@MainActor
class SpacePinnedTabListAdapter: TabListDataSource, ObservableObject {
private let tabManager: TabManager
let spaceId: UUID
private var cancellable: AnyCancellable?
init(tabManager: TabManager, spaceId: UUID) {
self.tabManager = tabManager
self.spaceId = spaceId
self.cancellable = tabManager.objectWillChange
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
}
var tabs: [Tab] {
tabManager.spacePinnedTabs(for: spaceId)
}
func moveTab(from sourceIndex: Int, to targetIndex: Int) {
objectWillChange.send()
guard sourceIndex < tabs.count else { return }
let tab = tabs[sourceIndex]
tabManager.reorderSpacePinned(tab, in: spaceId, to: targetIndex)
}
func selectTab(at index: Int) {
guard index < tabs.count else { return }
tabManager.browserManager?.selectTab(tabs[index])
}
func closeTab(at index: Int) {
objectWillChange.send()
guard index < tabs.count else { return }
tabManager.removeTab(tabs[index].id)
}
func toggleMuteTab(at index: Int) {
objectWillChange.send()
guard index < tabs.count else { return }
let tab = tabs[index]
if tab.hasAudioContent {
tab.toggleMute()
}
}
func contextMenuForTab(at index: Int) -> NSMenu? {
guard index < tabs.count else { return nil }
let tab = tabs[index]
let menu = NSMenu()
// Unpin from space
let unpinItem = NSMenuItem(title: "Unpin from Space", action: #selector(unpinFromSpace(_:)), keyEquivalent: "")
unpinItem.target = self
unpinItem.representedObject = tab
menu.addItem(unpinItem)
// Pin Globally
let pinGlobalItem = NSMenuItem(title: "Pin Globally", action: #selector(pinGlobally(_:)), keyEquivalent: "")
pinGlobalItem.target = self
pinGlobalItem.representedObject = tab
menu.addItem(pinGlobalItem)
// Audio toggle if relevant
if tab.hasAudioContent || tab.isAudioMuted {
let title = tab.isAudioMuted ? "Unmute Audio" : "Mute Audio"
let audioItem = NSMenuItem(title: title, action: #selector(toggleAudio(_:)), keyEquivalent: "")
audioItem.target = self
audioItem.representedObject = tab
menu.addItem(audioItem)
}
// Unload operations
let unloadItem = NSMenuItem(title: "Unload Tab", action: #selector(unloadTab(_:)), keyEquivalent: "")
unloadItem.target = self
unloadItem.representedObject = tab
unloadItem.isEnabled = !tab.isUnloaded
menu.addItem(unloadItem)
let unloadAllItem = NSMenuItem(title: "Unload All Inactive Tabs", action: #selector(unloadAllInactive(_:)), keyEquivalent: "")
unloadAllItem.target = self
unloadAllItem.representedObject = tab
menu.addItem(unloadAllItem)
menu.addItem(NSMenuItem.separator())
// Close tab
let closeItem = NSMenuItem(title: "Close tab", action: #selector(closeTab(_:)), keyEquivalent: "")
closeItem.target = self
closeItem.representedObject = tab
menu.addItem(closeItem)
return menu
}
@objc private func unpinFromSpace(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.unpinTabFromSpace(tab)
}
@objc private func pinGlobally(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.addToEssentials(tab)
}
@objc private func toggleAudio(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tab.toggleMute()
}
@objc private func unloadTab(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.unloadTab(tab)
}
@objc private func unloadAllInactive(_ sender: NSMenuItem) {
tabManager.unloadAllInactiveTabs()
}
@objc private func closeTab(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.removeTab(tab.id)
}
}
/// Adapter for essential tabs
@MainActor
class EssentialTabListAdapter: TabListDataSource, ObservableObject {
private let tabManager: TabManager
private var cancellable: AnyCancellable?
init(tabManager: TabManager) {
self.tabManager = tabManager
// Observe TabManager and relay to collection view
self.cancellable = tabManager.objectWillChange
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
}
deinit { cancellable?.cancel() }
var tabs: [Tab] {
// Profile-aware essentials: returns pinned tabs for current profile only
tabManager.essentialTabs
}
func moveTab(from sourceIndex: Int, to targetIndex: Int) {
objectWillChange.send()
guard sourceIndex < tabs.count else { return }
let tab = tabs[sourceIndex]
tabManager.reorderEssential(tab, to: targetIndex)
}
func selectTab(at index: Int) {
guard index < tabs.count else { return }
tabManager.browserManager?.selectTab(tabs[index])
}
func closeTab(at index: Int) {
guard index < tabs.count else { return }
tabManager.removeTab(tabs[index].id)
}
func toggleMuteTab(at index: Int) {
guard index < tabs.count else { return }
let tab = tabs[index]
if tab.hasAudioContent {
tab.toggleMute()
}
}
func contextMenuForTab(at index: Int) -> NSMenu? {
guard index < tabs.count else { return nil }
let tab = tabs[index]
let menu = NSMenu()
// Reload
let reloadItem = NSMenuItem(title: "Reload", action: #selector(reloadTab(_:)), keyEquivalent: "")
reloadItem.target = self
reloadItem.representedObject = tab
menu.addItem(reloadItem)
menu.addItem(NSMenuItem.separator())
// Audio toggle if relevant
if tab.hasAudioContent || tab.isAudioMuted {
let title = tab.isAudioMuted ? "Unmute Audio" : "Mute Audio"
let audioItem = NSMenuItem(title: title, action: #selector(toggleAudio(_:)), keyEquivalent: "")
audioItem.target = self
audioItem.representedObject = tab
menu.addItem(audioItem)
menu.addItem(NSMenuItem.separator())
}
// Unload operations
let unloadItem = NSMenuItem(title: "Unload Tab", action: #selector(unloadTab(_:)), keyEquivalent: "")
unloadItem.target = self
unloadItem.representedObject = tab
unloadItem.isEnabled = !tab.isUnloaded
menu.addItem(unloadItem)
let unloadAllItem = NSMenuItem(title: "Unload All Inactive Tabs", action: #selector(unloadAllInactive(_:)), keyEquivalent: "")
unloadAllItem.target = self
unloadAllItem.representedObject = tab
menu.addItem(unloadAllItem)
menu.addItem(NSMenuItem.separator())
// Remove from essentials
let removeItem = NSMenuItem(title: "Remove from Essentials", action: #selector(removeFromEssentials(_:)), keyEquivalent: "")
removeItem.target = self
removeItem.representedObject = tab
menu.addItem(removeItem)
// Close
let closeItem = NSMenuItem(title: "Close tab", action: #selector(closeTab(_:)), keyEquivalent: "")
closeItem.target = self
closeItem.representedObject = tab
menu.addItem(closeItem)
return menu
}
@objc private func reloadTab(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tab.refresh()
}
@objc private func removeFromEssentials(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.removeFromEssentials(tab)
}
@objc private func toggleAudio(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tab.toggleMute()
}
@objc private func unloadTab(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.unloadTab(tab)
}
@objc private func unloadAllInactive(_ sender: NSMenuItem) {
tabManager.unloadAllInactiveTabs()
}
@objc private func closeTab(_ sender: NSMenuItem) {
guard let tab = sender.representedObject as? Tab else { return }
tabManager.removeTab(tab.id)
}
}
```
## /Nook/Assets.xcassets/AccentColor.colorset/Contents.json
```json path="/Nook/Assets.xcassets/AccentColor.colorset/Contents.json"
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /Nook/Assets.xcassets/Contents.json
```json path="/Nook/Assets.xcassets/Contents.json"
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /Nook/Assets.xcassets/noise_texture.imageset/Contents.json
```json path="/Nook/Assets.xcassets/noise_texture.imageset/Contents.json"
{
"images" : [
{
"filename" : "noise_texture.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "noise_texture@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "noise_texture@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /Nook/Assets.xcassets/noise_texture.imageset/noise_texture.png
Binary file available at https://raw.githubusercontent.com/nook-browser/Nook/refs/heads/main/Nook/Assets.xcassets/noise_texture.imageset/noise_texture.png
## /Nook/Assets.xcassets/noise_texture.imageset/noise_texture@2x.png
Binary file available at https://raw.githubusercontent.com/nook-browser/Nook/refs/heads/main/Nook/Assets.xcassets/noise_texture.imageset/noise_texture@2x.png
## /Nook/Assets.xcassets/noise_texture.imageset/noise_texture@3x.png
Binary file available at https://raw.githubusercontent.com/nook-browser/Nook/refs/heads/main/Nook/Assets.xcassets/noise_texture.imageset/noise_texture@3x.png
## /Nook/Components/Browser/Window/SpaceGradientBackgroundView.swift
```swift path="/Nook/Components/Browser/Window/SpaceGradientBackgroundView.swift"
import SwiftUI
// Dithered gradient rendering
import CoreGraphics
// Renders the current space's gradient as a bottom background layer
struct SpaceGradientBackgroundView: View {
@EnvironmentObject var browserManager: BrowserManager
@EnvironmentObject var gradientColorManager: GradientColorManager
@EnvironmentObject var windowState: BrowserWindowState
private var isActiveWindow: Bool {
browserManager.activeWindowState?.id == windowState.id
}
private var gradient: SpaceGradient {
isActiveWindow ? gradientColorManager.displayGradient : windowState.activeGradient
}
var body: some View {
ZStack {
// Always use BarycentricGradientView for active window - it handles 1-3 colors smoothly
// For inactive windows, also use Barycentric since spaces only have 1-3 colors max
BarycentricGradientView(gradient: gradient)
}
.opacity(max(0.0, min(1.0, gradient.opacity)))
.allowsHitTesting(false) // Entire background should not intercept input
}
private func stops() -> [Gradient.Stop] {
// Map nodes to stops
var mapped: [Gradient.Stop] = gradient.sortedNodes.map { node in
Gradient.Stop(color: Color(hex: node.colorHex), location: CGFloat(node.location))
}
// Ensure at least two stops to satisfy LinearGradient requirements
if mapped.count == 0 {
// Fallback to default gradient stops
let def = SpaceGradient.default
mapped = def.sortedNodes.map { node in
Gradient.Stop(color: Color(hex: node.colorHex), location: CGFloat(node.location))
}
} else if mapped.count == 1 {
// Duplicate the single color across the full range
let single = mapped[0]
mapped = [
Gradient.Stop(color: single.color, location: 0.0),
Gradient.Stop(color: single.color, location: 1.0)
]
}
return mapped
}
// Compute start and end UnitPoints using a single trig pass
private func linePoints(angle: Double) -> (start: UnitPoint, end: UnitPoint) {
let theta = Angle(degrees: angle).radians
let dx = cos(theta)
let dy = sin(theta)
let start = UnitPoint(x: 0.5 - 0.5 * dx, y: 0.5 - 0.5 * dy)
let end = UnitPoint(x: 0.5 + 0.5 * dx, y: 0.5 + 0.5 * dy)
return (start, end)
}
}
```
## /Nook/Components/Browser/Window/SplitDropCaptureView.swift
```swift path="/Nook/Components/Browser/Window/SplitDropCaptureView.swift"
import AppKit
final class SplitDropCaptureView: NSView {
weak var browserManager: BrowserManager?
weak var splitManager: SplitViewManager?
var windowId: UUID?
private var isDragActive: Bool = false
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
wantsLayer = true
layer?.backgroundColor = NSColor.clear.cgColor
// Accept plain text drags (UUID string for a Tab)
registerForDraggedTypes([.string])
// Transparent to normal mouse events; only DnD uses these callbacks
isHidden = false
}
// Only intercept events during an active drag; otherwise pass through
override func hitTest(_ point: NSPoint) -> NSView? { isDragActive ? self : nil }
// MARK: - Dragging
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
isDragActive = true
updatePreview(sender)
return .copy
}
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
isDragActive = true
updatePreview(sender)
return .copy
}
override func draggingExited(_ sender: NSDraggingInfo?) {
isDragActive = false
if let windowId { splitManager?.endPreview(cancel: true, for: windowId) }
// Signal UI to clear any drag-hiding state even on invalid drops
NotificationCenter.default.post(name: .tabDragDidEnd, object: nil)
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
guard let bm = browserManager, let sm = splitManager, let windowId else { return false }
sm.endPreview(cancel: false, for: windowId)
let pb = sender.draggingPasteboard
guard let idString = pb.string(forType: .string), let id = UUID(uuidString: idString) else {
// Invalid payload; clear any lingering drag UI state
NotificationCenter.default.post(name: .tabDragDidEnd, object: nil)
return false
}
let all = bm.tabManager.allTabs()
guard let tab = all.first(where: { $0.id == id }) else { return false }
let side = sideForDrag(sender)
// Redundant replace guard
if sm.isSplit(for: windowId) {
let leftId = sm.leftTabId(for: windowId)
let rightId = sm.rightTabId(for: windowId)
if (side == .left && leftId == tab.id) || (side == .right && rightId == tab.id) {
return true
}
}
if let windowState = bm.windowStates[windowId] {
sm.enterSplit(with: tab, placeOn: side, in: windowState)
}
// Cancel any in-progress sidebar/tab drag to prevent unintended reorder/removal
DispatchQueue.main.async {
TabDragManager.shared.cancelDrag()
}
isDragActive = false
return true
}
// MARK: - Helpers
private func updatePreview(_ sender: NSDraggingInfo) {
let side = sideForDrag(sender)
if let windowId { splitManager?.beginPreview(side: side, for: windowId) }
}
private func sideForDrag(_ sender: NSDraggingInfo) -> SplitViewManager.Side {
let loc = convert(sender.draggingLocation, from: nil)
let w = max(bounds.width, 1)
return loc.x < (w / 2) ? .left : .right
}
}
```
## /Nook/Components/Browser/Window/TabCompositorView.swift
```swift path="/Nook/Components/Browser/Window/TabCompositorView.swift"
import SwiftUI
import AppKit
import WebKit
struct TabCompositorView: NSViewRepresentable {
let browserManager: BrowserManager
@EnvironmentObject var windowState: BrowserWindowState
func makeNSView(context: Context) -> NSView {
let view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.clear.cgColor
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// Update the compositor when tabs change or compositor version changes
updateCompositor(nsView)
}
private func updateCompositor(_ containerView: NSView) {
// Remove all existing webview subviews
containerView.subviews.forEach { $0.removeFromSuperview() }
// Only add the current tab's webView to avoid WKWebView conflicts
guard let currentTabId = windowState.currentTabId,
let currentTab = browserManager.tabsForDisplay(in: windowState).first(where: { $0.id == currentTabId }),
!currentTab.isUnloaded else {
return
}
// Create a window-specific web view for this tab
let webView = getOrCreateWebView(for: currentTab, in: windowState.id)
webView.frame = containerView.bounds
webView.autoresizingMask = [.width, .height]
containerView.addSubview(webView)
webView.isHidden = false
}
private func getOrCreateWebView(for tab: Tab, in windowId: UUID) -> WKWebView {
// Check if we already have a web view for this tab in this window
if let existingWebView = browserManager.getWebView(for: tab.id, in: windowId) {
return existingWebView
}
// Create a new web view for this tab in this window
return browserManager.createWebView(for: tab.id, in: windowId)
}
}
// MARK: - Tab Compositor Manager
@MainActor
class TabCompositorManager: ObservableObject {
private var unloadTimers: [UUID: Timer] = [:]
private var lastAccessTimes: [UUID: Date] = [:]
// Default unload timeout (5 minutes)
var unloadTimeout: TimeInterval = 300
init() {
// Listen for timeout changes
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTimeoutChange),
name: .tabUnloadTimeoutChanged,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func handleTimeoutChange(_ notification: Notification) {
if let timeout = notification.userInfo?["timeout"] as? TimeInterval {
setUnloadTimeout(timeout)
}
}
func setUnloadTimeout(_ timeout: TimeInterval) {
self.unloadTimeout = timeout
// Restart timers with new timeout
restartAllTimers()
}
func markTabAccessed(_ tabId: UUID) {
lastAccessTimes[tabId] = Date()
restartTimer(for: tabId)
}
func unloadTab(_ tab: Tab) {
print("🔄 [Compositor] Unloading tab: \(tab.name)")
// Stop any existing timer
unloadTimers[tab.id]?.invalidate()
unloadTimers.removeValue(forKey: tab.id)
lastAccessTimes.removeValue(forKey: tab.id)
// Unload the webview
tab.unloadWebView()
}
func loadTab(_ tab: Tab) {
print("🔄 [Compositor] Loading tab: \(tab.name)")
// Mark as accessed
markTabAccessed(tab.id)
// Load the webview if needed
tab.loadWebViewIfNeeded()
}
private func restartTimer(for tabId: UUID) {
// Cancel existing timer
unloadTimers[tabId]?.invalidate()
// Create new timer
let timer = Timer.scheduledTimer(withTimeInterval: unloadTimeout, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.handleTabTimeout(tabId)
}
}
unloadTimers[tabId] = timer
}
private func restartAllTimers() {
// Cancel all existing timers
unloadTimers.values.forEach { $0.invalidate() }
unloadTimers.removeAll()
// Restart timers for all accessed tabs
for tabId in lastAccessTimes.keys {
restartTimer(for: tabId)
}
}
private func handleTabTimeout(_ tabId: UUID) {
guard let tab = findTab(by: tabId) else { return }
// Don't unload if it's the current tab
if tab.id == tabId && tab.isCurrentTab {
// Restart timer for current tab
restartTimer(for: tabId)
return
}
// Don't unload if tab has playing media
if tab.hasPlayingVideo || tab.hasPlayingAudio || tab.hasAudioContent {
// Restart timer for tabs with media
restartTimer(for: tabId)
return
}
// Unload the tab
unloadTab(tab)
}
private func findTab(by id: UUID) -> Tab? {
guard let browserManager = browserManager else { return nil }
return browserManager.tabManager.allTabs().first { $0.id == id }
}
private func findTabByWebView(_ webView: WKWebView) -> Tab? {
guard let browserManager = browserManager else { return nil }
return browserManager.tabManager.allTabs().first { $0.webView === webView }
}
// MARK: - Public Interface
func updateTabVisibility(currentTabId: UUID?) {
guard let browserManager = browserManager else { return }
for (windowId, _) in browserManager.compositorContainers() {
guard let windowState = browserManager.windowStates[windowId] else { continue }
browserManager.refreshCompositor(for: windowState)
}
}
/// Update tab visibility for a specific window
func updateTabVisibility(for windowState: BrowserWindowState) {
browserManager?.refreshCompositor(for: windowState)
}
// MARK: - Dependencies
weak var browserManager: BrowserManager?
}
```
## /Nook/Components/Browser/Window/WindowBackgroundView.swift
```swift path="/Nook/Components/Browser/Window/WindowBackgroundView.swift"
//
// WindowBackgroundView.swift
// Nook
//
// Created by Maciek Bagiński on 30/07/2025.
//
import SwiftUI
struct WindowBackgroundView: View {
@EnvironmentObject var browserManager: BrowserManager
var body: some View {
Group {
if #available(macOS 26.0, *) {
if browserManager.settingsManager.isLiquidGlassEnabled {
Rectangle()
.fill(Color.clear)
.blur(radius: 40)
.glassEffect(in: .rect(cornerRadius: 0))
.clipped()
} else {
BlurEffectView(
material: browserManager.settingsManager
.currentMaterial,
state: .active
)
.overlay(
Color.black.opacity(0.25)
.blendMode(.darken)
)
}
} else {
if browserManager.settingsManager.isLiquidGlassEnabled {
Rectangle()
.fill(.clear)
.background(.thinMaterial) // Use thinMaterial for liquid glass effect for better compatability
.blur(radius: 40)
.clipped()
} else {
BlurEffectView(
material: browserManager.settingsManager
.currentMaterial,
state: .active
)
.overlay(
Color.black.opacity(0.25)
.blendMode(.darken)
)
}
} }
.backgroundDraggable()
}
}
```
## /Nook/Components/Browser/Window/WindowView.swift
```swift path="/Nook/Components/Browser/Window/WindowView.swift"
//
// WindowView.swift
// Nook
//
// Created by Maciek Bagiński on 30/07/2025.
//
import SwiftUI
struct WindowView: View {
@EnvironmentObject var browserManager: BrowserManager
@EnvironmentObject var windowState: BrowserWindowState
@StateObject private var hoverSidebarManager = HoverSidebarManager()
@Environment(\.colorScheme) var colorScheme
// Calculate webview Y offset (where the web content starts)
private var webViewYOffset: CGFloat {
// Approximate Y offset for web content start (nav bar + URL bar + padding)
if browserManager.settingsManager.topBarAddressView {
return 44 // Top bar height
} else {
return 20 // Accounts for navigation area height
}
}
var body: some View {
let isDark = colorScheme == .dark
GeometryReader { geometry in
ZStack {
// Gradient background for the current space (bottom-most layer)
SpaceGradientBackgroundView()
.environmentObject(windowState)
// Attach background context menu to the window background layer
Color.white.opacity(isDark ? 0.3 : 0.4)
.ignoresSafeArea(.all)
WindowBackgroundView()
.contextMenu {
Button("Customize Space Gradient...") {
browserManager.showGradientEditor()
}
.disabled(browserManager.tabManager.currentSpace == nil)
}
// Top bar when enabled
if browserManager.settingsManager.topBarAddressView {
VStack(spacing: 0) {
TopBarView()
.environmentObject(browserManager)
.environmentObject(windowState)
.background(Color.clear)
mainLayout
}
// TopBar Command Palette overlay
TopBarCommandPalette()
.environmentObject(browserManager)
.environmentObject(windowState)
.zIndex(3000)
} else {
mainLayout
}
// Mini command palette anchored exactly to URL bar's top-left
// Only show when topbar is disabled
if !browserManager.settingsManager.topBarAddressView {
MiniCommandPaletteOverlay()
.environmentObject(windowState)
}
// Hover-reveal Sidebar overlay (slides in over web content)
SidebarHoverOverlayView()
.environmentObject(hoverSidebarManager)
.environmentObject(windowState)
CommandPaletteView()
DialogView()
// Peek overlay for external link previews
PeekOverlayView()
// Find bar overlay - centered top bar
if browserManager.findManager.isFindBarVisible {
VStack {
HStack {
Spacer()
FindBarView(findManager: browserManager.findManager)
.frame(maxWidth: 500)
Spacer()
}
.padding(.top, 20)
Spacer()
}
}
// Toast overlays (matches WebsitePopup style/presentation)
VStack {
HStack {
Spacer()
VStack(spacing: 8) {
// Profile switch toast
if windowState.isShowingProfileSwitchToast,
let toast = windowState.profileSwitchToast
{
ProfileSwitchToastView(toast: toast)
.animation(
.spring(
response: 0.5,
dampingFraction: 0.8
),
value: windowState
.isShowingProfileSwitchToast
)
.onTapGesture {
browserManager.hideProfileSwitchToast(
for: windowState
)
}
}
// Tab closure toast
if browserManager.showTabClosureToast
&& browserManager.tabClosureToastCount > 0
{
TabClosureToast()
.environmentObject(browserManager)
.environmentObject(windowState)
.animation(
.spring(
response: 0.5,
dampingFraction: 0.8
),
value: browserManager
.showTabClosureToast
)
.onTapGesture {
browserManager.hideTabClosureToast()
}
}
// Zoom popup toast
if browserManager.shouldShowZoomPopup {
ZoomPopupView(
zoomManager: browserManager.zoomManager,
onZoomIn: {
browserManager.zoomInCurrentTab()
},
onZoomOut: {
browserManager.zoomOutCurrentTab()
},
onZoomReset: {
browserManager.resetZoomCurrentTab()
},
onZoomPresetSelected: { zoomLevel in
browserManager.applyZoomLevel(zoomLevel)
},
onDismiss: {
browserManager.shouldShowZoomPopup = false
}
)
.animation(
.spring(
response: 0.5,
dampingFraction: 0.8
),
value: browserManager.shouldShowZoomPopup
)
.onTapGesture {
browserManager.shouldShowZoomPopup = false
}
}
}
.padding(10)
}
Spacer()
}
}
// Named coordinate space for geometry preferences
.coordinateSpace(name: "WindowSpace")
// Keep BrowserManager aware of URL bar frame in window space
.onPreferenceChange(URLBarFramePreferenceKey.self) { frame in
browserManager.urlBarFrame = frame
windowState.urlBarFrame = frame
}
// Attach hover sidebar manager lifecycle
.onAppear {
hoverSidebarManager.attach(browserManager: browserManager)
hoverSidebarManager.start()
}
.onDisappear {
hoverSidebarManager.stop()
}
.environmentObject(browserManager)
.environmentObject(browserManager.gradientColorManager)
.environmentObject(browserManager.splitManager)
.environmentObject(hoverSidebarManager)
}
}
@ViewBuilder
private var mainLayout: some View {
let aiVisible = windowState.isSidebarAIChatVisible
let aiAppearsOnTrailingEdge = browserManager.settingsManager.sidebarPosition == .left
HStack(spacing: 0) {
if aiAppearsOnTrailingEdge {
sidebarColumn
websiteColumn
if aiVisible {
aiSidebar
}
} else {
if aiVisible {
aiSidebar
}
websiteColumn
sidebarColumn
}
}
.padding(.trailing, windowState.isFullScreen ? 0 : (windowState.isSidebarVisible && browserManager.settingsManager.sidebarPosition == .right ? 0 : aiVisible ? 0 : 8))
.padding(.leading, windowState.isFullScreen ? 0 : (windowState.isSidebarVisible && browserManager.settingsManager.sidebarPosition == .left ? 0 : aiVisible ? 0 : 8))
}
private var sidebarColumn: some View {
SidebarView()
// Overlay the resize handle spanning the sidebar/webview boundary
.overlay(alignment: browserManager.settingsManager.sidebarPosition == .left ? .trailing : .leading) {
if windowState.isSidebarVisible {
// Position to span 14pts into sidebar and 2pts into web content (moved 6pts left)
SidebarResizeView()
.frame(maxHeight: .infinity)
.environmentObject(browserManager)
.environmentObject(windowState)
.zIndex(2000) // Higher z-index to ensure it's above all other elements
.environmentObject(windowState)
}
}
.environmentObject(browserManager)
.environmentObject(windowState)
}
private var websiteColumn: some View {
VStack(spacing: 0) {
WebsiteLoadingIndicator()
WebsiteView()
}
.padding(.bottom, 8)
.zIndex(2000)
}
@ViewBuilder
private var aiSidebar: some View {
let handleAlignment: Alignment = browserManager.settingsManager.sidebarPosition == .left ? .leading : .trailing
SidebarAIChat()
.frame(width: windowState.aiSidebarWidth)
.overlay(alignment: handleAlignment) {
AISidebarResizeView()
.frame(maxHeight: .infinity)
.environmentObject(browserManager)
.environmentObject(windowState)
}
.transition(
.move(edge: browserManager.settingsManager.sidebarPosition == .left ? .trailing : .leading)
.combined(with: .opacity)
)
.environmentObject(browserManager)
.environmentObject(windowState)
.environment(browserManager.settingsManager)
}
}
// MARK: - Profile Switch Toast View
private struct ProfileSwitchToastView: View {
let toast: BrowserManager.ProfileSwitchToast
var body: some View {
HStack {
Text("Switched to \(toast.toProfile.name)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white)
Image(systemName: "person.crop.circle")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white)
.frame(width: 14, height: 14)
.padding(4)
.background(Color.white.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(.white.opacity(0.4), lineWidth: 1)
}
}
.padding(12)
.background(Color(hex: "3E4D2E"))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(.white.opacity(0.2), lineWidth: 2)
}
.transition(.scale(scale: 0.0, anchor: .top))
}
}
// MARK: - Mini Command Palette Overlay (above sidebar and webview)
private struct MiniCommandPaletteOverlay: View {
@EnvironmentObject var browserManager: BrowserManager
@EnvironmentObject var windowState: BrowserWindowState
var body: some View {
let isActiveWindow =
browserManager.activeWindowState?.id == windowState.id
let isVisible =
isActiveWindow && windowState.isMiniCommandPaletteVisible
&& !windowState.isCommandPaletteVisible
ZStack(alignment: browserManager.settingsManager.sidebarPosition == .left ? .topLeading : .topTrailing) {
if isVisible {
// Click-away hit target
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
.onTapGesture {
browserManager.hideMiniCommandPalette(for: windowState)
}
// Use reported URL bar frame when reliable; otherwise compute manual fallback
let barFrame = windowState.urlBarFrame
let hasFrame = barFrame.width > 1 && barFrame.height > 1
// Match sidebar's internal 8pt padding when geometry is unavailable
let fallbackX: CGFloat = 8
let topBarHeight: CGFloat = browserManager.settingsManager.topBarAddressView ? 44 : 0
let fallbackY: CGFloat =
8 /* sidebar top padding */ + 30 /* nav bar */
+ 8 /* vstack spacing */ + topBarHeight
let anchorX = hasFrame ? barFrame.minX : fallbackX
let anchorY = hasFrame ? barFrame.minY : fallbackY
// let width = hasFrame ? barFrame.width : browserManager.sidebarWidth
MiniCommandPaletteView(
forcedWidth: 400,
forcedCornerRadius: 12
)
.offset(x: browserManager.settingsManager.sidebarPosition == .left ? anchorX : -anchorX, y: anchorY)
.zIndex(1)
}
}
.allowsHitTesting(isVisible)
.zIndex(999) // ensure above web content
}
}
```
## /Nook/Components/ColorPicker/AngleDial.swift
```swift path="/Nook/Components/ColorPicker/AngleDial.swift"
import SwiftUI
// MARK: - AngleDial
// Dedicated dial for gradient angle with tick marks
struct AngleDial: View {
@Binding var angle: Double // degrees 0...360
var body: some View {
GeometryReader { proxy in
let size = min(proxy.size.width, proxy.size.height)
ZStack {
Circle().fill(.thinMaterial)
Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
// tick marks
ForEach(0..<24, id: \.self) { i in
let a = Double(i) / 24.0 * 2 * .pi
Capsule()
.fill(Color.primary.opacity(i % 6 == 0 ? 0.35 : 0.18))
.frame(width: CGFloat(i % 6 == 0 ? 3 : 2), height: CGFloat(i % 6 == 0 ? 10 : 6))
.offset(y: -size/2 + 10.0)
.rotationEffect(.radians(a))
}
// needle and handle
let a = Angle(degrees: angle)
let handle = CGPoint(x: cos(a.radians) * (size/2 - 12.0), y: sin(a.radians) * (size/2 - 12.0))
Path { p in
p.move(to: CGPoint(x: size/2, y: size/2))
p.addLine(to: CGPoint(x: size/2 + handle.x, y: size/2 + handle.y))
}
.stroke(Color.accentColor.opacity(0.8), lineWidth: 2)
Circle()
.fill(Color.accentColor)
.frame(width: 12, height: 12)
.position(x: size/2 + handle.x, y: size/2 + handle.y)
}
.frame(width: size, height: size)
.contentShape(Circle())
.gesture(DragGesture(minimumDistance: 0).onChanged { value in
let center = CGPoint(x: size/2, y: size/2)
let dx = value.location.x - center.x
let dy = value.location.y - center.y
var deg = atan2(dy, dx) * 180 / .pi
if deg < 0 { deg += 360 }
angle = deg
})
}
}
}
```
## /Nook/Components/ColorPicker/ColorPickerView.swift
```swift path="/Nook/Components/ColorPicker/ColorPickerView.swift"
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
// MARK: - ColorPickerView
// Quadrant-based color grid with 3x3 tones per quadrant
struct ColorPickerView: View {
// Current selection used to render selection border
var selectedColor: Color?
var onColorSelected: (Color) -> Void
private let cellSize: CGFloat = 32
private let cornerRadius: CGFloat = 8
private var columns: [GridItem] { Array(repeating: GridItem(.flexible(), spacing: 8), count: 3) }
// Generate tones for a base hue (0...1)
private func tones(hue: Double) -> [Color] {
// 3 brightness x 3 saturation
let saturations: [Double] = [0.45, 0.70, 0.95]
let brightness: [Double] = [0.45, 0.70, 0.95]
return brightness.flatMap { b in
saturations.map { s in
Color(hue: hue, saturation: s, brightness: b)
}
}
}
private var quadrants: [(title: String, hue: Double)] {
[
("Blue", 0.60),
("Red", 0.00),
("Green", 0.33),
("Yellow", 0.15)
]
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(quadrants.enumerated()), id: \.offset) { _, item in
VStack(alignment: .leading, spacing: 8) {
Text(item.title)
.font(.caption)
.foregroundStyle(.secondary)
LazyVGrid(columns: columns, spacing: 8) {
ForEach(Array(tones(hue: item.hue).enumerated()), id: \.offset) { _, color in
ColorCell(color: color,
isSelected: selectedColor.map { approxEqual($0, color) } ?? false,
size: cellSize,
cornerRadius: cornerRadius) {
onColorSelected(color)
}
}
}
}
.padding(.vertical, 4)
Divider()
}
}
}
// Loosely compare two colors in HSB space
private func approxEqual(_ a: Color, _ b: Color) -> Bool {
#if canImport(AppKit)
let nsA = NSColor(a)
let nsB = NSColor(b)
var (ha, sa, ba): (CGFloat, CGFloat, CGFloat) = (0,0,0)
var (hb, sb, bb): (CGFloat, CGFloat, CGFloat) = (0,0,0)
nsA.usingColorSpace(.deviceRGB)?.getHue(&ha, saturation: &sa, brightness: &ba, alpha: nil)
nsB.usingColorSpace(.deviceRGB)?.getHue(&hb, saturation: &sb, brightness: &bb, alpha: nil)
return abs(ha - hb) < 0.03 && abs(sa - sb) < 0.08 && abs(ba - bb) < 0.08
#else
return false
#endif
}
}
// MARK: - Cell
private struct ColorCell: View {
let color: Color
let isSelected: Bool
let size: CGFloat
let cornerRadius: CGFloat
let action: () -> Void
@State private var hovering = false
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(color)
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(isSelected ? Color.accentColor : Color.black.opacity(0.12), lineWidth: isSelected ? 2 : 1)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.black.opacity(hovering ? 0.06 : 0))
)
}
.frame(width: size, height: size)
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.onTapGesture { action() }
.onHover { hovering = $0 }
.animation(.easeInOut(duration: 0.12), value: hovering)
}
}
```
## /Nook/Components/ColorPicker/ColorSwatchRowView.swift
```swift path="/Nook/Components/ColorPicker/ColorSwatchRowView.swift"
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
// MARK: - ColorSwatchRowView
// Horizontal palette row with arrows
struct ColorSwatchRowView: View {
var selectedColor: Color?
var onSelect: (Color) -> Void
private let swatchSize: CGFloat = 28
private let palettes: [[Color]] = {
let base: [Color] = [
Color.white,
Color(red: 1.0, green: 0.55, blue: 0.75),
Color.purple,
Color.red,
Color.orange,
Color.yellow,
Color.green,
Color.cyan,
Color.blue,
Color.gray
]
let alt: [Color] = [
Color(white: 0.9),
Color(hue: 0.95, saturation: 0.6, brightness: 0.9),
Color(hue: 0.7, saturation: 0.5, brightness: 0.8),
Color(hue: 0.03, saturation: 0.7, brightness: 0.95),
Color(hue: 0.08, saturation: 0.7, brightness: 0.95),
Color(hue: 0.13, saturation: 0.8, brightness: 0.95),
Color(hue: 0.33, saturation: 0.75, brightness: 0.85),
Color(hue: 0.55, saturation: 0.6, brightness: 0.9),
Color(hue: 0.62, saturation: 0.7, brightness: 0.85),
Color(hue: 0.75, saturation: 0.4, brightness: 0.7)
]
return [base, alt]
}()
@State private var page: Int = 0
var body: some View {
HStack(spacing: 8) {
Button { page = max(0, page - 1) } label: {
Image(systemName: "chevron.left")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(page == 0)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(palettes[page].indices, id: \.self) { i in
let color = palettes[page][i]
Circle()
.fill(color)
.frame(width: swatchSize, height: swatchSize)
.overlay(
Circle().strokeBorder(Color.white, lineWidth: 2)
)
.overlay(
Circle().strokeBorder(
(selectedColor.map { approxEqual($0, color) } ?? false) ? Color.accentColor : Color.clear,
lineWidth: 2
)
)
.onTapGesture { onSelect(color) }
}
}
.padding(.horizontal, 4)
}
Button { page = min(palettes.count - 1, page + 1) } label: {
Image(systemName: "chevron.right")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(page >= palettes.count - 1)
}
}
private func approxEqual(_ a: Color, _ b: Color) -> Bool {
#if canImport(AppKit)
let nsA = NSColor(a)
let nsB = NSColor(b)
var (ha, sa, ba): (CGFloat, CGFloat, CGFloat) = (0,0,0)
var (hb, sb, bb): (CGFloat, CGFloat, CGFloat) = (0,0,0)
nsA.usingColorSpace(.deviceRGB)?.getHue(&ha, saturation: &sa, brightness: &ba, alpha: nil)
nsB.usingColorSpace(.deviceRGB)?.getHue(&hb, saturation: &sb, brightness: &bb, alpha: nil)
return abs(ha - hb) < 0.03 && abs(sa - sb) < 0.08 && abs(ba - bb) < 0.08
#else
return false
#endif
}
}
```
## /Nook/Components/ColorPicker/GradientCanvasEditor.swift
```swift path="/Nook/Components/ColorPicker/GradientCanvasEditor.swift"
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
// MARK: - GradientCanvasEditor
// Large canvas with dot grid background and draggable color stops
struct GradientCanvasEditor: View {
@Binding var gradient: SpaceGradient
@Binding var selectedNodeID: UUID?
var showDitherOverlay: Bool = true
@EnvironmentObject var gradientColorManager: GradientColorManager
// ephemeral Y-positions (0...1) for visual placement only
@State private var yPositions: [UUID: CGFloat] = [:]
@State private var xPositions: [UUID: CGFloat] = [:]
@State private var lightness: Double = 0.6 // HSL L component
// Lock a primary node identity when in 3-node mode
@State private var lockedPrimaryID: UUID?
// Haptic feedback state
@State private var lastHapticSpoke: String? = nil
@State private var lastHapticRadial: String? = nil
private let cornerRadius: CGFloat = 16
var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
let height = proxy.size.height
let padding: CGFloat = 24
let center = CGPoint(x: width/2, y: height/2)
let radius = min(width, height)/2 - padding
ZStack {
// No gradient preview in the editor canvas; focus on dot grid + handles
// Noise overlay (optional)
if showDitherOverlay {
Image("noise_texture")
.resizable()
.scaledToFill()
.opacity(max(0, min(1, gradient.grain)))
.blendMode(.overlay)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.allowsHitTesting(false)
}
// Dot grid
DotGrid()
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.allowsHitTesting(false)
// Draggable handles
ForEach(gradient.nodes) { node in
let posX = xPositions[node.id] ?? CGFloat(node.location)
let posY = yPositions[node.id] ?? defaultY(for: node)
let initial = CGPoint(x: posX * width, y: posY * height)
let clamped = clampToCircle(point: initial, center: center, radius: radius)
let primaryID = primaryNodeID()
let isPrimary = (gradient.nodes.count == 3) && (node.id == primaryID)
let handleSize: CGFloat = isPrimary ? 40 : 26
Handle(colorHex: node.colorHex, selected: selectedNodeID == node.id, size: handleSize)
.position(clamped)
.gesture(DragGesture(minimumDistance: 0)
.onChanged { value in
// Clamp to circle and map to HSL for color
let clamped = clampToCircle(point: value.location, center: center, radius: radius)
let nx = max(0, min(1, clamped.x / width))
let ny = max(0, min(1, clamped.y / height))
updateNodeFromCanvasDrag(node, newX: nx, newY: ny, absolute: clamped, center: center, radius: radius)
}
)
.onTapGesture { selectedNodeID = node.id }
}
// Plus / minus at bottom center
HStack(spacing: 24) {
Button(action: removeNode) {
Image(systemName: "minus")
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}.buttonStyle(.plain)
.disabled(gradient.nodes.count <= 1)
Button(action: addNode) {
Image(systemName: "plus")
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}.buttonStyle(.plain)
.disabled(gradient.nodes.count >= 3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, 10)
// Border stroke
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.onAppear { ensurePositions(width: width, height: height, center: center, radius: radius) }
.onChange(of: gradient.nodes.count) { ensurePositions(width: width, height: height, center: center, radius: radius) }
}
.frame(height: 300)
}
// MARK: - Helpers
private func defaultY(for node: GradientNode) -> CGFloat {
// spread defaults by index for nicer initial layout
if let idx = gradient.nodes.firstIndex(where: { $0.id == node.id }) {
return [0.35, 0.55, 0.45][min(idx, 2)]
}
return 0.5
}
private func ensurePositions(width: CGFloat, height: CGFloat, center: CGPoint, radius: CGFloat) {
// Special layout when exactly 3 nodes: place both companions on the same circular radius as the primary
// Also lock the primary node identity once when entering 3-node mode
if gradient.nodes.count == 3 {
// Only reassign lockedPrimaryID if it's nil or if the locked node no longer exists
if lockedPrimaryID == nil {
lockedPrimaryID = gradient.nodes.min(by: { $0.location < $1.location })?.id
} else if !gradient.nodes.contains(where: { $0.id == lockedPrimaryID }) {
lockedPrimaryID = gradient.nodes.min(by: { $0.location < $1.location })?.id
}
// Only update preferredPrimaryNodeID if it's different from lockedPrimaryID
if gradientColorManager.preferredPrimaryNodeID != lockedPrimaryID {
gradientColorManager.preferredPrimaryNodeID = lockedPrimaryID
}
} else if gradient.nodes.count == 2 {
// No locked primary in 2-node mode; keep or choose a stable preferred primary
lockedPrimaryID = nil
if let pid = gradientColorManager.preferredPrimaryNodeID,
gradient.nodes.contains(where: { $0.id == pid }) {
// keep existing preferred - don't change it!
} else {
// Only reassign if the current preferred doesn't exist
gradientColorManager.preferredPrimaryNodeID = gradient.nodes.min(by: { $0.location < $1.location })?.id
}
} else {
// Single node: trivially the only node is primary
lockedPrimaryID = nil
if gradientColorManager.preferredPrimaryNodeID != gradient.nodes.first?.id {
gradientColorManager.preferredPrimaryNodeID = gradient.nodes.first?.id
}
}
if gradient.nodes.count == 3, let primaryID = primaryNodeID() {
let allUnset = gradient.nodes.allSatisfy { xPositions[$0.id] == nil && yPositions[$0.id] == nil }
if allUnset {
// Seed primary at top-left; companions at top-right and bottom-center (same radius)
// Primary (top-left)
if let p = gradient.nodes.first(where: { $0.id == primaryID }) {
let primaryAngle: CGFloat = (.pi * 3.0) / 4.0 // 135° (top-left)
let pr = radius * 0.9
let px = center.x + cos(primaryAngle) * pr
let py = center.y + sin(primaryAngle) * pr
xPositions[p.id] = max(0, min(1, px / width))
yPositions[p.id] = max(0, min(1, py / height))
// DON'T update gradient.nodes[i].location - keep the 1st, 2nd, 3rd roles fixed!
}
// Companions: [top-right, bottom-center]
let rComp = radius * 0.9
let companionAngles: [CGFloat] = [(.pi / 4.0), (-.pi / 2.0)] // 45°, -90°
let companions = gradient.nodes.filter { $0.id != primaryID }
for (i, node) in companions.enumerated() where i < 2 {
let ang = companionAngles[i]
let x = center.x + cos(ang) * rComp
let y = center.y + sin(ang) * rComp
xPositions[node.id] = max(0, min(1, x / width))
yPositions[node.id] = max(0, min(1, y / height))
// DON'T update gradient.nodes[idx].location - keep the 1st, 2nd, 3rd roles fixed!
}
}
}
for n in gradient.nodes {
// Load saved positions from the model if available
if let savedX = n.xPosition, let savedY = n.yPosition {
xPositions[n.id] = CGFloat(savedX)
yPositions[n.id] = CGFloat(savedY)
} else {
// Use defaults if no saved positions
if yPositions[n.id] == nil { yPositions[n.id] = defaultY(for: n) }
if xPositions[n.id] == nil { xPositions[n.id] = CGFloat(n.location) }
// Save the default positions to the model
if let idx = gradient.nodes.firstIndex(where: { $0.id == n.id }) {
gradient.nodes[idx].xPosition = Double(xPositions[n.id] ?? CGFloat(n.location))
gradient.nodes[idx].yPosition = Double(yPositions[n.id] ?? defaultY(for: n))
}
}
// keep points within circle
let pt = CGPoint(x: (xPositions[n.id] ?? CGFloat(n.location)) * width,
y: (yPositions[n.id] ?? defaultY(for: n)) * height)
let clamped = clampToCircle(point: pt, center: center, radius: radius)
xPositions[n.id] = clamped.x / width
yPositions[n.id] = clamped.y / height
// Update the model with the clamped positions
if let idx = gradient.nodes.firstIndex(where: { $0.id == n.id }) {
gradient.nodes[idx].xPosition = Double(xPositions[n.id] ?? CGFloat(n.location))
gradient.nodes[idx].yPosition = Double(yPositions[n.id] ?? defaultY(for: n))
}
// Update color based on position to ensure consistency
updateNodeColorFromPosition(n, width: width, height: height, center: center, radius: radius)
}
// purge removed
xPositions = xPositions.filter { pair in gradient.nodes.contains { $0.id == pair.key } }
yPositions = yPositions.filter { pair in gradient.nodes.contains { $0.id == pair.key } }
}
private func updateNodeFromCanvasDrag(_ node: GradientNode, newX: CGFloat, newY: CGFloat, absolute: CGPoint, center: CGPoint, radius: CGFloat) {
guard let idx = gradient.nodes.firstIndex(where: { $0.id == node.id }) else { return }
// Check for haptic feedback triggers on invisible gridlines
checkHapticFeedback(newX: newX, newY: newY)
// Save visual positions in both the dictionaries (for immediate UI updates) and the model (for persistence)
xPositions[node.id] = newX
yPositions[node.id] = newY
gradient.nodes[idx].xPosition = Double(newX)
gradient.nodes[idx].yPosition = Double(newY)
// DON'T update the persistent location - keep the node's role in the gradient fixed!
// The location property determines the node's position in the gradient (primary, secondary, etc.)
// and should NOT change when dragging on the canvas
selectedNodeID = node.id
// Mark the currently dragged node as the active primary for background rendering
let designatedPrimary = primaryNodeID()
if node.id == designatedPrimary {
gradientColorManager.activePrimaryNodeID = node.id
}
// Map position on circle to HSL color - this is the key fix
let hsla = colorFromCircle(point: absolute, center: center, radius: radius, lightness: lightness)
let updated = colorWithPreservedAlpha(oldHex: gradient.nodes[idx].colorHex, newColor: hsla)
gradient.nodes[idx].colorHex = updated
// Auto-place companions only when dragging the designated primary
// But don't change their gradient positions - they should maintain their 1st, 2nd, 3rd roles
if gradient.nodes.count == 3, node.id == primaryNodeID() {
autoPlaceCompanions(primary: node, center: center, radius: radius)
} else if gradient.nodes.count == 2, node.id == primaryNodeID() {
autoPlaceCompanions(primary: node, center: center, radius: radius)
}
// Push live update to background
gradientColorManager.setImmediate(gradient)
}
private func checkHapticFeedback(newX: CGFloat, newY: CGFloat) {
#if canImport(AppKit)
// Simple gridlines that trigger haptic feedback
let gridLines: [CGFloat] = [0.0, 0.25, 0.5, 0.75, 1.0] // Major gridlines
let tolerance: CGFloat = 0.02 // How close you need to be to trigger
// Check X-axis gridlines
for line in gridLines {
if abs(newX - line) < tolerance {
if lastHapticSpoke != "x_\(line)" {
NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default)
lastHapticSpoke = "x_\(line)"
}
break
}
}
// Check Y-axis gridlines
for line in gridLines {
if abs(newY - line) < tolerance {
if lastHapticRadial != "y_\(line)" {
NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default)
lastHapticRadial = "y_\(line)"
}
break
}
}
// Reset haptic state when moving away from gridlines
if lastHapticSpoke != nil && !gridLines.contains(where: { abs(newX - $0) < tolerance }) {
lastHapticSpoke = nil
}
if lastHapticRadial != nil && !gridLines.contains(where: { abs(newY - $0) < tolerance }) {
lastHapticRadial = nil
}
#endif
}
private func clampToCircle(point: CGPoint, center: CGPoint, radius: CGFloat) -> CGPoint {
let dx = point.x - center.x
let dy = point.y - center.y
let dist = sqrt(dx*dx + dy*dy)
if dist <= radius { return point }
let angle = atan2(dy, dx)
return CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius)
}
private func autoPlaceCompanions(primary: GradientNode, center: CGPoint, radius: CGFloat) {
guard gradient.nodes.count > 1, let pX = xPositions[primary.id], let pY = yPositions[primary.id] else { return }
let others = gradient.nodes.filter { $0.id != primary.id }
if gradient.nodes.count == 3 {
// Place both companions on the same circular radius as primary, across with small angular spread
let centerNorm = CGPoint(x: 0.5, y: 0.5)
let dx = pX - centerNorm.x
let dy = pY - centerNorm.y
let baseAngle = atan2(dy, dx)
let r = min(0.49, sqrt(dx*dx + dy*dy)) // same normalized radius
let opposite = baseAngle + .pi
let spread: CGFloat = 0.6 // ~34°
let angles = [opposite - spread, opposite + spread]
for (i, other) in others.enumerated() where i < 2 {
let ang = angles[i]
let pos = CGPoint(x: centerNorm.x + cos(ang) * r, y: centerNorm.y + sin(ang) * r)
// Only update visual positions, NOT the gradient location
xPositions[other.id] = pos.x
yPositions[other.id] = pos.y
// DON'T update gradient.nodes[idx].location - keep their 1st, 2nd, 3rd roles fixed!
// Update the companion's color to match its new position
if let idx = gradient.nodes.firstIndex(where: { $0.id == other.id }) {
// Save positions to the model
gradient.nodes[idx].xPosition = Double(pos.x)
gradient.nodes[idx].yPosition = Double(pos.y)
let absolute = CGPoint(x: pos.x * (center.x * 2), y: pos.y * (center.y * 2))
let hsla = colorFromCircle(point: absolute, center: center, radius: radius, lightness: lightness)
let updated = colorWithPreservedAlpha(oldHex: gradient.nodes[idx].colorHex, newColor: hsla)
gradient.nodes[idx].colorHex = updated
}
}
} else {
// Two nodes: place the companion opposite side
let p = CGPoint(x: pX, y: pY)
let baseAngle = atan2(p.y - 0.5, p.x - 0.5)
let dist = min(0.5, sqrt(pow(p.x - 0.5, 2) + pow(p.y - 0.5, 2)))
let ang = baseAngle + .pi
let pos = CGPoint(x: 0.5 + cos(ang) * dist, y: 0.5 + sin(ang) * dist)
if let other = others.first {
// Only update visual positions, NOT the gradient location
xPositions[other.id] = pos.x
yPositions[other.id] = pos.y
// DON'T update gradient.nodes[idx].location - keep their 1st, 2nd roles fixed!
// Update the companion's color to match its new position
if let idx = gradient.nodes.firstIndex(where: { $0.id == other.id }) {
// Save positions to the model
gradient.nodes[idx].xPosition = Double(pos.x)
gradient.nodes[idx].yPosition = Double(pos.y)
let absolute = CGPoint(x: pos.x * (center.x * 2), y: pos.y * (center.y * 2))
let hsla = colorFromCircle(point: absolute, center: center, radius: radius, lightness: lightness)
let updated = colorWithPreservedAlpha(oldHex: gradient.nodes[idx].colorHex, newColor: hsla)
gradient.nodes[idx].colorHex = updated
}
}
}
}
private func primaryNodeID() -> UUID? {
if let locked = lockedPrimaryID { return locked }
if gradient.nodes.count <= 2 {
return gradientColorManager.preferredPrimaryNodeID ?? gradient.nodes.min(by: { $0.location < $1.location })?.id
}
return gradient.nodes.min(by: { $0.location < $1.location })?.id
}
private func colorFromCircle(point: CGPoint, center: CGPoint, radius: CGFloat, lightness: Double) -> String {
#if canImport(AppKit)
let dx = point.x - center.x
let dy = point.y - center.y
var angle = atan2(dy, dx)
if angle < 0 { angle += 2 * .pi }
// Map angle to hue (0-360 degrees)
let hue = Double(angle / (2 * .pi))
// Calculate distance from center (0 = center, 1 = edge)
let dist = min(1.0, Double(sqrt(dx*dx + dy*dy) / radius))
// More intuitive color mapping:
// - Saturation: High at center (vivid colors), very low at edges (pastels)
// - Brightness: Much higher at edges for light pastel effect
let saturation = max(0.1, min(1.0, 1.0 - 0.8 * dist)) // 0.1-1.0 range (very low saturation at edges)
let brightness = max(0.3, min(1.0, lightness + 0.4 * dist)) // Much brighter at edges for pastels
let ns = NSColor(hue: CGFloat(hue), saturation: CGFloat(saturation), brightness: CGFloat(brightness), alpha: 1)
return ns.toHexString(includeAlpha: true) ?? "#FFFFFFFF"
#else
return "#FFFFFFFF"
#endif
}
private func updateNodeColorFromPosition(_ node: GradientNode, width: CGFloat, height: CGFloat, center: CGPoint, radius: CGFloat) {
guard let idx = gradient.nodes.firstIndex(where: { $0.id == node.id }),
let xPos = xPositions[node.id],
let yPos = yPositions[node.id] else { return }
let absolute = CGPoint(x: xPos * width, y: yPos * height)
let hsla = colorFromCircle(point: absolute, center: center, radius: radius, lightness: lightness)
let updated = colorWithPreservedAlpha(oldHex: gradient.nodes[idx].colorHex, newColor: hsla)
gradient.nodes[idx].colorHex = updated
}
private func colorWithPreservedAlpha(oldHex: String, newColor: String) -> String {
let aOld = Color(hex: oldHex)
#if canImport(AppKit)
var oa: CGFloat = 1
var orv: CGFloat = 0, ogv: CGFloat = 0, obv: CGFloat = 0
NSColor(aOld).usingColorSpace(.sRGB)?.getRed(&orv, green: &ogv, blue: &obv, alpha: &oa)
var nr: CGFloat = 1, ng: CGFloat = 1, nb: CGFloat = 1, na: CGFloat = 1
NSColor(Color(hex: newColor)).usingColorSpace(.sRGB)?.getRed(&nr, green: &ng, blue: &nb, alpha: &na)
let combined = NSColor(srgbRed: nr, green: ng, blue: nb, alpha: oa)
return combined.toHexString(includeAlpha: true) ?? newColor
#else
return newColor
#endif
}
private func addNode() {
guard gradient.nodes.count < 3 else { return }
let source = selectedNodeID.flatMap { id in gradient.nodes.first(where: { $0.id == id }) }
let color = source?.colorHex ?? gradient.nodes.first?.colorHex ?? "#FFFFFFFF"
// Assign gradient positions based on current count to maintain order
let gradientPosition: Double
if gradient.nodes.count == 0 {
gradientPosition = 0.0 // First node is primary (0.0)
} else if gradient.nodes.count == 1 {
gradientPosition = 1.0 // Second node is secondary (1.0)
} else {
gradientPosition = 0.5 // Third node goes in the middle (0.5)
}
let new = GradientNode(id: UUID(), colorHex: color, location: gradientPosition, xPosition: 0.5, yPosition: 0.5)
gradient.nodes.append(new)
gradient.nodes.sort { $0.location < $1.location }
selectedNodeID = new.id
xPositions[new.id] = 0.5
yPositions[new.id] = 0.5
// Update color based on position after a brief delay to ensure layout is complete
DispatchQueue.main.async {
// We'll update the color in the next layout cycle when positions are properly set
self.gradientColorManager.setImmediate(self.gradient)
}
gradientColorManager.setImmediate(gradient)
// Update preferred primary mapping based on new count
if gradient.nodes.count == 3 {
if lockedPrimaryID == nil { lockedPrimaryID = gradient.nodes.min(by: { $0.location < $1.location })?.id }
gradientColorManager.preferredPrimaryNodeID = lockedPrimaryID
} else {
gradientColorManager.preferredPrimaryNodeID = nil
}
}
private func removeNode() {
guard gradient.nodes.count > 1 else { return }
if let id = selectedNodeID {
gradient.nodes.removeAll { $0.id == id }
yPositions.removeValue(forKey: id)
selectedNodeID = gradient.nodes.first?.id
} else {
let removed = gradient.nodes.removeLast()
yPositions.removeValue(forKey: removed.id)
selectedNodeID = gradient.nodes.last?.id
}
gradientColorManager.setImmediate(gradient)
// Update preferred primary mapping based on new count
if gradient.nodes.count == 3 {
if lockedPrimaryID == nil || (lockedPrimaryID != nil && !gradient.nodes.contains(where: { $0.id == lockedPrimaryID })) {
lockedPrimaryID = gradient.nodes.min(by: { $0.location < $1.location })?.id
}
gradientColorManager.preferredPrimaryNodeID = lockedPrimaryID
} else {
lockedPrimaryID = nil
gradientColorManager.preferredPrimaryNodeID = nil
}
}
private func stops() -> [Gradient.Stop] {
gradient.nodes
.sorted(by: { $0.location < $1.location })
.map { Gradient.Stop(color: Color(hex: $0.colorHex), location: CGFloat($0.location)) }
}
private func startPoint() -> UnitPoint {
let theta = Angle(degrees: gradient.angle).radians
return UnitPoint(x: 0.5 - 0.5 * cos(theta), y: 0.5 - 0.5 * sin(theta))
}
private func endPoint() -> UnitPoint {
let theta = Angle(degrees: gradient.angle).radians
return UnitPoint(x: 0.5 + 0.5 * cos(theta), y: 0.5 + 0.5 * sin(theta))
}
}
// MARK: - Handle
private struct Handle: View {
let colorHex: String
let selected: Bool
let size: CGFloat
var body: some View {
Circle()
.fill(Color(hex: colorHex))
.frame(width: size, height: size)
.overlay(
Circle().strokeBorder(Color.white, lineWidth: 4)
)
.overlay(
Circle().strokeBorder(selected ? Color.accentColor : Color.white.opacity(0), lineWidth: 2)
)
.shadow(color: .black.opacity(0.15), radius: 4, x: 0, y: 1)
.contentShape(Circle())
}
}
// MARK: - DotGrid
private struct DotGrid: View {
var body: some View {
GeometryReader { proxy in
let w = proxy.size.width
let h = proxy.size.height
let spacing: CGFloat = 10
Canvas { ctx, _ in
let cols = Int(w / spacing)
let rows = Int(h / spacing)
let dot = Path(ellipseIn: CGRect(x: 0, y: 0, width: 1.5, height: 1.5))
for r in 0...rows {
for c in 0...cols {
let x = CGFloat(c) * spacing + 2
let y = CGFloat(r) * spacing + 2
ctx.translateBy(x: x, y: y)
ctx.fill(dot, with: .color(Color.black.opacity(0.08)))
ctx.translateBy(x: -x, y: -y)
}
}
}
}
}
}
```
## /Nook/Components/ColorPicker/GradientEditorView.swift
```swift path="/Nook/Components/ColorPicker/GradientEditorView.swift"
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
// MARK: - GradientEditorView
// Composes preview, node/angle controls, color grid, and transparency/grain
struct GradientEditorView: View {
@Binding var gradient: SpaceGradient
@State private var selectedNodeID: UUID?
@EnvironmentObject var gradientColorManager: GradientColorManager
// No throttling: update in real time
var body: some View {
VStack(alignment: .leading, spacing: 16) {
GradientCanvasEditor(gradient: $gradient, selectedNodeID: $selectedNodeID, showDitherOverlay: false)
ColorSwatchRowView(selectedColor: selectedColor()) { color in
applyColorSelection(color)
}
// Global transparency for the whole gradient layer
TransparencySlider(gradient: $gradient)
}
.padding(16)
.onAppear { if selectedNodeID == nil { selectedNodeID = gradient.nodes.first?.id } }
.onChange(of: gradient) { _, newValue in
// Scrubbing should be immediate to avoid animation token races
gradientColorManager.setImmediate(newValue)
}
.onAppear {
// Ensure background starts from the current draft gradient
gradientColorManager.setImmediate(gradient)
gradientColorManager.beginInteractivePreview()
}
.onDisappear {
gradientColorManager.endInteractivePreview()
}
}
// MARK: - Selection Helpers
private func selectedNodeIndex() -> Int? {
if let id = selectedNodeID { return gradient.nodes.firstIndex(where: { $0.id == id }) }
return gradient.nodes.indices.first
}
private func bindingSelectedNode() -> Binding<GradientNode?> {
Binding<GradientNode?>(
get: {
if let idx = selectedNodeIndex() { return gradient.nodes[idx] }
return nil
},
set: { newValue in
guard let node = newValue, let idx = selectedNodeIndex() else { return }
gradient.nodes[idx] = node
}
)
}
private func selectedColor() -> Color? {
guard let idx = selectedNodeIndex() else { return nil }
return Color(hex: gradient.nodes[idx].colorHex)
}
private func applyColorSelection(_ color: Color) {
guard let idx = selectedNodeIndex() else { return }
#if canImport(AppKit)
// Preserve existing alpha from current node
let currentNS = NSColor(Color(hex: gradient.nodes[idx].colorHex)).usingColorSpace(.sRGB)
var oldA: CGFloat = 1.0
var cr: CGFloat = 1, cg: CGFloat = 1, cb: CGFloat = 1
currentNS?.getRed(&cr, green: &cg, blue: &cb, alpha: &oldA)
// Extract new RGB from selected Color
let newNS = NSColor(color).usingColorSpace(.sRGB)
var nr: CGFloat = 1, ng: CGFloat = 1, nb: CGFloat = 1, na: CGFloat = 1
newNS?.getRed(&nr, green: &ng, blue: &nb, alpha: &na)
let combined = NSColor(srgbRed: nr, green: ng, blue: nb, alpha: oldA)
gradient.nodes[idx].colorHex = combined.toHexString(includeAlpha: true) ?? gradient.nodes[idx].colorHex
#endif
}
// No bespoke hex helpers: rely on Color(hex:) and NSColor.toHexString
}
```
## /Nook/Components/ColorPicker/GradientNodePicker.swift
```swift path="/Nook/Components/ColorPicker/GradientNodePicker.swift"
import SwiftUI
// MARK: - GradientNodePicker
// Manage 1-3 gradient nodes and a rotatable angle dial
struct GradientNodePicker: View {
@Binding var gradient: SpaceGradient
@Binding var selectedNodeID: UUID?
private let swatchSize: CGFloat = 40
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
nodeSwatches
Spacer()
HStack(spacing: 8) {
Button(action: removeNode) {
Image(systemName: "minus.circle")
}
.buttonStyle(.plain)
.help("Remove node")
.disabled(gradient.nodes.count <= 1)
Button(action: addNode) {
Image(systemName: "plus.circle")
}
.buttonStyle(.plain)
.help("Add node")
.disabled(gradient.nodes.count >= 3)
}
}
angleDial
VStack(alignment: .leading, spacing: 12) {
ForEach(gradient.nodes) { node in
HStack {
Circle()
.fill(Color(hex: node.colorHex))
.frame(width: 14, height: 14)
Slider(value: binding(for: node), in: 0...1)
Text(String(format: "%.0f%%", (node.location * 100)))
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .trailing)
}
}
}
}
.onAppear { if selectedNodeID == nil { selectedNodeID = gradient.nodes.first?.id } }
}
// MARK: - Swatches
private var nodeSwatches: some View {
HStack(spacing: 8) {
ForEach(gradient.nodes) { node in
let isSelected = node.id == selectedNodeID
Circle()
.fill(Color(hex: node.colorHex))
.frame(width: swatchSize, height: swatchSize)
.overlay(Circle().strokeBorder(isSelected ? Color.accentColor : Color.primary.opacity(0.15), lineWidth: isSelected ? 3 : 1))
.shadow(color: .black.opacity(0.08), radius: 3, x: 0, y: 1)
.onTapGesture { selectedNodeID = node.id }
.contextMenu {
Button("Delete", role: .destructive) { removeSpecific(node) }
.disabled(gradient.nodes.count <= 1)
}
}
}
}
// MARK: - Angle Dial
private var angleDial: some View {
GeometryReader { proxy in
let size = min(proxy.size.width, proxy.size.height, 140)
ZStack {
Circle()
.fill(.thinMaterial)
Circle()
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
// Handle
let angle = Angle(degrees: gradient.angle)
let handle = point(onCircleOf: size/2 - 8, angle: angle)
Circle()
.fill(Color.accentColor)
.frame(width: 12, height: 12)
.position(x: size/2 + handle.x, y: size/2 + handle.y)
// Direction line
Path { p in
p.move(to: CGPoint(x: size/2, y: size/2))
p.addLine(to: CGPoint(x: size/2 + handle.x, y: size/2 + handle.y))
}
.stroke(Color.accentColor.opacity(0.7), lineWidth: 2)
}
.frame(width: size, height: size)
.contentShape(Circle())
.gesture(DragGesture(minimumDistance: 0).onChanged { value in
let center = CGPoint(x: size/2, y: size/2)
let dx = value.location.x - center.x
let dy = value.location.y - center.y
let degrees = atan2(dy, dx) * 180 / .pi
// Convert from atan2 (0 at +x) to SwiftUI gradient angle convention
var adjusted = degrees
if adjusted < 0 { adjusted += 360 }
gradient.angle = Double(adjusted)
})
}
.frame(height: 160)
}
private func point(onCircleOf radius: CGFloat, angle: Angle) -> CGPoint {
let r = radius
let a = CGFloat(angle.radians)
return CGPoint(x: cos(a) * r, y: sin(a) * r)
}
// MARK: - Node CRUD
private func addNode() {
guard gradient.nodes.count < 3 else { return }
let color = gradient.nodes.first?.colorHex ?? "#FFFFFFFF"
let new = GradientNode(id: UUID(), colorHex: color, location: min(1, max(0, (gradient.nodes.last?.location ?? 0.5) + 0.2)))
gradient.nodes.append(new)
selectedNodeID = new.id
gradient.nodes.sort { $0.location < $1.location }
}
private func removeNode() {
guard gradient.nodes.count > 1 else { return }
if let id = selectedNodeID, let idx = gradient.nodes.firstIndex(where: { $0.id == id }) {
gradient.nodes.remove(at: idx)
selectedNodeID = gradient.nodes.first?.id
} else {
_ = gradient.nodes.popLast()
selectedNodeID = gradient.nodes.last?.id
}
}
private func removeSpecific(_ node: GradientNode) {
guard gradient.nodes.count > 1 else { return }
gradient.nodes.removeAll { $0.id == node.id }
selectedNodeID = gradient.nodes.first?.id
}
private func binding(for node: GradientNode) -> Binding<Double> {
Binding<Double>(
get: {
gradient.nodes.first(where: { $0.id == node.id })?.location ?? node.location
},
set: { newValue in
if let idx = gradient.nodes.firstIndex(where: { $0.id == node.id }) {
gradient.nodes[idx].location = newValue
gradient.nodes.sort { $0.location < $1.location }
}
}
)
}
}
```
## /Nook/Components/ColorPicker/GradientPreview.swift
```swift path="/Nook/Components/ColorPicker/GradientPreview.swift"
import SwiftUI
// MARK: - GradientPreview
// Live preview for SpaceGradient with grain overlay
struct GradientPreview: View {
@Binding var gradient: SpaceGradient
var showDitherOverlay: Bool = true
private let cornerRadius: CGFloat = 12
private let size = CGSize(width: 300, height: 160)
var body: some View {
ZStack {
BarycentricGradientView(gradient: gradient)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
if showDitherOverlay {
Image("noise_texture")
.resizable()
.scaledToFill()
.opacity(max(0, min(1, gradient.grain)))
.blendMode(.overlay)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.allowsHitTesting(false)
}
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.12), lineWidth: 1)
}
.frame(width: size.width, height: size.height)
.shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 4)
.drawingGroup()
.opacity(max(0.0, min(1.0, gradient.opacity)))
}
}
```
## /Nook/Components/ColorPicker/GrainDial.swift
```swift path="/Nook/Components/ColorPicker/GrainDial.swift"
import SwiftUI
// MARK: - GrainDial
// Circular dial mapping rotation to 0...1 grain value
struct GrainDial: View {
@Binding var grain: Double // 0...1
var body: some View {
VStack(spacing: 8) {
ZStack {
GeometryReader { proxy in
let size = min(proxy.size.width, proxy.size.height)
dial(size: size)
}
}
.frame(height: 120)
Text("Grain: " + String(format: "%.0f%%", grain * 100))
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func dial(size: CGFloat) -> some View {
ZStack {
Circle().fill(.thinMaterial)
Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
let angle = Angle(degrees: grain * 360)
let handle = point(onCircleOf: size/2 - 8, angle: angle)
Path { p in
p.move(to: CGPoint(x: size/2, y: size/2))
p.addLine(to: CGPoint(x: size/2 + handle.x, y: size/2 + handle.y))
}
.stroke(Color.accentColor.opacity(0.7), lineWidth: 2)
Circle()
.fill(Color.accentColor)
.frame(width: 12, height: 12)
.position(x: size/2 + handle.x, y: size/2 + handle.y)
}
.frame(width: size, height: size)
.contentShape(Circle())
.gesture(DragGesture(minimumDistance: 0).onChanged { value in
let center = CGPoint(x: size/2, y: size/2)
let dx = value.location.x - center.x
let dy = value.location.y - center.y
var degrees = atan2(dy, dx) * 180 / .pi
if degrees < 0 { degrees += 360 }
grain = max(0, min(1, Double(degrees) / 360.0))
})
}
private func point(onCircleOf radius: CGFloat, angle: Angle) -> CGPoint {
let r = radius
let a = CGFloat(angle.radians)
return CGPoint(x: cos(a) * r, y: sin(a) * r)
}
}
```
## /Nook/Components/ColorPicker/GrainSlider.swift
```swift path="/Nook/Components/ColorPicker/GrainSlider.swift"
import SwiftUI
// MARK: - GrainSlider
// Custom horizontal slider with a sine-wave track and vertical white thumb
struct GrainSlider: View {
@Binding var value: Double
@StateObject private var dragLockManager = DragLockManager.shared
@State private var dragSessionID: String = UUID().uuidString
@State private var isDragging = false // 0...1
var body: some View {
GeometryReader { proxy in
let w = proxy.size.width
let h = proxy.size.height
ZStack {
// Track background
RoundedRectangle(cornerRadius: h/2, style: .continuous)
.fill(Color.black.opacity(0.08))
// Interpolated wave: amplitude goes 0 -> max with value
let amplitude = max(0.001, value) * (h * 0.22)
InterpolatedWave(amplitude: amplitude)
.stroke(
LinearGradient(colors: [
Color.black.opacity(0.15),
Color.black.opacity(0.45)
], startPoint: .leading, endPoint: .trailing),
lineWidth: 3
)
.padding(.horizontal, 16)
// Thumb
let x = CGFloat(value) * w
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.white)
.frame(width: 14 + CGFloat(value) * 10, height: (h - 8) + CGFloat(value) * 6)
.position(x: min(max(9, x), w - 9), y: h/2)
.shadow(color: .black.opacity(0.12), radius: 1, x: 0, y: 1)
}
.gesture(DragGesture(minimumDistance: 0).onChanged { g in
if !isDragging {
guard dragLockManager.startDrag(ownerID: dragSessionID) else {
print("🚫 [GrainSlider] Drag blocked - \(dragLockManager.debugInfo)")
return
}
isDragging = true
}
let x = min(max(0, g.location.x), w)
value = Double(x / w)
}.onEnded { _ in
isDragging = false
dragLockManager.endDrag(ownerID: dragSessionID)
})
}
.frame(height: 44)
}
}
private struct InterpolatedWave: Shape {
let amplitude: CGFloat // 0 = line, else sine amplitude
func path(in rect: CGRect) -> Path {
var p = Path()
let midY = rect.midY
let length = rect.width
p.move(to: CGPoint(x: rect.minX, y: midY))
let step: CGFloat = 2
let period: CGFloat = 18
for x in stride(from: CGFloat(0), through: length, by: step) {
let y = sin(x / period) * amplitude + midY
p.addLine(to: CGPoint(x: rect.minX + x, y: y))
}
return p
}
}
```
## /Nook/Components/ColorPicker/TransparencySlider.swift
```swift path="/Nook/Components/ColorPicker/TransparencySlider.swift"
import SwiftUI
// MARK: - TransparencySlider
// Controls global opacity of the gradient layer
struct TransparencySlider: View {
@Binding var gradient: SpaceGradient
@EnvironmentObject var gradientColorManager: GradientColorManager
@State private var localOpacity: Double = 1.0
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Opacity")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.0f%%", localOpacity * 100))
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
opacityPreview
.frame(width: 48, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
Slider(value: $localOpacity, in: 0...1)
}
}
.onAppear { localOpacity = clamp(gradient.opacity) }
.onChange(of: gradient.opacity) { _, newValue in localOpacity = clamp(newValue) }
.onChange(of: localOpacity) { _, newValue in
gradient.opacity = clamp(newValue)
// Push live background update immediately
gradientColorManager.setImmediate(gradient)
}
}
private var opacityPreview: some View {
ZStack {
CheckerboardBackground()
// Lightweight gradient preview inline
let pts = linePoints(angle: gradient.angle)
Rectangle()
.fill(LinearGradient(gradient: Gradient(stops: stops()), startPoint: pts.start, endPoint: pts.end))
.opacity(clamp(localOpacity))
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
}
private func clamp(_ v: Double) -> Double { min(1.0, max(0.0, v)) }
private func stops() -> [Gradient.Stop] {
var mapped: [Gradient.Stop] = gradient.sortedNodes.map { node in
Gradient.Stop(color: Color(hex: node.colorHex), location: CGFloat(node.location))
}
if mapped.count == 0 {
let def = SpaceGradient.default
mapped = def.sortedNodes.map { node in
Gradient.Stop(color: Color(hex: node.colorHex), location: CGFloat(node.location))
}
} else if mapped.count == 1 {
let single = mapped[0]
mapped = [
Gradient.Stop(color: single.color, location: 0.0),
Gradient.Stop(color: single.color, location: 1.0)
]
}
return mapped
}
private func linePoints(angle: Double) -> (start: UnitPoint, end: UnitPoint) {
let theta = Angle(degrees: angle).radians
let dx = cos(theta)
let dy = sin(theta)
let start = UnitPoint(x: 0.5 - 0.5 * dx, y: 0.5 - 0.5 * dy)
let end = UnitPoint(x: 0.5 + 0.5 * dx, y: 0.5 + 0.5 * dy)
return (start, end)
}
}
// MARK: - Checkerboard Background
private struct CheckerboardBackground: View {
var body: some View {
GeometryReader { proxy in
let size = proxy.size
let tile: CGFloat = 6
let cols = Int(ceil(size.width / tile))
let rows = Int(ceil(size.height / tile))
Canvas { context, _ in
for r in 0..<rows {
for c in 0..<cols {
let isDark = (r + c) % 2 == 0
let rect = CGRect(x: CGFloat(c) * tile, y: CGFloat(r) * tile, width: tile, height: tile)
context.fill(Path(rect), with: .color(isDark ? Color.black.opacity(0.08) : Color.white.opacity(0.9)))
}
}
}
}
}
}
```
## /Nook/Components/CommandPalette/CommandPaletteSuggestionView.swift
```swift path="/Nook/Components/CommandPalette/CommandPaletteSuggestionView.swift"
//
// CommandPaletteSuggestionView.swift
// Nook
//
// Created by Maciek Bagiński on 31/07/2025.
//
import SwiftUI
import FaviconFinder
struct CommandPaletteSuggestionView: View {
var favicon: SwiftUI.Image
var text: String
var secondaryText: String? = nil
var isTabSuggestion: Bool = false
var isSelected: Bool = false
var historyURL: URL? = nil
@State private var isHovered: Bool = false
@State private var resolvedFavicon: SwiftUI.Image? = nil
var body: some View {
HStack(alignment: .center,spacing: 12) {
(resolvedFavicon ?? favicon)
.resizable()
.scaledToFit()
.frame(width: 14, height: 14)
.foregroundStyle(.white.opacity(0.2))
if let secondary = secondaryText, !secondary.isEmpty {
HStack(spacing: 6) {
Text(text)
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
Text("-")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.35))
Text(secondary)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
.lineLimit(1)
.truncationMode(.tail)
}
} else {
Text(text)
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
if isTabSuggestion {
HStack(spacing: 6) {
Text("Switch to Tab")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
Image(systemName: "arrow.right")
.font(.system(size: 8, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovered = hovering
}
}
.onAppear {
guard let url = historyURL else { return }
Task { await fetchFavicon(for: url) }
}
}
private var backgroundColor: Color {
if isSelected {
return Color.white.opacity(0.25)
} else if isHovered {
return Color.white.opacity(0.15)
} else {
return Color.clear
}
}
// MARK: - Favicon Fetching (for history items)
private func fetchFavicon(for url: URL) async {
let defaultFavicon = SwiftUI.Image(systemName: "globe")
// Skip favicon fetching for non-web schemes
guard url.scheme == "http" || url.scheme == "https", url.host != nil else {
await MainActor.run { self.resolvedFavicon = defaultFavicon }
return
}
// Check cache first
let cacheKey = url.host ?? url.absoluteString
if let cachedFavicon = Tab.getCachedFavicon(for: cacheKey) {
await MainActor.run { self.resolvedFavicon = cachedFavicon }
return
}
do {
let favicon = try await FaviconFinder(url: url)
.fetchFaviconURLs()
.download()
.largest()
if let faviconImage = favicon.image {
let nsImage = faviconImage.image
let swiftUIImage = SwiftUI.Image(nsImage: nsImage)
// Cache the favicon
Tab.cacheFavicon(swiftUIImage, for: cacheKey)
await MainActor.run { self.resolvedFavicon = swiftUIImage }
} else {
await MainActor.run { self.resolvedFavicon = defaultFavicon }
}
} catch {
await MainActor.run { self.resolvedFavicon = defaultFavicon }
}
}
}
```
## /Nook/Components/CommandPalette/CommandPaletteView.swift
```swift path="/Nook/Components/CommandPalette/CommandPaletteView.swift"
//
// CommandPaletteView.swift
// Nook
//
// Created by Maciek Bagiński on 28/07/2025.
//
import AppKit
import SwiftUI
struct CommandPaletteView: View {
@EnvironmentObject var browserManager: BrowserManager
@EnvironmentObject var windowState: BrowserWindowState
@EnvironmentObject var gradientColorManager: GradientColorManager
@State private var searchManager = SearchManager()
@Environment(\.colorScheme) var colorScheme
@FocusState private var isSearchFocused: Bool
@State private var text: String = ""
@State private var selectedSuggestionIndex: Int = -1
@State private var hoveredSuggestionIndex: Int? = nil
let commandPaletteWidth: CGFloat = 765
let commandPaletteHorizontalPadding: CGFloat = 10
/// Active window width
private var currentWindowWidth: CGFloat {
return NSApplication.shared.keyWindow?.frame.width ?? 0
}
/// Check if the command palette fits in the window
private var isWindowTooNarrow: Bool {
let requiredWidth = commandPaletteWidth + (commandPaletteHorizontalPadding * 2)
return currentWindowWidth <= requiredWidth
}
/// Caclulate the correct command palette width
private var effectiveCommandPaletteWidth: CGFloat {
if isWindowTooNarrow {
return max(200, currentWindowWidth - (commandPaletteHorizontalPadding * 2))
} else {
return commandPaletteWidth
}
}
var body: some View {
let isDark = colorScheme == .dark
let isActiveWindow =
browserManager.activeWindowState?.id == windowState.id
let isVisible = isActiveWindow && windowState.isCommandPaletteVisible
ZStack {
Color.clear
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
browserManager.closeCommandPalette(for: windowState)
}
.gesture(WindowDragGesture())
VStack {
Spacer()
HStack {
Spacer()
VStack {
VStack(alignment: .center,spacing: 6) {
// Input field - fixed at top of box
HStack(spacing: 15) {
Image(
systemName: isLikelyURL(text)
? "globe" : "magnifyingglass"
)
.id(isLikelyURL(text) ? "globe" : "magnifyingglass")
.transition(.blur(intensity: 2, scale: 0.6).animation(.smooth(duration: 0.3)))
.font(.system(size: 14, weight: .regular))
.foregroundStyle(isDark ? .white : .black)
.frame(width: 15)
TextField("Search or enter URL...", text: $text)
.textFieldStyle(.plain)
.font(.system(size: 18, weight: .medium))
.foregroundColor(
text.isEmpty
? isDark
? .white.opacity(0.25)
: .black.opacity(0.25)
: isDark
? .white.opacity(0.9)
: .black.opacity(0.9)
)
.tint(gradientColorManager.primaryColor)
.focused($isSearchFocused)
.onKeyPress(.return) {
handleReturn()
return .handled
}
.onKeyPress(.upArrow) {
navigateSuggestions(direction: -1)
return .handled
}
.onKeyPress(.downArrow) {
navigateSuggestions(direction: 1)
return .handled
}
.onChange(of: text) { _, newValue in
selectedSuggestionIndex = -1
searchManager.searchSuggestions(
for: newValue
)
if windowState.commandPalettePrefilledText
!= newValue
{
windowState
.commandPalettePrefilledText =
newValue
}
}
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
// Separator
if !searchManager.suggestions.isEmpty {
RoundedRectangle(cornerRadius: 100)
.fill(
isDark
? Color.white.opacity(0.4)
: Color.black.opacity(0.4)
)
.frame(height: 0.5)
.frame(maxWidth: .infinity)
}
// Suggestions - expand the box downward
if !searchManager.suggestions.isEmpty {
let suggestions = searchManager.suggestions
CommandPaletteSuggestionsListView(
suggestions: suggestions,
selectedIndex: $selectedSuggestionIndex,
hoveredIndex: $hoveredSuggestionIndex,
onSelect: { suggestion in
selectSuggestion(suggestion)
}
)
}
}
.padding(10)
.frame(maxWidth: .infinity)
.frame(width: effectiveCommandPaletteWidth)
.background(.thickMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(
Color.white.opacity(isDark ? 0.3 : 0.6),
lineWidth: 0.5
)
)
.shadow(color: .black.opacity(0.4), radius: 50, x: 0, y: 4)
.animation(
.easeInOut(duration: 0.15),
value: searchManager.suggestions.count
)
Spacer()
.border(.red)
}
.frame(
width: effectiveCommandPaletteWidth,
height: 328
)
Spacer()
}
Spacer()
}
}
.allowsHitTesting(isVisible)
.opacity(isVisible ? 1.0 : 0.0)
.onChange(of: windowState.isCommandPaletteVisible) { _, newVisible in
if newVisible && isActiveWindow {
searchManager.setTabManager(browserManager.tabManager)
searchManager.setHistoryManager(browserManager.historyManager)
searchManager.updateProfileContext()
// Pre-fill text if provided and select all for easy replacement
text = windowState.commandPalettePrefilledText
DispatchQueue.main.async {
isSearchFocused = true
// Select all once focused so the URL is highlighted
DispatchQueue.main.async {
NSApplication.shared.sendAction(
#selector(NSText.selectAll(_:)),
to: nil,
from: nil
)
}
}
} else {
isSearchFocused = false
searchManager.clearSuggestions()
text = ""
selectedSuggestionIndex = -1
}
}
// Keep search profile context updated while palette is open
.onChange(of: browserManager.currentProfile?.id) { _, _ in
if windowState.isCommandPaletteVisible {
searchManager.updateProfileContext()
// Clear suggestions to avoid cross-profile residue
searchManager.clearSuggestions()
}
}
.onKeyPress(.escape) {
DispatchQueue.main.async {
browserManager.closeCommandPalette(for: windowState)
}
return .handled
}
.onChange(of: searchManager.suggestions.count) { _, newCount in
if newCount == 0 {
selectedSuggestionIndex = -1
} else if selectedSuggestionIndex >= newCount {
selectedSuggestionIndex = -1
}
}
.animation(.easeInOut(duration: 0.15), value: selectedSuggestionIndex)
.onChange(of: windowState.commandPalettePrefilledText) { _, newValue in
if isVisible {
text = newValue
DispatchQueue.main.async {
isSearchFocused = true
}
}
}
}
private func isEmoji(_ string: String) -> Bool {
return string.unicodeScalars.contains { scalar in
(scalar.value >= 0x1F300 && scalar.value <= 0x1F9FF)
|| (scalar.value >= 0x2600 && scalar.value <= 0x26FF)
|| (scalar.value >= 0x2700 && scalar.value <= 0x27BF)
}
}
// MARK: - Suggestions List Subview
private struct CommandPaletteSuggestionsListView: View {
@EnvironmentObject var gradientColorManager: GradientColorManager
let suggestions: [SearchManager.SearchSuggestion]
@Binding var selectedIndex: Int
@Binding var hoveredIndex: Int?
@Environment(\.colorScheme) var colorScheme
let onSelect: (SearchManager.SearchSuggestion) -> Void
var body: some View {
let isDark = colorScheme == .dark
LazyVStack(spacing: 5) {
ForEach(suggestions.indices, id: \.self) { index in
let suggestion = suggestions[index]
let isHovered = hoveredIndex == index
row(for: suggestion, isSelected: selectedIndex == index)
.padding(.horizontal, 10)
.padding(.vertical, 11)
.background(
selectedIndex == index
? gradientColorManager.primaryColor
: isHovered
? isDark
? .white.opacity(0.05)
: .black.opacity(0.05) : .clear
)
.clipShape(
RoundedRectangle(cornerRadius: 6)
)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.contentShape(RoundedRectangle(cornerRadius: 6))
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.12)) {
if hovering {
hoveredIndex = index
} else {
hoveredIndex = nil
}
}
}
.onTapGesture { onSelect(suggestion) }
}
}
}
@ViewBuilder
private func row(
for suggestion: SearchManager.SearchSuggestion,
isSelected: Bool
) -> some View {
switch suggestion.type {
case .tab(let tab):
TabSuggestionItem(tab: tab, isSelected: isSelected)
case .history(let entry):
HistorySuggestionItem(entry: entry, isSelected: isSelected)
case .url:
GenericSuggestionItem(
icon: Image(systemName: "link"),
text: suggestion.text,
isSelected: isSelected
)
case .search:
GenericSuggestionItem(
icon: Image(systemName: "magnifyingglass"),
text: suggestion.text,
isSelected: isSelected
)
}
}
}
private func handleReturn() {
if selectedSuggestionIndex >= 0
&& selectedSuggestionIndex < searchManager.suggestions.count
{
let suggestion = searchManager.suggestions[selectedSuggestionIndex]
selectSuggestion(suggestion)
} else {
// Create new suggestion from text input
let newSuggestion = SearchManager.SearchSuggestion(
text: text,
type: isLikelyURL(text) ? .url : .search
)
selectSuggestion(newSuggestion)
}
}
private func selectSuggestion(_ suggestion: SearchManager.SearchSuggestion)
{
switch suggestion.type {
case .tab(let existingTab):
// Switch to existing tab in this window
browserManager.selectTab(existingTab, in: windowState)
print("Switched to existing tab: \(existingTab.name)")
case .history(let historyEntry):
if windowState.shouldNavigateCurrentTab
&& browserManager.currentTab(for: windowState) != nil
{
// Navigate current tab to history URL
browserManager.currentTab(for: windowState)?.loadURL(
historyEntry.url.absoluteString
)
print(
"Navigated current tab to history URL: \(historyEntry.url)"
)
} else {
// Create new tab from history entry
browserManager.createNewTab(in: windowState)
browserManager.currentTab(for: windowState)?.loadURL(
historyEntry.url.absoluteString
)
print(
"Created new tab from history in window \(windowState.id)"
)
}
case .url, .search:
if windowState.shouldNavigateCurrentTab
&& browserManager.currentTab(for: windowState) != nil
{
// Navigate current tab to new URL with proper normalization
browserManager.currentTab(for: windowState)?.navigateToURL(
suggestion.text
)
print("Navigated current tab to: \(suggestion.text)")
} else {
// Create new tab
browserManager.createNewTab(in: windowState)
browserManager.currentTab(for: windowState)?.navigateToURL(
suggestion.text
)
print("Created new tab in window \(windowState.id)")
}
}
text = ""
selectedSuggestionIndex = -1
browserManager.closeCommandPalette(for: windowState)
}
private func navigateSuggestions(direction: Int) {
let maxIndex = searchManager.suggestions.count - 1
if direction > 0 {
selectedSuggestionIndex = min(selectedSuggestionIndex + 1, maxIndex)
} else {
selectedSuggestionIndex = max(selectedSuggestionIndex - 1, -1)
}
}
private func iconForSuggestion(_ suggestion: SearchManager.SearchSuggestion)
-> Image
{
switch suggestion.type {
case .tab(let tab):
return tab.favicon
case .history:
return Image(systemName: "globe")
case .url:
return Image(systemName: "link")
case .search:
return Image(systemName: "magnifyingglass")
}
}
@ViewBuilder
private func suggestionRow(
for suggestion: SearchManager.SearchSuggestion,
isSelected: Bool
) -> some View {
switch suggestion.type {
case .tab(let tab):
TabSuggestionItem(tab: tab, isSelected: isSelected)
.foregroundStyle(AppColors.textPrimary)
case .history(let entry):
HistorySuggestionItem(entry: entry, isSelected: isSelected)
.foregroundStyle(AppColors.textPrimary)
case .url:
GenericSuggestionItem(
icon: Image(systemName: "link"),
text: suggestion.text,
isSelected: isSelected
)
.foregroundStyle(AppColors.textPrimary)
case .search:
GenericSuggestionItem(
icon: Image(systemName: "magnifyingglass"),
text: suggestion.text,
isSelected: isSelected
)
.foregroundStyle(AppColors.textPrimary)
}
}
private func urlForSuggestion(_ suggestion: SearchManager.SearchSuggestion)
-> URL?
{
switch suggestion.type {
case .history(let entry):
return entry.url
default:
return nil
}
}
private func isTabSuggestion(_ suggestion: SearchManager.SearchSuggestion)
-> Bool
{
switch suggestion.type {
case .tab:
return true
case .search, .url, .history:
return false
}
}
}
```
## /Nook/Components/CommandPalette/GenericSuggestionItem.swift
```swift path="/Nook/Components/CommandPalette/GenericSuggestionItem.swift"
//
// GenericSuggestionItem.swift
// Nook
//
// Created by Maciek Bagiński on 18/08/2025.
//
import SwiftUI
struct GenericSuggestionItem: View {
let icon: Image
let text: String
var isSelected: Bool = false
@Environment(\.colorScheme) var colorScheme
var body: some View {
let isDark = colorScheme == .dark
HStack(alignment: .center, spacing: 12) {
ZStack {
icon
.resizable()
.scaledToFit()
.frame(width: 14, height: 14)
.foregroundStyle(isSelected ? .white : isDark ? .white.opacity(0.7) : .black.opacity(0.7))
}
.frame(width: 24, height: 24)
.clipShape(RoundedRectangle(cornerRadius: 4))
Text(text)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(isSelected ? .white : isDark ? .white.opacity(0.6) : .black.opacity(0.8))
.lineLimit(1)
.truncationMode(.tail)
Spacer()
}
.frame(maxWidth: .infinity)
}
}
```
## /Nook/Components/CommandPalette/HistorySuggestionItem.swift
```swift path="/Nook/Components/CommandPalette/HistorySuggestionItem.swift"
//
// HistorySuggestionItem.swift
// Nook
//
// Created by Maciek Bagiński on 18/08/2025.
//
import SwiftUI
import FaviconFinder
struct HistorySuggestionItem: View {
let entry: HistoryEntry
var isSelected: Bool = false
@State private var isHovered: Bool = false
@State private var resolvedFavicon: SwiftUI.Image? = nil
@Environment(\.colorScheme) var colorScheme
// Color configuration
private var colors: ColorConfig {
ColorConfig(
isDark: colorScheme == .dark,
isSelected: isSelected,
isHovered: isHovered
)
}
var body: some View {
HStack(alignment: .center, spacing: 9) {
ZStack {
(resolvedFavicon ?? Image(systemName: "globe"))
.resizable()
.scaledToFit()
.foregroundStyle(colors.faviconColor)
.frame(width: 14, height: 14)
}
.frame(width: 24, height: 24)
.background(colors.faviconBackground)
.clipShape(RoundedRectangle(cornerRadius: 4))
HStack(spacing: 4) {
Text(entry.displayTitle)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(colors.titleColor)
.lineLimit(1)
.truncationMode(.tail)
Text("-")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(colors.urlColor)
Text(entry.displayURL)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(colors.urlColor)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
}
.frame(maxWidth: .infinity)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovered = hovering
}
}
.onAppear {
Task { await fetchFavicon(for: entry.url) }
}
}
private func fetchFavicon(for url: URL) async {
let defaultFavicon = SwiftUI.Image(systemName: "globe")
guard url.scheme == "http" || url.scheme == "https", url.host != nil else {
await MainActor.run { self.resolvedFavicon = defaultFavicon }
return
}
let cacheKey = url.host ?? url.absoluteString
if let cachedFavicon = Tab.getCachedFavicon(for: cacheKey) {
await MainActor.run { self.resolvedFavicon = cachedFavicon }
return
}
do {
let favicon = try await FaviconFinder(url: url)
.fetchFaviconURLs()
.download()
.largest()
if let faviconImage = favicon.image {
let nsImage = faviconImage.image
let swiftUIImage = SwiftUI.Image(nsImage: nsImage)
Tab.cacheFavicon(swiftUIImage, for: cacheKey)
await MainActor.run { self.resolvedFavicon = swiftUIImage }
} else {
await MainActor.run { self.resolvedFavicon = defaultFavicon }
}
} catch {
await MainActor.run { self.resolvedFavicon = defaultFavicon }
}
}
}
// MARK: - Colors simplified
private struct ColorConfig {
let isDark: Bool
let isSelected: Bool
let isHovered: Bool
var titleColor: Color {
if isSelected {
return .white
}
return isDark ? .white : .black
}
var urlColor: Color {
if isSelected {
return .white.opacity(0.5)
}
return isDark ? .white.opacity(0.3) : .black.opacity(0.3)
}
var faviconColor: Color {
return .white.opacity(0.5)
}
var faviconBackground: Color {
return isSelected ? .white : .clear
}
}
```
## /Nook/Components/CommandPalette/MiniCommandPaletteView.swift
```swift path="/Nook/Components/CommandPalette/MiniCommandPaletteView.swift"
//
// MiniCommandPaletteView.swift
// Nook
//
// A compact command palette anchored to the URL bar.
//
import SwiftUI
//
// MiniCommandPaletteView.swift
// Nook
//
// A compact command palette anchored to the URL bar.
//
import SwiftUI
struct MiniCommandPaletteView: View {
@EnvironmentObject var browserManager: BrowserManager
@EnvironmentObject var windowState: BrowserWindowState
@EnvironmentObject var gradientColorManager: GradientColorManager
@State private var searchManager = SearchManager()
@Environment(\.colorScheme) var colorScheme
@FocusState private var isSearchFocused: Bool
@State private var text: String = ""
@State private var selectedSuggestionIndex: Int = -1
@State private var hoveredSuggestionIndex: Int? = nil
// Will be overridden by overlay to match URL bar width
var forcedWidth: CGFloat? = nil
var forcedCornerRadius: CGFloat? = nil
var body: some View {
let isDark = colorScheme == .dark
let symbolName = isLikelyURL(text) ? "globe" : "magnifyingglass"
let isActiveWindow = browserManager.activeWindowState?.id == windowState.id
let suggestions = searchManager.suggestions
VStack(spacing: 6) {
inputRow(symbolName: symbolName)
separatorIfNeeded(hasSuggestions: !suggestions.isEmpty)
suggestionsListView(suggestions: suggestions)
}
.padding(10)
.frame(width: forcedWidth ?? 460)
.background(.thickMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(
Color.white.opacity(isDark ? 0.3 : 0.6),
lineWidth: 0.5
)
)
.shadow(color: .black.opacity(0.4), radius: 50, x: 0, y: 4)
.onAppear {
// Wire managers
searchManager.setTabManager(browserManager.tabManager)
searchManager.setHistoryManager(browserManager.historyManager)
searchManager.updateProfileContext()
// Ensure prefill and focus when the mini palette is presented
text = windowState.commandPalettePrefilledText
DispatchQueue.main.async { isSearchFocused = true }
}
.onChange(of: windowState.isMiniCommandPaletteVisible) { _, newVisible in
if newVisible && isActiveWindow {
searchManager.setTabManager(browserManager.tabManager)
searchManager.setHistoryManager(browserManager.historyManager)
searchManager.updateProfileContext()
// Pre-fill and focus
text = windowState.commandPalettePrefilledText
DispatchQueue.main.async { isSearchFocused = true }
} else {
isSearchFocused = false
searchManager.clearSuggestions()
text = ""
selectedSuggestionIndex = -1
}
}
.onKeyPress(.escape) {
DispatchQueue.main.async { browserManager.hideMiniCommandPalette(for: windowState) }
return .handled
}
.onChange(of: searchManager.suggestions.count) { _, newCount in
if newCount == 0 {
selectedSuggestionIndex = -1
} else if selectedSuggestionIndex >= newCount {
selectedSuggestionIndex = -1
}
}
.onChange(of: windowState.commandPalettePrefilledText) { _, newValue in
if isActiveWindow && windowState.isMiniCommandPaletteVisible {
text = newValue
}
}
.onChange(of: browserManager.currentProfile?.id) { _, _ in
if isActiveWindow && windowState.isMiniCommandPaletteVisible {
searchManager.updateProfileContext()
searchManager.clearSuggestions()
}
}
}
private func inputRow(symbolName: String) -> some View {
HStack(spacing: 15) {
Image(systemName: symbolName)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(colorScheme == .dark ? .white : .black)
TextField("Search or enter URL...", text: $text)
.textFieldStyle(.plain)
.font(.system(size: 18, weight: .medium))
.foregroundColor(
text.isEmpty
? colorScheme == .dark
? .white.opacity(0.25)
: .black.opacity(0.25)
: colorScheme == .dark
? .white.opacity(0.9)
: .black.opacity(0.9)
)
.tint(gradientColorManager.primaryColor)
.focused($isSearchFocused)
.onKeyPress(.return) {
handleReturn()
return .handled
}
.onKeyPress(.upArrow) {
navigateSuggestions(direction: -1)
return .handled
}
.onKeyPress(.downArrow) {
navigateSuggestions(direction: 1)
return .handled
}
.onChange(of: text) { _, newValue in
selectedSuggestionIndex = -1
searchManager.searchSuggestions(for: newValue)
if windowState.commandPalettePrefilledText != newValue {
windowState.commandPalettePrefilledText = newValue
}
}
}
.padding(.vertical, 12)
.padding(.horizontal, 12)
}
@ViewBuilder
private func separatorIfNeeded(hasSuggestions: Bool) -> some View {
if hasSuggestions {
RoundedRectangle(cornerRadius: 100)
.fill(
colorScheme == .dark
? Color.white.opacity(0.4)
: Color.black.opacity(0.4)
)
.frame(height: 0.5)
.frame(maxWidth: .infinity)
}
}
@ViewBuilder
private func suggestionsListView(suggestions: [SearchManager.SearchSuggestion]) -> some View {
if suggestions.isEmpty {
EmptyView()
} else {
LazyVStack(spacing: 5) {
ForEach(suggestions.indices, id: \.self) { index in
let suggestion = suggestions[index]
suggestionRow(for: suggestion, isSelected: selectedSuggestionIndex == index)
.padding(.horizontal, 10)
.padding(.vertical, 11)
.background(
selectedSuggestionIndex == index
? gradientColorManager.primaryColor
: hoveredSuggestionIndex == index
? colorScheme == .dark
? .white.opacity(0.05)
: .black.opacity(0.05) : .clear
) .cornerRadius(8)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.12)) {
if hovering {
hoveredSuggestionIndex = index
} else {
hoveredSuggestionIndex = nil
}
}
}
.onTapGesture { selectSuggestion(suggestion) }
}
}
}
}
private func handleReturn() {
if selectedSuggestionIndex >= 0 && selectedSuggestionIndex < searchManager.suggestions.count {
selectSuggestion(searchManager.suggestions[selectedSuggestionIndex])
} else {
let newSuggestion = SearchManager.SearchSuggestion(
text: text,
type: isLikelyURL(text) ? .url : .search
)
selectSuggestion(newSuggestion)
}
}
private func selectSuggestion(_ suggestion: SearchManager.SearchSuggestion) {
switch suggestion.type {
case .tab(let existingTab):
browserManager.selectTab(existingTab, in: windowState)
case .history(let historyEntry):
if windowState.shouldNavigateCurrentTab && browserManager.currentTab(for: windowState) != nil {
browserManager.currentTab(for: windowState)?.loadURL(historyEntry.url.absoluteString)
} else {
browserManager.createNewTab(in: windowState)
browserManager.currentTab(for: windowState)?.loadURL(historyEntry.url.absoluteString)
}
case .url, .search:
if windowState.shouldNavigateCurrentTab && browserManager.currentTab(for: windowState) != nil {
browserManager.currentTab(for: windowState)?.navigateToURL(suggestion.text)
} else {
browserManager.createNewTab(in: windowState)
browserManager.currentTab(for: windowState)?.navigateToURL(suggestion.text)
}
}
text = ""
selectedSuggestionIndex = -1
browserManager.hideMiniCommandPalette(for: windowState)
}
private func navigateSuggestions(direction: Int) {
let maxIndex = searchManager.suggestions.count - 1
if direction > 0 {
selectedSuggestionIndex = min(selectedSuggestionIndex + 1, maxIndex)
} else {
selectedSuggestionIndex = max(selectedSuggestionIndex - 1, -1)
}
}
@ViewBuilder
private func suggestionRow(for suggestion: SearchManager.SearchSuggestion, isSelected: Bool) -> some View {
switch suggestion.type {
case .tab(let tab):
TabSuggestionItem(tab: tab, isSelected: isSelected)
.foregroundStyle(AppColors.textPrimary)
case .history(let entry):
HistorySuggestionItem(entry: entry, isSelected: isSelected)
.foregroundStyle(AppColors.textPrimary)
case .url:
GenericSuggestionItem(icon: Image(systemName: "link"), text: suggestion.text, isSelected: isSelected)
.foregroundStyle(AppColors.textPrimary)
case .search:
GenericSuggestionItem(icon: Image(systemName: "magnifyingglass"), text: suggestion.text, isSelected: isSelected)
.foregroundStyle(AppColors.textPrimary)
}
}
}
```
## /Nook/Components/CommandPalette/TabSuggestionItem.swift
```swift path="/Nook/Components/CommandPalette/TabSuggestionItem.swift"
//
// TabSuggestionItem.swift
// Nook
//
// Created by Maciek Bagiński on 18/08/2025.
//
import SwiftUI
struct TabSuggestionItem: View {
let tab: Tab
var isSelected: Bool = false
@State private var isHovered: Bool = false
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var gradientColorManager: GradientColorManager
var body: some View {
let isDark = colorScheme == .dark
HStack(alignment: .center, spacing: 0) {
HStack(spacing: 9) {
ZStack {
tab.favicon
.resizable()
.scaledToFit()
.foregroundStyle(.white.opacity(0.5))
.frame(width: 14, height: 14)
}
.frame(width: 24, height: 24)
.background(isSelected ? .white : .clear)
.clipShape(
RoundedRectangle(cornerRadius: 4)
)
Text(tab.name)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(isSelected ? .white : isDark ? .white.opacity(0.6) : .black.opacity(0.8))
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
HStack(spacing: 10) {
Text("Switch to Tab")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(isSelected ? .white : isDark ? .white.opacity(0.3) : .black.opacity(0.3))
ZStack {
Image(systemName: "arrow.right")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(isSelected ? gradientColorManager.primaryColor : isDark ? .white.opacity(0.5) : .black.opacity(0.5))
.frame(width: 16, height: 16)
}
.frame(width: 24, height: 24)
.background(isSelected ? .white : isDark ? .white.opacity(0.05) : .black.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.frame(maxWidth: .infinity)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovered = hovering
}
}
}
}
```
## /Nook/Components/Dialog/DialogView.swift
```swift path="/Nook/Components/Dialog/DialogView.swift"
//
// DialogView.swift
// Nook
//
// Created by Maciek Bagiński on 04/08/2025.
//
import SwiftUI
struct DialogView: View {
@EnvironmentObject var browserManager: BrowserManager
var body: some View {
ZStack {
if browserManager.dialogManager.isVisible,
let dialog = browserManager.dialogManager.activeDialog {
overlayBackground
dialogContent(dialog)
.transition(.asymmetric(
insertion: .offset(y: 30).combined(with: .blur(intensity: 3, scale: 1)),
removal: .offset(y: -30).combined(with: .blur(intensity: 3, scale: 1))
))
.zIndex(1)
}
}
.animation(.bouncy(duration: 0.2, extraBounce: -0.1), value: browserManager.dialogManager.isVisible)
}
@ViewBuilder
private var overlayBackground: some View {
Color.black.opacity(0.4)
.ignoresSafeArea()
.onTapGesture {
browserManager.dialogManager.closeDialog()
}
.transition(.opacity)
}
@ViewBuilder
private func dialogContent(_ dialog: AnyView) -> some View {
HStack {
Spacer()
dialog
Spacer()
}
}
}
```
## /Nook/Components/DragDrop/DragDropPreview.swift
```swift path="/Nook/Components/DragDrop/DragDropPreview.swift"
//
// DragDropPreview.swift
// Nook
//
// Live preview for testing the advanced drag & drop system
//
import SwiftUI
import AppKit
// MARK: - Mock Models
class MockTab: Identifiable, ObservableObject {
let id = UUID()
@Published var name: String
@Published var favicon: String
@Published var index: Int
@Published var spaceId: UUID?
var url: URL = URL(string: "https://example.com")!
init(name: String, favicon: String, index: Int = 0, spaceId: UUID? = nil) {
self.name = name
self.favicon = favicon
self.index = index
self.spaceId = spaceId
}
}
struct MockSpace: Identifiable {
let id = UUID()
let name: String
let icon: String
init(name: String, icon: String = "square.grid.2x2") {
self.name = name
self.icon = icon
}
}
// MARK: - Mock Tab Manager
@MainActor
class MockTabManager: ObservableObject {
@Published var globalPinnedTabs: [MockTab] = []
@Published var spacePinnedTabs: [UUID: [MockTab]] = [:]
@Published var regularTabs: [UUID: [MockTab]] = [:]
@Published var spaces: [MockSpace] = []
@Published var currentSpaceId: UUID?
var currentSpace: MockSpace? {
spaces.first { $0.id == currentSpaceId }
}
init() {
setupMockData()
}
private func setupMockData() {
// Create spaces
let devSpace = MockSpace(name: "Development", icon: "hammer")
let personalSpace = MockSpace(name: "Personal", icon: "person")
spaces = [devSpace, personalSpace]
currentSpaceId = devSpace.id
// Global pinned tabs
globalPinnedTabs = [
MockTab(name: "GitHub", favicon: "externaldrive.connected.to.line.below", index: 0),
MockTab(name: "Gmail", favicon: "envelope", index: 1),
MockTab(name: "Calendar", favicon: "calendar", index: 2)
]
// Space pinned tabs
spacePinnedTabs[devSpace.id] = [
MockTab(name: "Stack Overflow", favicon: "questionmark.circle", index: 0, spaceId: devSpace.id),
MockTab(name: "Documentation", favicon: "book", index: 1, spaceId: devSpace.id)
]
spacePinnedTabs[personalSpace.id] = [
MockTab(name: "Reddit", favicon: "bubble.left.and.bubble.right", index: 0, spaceId: personalSpace.id)
]
// Regular tabs
regularTabs[devSpace.id] = [
MockTab(name: "Claude", favicon: "brain", index: 0, spaceId: devSpace.id),
MockTab(name: "OpenAI", favicon: "lightbulb", index: 1, spaceId: devSpace.id),
MockTab(name: "Anthropic", favicon: "sparkles", index: 2, spaceId: devSpace.id)
]
regularTabs[personalSpace.id] = [
MockTab(name: "YouTube", favicon: "play.rectangle", index: 0, spaceId: personalSpace.id),
MockTab(name: "Netflix", favicon: "tv", index: 1, spaceId: personalSpace.id)
]
}
func handleDragOperation(_ operation: DragOperation) {
#if DEBUG
print("🎯 Mock drag operation: \(operation.fromContainer) → \(operation.toContainer) at \(operation.toIndex)")
#endif
guard let mockTab = mockTab(for: operation.tab.id) else {
#if DEBUG
print("❌ Mock tab not found for drag operation: \(operation.tab.id)")
#endif
return
}
// Mock implementation - just reorder within same container for now
switch (operation.fromContainer, operation.toContainer) {
case (.essentials, .essentials):
reorderGlobalPinned(mockTab, to: operation.toIndex)
case (.spacePinned(let spaceId), .spacePinned(let toSpaceId)) where spaceId == toSpaceId:
reorderSpacePinned(mockTab, in: spaceId, to: operation.toIndex)
case (.spaceRegular(let spaceId), .spaceRegular(let toSpaceId)) where spaceId == toSpaceId:
reorderRegular(mockTab, in: spaceId, to: operation.toIndex)
default:
#if DEBUG
print("Cross-container moves not implemented in preview")
#endif
}
}
private func reorderGlobalPinned(_ tab: MockTab, to index: Int) {
guard let currentIndex = globalPinnedTabs.firstIndex(where: { $0.id == tab.id }) else { return }
globalPinnedTabs.remove(at: currentIndex)
let clampedIndex = min(max(index, 0), globalPinnedTabs.count)
globalPinnedTabs.insert(tab, at: clampedIndex)
updateIndices(&globalPinnedTabs)
}
private func reorderSpacePinned(_ tab: MockTab, in spaceId: UUID, to index: Int) {
guard var tabs = spacePinnedTabs[spaceId],
let currentIndex = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
tabs.remove(at: currentIndex)
let clampedIndex = min(max(index, 0), tabs.count)
tabs.insert(tab, at: clampedIndex)
updateIndices(&tabs)
spacePinnedTabs[spaceId] = tabs
}
private func reorderRegular(_ tab: MockTab, in spaceId: UUID, to index: Int) {
guard var tabs = regularTabs[spaceId],
let currentIndex = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
tabs.remove(at: currentIndex)
let clampedIndex = min(max(index, 0), tabs.count)
tabs.insert(tab, at: clampedIndex)
updateIndices(&tabs)
regularTabs[spaceId] = tabs
}
private func mockTab(for id: UUID) -> MockTab? {
if let tab = globalPinnedTabs.first(where: { $0.id == id }) {
return tab
}
for (_, tabs) in spacePinnedTabs {
if let tab = tabs.first(where: { $0.id == id }) {
return tab
}
}
for (_, tabs) in regularTabs {
if let tab = tabs.first(where: { $0.id == id }) {
return tab
}
}
return nil
}
private func updateIndices(_ tabs: inout [MockTab]) {
for (index, tab) in tabs.enumerated() {
tab.index = index
}
}
}
// MARK: - Mock Tab View
struct MockTabView: View {
@ObservedObject var tab: MockTab
let action: () -> Void
@State private var isHovering: Bool = false
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: tab.favicon)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundColor(.secondary)
Text(tab.name)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.primary)
.lineLimit(1)
Spacer()
if isHovering {
Button(action: {
#if DEBUG
print("Close \(tab.name)")
#endif
}) {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .medium))
.foregroundColor(.primary)
.padding(3)
.background(Color.gray.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(PlainButtonStyle())
.transition(.scale.combined(with: .opacity))
}
}
.padding(.horizontal, 8)
.frame(height: 32)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHovering ? Color.gray.opacity(0.1) : Color.clear)
)
}
.buttonStyle(PlainButtonStyle())
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
.contextMenu {
Button("Move Up") {
#if DEBUG
print("Move \(tab.name) up")
#endif
}
Button("Move Down") {
#if DEBUG
print("Move \(tab.name) down")
#endif
}
Divider()
Button("Pin to Space") {
#if DEBUG
print("Pin \(tab.name) to space")
#endif
}
Button("Pin Globally") {
#if DEBUG
print("Pin \(tab.name) globally")
#endif
}
}
}
}
// MARK: - Mock Pinned Tab View
struct MockPinnedTabView: View {
@ObservedObject var tab: MockTab
let action: () -> Void
@State private var isHovering: Bool = false
var body: some View {
Button(action: action) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(width: 44, height: 44)
Image(systemName: tab.favicon)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(.primary)
}
}
.buttonStyle(PlainButtonStyle())
.scaleEffect(isHovering ? 1.05 : 1.0)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
.contextMenu {
Button("Unpin") {
#if DEBUG
print("Unpin \(tab.name)")
#endif
}
}
}
}
// MARK: - Drop Zone View
struct DropZoneView: View {
@Binding var isTargeted: Bool
let onDrop: () -> Bool
var body: some View {
Rectangle()
.fill(isTargeted ? Color.accentColor : Color.clear)
.frame(height: 3)
.animation(.easeInOut(duration: 0.2), value: isTargeted)
}
}
// MARK: - Main Preview View
struct DragDropPreview: View {
@StateObject private var dragManager = TabDragManager()
@StateObject private var tabManager = MockTabManager()
var body: some View {
TabDragContainerView(
dragManager: dragManager,
onDragCompleted: handleDragCompleted
) {
VStack(spacing: 16) {
Text("🧛 Dragula Preview")
.font(.title2)
.fontWeight(.bold)
VStack(spacing: 12) {
// Global Pinned Tabs (Essentials)
VStack(alignment: .leading, spacing: 8) {
Text("Essential Tabs (Global)")
.font(.headline)
.foregroundColor(.secondary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
ForEach(tabManager.globalPinnedTabs.indices, id: \.self) { index in
let tab = tabManager.globalPinnedTabs[index]
MockPinnedTabView(tab: tab) {
#if DEBUG
print("Activated: \(tab.name)")
#endif
}
.onDrag {
dragManager.startDrag(tab: convertToRealTab(tab), from: .essentials, at: index)
return NSItemProvider(object: tab.id.uuidString as NSString)
}
.onDrop(of: [.text], isTargeted: nil) { providers, location in
handleDrop(providers: providers, toContainer: .essentials, atIndex: index)
}
}
// Drop zone at the end of essentials
Rectangle()
.fill(Color.clear)
.frame(width: 44, height: 44)
.onDrop(of: [.text], isTargeted: nil) { providers, location in
handleDrop(providers: providers, toContainer: .essentials, atIndex: tabManager.globalPinnedTabs.count)
}
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Divider()
// Current Space
if let currentSpace = tabManager.currentSpace {
VStack(alignment: .leading, spacing: 12) {
Text("Space: \(currentSpace.name)")
.font(.headline)
.foregroundColor(.primary)
// Space Pinned Tabs
if let spacePinned = tabManager.spacePinnedTabs[currentSpace.id], !spacePinned.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Pinned in Space")
.font(.subheadline)
.foregroundColor(.secondary)
ForEach(spacePinned.indices, id: \.self) { index in
let tab = spacePinned[index]
VStack(spacing: 0) {
// Drop zone above each item
if index == 0 {
DropZoneView(isTargeted: .constant(false)) {
false
}
.onDrop(of: [.text], isTargeted: Binding(
get: { dragManager.isDragging },
set: { _ in }
)) { providers, location in
handleDrop(providers: providers, toContainer: .spacePinned(currentSpace.id), atIndex: index)
}
}
MockTabView(tab: tab) {
#if DEBUG
print("Activated: \(tab.name)")
#endif
}
.onDrag {
dragManager.startDrag(tab: convertToRealTab(tab), from: .spacePinned(currentSpace.id), at: index)
return NSItemProvider(object: tab.id.uuidString as NSString)
}
// Drop zone below each item
DropZoneView(isTargeted: .constant(false)) {
false
}
.onDrop(of: [.text], isTargeted: Binding(
get: { dragManager.isDragging },
set: { _ in }
)) { providers, location in
handleDrop(providers: providers, toContainer: .spacePinned(currentSpace.id), atIndex: index + 1)
}
}
}
}
Divider()
.padding(.vertical, 4)
}
// Regular Tabs
if let regularTabs = tabManager.regularTabs[currentSpace.id], !regularTabs.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Regular Tabs")
.font(.subheadline)
.foregroundColor(.secondary)
ForEach(regularTabs.indices, id: \.self) { index in
let tab = regularTabs[index]
VStack(spacing: 0) {
// Drop zone above each item
if index == 0 {
Rectangle()
.fill(dragManager.showInsertionLine && dragManager.insertionIndex == index && dragManager.dropTarget == .spaceRegular(currentSpace.id) ? Color.accentColor : Color.clear)
.frame(height: 3)
.onDrop(of: [.text], isTargeted: nil) { providers, location in
handleDrop(providers: providers, toContainer: .spaceRegular(currentSpace.id), atIndex: index)
}
}
MockTabView(tab: tab) {
#if DEBUG
print("Activated: \(tab.name)")
#endif
}
.onDrag {
dragManager.startDrag(tab: convertToRealTab(tab), from: .spaceRegular(currentSpace.id), at: index)
return NSItemProvider(object: tab.id.uuidString as NSString)
}
// Drop zone below each item
Rectangle()
.fill(dragManager.showInsertionLine && dragManager.insertionIndex == index + 1 && dragManager.dropTarget == .spaceRegular(currentSpace.id) ? Color.accentColor : Color.clear)
.frame(height: 3)
.onDrop(of: [.text], isTargeted: nil) { providers, location in
handleDrop(providers: providers, toContainer: .spaceRegular(currentSpace.id), atIndex: index + 1)
}
}
}
}
}
}
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
}
}
Spacer()
// Debug Info
VStack(alignment: .leading, spacing: 4) {
Text("Debug Info:")
.font(.caption)
.fontWeight(.bold)
Text(#"Is Dragging: \#(dragManager.isDragging.description)"#)
Text(#"Dragged Tab: \#(dragManager.draggedTab?.name ?? "None")"#)
Text(#"Insertion Index: \#(dragManager.insertionIndex.description)"#)
Text(#"Show Line: \#(dragManager.showInsertionLine.description)"#)
}
.font(.caption)
.foregroundColor(.secondary)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
.padding()
}
.insertionLineOverlay(dragManager: dragManager)
.frame(width: 300, height: 600)
}
private func handleDragCompleted(_ operation: DragOperation) {
#if DEBUG
print("🎯 Drag completed: \(operation)")
#endif
tabManager.handleDragOperation(operation)
}
private func convertToRealTab(_ mockTab: MockTab) -> Tab {
// Create a minimal Tab instance for drag purposes
return Tab(
id: mockTab.id,
url: mockTab.url,
name: mockTab.name,
favicon: mockTab.favicon,
spaceId: mockTab.spaceId,
index: mockTab.index
)
}
private func handleDrop(providers: [NSItemProvider], toContainer: TabDragManager.DragContainer, atIndex: Int) -> Bool {
#if DEBUG
print("🎯 Drop attempted: container=\(toContainer), index=\(atIndex)")
#endif
guard let draggedTab = dragManager.draggedTab else {
#if DEBUG
print("❌ No dragged tab found")
#endif
return false
}
// Find the mock tab to move
guard let mockTab = findMockTab(by: draggedTab.id) else {
#if DEBUG
print("❌ Could not find mock tab with ID: \(draggedTab.id)")
#endif
return false
}
// Remove from source
removeMockTab(mockTab)
// Add to destination
insertMockTab(mockTab, toContainer: toContainer, atIndex: atIndex)
// End the drag
_ = dragManager.endDrag(commit: true)
#if DEBUG
print("✅ Successfully moved \(mockTab.name) to \(toContainer) at index \(atIndex)")
#endif
return true
}
private func findMockTab(by id: UUID) -> MockTab? {
// Search in all containers
if let tab = tabManager.globalPinnedTabs.first(where: { $0.id == id }) { return tab }
for (_, tabs) in tabManager.spacePinnedTabs {
if let tab = tabs.first(where: { $0.id == id }) { return tab }
}
for (_, tabs) in tabManager.regularTabs {
if let tab = tabs.first(where: { $0.id == id }) { return tab }
}
return nil
}
private func removeMockTab(_ tab: MockTab) {
// Remove from global pinned
tabManager.globalPinnedTabs.removeAll { $0.id == tab.id }
// Remove from space pinned
for spaceId in tabManager.spacePinnedTabs.keys {
tabManager.spacePinnedTabs[spaceId]?.removeAll { $0.id == tab.id }
}
// Remove from regular tabs
for spaceId in tabManager.regularTabs.keys {
tabManager.regularTabs[spaceId]?.removeAll { $0.id == tab.id }
}
}
private func insertMockTab(_ tab: MockTab, toContainer: TabDragManager.DragContainer, atIndex: Int) {
switch toContainer {
case .essentials:
let clampedIndex = min(max(atIndex, 0), tabManager.globalPinnedTabs.count)
tabManager.globalPinnedTabs.insert(tab, at: clampedIndex)
tab.spaceId = nil
case .spacePinned(let spaceId):
if tabManager.spacePinnedTabs[spaceId] == nil {
tabManager.spacePinnedTabs[spaceId] = []
}
// Safely access the array with optional chaining instead of force unwrap
if var spacePinnedArray = tabManager.spacePinnedTabs[spaceId] {
let clampedIndex = min(max(atIndex, 0), spacePinnedArray.count)
spacePinnedArray.insert(tab, at: clampedIndex)
tabManager.spacePinnedTabs[spaceId] = spacePinnedArray
}
tab.spaceId = spaceId
case .spaceRegular(let spaceId):
if tabManager.regularTabs[spaceId] == nil {
tabManager.regularTabs[spaceId] = []
}
// Safely access the array with optional chaining instead of force unwrap
if var regularTabsArray = tabManager.regularTabs[spaceId] {
let clampedIndex = min(max(atIndex, 0), regularTabsArray.count)
regularTabsArray.insert(tab, at: clampedIndex)
tabManager.regularTabs[spaceId] = regularTabsArray
}
tab.spaceId = spaceId
case .none:
#if DEBUG
print("❌ Invalid drop container")
#endif
case .folder(_):
// Handle folder drop container
break
}
}
}
// MARK: - Preview
#Preview {
DragDropPreview()
.frame(width: 320, height: 640)
}
```
## /Nook/Components/DragDrop/DragEnabledSidebarView.swift
```swift path="/Nook/Components/DragDrop/DragEnabledSidebarView.swift"
//
// DragEnabledSidebarView.swift
// Nook
//
// Main sidebar with advanced drag & drop functionality
//
import SwiftUI
struct DragEnabledSidebarView: View {
@EnvironmentObject var browserManager: BrowserManager
private var dragManager = TabDragManager.shared
var body: some View {
SidebarView()
.environmentObject(browserManager)
.environmentObject(dragManager)
.tabDragManager(dragManager)
}
}
// MARK: - Environment Key for DragManager
struct TabDragManagerKey: EnvironmentKey {
static let defaultValue: TabDragManager? = nil
}
extension EnvironmentValues {
var tabDragManager: TabDragManager? {
get { self[TabDragManagerKey.self] }
set { self[TabDragManagerKey.self] = newValue }
}
}
extension View {
func tabDragManager(_ dragManager: TabDragManager) -> some View {
environment(\.tabDragManager, dragManager)
}
}
```
## /Nook/Components/DragDrop/DraggableTabView.swift
```swift path="/Nook/Components/DragDrop/DraggableTabView.swift"
//
// DraggableTabView.swift
// Nook
//
// Draggable wrapper for tab views with drag state management
//
import SwiftUI
import AppKit
struct DraggableTabView<Content: View>: View {
let tab: Tab
let container: TabDragManager.DragContainer
let index: Int
let dragManager: TabDragManager
let content: Content
@State private var dragGesture = false
@State private var dragOffset: CGSize = .zero
@StateObject private var dragLockManager = DragLockManager.shared
@State private var dragSessionID: String = UUID().uuidString
init(
tab: Tab,
container: TabDragManager.DragContainer,
index: Int,
dragManager: TabDragManager,
@ViewBuilder content: () -> Content
) {
self.tab = tab
self.container = container
self.index = index
self.dragManager = dragManager
self.content = content()
}
var body: some View {
content
.opacity(dragManager.isDragging && dragManager.draggedTab?.id == tab.id ? 0.5 : 1.0)
.scaleEffect(dragManager.isDragging && dragManager.draggedTab?.id == tab.id ? 0.95 : 1.0)
.offset(dragOffset)
.animation(.easeInOut(duration: 0.2), value: dragManager.isDragging)
.animation(.easeInOut(duration: 0.2), value: dragOffset)
.onDrag {
// Pre-emptively acquire drag lock BEFORE anything else
guard dragLockManager.startDrag(ownerID: dragSessionID) else {
print("🚫 [DraggableTabView] Tab drag blocked at onset - \(dragLockManager.debugInfo)")
return NSItemProvider(object: tab.id.uuidString as NSString)
}
// Start the drag operation with state validation
DispatchQueue.main.async {
// Harden the start guard to prevent concurrent drags
guard !dragManager.isDragging else {
dragLockManager.endDrag(ownerID: dragSessionID)
return
}
dragManager.startDrag(tab: tab, from: container, at: index)
}
return NSItemProvider(object: tab.id.uuidString as NSString)
}
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged { value in
if !dragGesture {
// Pre-emptively acquire drag lock for gesture-based drag
guard dragLockManager.startDrag(ownerID: dragSessionID) else {
print("🚫 [DraggableTabView] Gesture drag blocked at onset - \(dragLockManager.debugInfo)")
return
}
dragGesture = true
// Enhanced drag gesture coordination - prevent duplicate drag start calls
if !dragManager.isDragging {
dragManager.startDrag(tab: tab, from: container, at: index)
}
}
// Update drag offset for visual feedback
dragOffset = value.translation
}
.onEnded { value in
dragGesture = false
// Ensure drag offset is properly reset in all scenarios
withAnimation(.easeOut(duration: 0.15)) {
dragOffset = .zero
}
// Enhanced drag end handling with state validation
if dragManager.isDragging && dragManager.draggedTab?.id == tab.id {
// Add validation that the drag manager is in the expected state
guard dragManager.draggedTab != nil else {
dragLockManager.endDrag(ownerID: dragSessionID)
return
}
// Only cancel drag if no valid drop target
if dragManager.dropTarget == .none {
dragManager.cancelDrag()
}
}
// Always release drag lock when gesture ends
dragLockManager.endDrag(ownerID: dragSessionID)
}
)
.onReceive(NotificationCenter.default.publisher(for: .tabDragDidEnd)) { _ in
// Ensure drag lock is released when any tab drag ends
dragLockManager.endDrag(ownerID: dragSessionID)
}
}
}
// MARK: - Extension for easy usage
extension View {
func draggableTab(
tab: Tab,
container: TabDragManager.DragContainer,
index: Int,
dragManager: TabDragManager
) -> some View {
DraggableTabView(
tab: tab,
container: container,
index: index,
dragManager: dragManager
) {
self
}
}
}
```
## /Nook/Components/DragDrop/InsertionLineView.swift
```swift path="/Nook/Components/DragDrop/InsertionLineView.swift"
//
// InsertionLineView.swift
// Nook
//
// Visual insertion line for drag & drop feedback
//
import SwiftUI
struct InsertionLineView: View {
@ObservedObject var dragManager: TabDragManager
init(dragManager: TabDragManager) {
self.dragManager = dragManager
}
var body: some View {
let shouldShow = dragManager.showInsertionLine && dragManager.insertionLineFrame != .zero
return GeometryReader { _ in
ZStack {
if shouldShow {
let frame = dragManager.insertionLineFrame
RoundedRectangle(cornerRadius: 2)
.fill(
LinearGradient(
colors: [
Color.red.opacity(0.9),
Color.red,
Color.red.opacity(0.9)
],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: max(frame.width, 1), height: max(frame.height, 1))
.position(x: frame.midX, y: frame.midY)
.animation(.easeInOut(duration: 0.15), value: dragManager.insertionLineFrame)
.shadow(color: Color.black.opacity(0.3), radius: 1, x: 0, y: 1)
.transition(.scale.combined(with: .opacity))
.zIndex(9999)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.allowsHitTesting(false)
}
}
struct InsertionLineModifier: ViewModifier {
let dragManager: TabDragManager
func body(content: Content) -> some View {
ZStack {
content
// Separate layer for insertion line with high z-index
InsertionLineView(dragManager: dragManager)
.animation(.easeInOut(duration: 0.15), value: dragManager.showInsertionLine)
.zIndex(9999) // Very high z-index
}
}
}
extension View {
func insertionLineOverlay(dragManager: TabDragManager) -> some View {
modifier(InsertionLineModifier(dragManager: dragManager))
}
}
```
## /Nook/Components/DragDrop/SidebarDropSupport.swift
```swift path="/Nook/Components/DragDrop/SidebarDropSupport.swift"
//
// SidebarDropSupport.swift
// Nook
//
// Shared drop-delegate logic and boundary math adapted from SimpleDragPreview.swift
// to drive the actual Sidebar (essentials grid, space-pinned list, regular list).
//
import SwiftUI
import UniformTypeIdentifiers
#if canImport(AppKit)
import AppKit
#endif
// MARK: - Grid Boundary Model (Essentials)
enum SidebarGridBoundaryOrientation { case horizontal, vertical }
struct SidebarGridBoundary {
let index: Int
let orientation: SidebarGridBoundaryOrientation
let frame: CGRect // in grid local coordinates
}
// MARK: - Boundary Math (EXACT methodology as in SimpleDragPreview)
struct SidebarDropMath {
static func computeGridBoundaries(
frames: [Int: CGRect],
columns: Int,
containerWidth: CGFloat,
gridGap: CGFloat
) -> [SidebarGridBoundary] {
let count = frames.count
guard count > 0 else { return [] }
let sorted = (0..<count).compactMap { frames[$0] }
guard sorted.count == count else { return [] }
var boundaries: [SidebarGridBoundary] = []
func rowRange(forRow row: Int) -> Range<Int> {
let start = row * columns
let end = min(start + columns, count)
return start..<end
}
func rowOf(index: Int) -> Int { max(0, index / columns) }
// Precompute per-row vertical span
let totalRows = Int(ceil(Double(count) / Double(columns)))
var rowMinY: [CGFloat] = []
var rowMaxY: [CGFloat] = []
for r in 0..<totalRows {
let rr = rowRange(forRow: r)
let minY = rr.compactMap { frames[$0]?.minY }.min() ?? 0
let maxY = rr.compactMap { frames[$0]?.maxY }.max() ?? 0
rowMinY.append(minY)
rowMaxY.append(maxY)
}
for i in 0...count {
if i == 0 {
// Before first tile → vertical line at left edge of first column
if let first = frames[0] {
let r = rowOf(index: 0)
let x = first.minX - (gridGap / 2)
let yMid = (rowMinY[r] + rowMaxY[r]) / 2
let h = rowMaxY[r] - rowMinY[r]
let f = CGRect(x: x - 1.5, y: yMid - h/2, width: 3, height: h)
boundaries.append(SidebarGridBoundary(index: 0, orientation: .vertical, frame: f))
}
} else if i == count {
// After last tile: vertical if row incomplete, else horizontal below last row
let last = count - 1
let lastRow = rowOf(index: last)
let rowStart = rowRange(forRow: lastRow).lowerBound
let inRowIndex = last - rowStart + 1
if inRowIndex < columns, let prev = frames[last] {
// Vertical at right of last cell in last row
let x = prev.maxX + (gridGap / 2)
let yMid = (rowMinY[lastRow] + rowMaxY[lastRow]) / 2
let h = rowMaxY[lastRow] - rowMinY[lastRow]
let f = CGRect(x: x - 1.5, y: yMid - h/2, width: 3, height: h)
boundaries.append(SidebarGridBoundary(index: i, orientation: .vertical, frame: f))
} else {
// Horizontal below last row across container width
let y = rowMaxY[lastRow] + (gridGap / 2)
let f = CGRect(x: 0, y: y - 1.5, width: containerWidth, height: 3)
boundaries.append(SidebarGridBoundary(index: i, orientation: .horizontal, frame: f))
}
} else if i % columns == 0 {
// Between rows (horizontal)
let r = rowOf(index: i)
let prevRow = max(0, r - 1)
let y = (rowMaxY[prevRow] + rowMinY[r]) / 2
let f = CGRect(x: 0, y: y - 1.5, width: containerWidth, height: 3)
boundaries.append(SidebarGridBoundary(index: i, orientation: .horizontal, frame: f))
} else {
// Between columns (vertical) in same row
if let left = frames[i - 1], let right = frames[i] {
let x = (left.maxX + right.minX) / 2
// Vertical span is the row height
let r = rowOf(index: i)
let yMid = (rowMinY[r] + rowMaxY[r]) / 2
let h = rowMaxY[r] - rowMinY[r]
let f = CGRect(x: x - 1.5, y: yMid - h/2, width: 3, height: h)
boundaries.append(SidebarGridBoundary(index: i, orientation: .vertical, frame: f))
}
}
}
return boundaries
}
static func estimatedGridWidth(from frames: [Int: CGRect]) -> CGFloat {
let minX = frames.values.map { $0.minX }.min() ?? 0
let maxX = frames.values.map { $0.maxX }.max() ?? 0
return max(0, maxX - minX)
}
static func computeListBoundaries(frames: [Int: CGRect]) -> [CGFloat] {
let count = frames.count
guard count > 0 else { return [] }
let ordered = (0..<count).compactMap { frames[$0] }
guard ordered.count == count else { return [] }
var boundaries: [CGFloat] = []
// Before first item
boundaries.append(ordered[0].minY)
// Between each pair of items - simple midpoints
for i in 0..<(count - 1) {
let midpoint = (ordered[i].maxY + ordered[i + 1].minY) / 2
boundaries.append(midpoint)
}
// After last item
boundaries.append(ordered[count - 1].maxY)
return boundaries
}
}
// MARK: - Drop Delegates (generalized)
struct SidebarSectionDropDelegate: DropDelegate {
let dragManager: TabDragManager
let container: TabDragManager.DragContainer
let boundariesProvider: () -> [CGFloat]
let insertionLineFrameProvider: (() -> CGRect)?
// Optional global container frame provider for converting local frames to global
let globalFrameProvider: (() -> CGRect)?
let onPerform: (DragOperation) -> Void
func validateDrop(info: DropInfo) -> Bool { true }
func dropEntered(info: DropInfo) {
print("🎯 [SidebarSectionDropDelegate] Drop entered: container=\(container), location=\(info.location)")
updateTarget(with: info)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
updateTarget(with: info)
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
_ = updateTarget(with: info)
if let op = dragManager.endDrag(commit: true) {
onPerform(op)
}
return true
}
@discardableResult
private func updateTarget(with info: DropInfo) -> Int {
let y = info.location.y
let index = insertionIndex(forY: y)
let boundaries = boundariesProvider()
// Enhanced state validation - ensure insertionIndex is always valid for current container
guard index >= 0 else {
return 0
}
// Handle insertion line coordination between global and local overlays
if boundaries.isEmpty {
// Empty zone: use global insertion line via frame provider
if let frameProvider = insertionLineFrameProvider {
let frame = frameProvider()
print("📐 [SidebarSectionDropDelegate] Empty zone frame (raw): \(frame)")
// If a global container frame is provided, convert local to global by offsetting
if let containerFrame = globalFrameProvider?() {
let converted = CGRect(
x: containerFrame.minX + frame.minX,
y: containerFrame.minY + frame.minY,
width: frame.width,
height: frame.height
)
print("📐 [SidebarSectionDropDelegate] Empty zone frame (global): \(converted)")
dragManager.updateInsertionLine(frame: converted)
} else {
dragManager.updateInsertionLine(frame: frame)
}
} else {
print("⚠️ [SidebarSectionDropDelegate] No frame provider for empty zone")
dragManager.updateInsertionLine(frame: .zero)
}
} else {
// Non-empty zone: calculate insertion line frame based on boundaries
print("📐 [SidebarSectionDropDelegate] Non-empty boundaries: \(boundaries.count) items")
// Calculate the frame for the insertion line at the target index
let targetY = (index < boundaries.count) ? boundaries[index] : (boundaries.last ?? 0)
let lineHeight: CGFloat = 3
if let containerFrame = globalFrameProvider?() {
// Convert local boundary Y into global coordinates using container frame
let insertionFrame = CGRect(
x: containerFrame.minX + 10,
y: containerFrame.minY + targetY - lineHeight/2,
width: max(containerFrame.width - 20, 1),
height: lineHeight
)
print("📐 [SidebarSectionDropDelegate] Calculated insertion frame (global): \(insertionFrame)")
dragManager.updateInsertionLine(frame: insertionFrame)
} else {
let containerWidth: CGFloat = 200 // Fallback sidebar width
let insertionFrame = CGRect(
x: 10,
y: targetY - lineHeight/2,
width: containerWidth - 20,
height: lineHeight
)
print("📐 [SidebarSectionDropDelegate] Calculated insertion frame (local): \(insertionFrame)")
dragManager.updateInsertionLine(frame: insertionFrame)
}
}
// Add state consistency checks before updating drag target
if validateDragManagerState(for: container, index: index) {
dragManager.updateDragTarget(container: container, insertionIndex: index, spaceId: spaceId)
}
performMoveHapticIfNeeded(currentIndex: index)
return index
}
private var spaceId: UUID? {
switch container {
case .spacePinned(let sid): return sid
case .spaceRegular(let sid): return sid
default: return nil
}
}
private func insertionIndex(forY y: CGFloat) -> Int {
let boundaries = boundariesProvider()
// Enhanced empty zone handling - ensure we never return invalid indices
guard !boundaries.isEmpty else {
return 0
}
// Add bounds checking for Y coordinates
guard y.isFinite && !y.isNaN else {
return 0
}
// Simple Y-coordinate comparison against midpoints
for (i, boundary) in boundaries.enumerated() {
if y <= boundary {
return max(0, i) // Ensure index is never negative
}
}
// Enhanced fallback - ensure we never return invalid indices
let maxIndex = boundaries.count - 1
return max(0, maxIndex)
}
private func performMoveHapticIfNeeded(currentIndex: Int) {
// Let TabDragManager handle haptics to avoid duplicate calls
}
// MARK: - State validation helpers
private func validateDragManagerState(for container: TabDragManager.DragContainer, index: Int) -> Bool {
// Ensure the drag manager's current state is consistent with the delegate's container
guard dragManager.isDragging else { return false }
// Add checks that prevent invalid state transitions
if index < 0 { return false }
// Validate that insertion indices are appropriate for the current container content
switch container {
case .none:
return false
case .essentials, .spacePinned, .spaceRegular, .folder:
// Basic validation - more sophisticated checks could be added
return true
}
}
}
struct SidebarGridDropDelegate: DropDelegate {
let dragManager: TabDragManager
let container: TabDragManager.DragContainer = .essentials
let boundariesProvider: () -> [SidebarGridBoundary]
let onPerform: (DragOperation) -> Void
func validateDrop(info: DropInfo) -> Bool { true }
func dropEntered(info: DropInfo) { updateTarget(with: info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
updateTarget(with: info)
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
_ = updateTarget(with: info)
if let op = dragManager.endDrag(commit: true) {
onPerform(op)
}
return true
}
@discardableResult
private func updateTarget(with info: DropInfo) -> Int {
let index = insertionIndex(for: info.location)
// Add state consistency checks for grid delegate
guard validateGridDragManagerState(for: index) else {
return index
}
dragManager.updateDragTarget(container: container, insertionIndex: index, spaceId: nil)
// Calculate and set insertion line frame for grid
let bounds = boundariesProvider()
if !bounds.isEmpty, let boundary = bounds.first(where: { $0.index == index }) {
print("📐 [SidebarGridDropDelegate] Grid insertion frame: \(boundary.frame)")
dragManager.updateInsertionLine(frame: boundary.frame)
} else {
print("📐 [SidebarGridDropDelegate] No boundary found for index \(index)")
dragManager.updateInsertionLine(frame: .zero)
}
return index
}
private func insertionIndex(for point: CGPoint) -> Int {
let bounds = boundariesProvider()
guard !bounds.isEmpty else { return 0 }
// Add validation for point coordinates
guard point.x.isFinite && point.y.isFinite && !point.x.isNaN && !point.y.isNaN else {
return 0
}
var best = bounds[0]
var bestDist = distance(point, to: bounds[0])
for b in bounds.dropFirst() {
let d = distance(point, to: b)
if d < bestDist {
bestDist = d
best = b
}
}
// Ensure returned index is valid
return max(0, best.index)
}
private func distance(_ p: CGPoint, to b: SidebarGridBoundary) -> CGFloat {
switch b.orientation {
case .horizontal:
let y = b.frame.midY
let dx: CGFloat
if p.x < b.frame.minX { dx = b.frame.minX - p.x }
else if p.x > b.frame.maxX { dx = p.x - b.frame.maxX }
else { dx = 0 }
let dy = abs(p.y - y)
return hypot(dx, dy)
case .vertical:
let x = b.frame.midX
let dy: CGFloat
if p.y < b.frame.minY { dy = b.frame.minY - p.y }
else if p.y > b.frame.maxY { dy = p.y - b.frame.maxY }
else { dy = 0 }
let dx = abs(p.x - x)
return hypot(dx, dy)
}
}
// MARK: - State validation helpers
private func validateGridDragManagerState(for index: Int) -> Bool {
// Ensure the drag manager's current state is consistent with the grid delegate
guard dragManager.isDragging else { return false }
// Validate that insertion indices are appropriate for grid container
guard index >= 0 else { return false }
return true
}
}
// MARK: - Overlay Helpers
struct SidebarSectionInsertionOverlay: View {
let isActive: Bool
let index: Int
let boundaries: [CGFloat]
var body: some View {
let _ = print("🟦 [SidebarSectionInsertionOverlay] isActive=\(isActive), index=\(index), boundaries.count=\(boundaries.count)")
return GeometryReader { proxy in
ZStack {
if isActive {
let _ = print("🟦 [SidebarSectionInsertionOverlay] Showing blue line at index \(index)")
let y: CGFloat = {
if !boundaries.isEmpty, index >= 0, index < boundaries.count {
return min(max(boundaries[index], 1.5), proxy.size.height - 1.5)
} else {
// Enhanced empty zone fallback - position at top third rather than center
return max(proxy.size.height / 3, 1.5)
}
}()
let _ = print("🟦 [SidebarSectionInsertionOverlay] Blue line y-position: \(y)")
// Enhanced styling for better visibility
RoundedRectangle(cornerRadius: 2)
.fill(
LinearGradient(
colors: [
Color.blue.opacity(0.9),
Color.blue,
Color.blue.opacity(0.9)
],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: proxy.size.width, height: 4)
.position(x: proxy.size.width / 2, y: y)
.shadow(color: Color.black.opacity(0.3), radius: 1, x: 0, y: 1)
.animation(.easeInOut(duration: 0.15), value: index)
.transition(.scale.combined(with: .opacity))
.zIndex(999) // High z-index to appear above content
}
}
}
.allowsHitTesting(false)
}
}
struct SidebarGridInsertionOverlay: View {
let isActive: Bool
let index: Int
let boundaries: [SidebarGridBoundary]
var body: some View {
GeometryReader { proxy in
ZStack {
if isActive {
if let b = boundaries.first(where: { $0.index == index }) {
switch b.orientation {
case .horizontal:
Rectangle()
.fill(Color.red)
.frame(width: b.frame.width, height: 10)
.position(x: b.frame.midX, y: b.frame.midY)
.shadow(color: .black, radius: 2)
.animation(.easeInOut(duration: 0.12), value: index)
case .vertical:
Rectangle()
.fill(Color.red)
.frame(width: 10, height: b.frame.height)
.position(x: b.frame.midX, y: b.frame.midY)
.shadow(color: .black, radius: 2)
.animation(.easeInOut(duration: 0.12), value: index)
}
}
}
}
}
.allowsHitTesting(false)
}
}
// MARK: - Haptics helper (kept for parity)
@inline(__always)
private func performMoveHaptic() {
#if canImport(AppKit)
NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
#endif
}
```
## /Nook/Components/DragDrop/SimpleDnD.swift
```swift path="/Nook/Components/DragDrop/SimpleDnD.swift"
//
// SimpleDnD.swift
// Nook
//
// Lightweight Ora-style drag & drop for the sidebar.
// Uses NSItemProvider with Tab UUIDs and simple DropDelegates
// to reorder/move tabs by calling TabManager directly.
//
import SwiftUI
import AppKit
// MARK: - Target Section
enum SidebarTargetSection: Equatable {
case essentials
case spacePinned(UUID) // spaceId
case spaceRegular(UUID) // spaceId
case folder(UUID) // folderId
}
// MARK: - Helpers
private func haptic(_ pattern: NSHapticFeedbackManager.FeedbackPattern = .alignment) {
NSHapticFeedbackManager.defaultPerformer.perform(pattern, performanceTime: .now)
}
@MainActor
private func containerFor(tab: Tab, tabManager: TabManager) -> (TabDragManager.DragContainer, Int, UUID?) {
if tab.spaceId == nil {
// Essentials (global pinned)
return (.essentials, tab.index, nil)
} else if let folderId = tab.folderId {
// Tab is in a folder
return (.folder(folderId), tab.index, tab.spaceId)
} else if let sid = tab.spaceId {
// Distinguish space-pinned vs regular by membership
let pinned = tabManager.spacePinnedTabs(for: sid)
if pinned.contains(where: { $0.id == tab.id }) {
return (.spacePinned(sid), tab.index, sid)
} else {
return (.spaceRegular(sid), tab.index, sid)
}
}
return (.none, -1, nil)
}
private func targetContainer(from section: SidebarTargetSection) -> (TabDragManager.DragContainer, UUID?) {
switch section {
case .essentials:
return (.essentials, nil)
case .spacePinned(let sid):
return (.spacePinned(sid), sid)
case .spaceRegular(let sid):
return (.spaceRegular(sid), sid)
case .folder(let folderId):
return (.folder(folderId), nil)
}
}
// MARK: - Item Drop Delegate (reorder relative to an item)
@MainActor
struct SidebarTabDropDelegateSimple: DropDelegate {
let item: Tab // target tab (to)
@Binding var draggedItem: UUID?
let targetSection: SidebarTargetSection
let tabManager: TabManager
func dropEntered(info: DropInfo) {
guard let provider = info.itemProviders(for: [.text]).first else { return }
provider.loadObject(ofClass: NSString.self) { object, _ in
guard
let string = object as? String,
let uuid = UUID(uuidString: string)
else { return }
DispatchQueue.main.async {
let all = tabManager.allTabs()
guard let from = all.first(where: { $0.id == uuid }) else { return }
guard from.id != self.item.id else { return }
let (fromContainer, fromIndex, _) = containerFor(tab: from, tabManager: tabManager)
let (toContainer, toSpace) = targetContainer(from: self.targetSection)
let toIndex = self.item.index
let op = DragOperation(
tab: from,
fromContainer: fromContainer,
fromIndex: max(fromIndex, 0),
toContainer: toContainer,
toIndex: max(toIndex, 0),
toSpaceId: toSpace
)
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
tabManager.handleDragOperation(op)
}
self.draggedItem = uuid
haptic(.alignment)
}
}
}
func validateDrop(info: DropInfo) -> Bool { true }
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func dropExited(info: DropInfo) {
draggedItem = nil
NotificationCenter.default.post(name: .tabDragDidEnd, object: nil)
}
func performDrop(info: DropInfo) -> Bool {
draggedItem = nil
NotificationCenter.default.post(name: .tabDragDidEnd, object: nil)
return true
}
}
// MARK: - Section Drop Delegate (drop into section/empty area)
@MainActor
struct SidebarSectionDropDelegateSimple: DropDelegate {
let itemsCount: () -> Int // current count to append at end
@Binding var draggedItem: UUID?
let targetSection: SidebarTargetSection
let tabManager: TabManager
let targetIndex: (() -> Int)?
private let onDropEntered: (() -> Void)?
private let onDropCompleted: (() -> Void)?
private let onDropExited: (() -> Void)?
init(
itemsCount: @escaping () -> Int,
draggedItem: Binding<UUID?>,
targetSection: SidebarTargetSection,
tabManager: TabManager,
targetIndex: (() -> Int)? = nil,
onDropEntered: (() -> Void)? = nil,
onDropCompleted: (() -> Void)? = nil,
onDropExited: (() -> Void)? = nil
) {
self.itemsCount = itemsCount
self._draggedItem = draggedItem
self.targetSection = targetSection
self.tabManager = tabManager
self.targetIndex = targetIndex
self.onDropEntered = onDropEntered
self.onDropCompleted = onDropCompleted
self.onDropExited = onDropExited
}
func dropEntered(info: DropInfo) {
guard let provider = info.itemProviders(for: [.text]).first else { return }
provider.loadObject(ofClass: NSString.self) { object, _ in
guard
let string = object as? String,
let uuid = UUID(uuidString: string)
else { return }
DispatchQueue.main.async {
self.draggedItem = uuid
self.onDropEntered?()
}
}
}
func validateDrop(info: DropInfo) -> Bool { true }
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func dropExited(info: DropInfo) {
finishDrop()
}
func performDrop(info: DropInfo) -> Bool {
if case .folder = targetSection {
handleFolderDrop(info: info)
} else {
handleRegularDrop(info: info)
}
return true
}
private func handleRegularDrop(info: DropInfo) {
guard let provider = info.itemProviders(for: [.text]).first else {
finishDrop()
return
}
provider.loadObject(ofClass: NSString.self) { object, _ in
guard
let string = object as? String,
let uuid = UUID(uuidString: string)
else {
DispatchQueue.main.async {
self.finishDrop()
}
return
}
DispatchQueue.main.async {
let all = tabManager.allTabs()
guard let from = all.first(where: { $0.id == uuid }) else {
self.finishDrop()
return
}
let (fromContainer, fromIndex, _) = containerFor(tab: from, tabManager: tabManager)
let (toContainer, toSpace) = targetContainer(from: self.targetSection)
let toIndex = max(0, self.targetIndex?() ?? self.itemsCount())
let op = DragOperation(
tab: from,
fromContainer: fromContainer,
fromIndex: max(fromIndex, 0),
toContainer: toContainer,
toIndex: toIndex,
toSpaceId: toSpace
)
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
tabManager.handleDragOperation(op)
}
self.onDropCompleted?()
self.finishDrop()
}
}
}
private func handleFolderDrop(info: DropInfo) {
guard let provider = info.itemProviders(for: [.text]).first else {
finishDrop()
return
}
provider.loadObject(ofClass: NSString.self) { object, _ in
guard
let string = object as? String,
let uuid = UUID(uuidString: string)
else {
DispatchQueue.main.async {
self.finishDrop()
}
return
}
DispatchQueue.main.async {
let all = tabManager.allTabs()
guard let from = all.first(where: { $0.id == uuid }) else {
self.finishDrop()
return
}
let (fromContainer, fromIndex, _) = containerFor(tab: from, tabManager: tabManager)
let (toContainer, toSpace) = targetContainer(from: self.targetSection)
let toIndex = max(0, self.targetIndex?() ?? self.itemsCount())
let op = DragOperation(
tab: from,
fromContainer: fromContainer,
fromIndex: max(fromIndex, 0),
toContainer: toContainer,
toIndex: toIndex,
toSpaceId: toSpace
)
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
tabManager.handleDragOperation(op)
}
haptic(.alignment)
self.onDropCompleted?()
self.finishDrop()
}
}
}
private func finishDrop() {
DispatchQueue.main.async {
self.onDropExited?()
self.draggedItem = nil
NotificationCenter.default.post(name: .tabDragDidEnd, object: nil)
}
}
}
// MARK: - Drag Provider helper
extension View {
func onTabDrag(_ id: UUID, draggedItem: Binding<UUID?>) -> some View {
onDrag {
draggedItem.wrappedValue = id
return NSItemProvider(object: id.uuidString as NSString)
}
}
}
```
## /Nook/Components/Extensions/ExtensionActionView.swift
```swift path="/Nook/Components/Extensions/ExtensionActionView.swift"
//
// ExtensionActionView.swift
// Nook
//
// Clean ExtensionActionView using ONLY native WKWebExtension APIs
//
import SwiftUI
import WebKit
import AppKit
@available(macOS 15.5, *)
struct ExtensionActionView: View {
let extensions: [InstalledExtension]
@EnvironmentObject var browserManager: BrowserManager
var body: some View {
HStack(spacing: 4) {
ForEach(extensions.filter { $0.isEnabled }, id: \.id) { ext in
ExtensionActionButton(ext: ext)
.environmentObject(browserManager)
}
}
}
}
@available(macOS 15.5, *)
struct ExtensionActionButton: View {
let ext: InstalledExtension
@EnvironmentObject var browserManager: BrowserManager
@EnvironmentObject var windowState: BrowserWindowState
var body: some View {
Button(action: {
showExtensionPopup()
}) {
Group {
if let iconPath = ext.iconPath,
let nsImage = NSImage(contentsOfFile: iconPath) {
Image(nsImage: nsImage)
.resizable()
} else {
Image(systemName: "puzzlepiece.extension")
.foregroundColor(.blue)
}
}
.frame(width: 20, height: 20)
.background(ActionAnchorView(extensionId: ext.id))
}
.buttonStyle(.plain)
.help(ext.name)
}
private func showExtensionPopup() {
print("🎯 Performing action for extension: \(ext.name)")
guard let extensionContext = ExtensionManager.shared.getExtensionContext(for: ext.id) else {
print("❌ No extension context found")
return
}
print("✅ Calling performAction() - this should trigger the delegate")
if let current = browserManager.currentTab(for: windowState) {
if let adapter = ExtensionManager.shared.stableAdapter(for: current) {
extensionContext.performAction(for: adapter)
} else {
extensionContext.performAction(for: nil)
}
} else {
extensionContext.performAction(for: nil)
}
}
}
@available(macOS 15.5, *)
#Preview {
ExtensionActionView(extensions: [])
}
// MARK: - Anchor View for Popover Positioning
private struct ActionAnchorView: NSViewRepresentable {
let extensionId: String
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
if #available(macOS 15.5, *) {
ExtensionManager.shared.setActionAnchor(for: extensionId, anchorView: view)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if #available(macOS 15.5, *) {
ExtensionManager.shared.setActionAnchor(for: extensionId, anchorView: nsView)
}
}
}
```
## /Nook/Components/Settings/SettingsTabBar.swift
```swift path="/Nook/Components/Settings/SettingsTabBar.swift"
//
// SettingsTabBar.swift
// Nook
//
// Created by Maciek Bagiński on 03/08/2025.
//
import SwiftUI
struct SettingsTabBar: View {
@EnvironmentObject var browserManager: BrowserManager
var body: some View {
ZStack {
BlurEffectView(
material: browserManager.settingsManager.currentMaterial,
state: .active
)
HStack {
MacButtonsView()
.frame(width: 70, height: 32)
Spacer()
Text(browserManager.settingsManager.currentSettingsTab.name)
.font(.headline)
Spacer()
}
}
.backgroundDraggable()
}
}
```
## /Nook/Components/Sidebar/URLBarFramePreferenceKey.swift
```swift path="/Nook/Components/Sidebar/URLBarFramePreferenceKey.swift"
import SwiftUI
struct URLBarFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
```
## /Nook/Info.plist.bak
Binary file available at https://raw.githubusercontent.com/nook-browser/Nook/refs/heads/main/Nook/Info.plist.bak
## /Nook/Managers/ExtensionManager/README-URLSchemeHandler.md
# README
## /assets/icon.png
Binary file available at https://raw.githubusercontent.com/nook-browser/Nook/refs/heads/main/assets/icon.png
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.