mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #426 from macanoclothing-blip/bugfixesandimprvements
small improvements
This commit is contained in:
commit
4f3f23bb77
20 changed files with 1349 additions and 351 deletions
|
|
@ -116,3 +116,157 @@ struct MarqueeText: View {
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
struct MusicExplicitBadge: View {
|
||||
var label: String = "E"
|
||||
var fontSize: CGFloat = 9
|
||||
var height: CGFloat = 14
|
||||
var horizontalPadding: CGFloat = 4
|
||||
var minWidth: CGFloat? = nil
|
||||
var foregroundColor: Color = .white.opacity(0.92)
|
||||
var backgroundColor: Color = .white.opacity(0.18)
|
||||
var cornerRadius: CGFloat? = nil
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.system(size: fontSize, weight: .bold, design: .rounded))
|
||||
.foregroundColor(foregroundColor)
|
||||
.padding(.horizontal, max(horizontalPadding, height * 0.2))
|
||||
.frame(minWidth: minWidth, minHeight: height)
|
||||
.background(
|
||||
RoundedRectangle(
|
||||
cornerRadius: cornerRadius ?? max(4, height * 0.28),
|
||||
style: .continuous
|
||||
)
|
||||
.fill(backgroundColor)
|
||||
)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
struct MusicTitleMarqueeView: View {
|
||||
let text: String
|
||||
let isExplicit: Bool
|
||||
let font: Font
|
||||
let nsFont: NSFont.TextStyle
|
||||
let textColor: Color
|
||||
let backgroundColor: Color
|
||||
let minDuration: Double
|
||||
let frameWidth: CGFloat
|
||||
let alignment: Alignment
|
||||
let badgeSpacing: CGFloat
|
||||
let badgeLabel: String
|
||||
let badgeFontSize: CGFloat?
|
||||
let badgeHeight: CGFloat?
|
||||
let badgeForegroundColor: Color
|
||||
let badgeBackgroundColor: Color
|
||||
let badgeHorizontalPadding: CGFloat
|
||||
let badgeMinWidth: CGFloat?
|
||||
let badgeCornerRadius: CGFloat?
|
||||
|
||||
init(
|
||||
text: String,
|
||||
isExplicit: Bool,
|
||||
font: Font = .body,
|
||||
nsFont: NSFont.TextStyle = .body,
|
||||
textColor: Color = .primary,
|
||||
backgroundColor: Color = .clear,
|
||||
minDuration: Double = 3.0,
|
||||
frameWidth: CGFloat = 200,
|
||||
alignment: Alignment = .leading,
|
||||
badgeSpacing: CGFloat = 6,
|
||||
badgeLabel: String = "E",
|
||||
badgeFontSize: CGFloat? = nil,
|
||||
badgeHeight: CGFloat? = nil,
|
||||
badgeForegroundColor: Color = .white.opacity(0.92),
|
||||
badgeBackgroundColor: Color = .white.opacity(0.18),
|
||||
badgeHorizontalPadding: CGFloat = 4,
|
||||
badgeMinWidth: CGFloat? = nil,
|
||||
badgeCornerRadius: CGFloat? = nil
|
||||
) {
|
||||
self.text = text
|
||||
self.isExplicit = isExplicit
|
||||
self.font = font
|
||||
self.nsFont = nsFont
|
||||
self.textColor = textColor
|
||||
self.backgroundColor = backgroundColor
|
||||
self.minDuration = minDuration
|
||||
self.frameWidth = frameWidth
|
||||
self.alignment = alignment
|
||||
self.badgeSpacing = badgeSpacing
|
||||
self.badgeLabel = badgeLabel
|
||||
self.badgeFontSize = badgeFontSize
|
||||
self.badgeHeight = badgeHeight
|
||||
self.badgeForegroundColor = badgeForegroundColor
|
||||
self.badgeBackgroundColor = badgeBackgroundColor
|
||||
self.badgeHorizontalPadding = badgeHorizontalPadding
|
||||
self.badgeMinWidth = badgeMinWidth
|
||||
self.badgeCornerRadius = badgeCornerRadius
|
||||
}
|
||||
|
||||
private var resolvedBadgeHeight: CGFloat {
|
||||
badgeHeight ?? max(12, NSFont.preferredFont(forTextStyle: nsFont).pointSize * 1.12)
|
||||
}
|
||||
|
||||
private var resolvedBadgeFontSize: CGFloat {
|
||||
badgeFontSize ?? max(8, resolvedBadgeHeight * 0.6)
|
||||
}
|
||||
|
||||
private var reservedBadgeWidth: CGFloat {
|
||||
guard isExplicit else { return 0 }
|
||||
return resolvedBadgeHeight + (resolvedBadgeHeight * 0.7) + badgeSpacing
|
||||
}
|
||||
|
||||
private var titleFrameWidth: CGFloat {
|
||||
max(0, frameWidth - reservedBadgeWidth)
|
||||
}
|
||||
|
||||
private var measurementFont: NSFont {
|
||||
NSFont.preferredFont(forTextStyle: nsFont)
|
||||
}
|
||||
|
||||
private var measuredTextWidth: CGFloat {
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: measurementFont]
|
||||
return ceil((text as NSString).size(withAttributes: attributes).width)
|
||||
}
|
||||
|
||||
private var needsScrolling: Bool {
|
||||
measuredTextWidth > titleFrameWidth
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: badgeSpacing) {
|
||||
if needsScrolling {
|
||||
MarqueeText(
|
||||
.constant(text),
|
||||
font: font,
|
||||
nsFont: nsFont,
|
||||
textColor: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
minDuration: minDuration,
|
||||
frameWidth: titleFrameWidth
|
||||
)
|
||||
} else {
|
||||
Text(text)
|
||||
.font(font)
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
|
||||
if isExplicit {
|
||||
MusicExplicitBadge(
|
||||
label: badgeLabel,
|
||||
fontSize: resolvedBadgeFontSize,
|
||||
height: resolvedBadgeHeight,
|
||||
horizontalPadding: badgeHorizontalPadding,
|
||||
minWidth: badgeMinWidth,
|
||||
foregroundColor: badgeForegroundColor,
|
||||
backgroundColor: badgeBackgroundColor,
|
||||
cornerRadius: badgeCornerRadius
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: frameWidth, alignment: alignment)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,18 +42,42 @@ struct LockScreenLiveActivity: View {
|
|||
private var indicatorDimension: CGFloat {
|
||||
max(0, vm.effectiveClosedNotchHeight - 12)
|
||||
}
|
||||
|
||||
private var indicatorSideHeight: CGFloat {
|
||||
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
||||
}
|
||||
|
||||
private var expandedIndicatorSideWidth: CGFloat {
|
||||
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2)
|
||||
}
|
||||
|
||||
private var indicatorSideWidth: CGFloat {
|
||||
isExpanded ? expandedIndicatorSideWidth : 0
|
||||
}
|
||||
|
||||
private var indicatorVisibilityProgress: CGFloat {
|
||||
guard expandedIndicatorSideWidth > 0 else { return 0 }
|
||||
return min(max(indicatorSideWidth / expandedIndicatorSideWidth, 0), 1)
|
||||
}
|
||||
|
||||
private var indicatorOpacity: Double {
|
||||
let fadeThreshold: CGFloat = 0.16
|
||||
guard indicatorVisibilityProgress < fadeThreshold else { return 1 }
|
||||
return Double(max(0, indicatorVisibilityProgress / fadeThreshold))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left - Lock icon with subtle glow
|
||||
Color.clear
|
||||
.overlay(alignment: .leading) {
|
||||
if isExpanded {
|
||||
LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor)
|
||||
.frame(width: indicatorDimension, height: indicatorDimension)
|
||||
}
|
||||
LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor)
|
||||
.frame(width: indicatorDimension, height: indicatorDimension)
|
||||
.opacity(indicatorOpacity)
|
||||
.scaleEffect(0.96 + (indicatorVisibilityProgress * 0.04))
|
||||
}
|
||||
.frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
||||
.frame(width: indicatorSideWidth, height: indicatorSideHeight, alignment: .leading)
|
||||
.clipped()
|
||||
|
||||
// Center - Black fill
|
||||
Rectangle()
|
||||
|
|
@ -62,7 +86,7 @@ struct LockScreenLiveActivity: View {
|
|||
|
||||
// Right - Empty for symmetry with animation
|
||||
Color.clear
|
||||
.frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
||||
.frame(width: indicatorSideWidth, height: indicatorSideHeight)
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0))
|
||||
.onAppear {
|
||||
|
|
@ -100,6 +124,5 @@ struct LockScreenLiveActivity: View {
|
|||
}
|
||||
}
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked)
|
||||
.animation(.easeOut(duration: 0.25), value: isExpanded)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ struct LockScreenMusicPanel: View {
|
|||
@Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant
|
||||
@Default(.lockScreenShowAppIcon) var showAppIcon
|
||||
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
||||
@Default(.lockScreenMusicUsesEnhancedLiquidBorder) private var useEnhancedLiquidBorder
|
||||
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
||||
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||
|
|
@ -113,6 +114,10 @@ struct LockScreenMusicPanel: View {
|
|||
private var usesLiquidGlass: Bool {
|
||||
usesCustomLiquidGlass || usesStandardLiquidGlass
|
||||
}
|
||||
|
||||
private var usesEnhancedCustomLiquidBorder: Bool {
|
||||
usesCustomLiquidGlass && useEnhancedLiquidBorder
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isActive && musicManager.hasActiveSession {
|
||||
|
|
@ -131,9 +136,8 @@ struct LockScreenMusicPanel: View {
|
|||
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
|
||||
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
|
||||
.overlay {
|
||||
if showPanelBorder && !usesLiquidGlass {
|
||||
RoundedRectangle(cornerRadius: panelCornerRadius)
|
||||
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
|
||||
if showPanelBorder {
|
||||
panelBorderOverlay
|
||||
}
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
||||
|
|
@ -255,19 +259,34 @@ struct LockScreenMusicPanel: View {
|
|||
HStack(alignment: .center, spacing: 16) {
|
||||
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
GeometryReader { geo in
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
MusicTitleMarqueeView(
|
||||
text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
|
||||
isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
|
||||
font: .system(size: 12, weight: .semibold),
|
||||
nsFont: .subheadline,
|
||||
textColor: .white,
|
||||
minDuration: 0.45,
|
||||
frameWidth: geo.size.width,
|
||||
badgeSpacing: 5,
|
||||
badgeLabel: "E",
|
||||
badgeHeight: 13,
|
||||
badgeForegroundColor: Color.black.opacity(0.72),
|
||||
badgeBackgroundColor: Color.white.opacity(0.46),
|
||||
badgeHorizontalPadding: 4,
|
||||
badgeMinWidth: 14,
|
||||
badgeCornerRadius: 4
|
||||
)
|
||||
|
||||
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.lineLimit(1)
|
||||
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading)
|
||||
|
||||
visualizer(width: 20, height: 16)
|
||||
}
|
||||
|
|
@ -276,19 +295,34 @@ struct LockScreenMusicPanel: View {
|
|||
|
||||
private var expandedHeader: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
GeometryReader { geo in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
MusicTitleMarqueeView(
|
||||
text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
|
||||
isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
|
||||
font: .system(size: 18, weight: .semibold),
|
||||
nsFont: .title3,
|
||||
textColor: .white,
|
||||
minDuration: 0.55,
|
||||
frameWidth: geo.size.width,
|
||||
badgeSpacing: 6,
|
||||
badgeLabel: "E",
|
||||
badgeHeight: 16,
|
||||
badgeForegroundColor: Color.black.opacity(0.74),
|
||||
badgeBackgroundColor: Color.white.opacity(0.5),
|
||||
badgeHorizontalPadding: 5,
|
||||
badgeMinWidth: 17,
|
||||
badgeCornerRadius: 5
|
||||
)
|
||||
|
||||
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray)
|
||||
.lineLimit(2)
|
||||
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
|
||||
|
||||
visualizer(width: 24, height: 20)
|
||||
}
|
||||
|
|
@ -1047,6 +1081,53 @@ struct LockScreenMusicPanel: View {
|
|||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var panelBorderOverlay: some View {
|
||||
if usesEnhancedCustomLiquidBorder {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
|
||||
.stroke(
|
||||
Color.white.opacity(0.22),
|
||||
lineWidth: 1.05
|
||||
)
|
||||
|
||||
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
|
||||
.inset(by: 0.85)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.28),
|
||||
Color.white.opacity(0.1),
|
||||
Color.black.opacity(0.18)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 0.9
|
||||
)
|
||||
|
||||
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
|
||||
.inset(by: 1.55)
|
||||
.stroke(
|
||||
Color.black.opacity(0.16),
|
||||
lineWidth: 0.55
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
} else if usesLiquidGlass {
|
||||
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
|
||||
.stroke(
|
||||
Color.white.opacity(usesCustomLiquidGlass ? 0.15 : 0.14),
|
||||
lineWidth: usesCustomLiquidGlass ? 0.95 : 0.9
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View {
|
||||
Image(nsImage: musicManager.albumArt)
|
||||
.resizable()
|
||||
|
|
|
|||
|
|
@ -41,13 +41,16 @@ struct MinimalisticMusicView: View {
|
|||
GeometryReader { geo in
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
if !musicManager.songTitle.isEmpty {
|
||||
MarqueeText(
|
||||
$musicManager.songTitle,
|
||||
MusicTitleMarqueeView(
|
||||
text: musicManager.songTitle,
|
||||
isExplicit: musicManager.isCurrentTrackExplicit,
|
||||
font: .system(size: 12, weight: .semibold),
|
||||
nsFont: .subheadline,
|
||||
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
|
||||
minDuration: 0.4,
|
||||
frameWidth: max(0, geo.size.width - 8)
|
||||
frameWidth: max(0, geo.size.width - 8),
|
||||
alignment: .center,
|
||||
badgeHeight: 13
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,12 +83,14 @@ struct MinimalisticMusicPlayerView: View {
|
|||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
if !musicManager.songTitle.isEmpty {
|
||||
MarqueeText(
|
||||
$musicManager.songTitle,
|
||||
MusicTitleMarqueeView(
|
||||
text: musicManager.songTitle,
|
||||
isExplicit: musicManager.isCurrentTrackExplicit,
|
||||
font: .system(size: 12, weight: .semibold),
|
||||
nsFont: .subheadline,
|
||||
textColor: .white,
|
||||
frameWidth: textWidth
|
||||
frameWidth: textWidth,
|
||||
badgeHeight: 13
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,15 @@ struct MusicControlsView: View {
|
|||
|
||||
private func songInfo(width: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width)
|
||||
MusicTitleMarqueeView(
|
||||
text: musicManager.songTitle,
|
||||
isExplicit: musicManager.isCurrentTrackExplicit,
|
||||
font: .headline,
|
||||
nsFont: .headline,
|
||||
textColor: .white,
|
||||
frameWidth: width,
|
||||
badgeHeight: 14
|
||||
)
|
||||
MarqueeText(
|
||||
$musicManager.artistName,
|
||||
font: .headline,
|
||||
|
|
|
|||
|
|
@ -2670,6 +2670,12 @@ struct Media: View {
|
|||
Text("Show panel border")
|
||||
}
|
||||
.disabled(!enableLockScreenMediaWidget)
|
||||
if lockScreenGlassCustomizationMode == .customLiquid {
|
||||
Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) {
|
||||
Text("Use enhanced liquid border")
|
||||
}
|
||||
.disabled(!enableLockScreenMediaWidget)
|
||||
}
|
||||
if lockScreenGlassCustomizationMode == .customLiquid {
|
||||
customLiquidBlurRow
|
||||
.opacity(enableLockScreenMediaWidget ? 1 : 0.5)
|
||||
|
|
@ -4772,6 +4778,13 @@ struct LockScreenSettings: View {
|
|||
}
|
||||
.disabled(!enableLockScreenMediaWidget)
|
||||
.settingsHighlight(id: highlightID("Show panel border"))
|
||||
if lockScreenGlassCustomizationMode == .customLiquid {
|
||||
Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) {
|
||||
Text("Use enhanced liquid border")
|
||||
}
|
||||
.disabled(!enableLockScreenMediaWidget)
|
||||
.settingsHighlight(id: highlightID("Use enhanced liquid border"))
|
||||
}
|
||||
if lockScreenGlassCustomizationMode == .customLiquid {
|
||||
variantSlider(
|
||||
title: "Music panel variant",
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ final class ShelfItemViewModel: ObservableObject {
|
|||
|
||||
// MARK: - Actions
|
||||
func handleClick(event: NSEvent, view: NSView) {
|
||||
let flags = event.modifierFlags
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
if flags.contains(.shift) {
|
||||
selection.shiftSelect(to: item, in: ShelfStateViewModel.shared.items)
|
||||
} else if flags.contains(.command) {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,19 @@ final class ShelfSelectionModel: ObservableObject {
|
|||
lastAnchorID = nil
|
||||
}
|
||||
|
||||
func reconcileSelection(with allItems: [ShelfItem]) {
|
||||
let validIDs = Set(allItems.map(\.id))
|
||||
let filteredSelection = selectedIDs.intersection(validIDs)
|
||||
|
||||
if filteredSelection != selectedIDs {
|
||||
selectedIDs = filteredSelection
|
||||
}
|
||||
|
||||
if let anchor = lastAnchorID, !validIDs.contains(anchor) {
|
||||
lastAnchorID = selectedIDs.first
|
||||
}
|
||||
}
|
||||
|
||||
// Keep anchor sane if items array changed drastically (optional helper)
|
||||
func ensureValidAnchor(in allItems: [ShelfItem]) {
|
||||
if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ final class ShelfStateViewModel: ObservableObject {
|
|||
static let shared = ShelfStateViewModel()
|
||||
|
||||
@Published private(set) var items: [ShelfItem] = [] {
|
||||
didSet { ShelfPersistenceService.shared.save(items) }
|
||||
didSet {
|
||||
ShelfPersistenceService.shared.save(items)
|
||||
ShelfSelectionModel.shared.reconcileSelection(with: items)
|
||||
}
|
||||
}
|
||||
|
||||
@Published var isLoading: Bool = false
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
|
|||
private let dragThreshold: CGFloat = 3.0
|
||||
private var draggedURLs: [URL] = []
|
||||
private var draggedItems: [ShelfItem] = []
|
||||
private var didStartDragSession = false
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
onRightClick?(event, self)
|
||||
|
|
@ -244,7 +245,21 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
|
|||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
mouseDownEvent = event
|
||||
onClick?(event, self)
|
||||
didStartDragSession = false
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
defer {
|
||||
mouseDownEvent = nil
|
||||
didStartDragSession = false
|
||||
}
|
||||
|
||||
guard let mouseDownEvent, !didStartDragSession else {
|
||||
super.mouseUp(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
onClick?(mouseDownEvent, self)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
|
|
@ -259,12 +274,26 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
|
|||
)
|
||||
|
||||
if dragDistance > dragThreshold {
|
||||
didStartDragSession = true
|
||||
prepareSelectionForDrag(using: mouseDownEvent)
|
||||
startDragSession(with: event)
|
||||
self.mouseDownEvent = nil
|
||||
} else {
|
||||
super.mouseDragged(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareSelectionForDrag(using initialEvent: NSEvent) {
|
||||
let flags = initialEvent.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
|
||||
if flags.contains(.shift) || flags.contains(.command) || flags.contains(.control) {
|
||||
return
|
||||
}
|
||||
|
||||
if !ShelfSelectionModel.shared.isSelected(item.id) {
|
||||
ShelfSelectionModel.shared.selectSingle(item)
|
||||
}
|
||||
}
|
||||
|
||||
private func startDragSession(with event: NSEvent) {
|
||||
// Prepare dragging items
|
||||
|
|
|
|||
|
|
@ -23,6 +23,28 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
private struct ShelfBackgroundClickCatcher: NSViewRepresentable {
|
||||
let onClick: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> BackgroundClickView {
|
||||
let view = BackgroundClickView()
|
||||
view.onClick = onClick
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: BackgroundClickView, context: Context) {
|
||||
nsView.onClick = onClick
|
||||
}
|
||||
|
||||
final class BackgroundClickView: NSView {
|
||||
var onClick: (() -> Void)?
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
onClick?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShelfView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@StateObject var tvm = ShelfStateViewModel.shared
|
||||
|
|
@ -82,14 +104,20 @@ struct ShelfView: View {
|
|||
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
|
||||
)
|
||||
.overlay {
|
||||
content
|
||||
.padding()
|
||||
ZStack {
|
||||
ShelfBackgroundClickCatcher {
|
||||
guard !selection.isDragging else { return }
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
content
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.transaction { transaction in
|
||||
transaction.animation = vm.animation
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selection.clear() }
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue