diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 357cb91..9a627ef 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -16654,6 +16654,9 @@ } } } + }, + "Show floating pause/stop controls" : { + }, "Show on a specific display" : { "localizations" : { @@ -18150,6 +18153,9 @@ }, "TheDynamicIsland" : { + }, + "These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing." : { + }, "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 4a8cd95..e5b0dc4 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1716,6 +1716,7 @@ struct TimerSettings: View { @Default(.timerShowsLabel) private var showsLabel @Default(.timerShowsProgress) private var showsProgress @Default(.timerProgressStyle) private var progressStyle + @Default(.timerControlWindowEnabled) private var controlWindowEnabled @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 @State private var customHours: Int = 0 @State private var customMinutes: Int = 10 @@ -1738,134 +1739,150 @@ struct TimerSettings: View { } if enableTimerFeature { - Section { - VStack(alignment: .leading, spacing: 12) { - Text("Default Custom Timer") - .font(.headline) - - TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23) - TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59) - TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59) - - HStack { - Text("Current default:") + Group { + Section { + VStack(alignment: .leading, spacing: 12) { + Text("Default Custom Timer") + .font(.headline) + + TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23) + TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59) + TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59) + + HStack { + Text("Current default:") + .foregroundStyle(.secondary) + Text(customDurationDisplay) + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + Spacer() + } + } + .padding(.vertical, 4) + .onChange(of: customHours) { _, _ in updateCustomDuration() } + .onChange(of: customMinutes) { _, _ in updateCustomDuration() } + .onChange(of: customSeconds) { _, _ in updateCustomDuration() } + } header: { + Text("Custom Timer") + } footer: { + Text("This duration powers the \"Custom\" option inside the timer popover for quick access.") + } + + Section { + Picker("Timer tint", selection: $colorMode) { + ForEach(TimerIconColorMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + + if colorMode == .solid { + ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false) + } + + Toggle("Show timer name", isOn: $showsLabel) + Toggle("Show countdown", isOn: $showsCountdown) + Toggle("Show progress", isOn: $showsProgress) + + Toggle("Show floating pause/stop controls", isOn: $controlWindowEnabled) + .disabled(showsLabel) + .help("These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing.") + + Picker("Progress style", selection: $progressStyle) { + ForEach(TimerProgressStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .pickerStyle(.segmented) + .disabled(!showsProgress) + } header: { + Text("Appearance") + } footer: { + Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.") + } + + Section { + if timerPresets.isEmpty { + Text("No presets configured. Add a preset to make it appear in the timer popover.") + .font(.system(size: 12)) .foregroundStyle(.secondary) - Text(customDurationDisplay) - .font(.system(.body, design: .monospaced)) - .fontWeight(.medium) - Spacer() - } - } - .padding(.vertical, 4) - .onChange(of: customHours) { _, _ in updateCustomDuration() } - .onChange(of: customMinutes) { _, _ in updateCustomDuration() } - .onChange(of: customSeconds) { _, _ in updateCustomDuration() } - } header: { - Text("Custom Timer") - } footer: { - Text("This duration powers the \"Custom\" option inside the timer popover for quick access.") - } - - Section { - Picker("Timer tint", selection: $colorMode) { - ForEach(TimerIconColorMode.allCases) { mode in - Text(mode.displayName).tag(mode) - } - } - .pickerStyle(.segmented) - - if colorMode == .solid { - ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false) - } - - Toggle("Show timer name", isOn: $showsLabel) - Toggle("Show countdown", isOn: $showsCountdown) - Toggle("Show progress", isOn: $showsProgress) - - Picker("Progress style", selection: $progressStyle) { - ForEach(TimerProgressStyle.allCases) { style in - Text(style.rawValue).tag(style) - } - } - .pickerStyle(.segmented) - .disabled(!showsProgress) - } header: { - Text("Appearance") - } footer: { - Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.") - } - - Section { - if timerPresets.isEmpty { - Text("No presets configured. Add a preset to make it appear in the timer popover.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .padding(.vertical, 4) - } else { - ForEach(timerPresets.indices, id: \.self) { index in - TimerPresetEditorRow( - preset: $timerPresets[index], - isFirst: index == timerPresets.startIndex, - isLast: index == timerPresets.index(before: timerPresets.endIndex), - moveUp: { movePresetUp(index) }, - moveDown: { movePresetDown(index) }, - remove: { removePreset(index) } - ) - } - } - - HStack { - Button(action: addPreset) { - Label("Add Preset", systemImage: "plus") - } - .buttonStyle(.bordered) - - Spacer() - - Button(role: .destructive, action: { showingResetConfirmation = true }) { - Label("Restore Defaults", systemImage: "arrow.counterclockwise") - } - .buttonStyle(.bordered) - .confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) { - Button("Restore", role: .destructive, action: resetPresets) - } - } - } header: { - Text("Timer Presets") - } footer: { - Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.") - } - - Section { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Timer Sound") - .font(.system(size: 16, weight: .medium)) - Spacer() - Button("Choose File", action: selectCustomTimerSound) - .buttonStyle(.bordered) - } - - if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") { - Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)") - .font(.caption) - .foregroundColor(.secondary) + .padding(.vertical, 4) } else { - Text("Default: dynamic.m4a") - .font(.caption) - .foregroundColor(.secondary) + ForEach(timerPresets.indices, id: \.self) { index in + TimerPresetEditorRow( + preset: $timerPresets[index], + isFirst: index == timerPresets.startIndex, + isLast: index == timerPresets.index(before: timerPresets.endIndex), + moveUp: { movePresetUp(index) }, + moveDown: { movePresetDown(index) }, + remove: { removePreset(index) } + ) + } } - - Button("Reset to Default") { - UserDefaults.standard.removeObject(forKey: "customTimerSoundPath") + + HStack { + Button(action: addPreset) { + Label("Add Preset", systemImage: "plus") + } + .buttonStyle(.bordered) + + Spacer() + + Button(role: .destructive, action: { showingResetConfirmation = true }) { + Label("Restore Defaults", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) { + Button("Restore", role: .destructive, action: resetPresets) + } } - .buttonStyle(.bordered) - .disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil) + } header: { + Text("Timer Presets") + } footer: { + Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.") + } + + Section { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Timer Sound") + .font(.system(size: 16, weight: .medium)) + Spacer() + Button("Choose File", action: selectCustomTimerSound) + .buttonStyle(.bordered) + } + + if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") { + Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Default: dynamic.m4a") + .font(.caption) + .foregroundColor(.secondary) + } + + Button("Reset to Default") { + UserDefaults.standard.removeObject(forKey: "customTimerSoundPath") + } + .buttonStyle(.bordered) + .disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil) + } + } header: { + Text("Timer Sound") + } footer: { + Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.") + } + } + .onAppear { + if showsLabel { + controlWindowEnabled = false + } + } + .onChange(of: showsLabel) { _, show in + if show { + controlWindowEnabled = false } - } header: { - Text("Timer Sound") - } footer: { - Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.") } } } diff --git a/DynamicIsland/components/Timer/TimerControlOverlay.swift b/DynamicIsland/components/Timer/TimerControlOverlay.swift new file mode 100644 index 0000000..86453e6 --- /dev/null +++ b/DynamicIsland/components/Timer/TimerControlOverlay.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct TimerControlOverlay: View { + let notchHeight: CGFloat + let cornerRadius: CGFloat + + @ObservedObject private var timerManager = TimerManager.shared + + private var pauseIcon: String { + timerManager.isPaused ? "play.fill" : "pause.fill" + } + + private var pauseForeground: Color { + .white + } + + private var helpText: String { + timerManager.isPaused ? "Resume" : "Pause" + } + + private var buttonSize: CGFloat { + max(notchHeight - 20, 22) + } + + private var iconSize: CGFloat { + 14 + } + + private var windowCornerRadius: CGFloat { + max(cornerRadius - 6, 12) + } + + var body: some View { + HStack(spacing: 12) { + Button(action: togglePause) { + Image(systemName: pauseIcon) + .font(.system(size: iconSize, weight: .semibold)) + .frame(width: buttonSize, height: buttonSize) + .foregroundStyle(pauseForeground) + } + .buttonStyle(.plain) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .help(helpText) + + Button(action: stopTimer) { + Image(systemName: "stop.fill") + .font(.system(size: iconSize, weight: .semibold)) + .frame(width: buttonSize, height: buttonSize) + .foregroundStyle(Color.red) + } + .buttonStyle(.plain) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .help("Stop") + } + .padding(.horizontal, 12) + .frame(height: notchHeight) + .frame(minWidth: buttonSize * 2 + 32) + .background { + RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous) + .fill(Color.black.opacity(0.9)) + } + .compositingGroup() + .animation(.smooth(duration: 0.2), value: timerManager.isPaused) + .animation(.smooth(duration: 0.2), value: timerManager.isFinished) + .animation(.smooth(duration: 0.2), value: timerManager.isOvertime) + } + + private func togglePause() { + if timerManager.isPaused { + timerManager.resumeTimer() + } else { + timerManager.pauseTimer() + } + } + + private func stopTimer() { +#if os(macOS) + TimerControlWindowManager.shared.hide(animated: true) +#endif + timerManager.stopTimer() + } +} + +#Preview { + TimerControlOverlay(notchHeight: 34, cornerRadius: 14) + .padding() + .background(Color.gray.opacity(0.2)) +} diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift index 59d9401..e9bd040 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -20,6 +20,9 @@ struct TimerLiveActivity: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var timerManager = TimerManager.shared @State private var isHovering: Bool = false + @State private var showTransientLabel: Bool = false + @State private var labelHideTask: DispatchWorkItem? + @State private var isControlWindowVisible: Bool = false @Default(.timerShowsCountdown) private var showsCountdown @Default(.timerShowsProgress) private var showsProgress @Default(.timerShowsLabel) private var showsLabel @@ -27,12 +30,15 @@ struct TimerLiveActivity: View { @Default(.timerIconColorMode) private var colorMode @Default(.timerSolidColor) private var solidColor @Default(.timerPresets) private var timerPresets + @Default(.timerControlWindowEnabled) private var controlWindowEnabled private var notchContentHeight: CGFloat { max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) } - private var wingPadding: CGFloat { 18 } + private var wingPadding: CGFloat { 22 } + private var ringStrokeWidth: CGFloat { 3 } + private var transientLabelDuration: TimeInterval { 4 } private var ringWrapsIcon: Bool { showsRingProgress && showsCountdown @@ -43,13 +49,13 @@ struct TimerLiveActivity: View { } private var iconWidth: CGFloat { - ringWrapsIcon ? max(notchContentHeight, 32) : max(0, notchContentHeight) + ringWrapsIcon ? max(notchContentHeight - 6, 28) : max(0, notchContentHeight) } private var infoContentWidth: CGFloat { guard showsInfoSection else { return 0 } - let textWidth = min(max(titleTextWidth, 44), 220) - if showsLabel { + if shouldDisplayLabel { + let textWidth = min(max(titleTextWidth, 44), 220) return textWidth } else { return min(max(notchContentHeight * 1.4, 64), 220) @@ -70,7 +76,7 @@ struct TimerLiveActivity: View { } private var ringWidth: CGFloat { - ringOnRight ? 32 : 0 + ringOnRight ? 30 : 0 } private var rightWingWidth: CGFloat { @@ -97,7 +103,7 @@ struct TimerLiveActivity: View { private var countdownWidth: CGFloat { guard showsCountdown else { return 0 } - return max(countdownTextWidth + 20, 80) + return max(countdownTextWidth + 16, 72) } private var clampedProgress: Double { @@ -121,14 +127,22 @@ struct TimerLiveActivity: View { showsProgress && progressStyle == .bar } + private var shouldDisplayLabel: Bool { + showsLabel || showTransientLabel + } + private var showsInfoSection: Bool { - showsLabel || (showsBarProgress && !showsCountdown) + shouldDisplayLabel || (showsBarProgress && !showsCountdown) } private var activePresetColor: Color? { guard let presetId = timerManager.activePresetId else { return nil } return timerPresets.first { $0.id == presetId }?.color } + + private var shouldShowControlWindow: Bool { + controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive + } private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat { let attributes: [NSAttributedString.Key: Any] = [.font: font] @@ -192,22 +206,102 @@ struct TimerLiveActivity: View { withAnimation(.smooth(duration: 0.18)) { isHovering = hovering } + syncControlWindow(forceRefresh: true) + } + .onAppear { + syncControlWindow(forceRefresh: true) + if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { + triggerTransientLabel() + } + } + .onDisappear { + hideControlWindow() + } + .onChange(of: timerManager.isTimerActive) { _, isActive in + if isActive { + if !timerManager.isFinished && !timerManager.isOvertime { + syncControlWindow(forceRefresh: true) + triggerTransientLabel() + } + } else { + hideControlWindow() + cancelTransientLabel() + showTransientLabel = false + isHovering = false + } + } + .onChange(of: timerManager.timerName) { _, _ in + if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { + syncControlWindow(forceRefresh: true) + triggerTransientLabel() + } + } + .onChange(of: timerManager.isFinished) { _, finished in + if finished { + cancelTransientLabel() + withAnimation(.smooth) { + showTransientLabel = true + isHovering = false + } + syncControlWindow() + } + } + .onChange(of: timerManager.isOvertime) { _, overtime in + if overtime { + cancelTransientLabel() + withAnimation(.smooth) { + showTransientLabel = true + isHovering = false + } + syncControlWindow() + } + } + .onChange(of: timerManager.isPaused) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: vm.closedNotchSize) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: vm.screen) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: vm.hideOnClosed) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: controlWindowEnabled) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: showsLabel) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: showTransientLabel) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: showsCountdown) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: showsProgress) { _, _ in + syncControlWindow(forceRefresh: true) + } + .onChange(of: progressStyle) { _, _ in + syncControlWindow(forceRefresh: true) } } private var iconSection: some View { - let ringDiameter = ringWrapsIcon ? max(iconWidth, 32) : iconWidth - let iconSize = ringWrapsIcon ? max(ringDiameter - 10, 18) : max(20, iconWidth - 4) + let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth + let ringDiameter = ringWrapsIcon ? max(min(baseDiameter, notchContentHeight - 2), 22) : iconWidth + let iconSize = ringWrapsIcon ? max(ringDiameter - 12, 16) : max(18, iconWidth - 6) return ZStack { if ringWrapsIcon { Circle() - .stroke(Color.white.opacity(0.15), lineWidth: 3) + .stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth) .frame(width: ringDiameter, height: ringDiameter) Circle() .trim(from: 0, to: clampedProgress) - .stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(.smooth(duration: 0.25), value: clampedProgress) .frame(width: ringDiameter, height: ringDiameter) @@ -224,19 +318,20 @@ struct TimerLiveActivity: View { } private var infoSection: some View { - let availableWidth = max(0, infoWidth - 18) - let resolvedTextWidth = min(max(titleTextWidth, 44), availableWidth) - let shouldMarquee = showsLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth) + let availableWidth = max(0, infoWidth - 10) + let safeWidth = max(44, availableWidth - 6) + let resolvedTextWidth = min(max(titleTextWidth, 44), safeWidth) + let marqueeLabel = shouldDisplayLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth) let showsBarHere = showsBarProgress && !showsCountdown - let barWidth = showsLabel ? resolvedTextWidth : availableWidth + let barWidth = shouldDisplayLabel ? resolvedTextWidth : availableWidth return Rectangle() .fill(.black) .frame(width: infoWidth, height: notchContentHeight) .overlay(alignment: .leading) { VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) { - if showsLabel { - if shouldMarquee { + if shouldDisplayLabel { + if marqueeLabel { MarqueeText( .constant(timerManager.timerName), font: .system(size: 12, weight: .medium), @@ -274,16 +369,17 @@ struct TimerLiveActivity: View { } private var ringSection: some View { - ZStack { + let diameter = max(min(notchContentHeight - 4, 26), 20) + return ZStack { Circle() - .stroke(Color.white.opacity(0.15), lineWidth: 3) + .stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth) Circle() .trim(from: 0, to: clampedProgress) - .stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(.smooth(duration: 0.25), value: clampedProgress) } - .frame(width: 26, height: 26) + .frame(width: diameter, height: diameter) .frame(width: ringWidth, height: notchContentHeight, alignment: .center) } @@ -310,10 +406,73 @@ struct TimerLiveActivity: View { .frame(maxWidth: .infinity, alignment: .trailing) } } - .padding(.trailing, 8) + .padding(.trailing, 8) .frame(width: countdownWidth, height: notchContentHeight, alignment: .center) } + + private func triggerTransientLabel() { + guard !showsLabel else { return } + cancelTransientLabel() + withAnimation(.smooth) { + showTransientLabel = true + } + let task = DispatchWorkItem { + withAnimation(.smooth) { + showTransientLabel = false + } + syncControlWindow(forceRefresh: true) + } + labelHideTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task) + } + + private func cancelTransientLabel() { + labelHideTask?.cancel() + labelHideTask = nil + } + + private func syncControlWindow(forceRefresh: Bool = false) { +#if os(macOS) + let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0 + let targetVisible = shouldShowControlWindow && notchAvailable + if targetVisible { + let metrics = currentControlWindowMetrics() + if !isControlWindowVisible { + let didPresent = TimerControlWindowManager.shared.present(using: vm, metrics: metrics) + isControlWindowVisible = didPresent + } else if forceRefresh { + let didRefresh = TimerControlWindowManager.shared.refresh(using: vm, metrics: metrics) + isControlWindowVisible = didRefresh + if !didRefresh { + TimerControlWindowManager.shared.hide() + } + } + } else if isControlWindowVisible { + TimerControlWindowManager.shared.hide() + isControlWindowVisible = false + } +#endif + } + + private func hideControlWindow() { +#if os(macOS) + if isControlWindowVisible { + TimerControlWindowManager.shared.hide() + isControlWindowVisible = false + } +#endif + } + + private func currentControlWindowMetrics() -> TimerControlWindowMetrics { + TimerControlWindowMetrics( + notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight), + notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0), + rightWingWidth: rightWingWidth, + cornerRadius: cornerRadiusInsets.closed.bottom, + spacing: 6 + ) + } } #Preview { diff --git a/DynamicIsland/managers/TimerControlWindowManager.swift b/DynamicIsland/managers/TimerControlWindowManager.swift new file mode 100644 index 0000000..5eed75f --- /dev/null +++ b/DynamicIsland/managers/TimerControlWindowManager.swift @@ -0,0 +1,199 @@ +#if os(macOS) +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore + +struct TimerControlWindowMetrics { + let notchHeight: CGFloat + let notchWidth: CGFloat + let rightWingWidth: CGFloat + let cornerRadius: CGFloat + let spacing: CGFloat +} + +@MainActor +final class TimerControlWindowManager { + static let shared = TimerControlWindowManager() + + private var window: NSWindow? + private var hostingView: NSHostingView? + private var hasDelegated = false + private var lastMetrics: TimerControlWindowMetrics? + + private init() {} + + @discardableResult + func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool { + guard let screen = resolveScreen(from: viewModel) else { return false } + guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else { + hide() + return false + } + + let overlay = TimerControlOverlay( + notchHeight: metrics.notchHeight, + cornerRadius: metrics.cornerRadius + ) + let hosting = ensureHostingView(with: overlay) + let fittingSize = measuredSize(for: hosting) + hosting.frame = NSRect(origin: .zero, size: fittingSize) + + let window = ensureWindow(on: screen) + if window.contentView !== hosting { + window.contentView = hosting + } + + let targetFrame = frame(for: fittingSize, viewModel: viewModel, screen: screen, metrics: metrics) + lastMetrics = metrics + + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(window) + hasDelegated = true + } + + if window.alphaValue <= 0.01 { + let startFrame = initialFrame(for: targetFrame, metrics: metrics) + window.setFrame(startFrame, display: true) + window.alphaValue = 0 + window.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.24 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(targetFrame, display: true) + window.animator().alphaValue = 1 + } completionHandler: { + window.setFrame(targetFrame, display: false) + } + } else { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(targetFrame, display: true) + } completionHandler: { + window.setFrame(targetFrame, display: false) + } + window.orderFrontRegardless() + window.alphaValue = 1 + } + + return true + } + + @discardableResult + func refresh(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool { + guard window != nil else { + return present(using: viewModel, metrics: metrics) + } + return present(using: viewModel, metrics: metrics) + } + + func hide(animated: Bool = true) { + guard let window else { return } + + guard animated, window.alphaValue > 0.01 else { + window.orderOut(nil) + window.alphaValue = 0 + return + } + + let retreatFrame = notchRetreatFrame(from: window.frame) + let originalFrame = window.frame + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + window.animator().setFrame(retreatFrame, display: true) + window.animator().alphaValue = 0 + } completionHandler: { + window.setFrame(originalFrame, display: false) + window.orderOut(nil) + window.alphaValue = 0 + } + } + + private func ensureHostingView(with overlay: TimerControlOverlay) -> NSHostingView { + if let hostingView { + hostingView.rootView = overlay + return hostingView + } + let view = NSHostingView(rootView: overlay) + hostingView = view + return view + } + + private func ensureWindow(on screen: NSScreen) -> NSWindow { + if let window { + return window + } + + let window = NSWindow( + contentRect: NSRect(x: screen.frame.midX, y: screen.frame.midY, width: 220, height: 64), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + window.isReleasedWhenClosed = false + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + window.ignoresMouseEvents = false + window.isMovable = false + window.alphaValue = 0 + + self.window = window + self.hasDelegated = false + return window + } + + private func measuredSize(for hosting: NSHostingView) -> CGSize { + hosting.layoutSubtreeIfNeeded() + let size = hosting.fittingSize + return CGSize(width: ceil(size.width), height: ceil(size.height)) + } + + private func resolveScreen(from viewModel: DynamicIslandViewModel) -> NSScreen? { + if let screenName = viewModel.screen, + let targetScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) { + return targetScreen + } + return NSScreen.main + } + + private func frame(for size: CGSize, viewModel: DynamicIslandViewModel, screen: NSScreen, metrics: TimerControlWindowMetrics) -> NSRect { + let screenFrame = screen.frame + let notchOriginX = screenFrame.midX - (metrics.notchWidth / 2) + let originY = screenFrame.maxY - size.height + + let rightEdge = notchOriginX + metrics.notchWidth + metrics.rightWingWidth + let rawOriginX = rightEdge + metrics.spacing + + let clampedOriginX = max(screenFrame.minX + 8, min(rawOriginX, screenFrame.maxX - size.width - 8)) + + return NSRect(x: clampedOriginX, y: originY, width: size.width, height: size.height) + } + + private func initialFrame(for targetFrame: NSRect, metrics: TimerControlWindowMetrics) -> NSRect { + var frame = targetFrame + let notchRightEdge = targetFrame.minX - metrics.spacing + frame.origin.x = notchRightEdge - targetFrame.width + return frame + } + + private func notchRetreatFrame(from currentFrame: NSRect) -> NSRect { + guard let metrics = lastMetrics else { + return currentFrame.offsetBy(dx: -currentFrame.width * 0.75, dy: 0) + } + + let notchRightEdge = currentFrame.minX - metrics.spacing + var frame = currentFrame + frame.origin.x = notchRightEdge - currentFrame.width + return frame + } +} + +#endif diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index bba7d00..22cda55 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -435,6 +435,7 @@ extension Defaults.Keys { static let timerShowsLabel = Key("timerShowsLabel", default: true) static let timerShowsProgress = Key("timerShowsProgress", default: true) static let timerProgressStyle = Key("timerProgressStyle", default: .bar) + static let timerControlWindowEnabled = Key("timerControlWindowEnabled", default: true) // MARK: ColorPicker Feature static let enableColorPickerFeature = Key("enableColorPickerFeature", default: true)