Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions LaunchNext/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,23 @@ final class AppStore: ObservableObject {
}
}

enum BackgroundImageSource: String, CaseIterable, Codable, Identifiable {
case liveDesktop
case wallpaper

var id: String { rawValue }

var localizationKey: LocalizationKey {
switch self {
case .liveDesktop: return .backgroundImageSourceLiveDesktop
case .wallpaper: return .backgroundImageSourceWallpaper
}
}
}

static let wallpaperBlurRadiusRange: ClosedRange<Double> = 0...40
static let defaultWallpaperBlurRadius: Double = 24

enum DockDragSide: String, CaseIterable, Codable, Identifiable {
case disabled
case bottom
Expand Down Expand Up @@ -341,6 +358,8 @@ final class AppStore: ObservableObject {
static let rowSpacingKey = "gridRowSpacing"
static let iconLabelFontWeightKey = "iconLabelFontWeight"
static let showQuickRefreshButtonKey = "showQuickRefreshButton"
static let showQuickCompactLayoutButtonKey = "showQuickCompactLayoutButton"
static let closeOnBlankAreaClickKey = "closeOnBlankAreaClick"
static let lockLayoutKey = "lockLayoutEnabled"
static let rememberPageKey = "rememberLastPage"
static let rememberedPageIndexKey = "rememberedPageIndex"
Expand Down Expand Up @@ -381,6 +400,8 @@ final class AppStore: ObservableObject {
private static let cliPathSnippetHeader = "# >>> LaunchNext CLI >>>"
private static let cliPathSnippetFooter = "# <<< LaunchNext CLI <<<"
static let backgroundStyleKey = "launchpadBackgroundStyle"
static let backgroundImageSourceKey = "launchpadBackgroundImageSource"
static let wallpaperBlurRadiusKey = "launchpadWallpaperBlurRadius"
static let backgroundMaskEnabledKey = "launchpadBackgroundMaskEnabled"
static let backgroundMaskLightKey = "launchpadBackgroundMaskLight"
static let backgroundMaskDarkKey = "launchpadBackgroundMaskDark"
Expand Down Expand Up @@ -425,6 +446,23 @@ final class AppStore: ObservableObject {
return .glass
}

private static func loadBackgroundImageSource() -> BackgroundImageSource {
if let raw = UserDefaults.standard.string(forKey: backgroundImageSourceKey),
let source = BackgroundImageSource(rawValue: raw) {
return source
}
return .liveDesktop
}

private static func loadWallpaperBlurRadius() -> Double {
let defaults = UserDefaults.standard
if defaults.object(forKey: wallpaperBlurRadiusKey) == nil {
return defaultWallpaperBlurRadius
}
let value = defaults.double(forKey: wallpaperBlurRadiusKey)
return min(max(value, wallpaperBlurRadiusRange.lowerBound), wallpaperBlurRadiusRange.upperBound)
}

private static func loadFolderLayoutMode(from defaults: UserDefaults = .standard,
isExistingInstall: Bool? = nil) -> FolderLayoutMode {
if let raw = defaults.string(forKey: folderLayoutModeKey),
Expand Down Expand Up @@ -650,6 +688,25 @@ final class AppStore: ObservableObject {
}
}

@Published var launchpadBackgroundImageSource: BackgroundImageSource = AppStore.loadBackgroundImageSource() {
didSet {
guard launchpadBackgroundImageSource != oldValue else { return }
UserDefaults.standard.set(launchpadBackgroundImageSource.rawValue, forKey: Self.backgroundImageSourceKey)
}
}

@Published var wallpaperBlurRadius: Double = AppStore.loadWallpaperBlurRadius() {
didSet {
let clamped = min(max(wallpaperBlurRadius, Self.wallpaperBlurRadiusRange.lowerBound), Self.wallpaperBlurRadiusRange.upperBound)
if wallpaperBlurRadius != clamped {
wallpaperBlurRadius = clamped
return
}
guard wallpaperBlurRadius != oldValue else { return }
UserDefaults.standard.set(wallpaperBlurRadius, forKey: Self.wallpaperBlurRadiusKey)
}
}

// Development-only override to capture flat screenshots quickly.
@Published var developmentBackgroundOverride: DevelopmentBackgroundOverride = .none

Expand Down Expand Up @@ -702,6 +759,8 @@ final class AppStore: ObservableObject {
defaults.set(SidebarIconPreset.large.rawValue, forKey: Self.sidebarIconPresetKey)
defaults.set(AppearancePreference.system.rawValue, forKey: "appearancePreference")
defaults.set(BackgroundStyle.glass.rawValue, forKey: Self.backgroundStyleKey)
defaults.set(BackgroundImageSource.liveDesktop.rawValue, forKey: Self.backgroundImageSourceKey)
defaults.set(Self.defaultWallpaperBlurRadius, forKey: Self.wallpaperBlurRadiusKey)
defaults.set(false, forKey: Self.backgroundMaskEnabledKey)
Self.persistBackgroundMaskColor(Self.defaultBackgroundMaskColor, forKey: Self.backgroundMaskLightKey)
Self.persistBackgroundMaskColor(Self.defaultBackgroundMaskColor, forKey: Self.backgroundMaskDarkKey)
Expand Down Expand Up @@ -762,6 +821,8 @@ final class AppStore: ObservableObject {
}

launchpadBackgroundStyle = Self.loadBackgroundStyle()
launchpadBackgroundImageSource = Self.loadBackgroundImageSource()
wallpaperBlurRadius = Self.loadWallpaperBlurRadius()
backgroundMaskEnabled = Self.loadBackgroundMaskEnabled()
backgroundMaskLightColor = Self.loadBackgroundMaskColor(forKey: Self.backgroundMaskLightKey)
backgroundMaskDarkColor = Self.loadBackgroundMaskColor(forKey: Self.backgroundMaskDarkKey)
Expand Down Expand Up @@ -823,6 +884,9 @@ final class AppStore: ObservableObject {
}

uninstallToolAppPath = UserDefaults.standard.string(forKey: AppStore.uninstallToolAppPathKey) ?? ""
showQuickRefreshButton = UserDefaults.standard.object(forKey: AppStore.showQuickRefreshButtonKey) as? Bool ?? false
showQuickCompactLayoutButton = UserDefaults.standard.object(forKey: AppStore.showQuickCompactLayoutButtonKey) as? Bool ?? false
closeOnBlankAreaClick = UserDefaults.standard.object(forKey: AppStore.closeOnBlankAreaClickKey) as? Bool ?? false
reloadAppearancePreferencesFromDefaults()

developmentEnableCLICode = UserDefaults.standard.object(forKey: Self.developmentEnableCLICodeKey) as? Bool ?? false
Expand Down Expand Up @@ -1516,6 +1580,26 @@ final class AppStore: ObservableObject {
}
}

@Published var showQuickCompactLayoutButton: Bool = {
if UserDefaults.standard.object(forKey: AppStore.showQuickCompactLayoutButtonKey) == nil { return false }
return UserDefaults.standard.bool(forKey: AppStore.showQuickCompactLayoutButtonKey)
}() {
didSet {
guard showQuickCompactLayoutButton != oldValue else { return }
UserDefaults.standard.set(showQuickCompactLayoutButton, forKey: AppStore.showQuickCompactLayoutButtonKey)
}
}

@Published var closeOnBlankAreaClick: Bool = {
if UserDefaults.standard.object(forKey: AppStore.closeOnBlankAreaClickKey) == nil { return false }
return UserDefaults.standard.bool(forKey: AppStore.closeOnBlankAreaClickKey)
}() {
didSet {
guard closeOnBlankAreaClick != oldValue else { return }
UserDefaults.standard.set(closeOnBlankAreaClick, forKey: AppStore.closeOnBlankAreaClickKey)
}
}

@Published var uninstallToolAppPath: String = {
UserDefaults.standard.string(forKey: AppStore.uninstallToolAppPathKey) ?? ""
}() {
Expand Down Expand Up @@ -4357,6 +4441,8 @@ final class AppStore: ObservableObject {
Self.sidebarIconPresetKey,
"appearancePreference",
Self.backgroundStyleKey,
Self.backgroundImageSourceKey,
Self.wallpaperBlurRadiusKey,
Self.backgroundMaskEnabledKey,
Self.backgroundMaskLightKey,
Self.backgroundMaskDarkKey,
Expand Down Expand Up @@ -5387,6 +5473,51 @@ final class AppStore: ObservableObject {
triggerFolderUpdate()
triggerGridRefresh()
}

func autoFillEmptySlots() {
let sourceItems = filteredItemsRemovingHidden(from: items)
let compacted = sourceItems.filter { item in
switch item {
case .empty:
return false
default:
return true
}
}

let existingPlaceholders = sourceItems.compactMap { item -> LaunchpadItem? in
guard case .empty = item else { return nil }
return item
}
let fillCount = max(0, sourceItems.count - compacted.count)
let preservedPlaceholders = Array(existingPlaceholders.prefix(fillCount))
let newPlaceholderCount = fillCount - preservedPlaceholders.count
let appendedPlaceholders = (0..<newPlaceholderCount).map { _ in
LaunchpadItem.empty(UUID().uuidString)
}

items = compacted + preservedPlaceholders + appendedPlaceholders

removeEmptyPages()
cleanupUnusedNewPage()
let maxPageIndex = max(0, (items.count - 1) / max(itemsPerPage, 1))
if currentPage > maxPageIndex {
currentPage = maxPageIndex
}
triggerFolderUpdate()
triggerGridRefresh()
saveAllOrder()
}

func compactLayout() {
compactItemsWithinPages()
removeEmptyPages()
cleanupUnusedNewPage()
clampCurrentPageWithinBounds()
triggerFolderUpdate()
triggerGridRefresh()
saveAllOrder()
}

/// 清除缓存
func clearCache() {
Expand Down
113 changes: 99 additions & 14 deletions LaunchNext/CAFolderGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import AppKit
import QuartzCore

final class CAFolderGridView: NSView {
private enum ScrollAxisLock {
case undecided
case horizontal
case vertical
}

var apps: [AppInfo] = [] {
didSet { rebuildLayers() }
}
Expand Down Expand Up @@ -133,6 +139,11 @@ final class CAFolderGridView: NSView {
private var wheelLastDirection = 0
private var wheelLastFlipAt: Date?
private let wheelFlipCooldown: TimeInterval = 0.15
private var automaticScrollAxis: ScrollAxisLock = .undecided
private var automaticScrollAccumulatedX: CGFloat = 0
private var automaticScrollAccumulatedY: CGFloat = 0
private let automaticScrollAxisThreshold: CGFloat = 10
private let automaticScrollAxisDominance: CGFloat = 1.4
private var currentHoverIndex: Int?
private var lastReportedPage: Int?
private var lastReportedPageCount: Int?
Expand Down Expand Up @@ -676,11 +687,24 @@ final class CAFolderGridView: NSView {
}

private func handlePagedScroll(_ event: NSEvent) {
let deltaX = event.scrollingDeltaX
let deltaY = event.scrollingDeltaY
let dominant = scaledPageDelta(deltaX: deltaX, deltaY: deltaY)
let axes = resolvedPagedAxes(for: event)
let deltaX = axes.x
let deltaY = axes.y
let isContinuousGesture = isContinuousScrollGesture(event)
if event.phase.contains(.began) {
resetAutomaticScrollAxis()
}

guard let dominant = scaledPageDelta(deltaX: deltaX,
deltaY: deltaY,
isContinuousGesture: isContinuousGesture) else {
if event.phase.contains(.ended) || event.phase.contains(.cancelled) {
resetAutomaticScrollAxis()
}
return
}

if !event.hasPreciseScrollingDeltas {
if !isContinuousGesture {
if dominant != 0 {
handleWheelPaging(with: dominant)
}
Expand All @@ -700,18 +724,20 @@ final class CAFolderGridView: NSView {
}

if (phase.contains(.changed) || phaseLessScroll), dominant != 0 {
if !(isPageScrollAnimating && !isPageScrollDragging) {
if !isPageScrollDragging { beginPageScroll() }
updatePageScroll(by: dominant)
if !isPageScrollDragging {
beginPageScroll()
}

if phaseLessScroll {
schedulePageScrollSnap(velocity: dominant)
}
updatePageScroll(by: dominant)

if phaseLessScroll {
schedulePageScrollSnap(velocity: dominant)
}
}

if ended {
finishPageScroll(velocity: dominant)
resetAutomaticScrollAxis()
}
}

Expand All @@ -724,8 +750,66 @@ final class CAFolderGridView: NSView {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: workItem)
}

private func scaledPageDelta(deltaX: CGFloat, deltaY: CGFloat) -> CGFloat {
let rawDelta = abs(deltaX) > abs(deltaY) ? deltaX : -deltaY
private func resetAutomaticScrollAxis() {
automaticScrollAxis = .undecided
automaticScrollAccumulatedX = 0
automaticScrollAccumulatedY = 0
}

private func lockedAutomaticPagedDelta(deltaX: CGFloat, deltaY: CGFloat, isContinuousGesture: Bool) -> CGFloat? {
guard isContinuousGesture else {
return -deltaY
}

automaticScrollAccumulatedX += deltaX
automaticScrollAccumulatedY += deltaY

if automaticScrollAxis == .undecided {
let x = abs(automaticScrollAccumulatedX)
let y = abs(automaticScrollAccumulatedY)
if x >= automaticScrollAxisThreshold,
x >= y * automaticScrollAxisDominance {
automaticScrollAxis = .horizontal
let initialDelta = automaticScrollAccumulatedX
automaticScrollAccumulatedX = 0
automaticScrollAccumulatedY = 0
return initialDelta
} else if y >= automaticScrollAxisThreshold * 2,
y >= x * (automaticScrollAxisDominance + 0.5) {
automaticScrollAxis = .vertical
} else {
return nil
}
}

switch automaticScrollAxis {
case .horizontal:
return deltaX
case .vertical:
return nil
case .undecided:
return nil
}
}

private func isContinuousScrollGesture(_ event: NSEvent) -> Bool {
!event.phase.isEmpty || !event.momentumPhase.isEmpty
}

private func resolvedPagedAxes(for event: NSEvent) -> (x: CGFloat, y: CGFloat) {
if isContinuousScrollGesture(event) {
return (event.scrollingDeltaX, event.scrollingDeltaY)
}

let deltaX = event.deltaX != 0 ? event.deltaX : event.scrollingDeltaX
let deltaY = event.deltaY != 0 ? event.deltaY : event.scrollingDeltaY
return (deltaX, deltaY)
}

private func scaledPageDelta(deltaX: CGFloat, deltaY: CGFloat, isContinuousGesture: Bool) -> CGFloat? {
guard let rawDelta = lockedAutomaticPagedDelta(deltaX: deltaX,
deltaY: deltaY,
isContinuousGesture: isContinuousGesture) else { return nil }
let baseline = max(AppStore.defaultScrollSensitivity, 0.0001)
let sensitivityScale = CGFloat(max(scrollSensitivity, 0.0001) / baseline)
return rawDelta * sensitivityScale
Expand Down Expand Up @@ -821,10 +905,10 @@ final class CAFolderGridView: NSView {

private func handleVerticalScroll(_ event: NSEvent) {
let metrics = makeMetrics()
let raw = event.scrollingDeltaY
let raw = resolvedPagedAxes(for: event).y
let baseline = max(AppStore.defaultScrollSensitivity, 0.0001)
let sensitivityScale = CGFloat(max(scrollSensitivity, 0.0001) / baseline)
var delta = (event.hasPreciseScrollingDeltas ? raw : -raw) * sensitivityScale
var delta = (isContinuousScrollGesture(event) ? raw : -raw) * sensitivityScale
if reverseWheelPagingDirection {
delta = -delta
}
Expand All @@ -844,6 +928,7 @@ final class CAFolderGridView: NSView {
targetHorizontalOffset = resolvedOffset
wheelAccumulatedDelta = 0
wheelLastDirection = 0
resetAutomaticScrollAxis()
let needsAnimation = animated && animationsEnabled && abs(horizontalOffset - targetHorizontalOffset) > 0.5
if needsAnimation {
setupDisplayLinkIfNeeded()
Expand Down
Loading