diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index ac86707..a0917e7 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -751,6 +751,14 @@ } } }, + "%@ Offset" : { + "comment" : "A control within the lock screen settings that allows the user to adjust the offset for a specific dimension (e.g. width or height). The argument is the name of the dimension, and the second argument is", + "isCommentAutoGenerated" : true + }, + "%@ pt" : { + "comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.", + "isCommentAutoGenerated" : true + }, "%@°" : { "comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).", "isCommentAutoGenerated" : true @@ -7052,6 +7060,9 @@ } } } + }, + "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible." : { + }, "Drop files here" : { "localizations" : { @@ -10225,6 +10236,9 @@ } } } + }, + "Inline snapshot preview" : { + }, "Install updates automatically" : { "extractionState" : "stale", @@ -10587,6 +10601,14 @@ }, "Lock screen material" : { + }, + "Lock screen panel preview" : { + "comment" : "A description of the content displayed in the lock screen panel.", + "isCommentAutoGenerated" : true + }, + "Lock Screen Positioning" : { + "comment" : "The header text for the \"Lock Screen Positioning\" section in the settings view.", + "isCommentAutoGenerated" : true }, "Lock Screen Reminder Widget" : { "comment" : "A section header for the lock screen reminder widget settings.", @@ -19766,6 +19788,10 @@ } } }, + "Weather" : { + "comment" : "A label and its accompanying text displayed in a lock screen panel.", + "isCommentAutoGenerated" : true + }, "Weather data provider" : { }, diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index d7aa82f..1ec9978 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1436,6 +1436,8 @@ struct Appearance: View { Text("General") } + LockScreenPositioningControls() + Section { Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) Defaults @@ -1693,6 +1695,228 @@ struct Appearance: View { } } +private struct LockScreenPositioningControls: View { + @Default(.lockScreenWeatherVerticalOffset) private var weatherOffset + @Default(.lockScreenMusicVerticalOffset) private var musicOffset + private let offsetRange: ClosedRange = -160...160 + + var body: some View { + Section { + let weatherBinding = Binding( + get: { weatherOffset }, + set: { newValue in + let clampedValue = clamp(newValue) + if weatherOffset != clampedValue { + weatherOffset = clampedValue + } + propagateWeatherOffsetChange(animated: false) + } + ) + + let musicBinding = Binding( + get: { musicOffset }, + set: { newValue in + let clampedValue = clamp(newValue) + if musicOffset != clampedValue { + musicOffset = clampedValue + } + propagateMusicOffsetChange(animated: false) + } + ) + + LockScreenPositioningPreview(weatherOffset: weatherBinding, musicOffset: musicBinding) + .frame(height: 260) + .padding(.vertical, 8) + + HStack(alignment: .top, spacing: 24) { + offsetColumn( + title: "Weather", + value: weatherOffset, + resetTitle: "Reset Weather", + resetAction: resetWeatherOffset + ) + + Divider() + .frame(height: 64) + + offsetColumn( + title: "Music", + value: musicOffset, + resetTitle: "Reset Music", + resetAction: resetMusicOffset + ) + + Spacer() + } + } header: { + Text("Lock Screen Positioning") + } footer: { + Text("Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible.") + .textCase(nil) + } + } + + private func clamp(_ value: Double) -> Double { + min(max(value, offsetRange.lowerBound), offsetRange.upperBound) + } + + private func resetWeatherOffset() { + weatherOffset = 0 + propagateWeatherOffsetChange(animated: true) + } + + private func resetMusicOffset() { + musicOffset = 0 + propagateMusicOffsetChange(animated: true) + } + + private func propagateWeatherOffsetChange(animated: Bool) { + Task { @MainActor in + LockScreenWeatherPanelManager.shared.refreshPositionForOffsets(animated: animated) + } + } + + private func propagateMusicOffsetChange(animated: Bool) { + Task { @MainActor in + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated) + } + } + + @ViewBuilder + private func offsetColumn(title: String, value: Double, resetTitle: String, resetAction: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("\(title) Offset") + .font(.subheadline.weight(.semibold)) + + Text("\(formattedPoints(value)) pt") + .font(.caption) + .foregroundStyle(.secondary) + + Button(resetTitle) { + resetAction() + } + .buttonStyle(.bordered) + } + } + + private func formattedPoints(_ value: Double) -> String { + String(format: "%+.0f", value) + } +} + +private struct LockScreenPositioningPreview: View { + @Binding var weatherOffset: Double + @Binding var musicOffset: Double + + @State private var weatherStartOffset: Double = 0 + @State private var musicStartOffset: Double = 0 + @State private var isWeatherDragging = false + @State private var isMusicDragging = false + + var body: some View { + GeometryReader { geometry in + let centerX = geometry.size.width / 2 + let weatherBaseY = geometry.size.height * 0.34 + let musicBaseY = geometry.size.height * 0.70 + + ZStack { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.6)) + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + + weatherPanel + .position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset)) + .gesture(weatherDragGesture) + + musicPanel + .position(x: centerX, y: musicBaseY - CGFloat(musicOffset)) + .gesture(musicDragGesture) + } + } + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: weatherOffset) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicOffset) + } + + private var weatherPanel: some View { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill( + LinearGradient( + colors: [Color.blue.opacity(0.72), Color.blue.opacity(0.48)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 220, height: 88) + .overlay(alignment: .leading) { + VStack(alignment: .leading, spacing: 6) { + Label("Weather", systemImage: "cloud.sun.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.white) + Text("Inline snapshot preview") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.7)) + } + .padding(.horizontal, 18) + } + .shadow(color: Color.blue.opacity(0.25), radius: 12, x: 0, y: 8) + } + + private var musicPanel: some View { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill( + LinearGradient( + colors: [Color.purple.opacity(0.68), Color.pink.opacity(0.52)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 280, height: 132) + .overlay(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { + Label("Media", systemImage: "play.square.stack") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.white) + Text("Lock screen panel preview") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.7)) + } + .padding(.horizontal, 22) + } + .shadow(color: Color.purple.opacity(0.3), radius: 16, x: 0, y: 10) + } + + private var weatherDragGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + if !isWeatherDragging { + isWeatherDragging = true + weatherStartOffset = weatherOffset + } + let proposed = weatherStartOffset - Double(value.translation.height) + weatherOffset = proposed + } + .onEnded { _ in + isWeatherDragging = false + } + } + + private var musicDragGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + if !isMusicDragging { + isMusicDragging = true + musicStartOffset = musicOffset + } + let proposed = musicStartOffset - Double(value.translation.height) + musicOffset = proposed + } + .onEnded { _ in + isMusicDragging = false + } + } +} + struct Shortcuts: View { @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableClipboardManager) var enableClipboardManager diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 4703304..3e6eda8 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -23,6 +23,8 @@ class LockScreenPanelManager { private var panelWindow: NSWindow? private var hasDelegated = false private var collapsedFrame: NSRect? + private var isPanelExpanded = false + private var currentAdditionalHeight: CGFloat = 0 private let collapsedPanelCornerRadius: CGFloat = 28 private let expandedPanelCornerRadius: CGFloat = 52 private(set) var latestFrame: NSRect? @@ -53,13 +55,11 @@ class LockScreenPanelManager { return } - let collapsedSize = LockScreenMusicPanel.collapsedSize let screenFrame = screen.frame - let centerX = screenFrame.origin.x + (screenFrame.width / 2) - let originX = centerX - (collapsedSize.width / 2) - let originY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32 - let targetFrame = NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height) + let targetFrame = collapsedFrame(for: screenFrame) collapsedFrame = targetFrame + isPanelExpanded = false + currentAdditionalHeight = 0 let window: NSWindow @@ -152,6 +152,19 @@ class LockScreenPanelManager { } else { window.contentView?.layer?.cornerRadius = targetRadius } + + isPanelExpanded = expanded + currentAdditionalHeight = additionalHeight + } + + func applyOffsetAdjustment(animated: Bool = true) { + guard let screen = NSScreen.main else { return } + let screenFrame = screen.frame + let newCollapsed = collapsedFrame(for: screenFrame) + collapsedFrame = newCollapsed + + guard panelWindow != nil else { return } + updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated) } func hidePanel() { @@ -177,4 +190,14 @@ class LockScreenPanelManager { } } } + + private func collapsedFrame(for screenFrame: NSRect) -> NSRect { + let collapsedSize = LockScreenMusicPanel.collapsedSize + let originX = screenFrame.midX - (collapsedSize.width / 2) + let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32 + let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + let originY = baseOriginY + clampedOffset + return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height) + } } diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index 77fca3c..f95df89 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -1,6 +1,8 @@ import AppKit import SwiftUI import SkyLightWindow +import Defaults +import QuartzCore @MainActor final class LockScreenWeatherPanelManager { @@ -9,6 +11,8 @@ final class LockScreenWeatherPanelManager { private var window: NSWindow? private var hasDelegated = false private(set) var latestFrame: NSRect? + private var lastSnapshot: LockScreenWeatherSnapshot? + private var lastContentSize: CGSize? private init() {} @@ -25,6 +29,8 @@ final class LockScreenWeatherPanelManager { window.orderOut(nil) window.contentView = nil latestFrame = nil + lastSnapshot = nil + lastContentSize = nil } private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) { @@ -43,6 +49,8 @@ final class LockScreenWeatherPanelManager { window.setFrame(targetFrame, display: true) latestFrame = targetFrame window.contentView = hostingView + lastSnapshot = snapshot + lastContentSize = fittingSize if makeVisible { window.orderFrontRegardless() @@ -86,10 +94,30 @@ final class LockScreenWeatherPanelManager { let baseY = min(maxY, screenFrame.midY + verticalOffset) let loweredY = baseY - 36 - let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 28 : 0 - let adjustedY = loweredY + inlineLift + let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 44 : 0 + let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + let adjustedY = loweredY + inlineLift + clampedOffset let upperClampedY = min(maxY, adjustedY) let clampedY = max(screenFrame.minY + 80, upperClampedY) return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) } + + func refreshPositionForOffsets(animated: Bool = true) { + guard let window, let snapshot = lastSnapshot else { return } + guard let screen = NSScreen.main else { return } + let size = lastContentSize ?? window.frame.size + let targetFrame = frame(for: size, snapshot: snapshot, on: screen) + latestFrame = targetFrame + + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(targetFrame, display: true) + } + } else { + window.setFrame(targetFrame, display: true) + } + } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 55cf754..c3eb0eb 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -438,6 +438,8 @@ extension Defaults.Keys { static let lockScreenWeatherAQIScale = Key("lockScreenWeatherAQIScale", default: .us) static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) + static let lockScreenWeatherVerticalOffset = Key("lockScreenWeatherVerticalOffset", default: 0) + static let lockScreenMusicVerticalOffset = Key("lockScreenMusicVerticalOffset", default: 0) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true)