mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 23:54:32 +00:00
Worked on some stuff, made it look better
This commit is contained in:
parent
eefd951522
commit
4f963dbb68
6 changed files with 606 additions and 136 deletions
|
|
@ -16654,6 +16654,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Show floating pause/stop controls" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Show on a specific display" : {
|
"Show on a specific display" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -18150,6 +18153,9 @@
|
||||||
},
|
},
|
||||||
"TheDynamicIsland" : {
|
"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." : {
|
"These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1716,6 +1716,7 @@ struct TimerSettings: View {
|
||||||
@Default(.timerShowsLabel) private var showsLabel
|
@Default(.timerShowsLabel) private var showsLabel
|
||||||
@Default(.timerShowsProgress) private var showsProgress
|
@Default(.timerShowsProgress) private var showsProgress
|
||||||
@Default(.timerProgressStyle) private var progressStyle
|
@Default(.timerProgressStyle) private var progressStyle
|
||||||
|
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||||||
@State private var customHours: Int = 0
|
@State private var customHours: Int = 0
|
||||||
@State private var customMinutes: Int = 10
|
@State private var customMinutes: Int = 10
|
||||||
|
|
@ -1738,134 +1739,150 @@ struct TimerSettings: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if enableTimerFeature {
|
if enableTimerFeature {
|
||||||
Section {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
Section {
|
||||||
Text("Default Custom Timer")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.font(.headline)
|
Text("Default Custom Timer")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
|
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
|
||||||
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
|
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
|
||||||
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
|
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Current default:")
|
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)
|
.foregroundStyle(.secondary)
|
||||||
Text(customDurationDisplay)
|
.padding(.vertical, 4)
|
||||||
.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)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Default: dynamic.m4a")
|
ForEach(timerPresets.indices, id: \.self) { index in
|
||||||
.font(.caption)
|
TimerPresetEditorRow(
|
||||||
.foregroundColor(.secondary)
|
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") {
|
HStack {
|
||||||
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
|
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)
|
} header: {
|
||||||
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
|
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.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
DynamicIsland/components/Timer/TimerControlOverlay.swift
Normal file
88
DynamicIsland/components/Timer/TimerControlOverlay.swift
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,9 @@ struct TimerLiveActivity: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@ObservedObject var timerManager = TimerManager.shared
|
@ObservedObject var timerManager = TimerManager.shared
|
||||||
@State private var isHovering: Bool = false
|
@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(.timerShowsCountdown) private var showsCountdown
|
||||||
@Default(.timerShowsProgress) private var showsProgress
|
@Default(.timerShowsProgress) private var showsProgress
|
||||||
@Default(.timerShowsLabel) private var showsLabel
|
@Default(.timerShowsLabel) private var showsLabel
|
||||||
|
|
@ -27,12 +30,15 @@ struct TimerLiveActivity: View {
|
||||||
@Default(.timerIconColorMode) private var colorMode
|
@Default(.timerIconColorMode) private var colorMode
|
||||||
@Default(.timerSolidColor) private var solidColor
|
@Default(.timerSolidColor) private var solidColor
|
||||||
@Default(.timerPresets) private var timerPresets
|
@Default(.timerPresets) private var timerPresets
|
||||||
|
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||||||
|
|
||||||
private var notchContentHeight: CGFloat {
|
private var notchContentHeight: CGFloat {
|
||||||
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
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 {
|
private var ringWrapsIcon: Bool {
|
||||||
showsRingProgress && showsCountdown
|
showsRingProgress && showsCountdown
|
||||||
|
|
@ -43,13 +49,13 @@ struct TimerLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconWidth: CGFloat {
|
private var iconWidth: CGFloat {
|
||||||
ringWrapsIcon ? max(notchContentHeight, 32) : max(0, notchContentHeight)
|
ringWrapsIcon ? max(notchContentHeight - 6, 28) : max(0, notchContentHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var infoContentWidth: CGFloat {
|
private var infoContentWidth: CGFloat {
|
||||||
guard showsInfoSection else { return 0 }
|
guard showsInfoSection else { return 0 }
|
||||||
let textWidth = min(max(titleTextWidth, 44), 220)
|
if shouldDisplayLabel {
|
||||||
if showsLabel {
|
let textWidth = min(max(titleTextWidth, 44), 220)
|
||||||
return textWidth
|
return textWidth
|
||||||
} else {
|
} else {
|
||||||
return min(max(notchContentHeight * 1.4, 64), 220)
|
return min(max(notchContentHeight * 1.4, 64), 220)
|
||||||
|
|
@ -70,7 +76,7 @@ struct TimerLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ringWidth: CGFloat {
|
private var ringWidth: CGFloat {
|
||||||
ringOnRight ? 32 : 0
|
ringOnRight ? 30 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private var rightWingWidth: CGFloat {
|
private var rightWingWidth: CGFloat {
|
||||||
|
|
@ -97,7 +103,7 @@ struct TimerLiveActivity: View {
|
||||||
|
|
||||||
private var countdownWidth: CGFloat {
|
private var countdownWidth: CGFloat {
|
||||||
guard showsCountdown else { return 0 }
|
guard showsCountdown else { return 0 }
|
||||||
return max(countdownTextWidth + 20, 80)
|
return max(countdownTextWidth + 16, 72)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var clampedProgress: Double {
|
private var clampedProgress: Double {
|
||||||
|
|
@ -121,8 +127,12 @@ struct TimerLiveActivity: View {
|
||||||
showsProgress && progressStyle == .bar
|
showsProgress && progressStyle == .bar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var shouldDisplayLabel: Bool {
|
||||||
|
showsLabel || showTransientLabel
|
||||||
|
}
|
||||||
|
|
||||||
private var showsInfoSection: Bool {
|
private var showsInfoSection: Bool {
|
||||||
showsLabel || (showsBarProgress && !showsCountdown)
|
shouldDisplayLabel || (showsBarProgress && !showsCountdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activePresetColor: Color? {
|
private var activePresetColor: Color? {
|
||||||
|
|
@ -130,6 +140,10 @@ struct TimerLiveActivity: View {
|
||||||
return timerPresets.first { $0.id == presetId }?.color
|
return timerPresets.first { $0.id == presetId }?.color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var shouldShowControlWindow: Bool {
|
||||||
|
controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive
|
||||||
|
}
|
||||||
|
|
||||||
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
|
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
|
||||||
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
||||||
let width = NSAttributedString(string: text, attributes: attributes).size().width
|
let width = NSAttributedString(string: text, attributes: attributes).size().width
|
||||||
|
|
@ -192,22 +206,102 @@ struct TimerLiveActivity: View {
|
||||||
withAnimation(.smooth(duration: 0.18)) {
|
withAnimation(.smooth(duration: 0.18)) {
|
||||||
isHovering = hovering
|
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 {
|
private var iconSection: some View {
|
||||||
let ringDiameter = ringWrapsIcon ? max(iconWidth, 32) : iconWidth
|
let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth
|
||||||
let iconSize = ringWrapsIcon ? max(ringDiameter - 10, 18) : max(20, iconWidth - 4)
|
let ringDiameter = ringWrapsIcon ? max(min(baseDiameter, notchContentHeight - 2), 22) : iconWidth
|
||||||
|
let iconSize = ringWrapsIcon ? max(ringDiameter - 12, 16) : max(18, iconWidth - 6)
|
||||||
|
|
||||||
return ZStack {
|
return ZStack {
|
||||||
if ringWrapsIcon {
|
if ringWrapsIcon {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(Color.white.opacity(0.15), lineWidth: 3)
|
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
|
||||||
.frame(width: ringDiameter, height: ringDiameter)
|
.frame(width: ringDiameter, height: ringDiameter)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: clampedProgress)
|
.trim(from: 0, to: clampedProgress)
|
||||||
.stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||||
.frame(width: ringDiameter, height: ringDiameter)
|
.frame(width: ringDiameter, height: ringDiameter)
|
||||||
|
|
@ -224,19 +318,20 @@ struct TimerLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var infoSection: some View {
|
private var infoSection: some View {
|
||||||
let availableWidth = max(0, infoWidth - 18)
|
let availableWidth = max(0, infoWidth - 10)
|
||||||
let resolvedTextWidth = min(max(titleTextWidth, 44), availableWidth)
|
let safeWidth = max(44, availableWidth - 6)
|
||||||
let shouldMarquee = showsLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth)
|
let resolvedTextWidth = min(max(titleTextWidth, 44), safeWidth)
|
||||||
|
let marqueeLabel = shouldDisplayLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth)
|
||||||
let showsBarHere = showsBarProgress && !showsCountdown
|
let showsBarHere = showsBarProgress && !showsCountdown
|
||||||
let barWidth = showsLabel ? resolvedTextWidth : availableWidth
|
let barWidth = shouldDisplayLabel ? resolvedTextWidth : availableWidth
|
||||||
|
|
||||||
return Rectangle()
|
return Rectangle()
|
||||||
.fill(.black)
|
.fill(.black)
|
||||||
.frame(width: infoWidth, height: notchContentHeight)
|
.frame(width: infoWidth, height: notchContentHeight)
|
||||||
.overlay(alignment: .leading) {
|
.overlay(alignment: .leading) {
|
||||||
VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) {
|
VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) {
|
||||||
if showsLabel {
|
if shouldDisplayLabel {
|
||||||
if shouldMarquee {
|
if marqueeLabel {
|
||||||
MarqueeText(
|
MarqueeText(
|
||||||
.constant(timerManager.timerName),
|
.constant(timerManager.timerName),
|
||||||
font: .system(size: 12, weight: .medium),
|
font: .system(size: 12, weight: .medium),
|
||||||
|
|
@ -274,16 +369,17 @@ struct TimerLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ringSection: some View {
|
private var ringSection: some View {
|
||||||
ZStack {
|
let diameter = max(min(notchContentHeight - 4, 26), 20)
|
||||||
|
return ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(Color.white.opacity(0.15), lineWidth: 3)
|
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: clampedProgress)
|
.trim(from: 0, to: clampedProgress)
|
||||||
.stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||||
}
|
}
|
||||||
.frame(width: 26, height: 26)
|
.frame(width: diameter, height: diameter)
|
||||||
.frame(width: ringWidth, height: notchContentHeight, alignment: .center)
|
.frame(width: ringWidth, height: notchContentHeight, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,10 +406,73 @@ struct TimerLiveActivity: View {
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
.frame(width: countdownWidth,
|
.frame(width: countdownWidth,
|
||||||
height: notchContentHeight, alignment: .center)
|
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 {
|
#Preview {
|
||||||
|
|
|
||||||
199
DynamicIsland/managers/TimerControlWindowManager.swift
Normal file
199
DynamicIsland/managers/TimerControlWindowManager.swift
Normal file
|
|
@ -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<TimerControlOverlay>?
|
||||||
|
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<TimerControlOverlay> {
|
||||||
|
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<TimerControlOverlay>) -> 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
|
||||||
|
|
@ -435,6 +435,7 @@ extension Defaults.Keys {
|
||||||
static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
|
static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
|
||||||
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
|
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
|
||||||
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
|
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
|
||||||
|
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
|
||||||
|
|
||||||
// MARK: ColorPicker Feature
|
// MARK: ColorPicker Feature
|
||||||
static let enableColorPickerFeature = Key<Bool>("enableColorPickerFeature", default: true)
|
static let enableColorPickerFeature = Key<Bool>("enableColorPickerFeature", default: true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue