Initial release of Pommedoro
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cc23058b1d
commit
b170202033
|
|
@ -1,4 +1,5 @@
|
||||||
|
# Swift / SPM build
|
||||||
.build/
|
.build/
|
||||||
|
|
||||||
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.o
|
|
||||||
Package.resolved
|
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -83,6 +83,8 @@ dmg: bundle
|
||||||
@mkdir -p $(BUILD_DIR)/dmg-staging
|
@mkdir -p $(BUILD_DIR)/dmg-staging
|
||||||
@cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/
|
@cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/
|
||||||
@ln -s /Applications $(BUILD_DIR)/dmg-staging/Applications
|
@ln -s /Applications $(BUILD_DIR)/dmg-staging/Applications
|
||||||
|
@cp Resources/$(APP_NAME).icns $(BUILD_DIR)/dmg-staging/.VolumeIcon.icns
|
||||||
|
@SetFile -a C $(BUILD_DIR)/dmg-staging
|
||||||
@hdiutil create -volname "$(APP_NAME)" \
|
@hdiutil create -volname "$(APP_NAME)" \
|
||||||
-srcfolder $(BUILD_DIR)/dmg-staging \
|
-srcfolder $(BUILD_DIR)/dmg-staging \
|
||||||
-ov -format UDZO \
|
-ov -format UDZO \
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "sentry-cocoa",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/getsentry/sentry-cocoa.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
|
||||||
|
"version" : "8.58.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var statusMenuItem: NSMenuItem!
|
var statusMenuItem: NSMenuItem!
|
||||||
var debugMenuItem: NSMenuItem!
|
var debugMenuItem: NSMenuItem!
|
||||||
var launchAtLoginMenuItem: NSMenuItem!
|
var launchAtLoginMenuItem: NSMenuItem!
|
||||||
|
var settingsMenuBuilder: SettingsMenuBuilder!
|
||||||
|
|
||||||
// Overlay windows
|
// Overlay windows
|
||||||
var overlayWindows: [NSWindow] = []
|
var overlayWindows: [NSWindow] = []
|
||||||
|
|
@ -38,6 +39,23 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
startCountdown()
|
startCountdown()
|
||||||
NotificationManager.shared.requestPermission()
|
NotificationManager.shared.requestPermission()
|
||||||
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(settingsDidChange),
|
||||||
|
name: Settings.didChangeNotification, object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func settingsDidChange() {
|
||||||
|
let color = Settings.shared.gradientColor
|
||||||
|
for label in countdownLabels {
|
||||||
|
label.textColor = color
|
||||||
|
}
|
||||||
|
for window in overlayWindows {
|
||||||
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
||||||
|
view.color = color
|
||||||
|
view.needsDisplay = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCountdown() {
|
func startCountdown() {
|
||||||
|
|
@ -53,7 +71,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let s = self.remainingSeconds
|
let s = self.remainingSeconds
|
||||||
|
|
||||||
if s > 60 {
|
if s > 60 {
|
||||||
// 5:00 to 3:00 -> every 60 seconds, 3:00 to 1:00 -> every 30 seconds
|
|
||||||
let shouldBlink: Bool
|
let shouldBlink: Bool
|
||||||
if s > 180 {
|
if s > 180 {
|
||||||
shouldBlink = (s % 60 == 0)
|
shouldBlink = (s % 60 == 0)
|
||||||
|
|
@ -62,7 +79,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
if shouldBlink { self.triggerBlink() }
|
if shouldBlink { self.triggerBlink() }
|
||||||
} else if s > 5 {
|
} else if s > 5 {
|
||||||
// Last minute: same as debug mode escalation
|
|
||||||
let shouldBlink: Bool
|
let shouldBlink: Bool
|
||||||
if s > 30 {
|
if s > 30 {
|
||||||
shouldBlink = (s % 10 == 0)
|
shouldBlink = (s % 10 == 0)
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,15 @@ extension AppDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var breakScreenColor: NSColor {
|
||||||
|
Settings.shared.gradientColor.withAlphaComponent(0.85)
|
||||||
|
}
|
||||||
|
|
||||||
func buildSuggestionScreen(window: NSWindow, isPrimary: Bool) {
|
func buildSuggestionScreen(window: NSWindow, isPrimary: Bool) {
|
||||||
let size = window.frame.size
|
let size = window.frame.size
|
||||||
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
view.layer?.backgroundColor = NSColor.systemTeal.withAlphaComponent(0.85).cgColor
|
view.layer?.backgroundColor = breakScreenColor.cgColor
|
||||||
|
|
||||||
let countdownLabel = NSTextField(labelWithString: "05:00")
|
let countdownLabel = NSTextField(labelWithString: "05:00")
|
||||||
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 72, weight: .bold)
|
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 72, weight: .bold)
|
||||||
|
|
@ -128,11 +132,13 @@ extension AppDelegate {
|
||||||
|
|
||||||
@objc func didSuccess() {
|
@objc func didSuccess() {
|
||||||
breakCountdownLabels.removeAll()
|
breakCountdownLabels.removeAll()
|
||||||
|
let bgColor = breakScreenColor
|
||||||
|
|
||||||
for (i, window) in overlayWindows.enumerated() {
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
let size = window.frame.size
|
let size = window.frame.size
|
||||||
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
view.layer?.backgroundColor = NSColor.systemTeal.withAlphaComponent(0.85).cgColor
|
view.layer?.backgroundColor = bgColor.cgColor
|
||||||
|
|
||||||
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
|
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
|
||||||
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
||||||
|
|
@ -215,11 +221,13 @@ extension AppDelegate {
|
||||||
|
|
||||||
@objc func reflectionDone() {
|
@objc func reflectionDone() {
|
||||||
breakCountdownLabels.removeAll()
|
breakCountdownLabels.removeAll()
|
||||||
|
let bgColor = breakScreenColor
|
||||||
|
|
||||||
for (i, window) in overlayWindows.enumerated() {
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
let size = window.frame.size
|
let size = window.frame.size
|
||||||
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
view.layer?.backgroundColor = NSColor.systemTeal.withAlphaComponent(0.85).cgColor
|
view.layer?.backgroundColor = bgColor.cgColor
|
||||||
|
|
||||||
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
|
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
|
||||||
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import AppKit
|
||||||
|
|
||||||
class EdgeGradientView: NSView {
|
class EdgeGradientView: NSView {
|
||||||
var intensity: CGFloat = 0.4
|
var intensity: CGFloat = 0.4
|
||||||
|
var color: NSColor = Settings.shared.gradientColor
|
||||||
|
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
||||||
let w = bounds.width, h = bounds.height, edge: CGFloat = 220
|
let w = bounds.width, h = bounds.height, edge: CGFloat = 220
|
||||||
let color = NSColor.systemTeal
|
|
||||||
let colors = [color.withAlphaComponent(intensity).cgColor, color.withAlphaComponent(0.0).cgColor] as CFArray
|
let colors = [color.withAlphaComponent(intensity).cgColor, color.withAlphaComponent(0.0).cgColor] as CFArray
|
||||||
guard let grad = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0, 1]) else { return }
|
guard let grad = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0, 1]) else { return }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ extension AppDelegate {
|
||||||
|
|
||||||
let label = NSTextField(labelWithString: "00:10")
|
let label = NSTextField(labelWithString: "00:10")
|
||||||
label.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
label.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
||||||
label.textColor = .systemTeal
|
label.textColor = Settings.shared.gradientColor
|
||||||
label.alignment = .center
|
label.alignment = .center
|
||||||
label.frame = NSRect(x: 0, y: 14, width: pillW, height: 50)
|
label.frame = NSRect(x: 0, y: 14, width: pillW, height: 50)
|
||||||
pillView.addSubview(label)
|
pillView.addSubview(label)
|
||||||
|
|
@ -88,30 +88,37 @@ extension AppDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerBlink() {
|
func triggerBlink() {
|
||||||
|
let settings = Settings.shared
|
||||||
|
let scale = CGFloat(settings.intensityScale)
|
||||||
|
let durationScale = settings.blinkDurationScale
|
||||||
|
|
||||||
// Intensity scales across the effective countdown window (capped to work timer)
|
// Intensity scales across the effective countdown window (capped to work timer)
|
||||||
let effectiveCountdown = min(countdownDuration, workTimerDuration)
|
let effectiveCountdown = min(countdownDuration, workTimerDuration)
|
||||||
let countdownElapsed = Double(effectiveCountdown - remainingSeconds)
|
let countdownElapsed = Double(effectiveCountdown - remainingSeconds)
|
||||||
let countdownTotal = Double(effectiveCountdown)
|
let countdownTotal = Double(effectiveCountdown)
|
||||||
let progress = min(max(countdownElapsed / countdownTotal, 0.0), 1.0)
|
let progress = min(max(countdownElapsed / countdownTotal, 0.0), 1.0)
|
||||||
let intensity = 0.15 + (0.60 * progress) // 0.15 at start, up to 0.75 at 0:00
|
let baseIntensity = 0.15 + (0.60 * progress) // 0.15 at start, up to 0.75 at 0:00
|
||||||
|
let intensity = min(baseIntensity * Double(scale), 1.0)
|
||||||
|
|
||||||
let fadeIn: Double
|
let fadeIn: Double
|
||||||
let hold: Double
|
let hold: Double
|
||||||
let fadeOut: Double
|
let fadeOut: Double
|
||||||
if remainingSeconds > 60 {
|
if remainingSeconds > 60 {
|
||||||
// 5:00 to 1:00: gentle, slow flashes
|
fadeIn = 1.0 * durationScale; hold = 0.8 * durationScale; fadeOut = 1.0 * durationScale
|
||||||
fadeIn = 1.0; hold = 0.8; fadeOut = 1.0
|
|
||||||
} else if remainingSeconds > 30 {
|
} else if remainingSeconds > 30 {
|
||||||
fadeIn = 0.8; hold = 0.6; fadeOut = 0.8
|
fadeIn = 0.8 * durationScale; hold = 0.6 * durationScale; fadeOut = 0.8 * durationScale
|
||||||
} else if remainingSeconds > 10 {
|
} else if remainingSeconds > 10 {
|
||||||
fadeIn = 0.5; hold = 0.4; fadeOut = 0.6
|
fadeIn = 0.5 * durationScale; hold = 0.4 * durationScale; fadeOut = 0.6 * durationScale
|
||||||
} else {
|
} else {
|
||||||
fadeIn = 0.3; hold = 0.3; fadeOut = 0.4
|
fadeIn = 0.3 * durationScale; hold = 0.3 * durationScale; fadeOut = 0.4 * durationScale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let color = settings.gradientColor
|
||||||
|
|
||||||
for window in overlayWindows {
|
for window in overlayWindows {
|
||||||
guard let view = window.contentView as? EdgeGradientView else { continue }
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
||||||
view.intensity = CGFloat(intensity)
|
view.intensity = CGFloat(intensity)
|
||||||
|
view.color = color
|
||||||
view.needsDisplay = true
|
view.needsDisplay = true
|
||||||
|
|
||||||
NSAnimationContext.runAnimationGroup { ctx in
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
|
@ -130,9 +137,14 @@ extension AppDelegate {
|
||||||
|
|
||||||
func makeSolid() {
|
func makeSolid() {
|
||||||
isSolid = true
|
isSolid = true
|
||||||
|
let settings = Settings.shared
|
||||||
|
let maxIntensity = min(0.75 * CGFloat(settings.intensityScale), 1.0)
|
||||||
|
let color = settings.gradientColor
|
||||||
|
|
||||||
for window in overlayWindows {
|
for window in overlayWindows {
|
||||||
guard let view = window.contentView as? EdgeGradientView else { continue }
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
||||||
view.intensity = 0.75
|
view.intensity = maxIntensity
|
||||||
|
view.color = color
|
||||||
view.needsDisplay = true
|
view.needsDisplay = true
|
||||||
NSAnimationContext.runAnimationGroup { ctx in
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
ctx.duration = 0.3
|
ctx.duration = 0.3
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Settings Keys
|
||||||
|
private enum SettingsKey {
|
||||||
|
static let intensityScale = "pommedoro.intensityScale"
|
||||||
|
static let blinkDurationScale = "pommedoro.blinkDurationScale"
|
||||||
|
static let gradientColorRed = "pommedoro.gradientColor.red"
|
||||||
|
static let gradientColorGreen = "pommedoro.gradientColor.green"
|
||||||
|
static let gradientColorBlue = "pommedoro.gradientColor.blue"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Manager
|
||||||
|
class Settings {
|
||||||
|
static let shared = Settings()
|
||||||
|
|
||||||
|
/// Notification posted when any setting changes
|
||||||
|
static let didChangeNotification = Notification.Name("PommedoroSettingsDidChange")
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
registerDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerDefaults() {
|
||||||
|
// Extract default teal components for consistent defaults
|
||||||
|
let teal = NSColor.systemTeal.usingColorSpace(.sRGB) ?? NSColor.systemTeal
|
||||||
|
defaults.register(defaults: [
|
||||||
|
SettingsKey.intensityScale: 1.0,
|
||||||
|
SettingsKey.blinkDurationScale: 1.0,
|
||||||
|
SettingsKey.gradientColorRed: Double(teal.redComponent),
|
||||||
|
SettingsKey.gradientColorGreen: Double(teal.greenComponent),
|
||||||
|
SettingsKey.gradientColorBlue: Double(teal.blueComponent),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intensity multiplier (0.25 to 2.0, default 1.0)
|
||||||
|
var intensityScale: Double {
|
||||||
|
get { defaults.double(forKey: SettingsKey.intensityScale) }
|
||||||
|
set {
|
||||||
|
defaults.set(newValue.clamped(to: 0.25...2.0), forKey: SettingsKey.intensityScale)
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blink duration multiplier (0.25 to 3.0, default 1.0)
|
||||||
|
var blinkDurationScale: Double {
|
||||||
|
get { defaults.double(forKey: SettingsKey.blinkDurationScale) }
|
||||||
|
set {
|
||||||
|
defaults.set(newValue.clamped(to: 0.25...3.0), forKey: SettingsKey.blinkDurationScale)
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gradient color as NSColor
|
||||||
|
var gradientColor: NSColor {
|
||||||
|
get {
|
||||||
|
NSColor(
|
||||||
|
srgbRed: CGFloat(defaults.double(forKey: SettingsKey.gradientColorRed)),
|
||||||
|
green: CGFloat(defaults.double(forKey: SettingsKey.gradientColorGreen)),
|
||||||
|
blue: CGFloat(defaults.double(forKey: SettingsKey.gradientColorBlue)),
|
||||||
|
alpha: 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let c = newValue.usingColorSpace(.sRGB) ?? newValue
|
||||||
|
defaults.set(Double(c.redComponent), forKey: SettingsKey.gradientColorRed)
|
||||||
|
defaults.set(Double(c.greenComponent), forKey: SettingsKey.gradientColorGreen)
|
||||||
|
defaults.set(Double(c.blueComponent), forKey: SettingsKey.gradientColorBlue)
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToDefaults() {
|
||||||
|
for key in [
|
||||||
|
SettingsKey.intensityScale,
|
||||||
|
SettingsKey.blinkDurationScale,
|
||||||
|
SettingsKey.gradientColorRed,
|
||||||
|
SettingsKey.gradientColorGreen,
|
||||||
|
SettingsKey.gradientColorBlue,
|
||||||
|
] {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyChange() {
|
||||||
|
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comparable Clamping
|
||||||
|
private extension Comparable {
|
||||||
|
func clamped(to range: ClosedRange<Self>) -> Self {
|
||||||
|
min(max(self, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Slider Menu Item View
|
||||||
|
class SliderMenuItemView: NSView {
|
||||||
|
let slider: NSSlider
|
||||||
|
let valueLabel: NSTextField
|
||||||
|
private let titleLabel: NSTextField
|
||||||
|
var onChanged: ((Double) -> Void)?
|
||||||
|
|
||||||
|
init(title: String, value: Double, minValue: Double, maxValue: Double, width: CGFloat = 280) {
|
||||||
|
titleLabel = NSTextField(labelWithString: title)
|
||||||
|
slider = NSSlider(value: value, minValue: minValue, maxValue: maxValue,
|
||||||
|
target: nil, action: nil)
|
||||||
|
valueLabel = NSTextField(labelWithString: String(format: "%.2fx", value))
|
||||||
|
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 50))
|
||||||
|
|
||||||
|
titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
titleLabel.textColor = .labelColor
|
||||||
|
titleLabel.frame = NSRect(x: 16, y: 28, width: width - 32, height: 16)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
|
||||||
|
let sliderW = width - 80
|
||||||
|
slider.frame = NSRect(x: 16, y: 4, width: sliderW, height: 20)
|
||||||
|
slider.target = self
|
||||||
|
slider.action = #selector(sliderMoved)
|
||||||
|
slider.isContinuous = true
|
||||||
|
addSubview(slider)
|
||||||
|
|
||||||
|
valueLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
|
||||||
|
valueLabel.textColor = .secondaryLabelColor
|
||||||
|
valueLabel.alignment = .right
|
||||||
|
valueLabel.frame = NSRect(x: sliderW + 20, y: 4, width: 44, height: 20)
|
||||||
|
addSubview(valueLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
@objc private func sliderMoved() {
|
||||||
|
let v = slider.doubleValue
|
||||||
|
valueLabel.stringValue = String(format: "%.2fx", v)
|
||||||
|
onChanged?(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(value: Double) {
|
||||||
|
slider.doubleValue = value
|
||||||
|
valueLabel.stringValue = String(format: "%.2fx", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Swatch Menu Item View
|
||||||
|
class ColorSwatchMenuItemView: NSView {
|
||||||
|
private let titleLabel: NSTextField
|
||||||
|
private var swatchButtons: [NSButton] = []
|
||||||
|
private var selectedIndex: Int = -1
|
||||||
|
var onChanged: ((NSColor) -> Void)?
|
||||||
|
|
||||||
|
static let presetColors: [(String, NSColor)] = [
|
||||||
|
("Teal", .systemTeal),
|
||||||
|
("Blue", .systemBlue),
|
||||||
|
("Purple", .systemPurple),
|
||||||
|
("Pink", .systemPink),
|
||||||
|
("Red", .systemRed),
|
||||||
|
("Orange", .systemOrange),
|
||||||
|
("Yellow", .systemYellow),
|
||||||
|
("Green", .systemGreen),
|
||||||
|
("Indigo", .systemIndigo),
|
||||||
|
]
|
||||||
|
|
||||||
|
init(title: String, currentColor: NSColor, width: CGFloat = 280) {
|
||||||
|
titleLabel = NSTextField(labelWithString: title)
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 56))
|
||||||
|
|
||||||
|
titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
titleLabel.textColor = .labelColor
|
||||||
|
titleLabel.frame = NSRect(x: 16, y: 34, width: width - 32, height: 16)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
|
||||||
|
let swatchSize: CGFloat = 22
|
||||||
|
let gap: CGFloat = 4
|
||||||
|
let count = Self.presetColors.count
|
||||||
|
let totalW = CGFloat(count) * swatchSize + CGFloat(count - 1) * gap
|
||||||
|
var x = (width - totalW) / 2
|
||||||
|
|
||||||
|
for (i, (_, color)) in Self.presetColors.enumerated() {
|
||||||
|
let btn = NSButton(frame: NSRect(x: x, y: 6, width: swatchSize, height: swatchSize))
|
||||||
|
btn.wantsLayer = true
|
||||||
|
btn.isBordered = false
|
||||||
|
btn.title = ""
|
||||||
|
btn.layer?.backgroundColor = color.cgColor
|
||||||
|
btn.layer?.cornerRadius = swatchSize / 2
|
||||||
|
btn.layer?.borderWidth = 2
|
||||||
|
btn.layer?.borderColor = NSColor.clear.cgColor
|
||||||
|
btn.tag = i
|
||||||
|
btn.target = self
|
||||||
|
btn.action = #selector(swatchClicked(_:))
|
||||||
|
addSubview(btn)
|
||||||
|
swatchButtons.append(btn)
|
||||||
|
x += swatchSize + gap
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightClosest(to: currentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
@objc private func swatchClicked(_ sender: NSButton) {
|
||||||
|
let idx = sender.tag
|
||||||
|
guard idx >= 0 && idx < Self.presetColors.count else { return }
|
||||||
|
setSelected(idx)
|
||||||
|
onChanged?(Self.presetColors[idx].1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSelected(_ index: Int) {
|
||||||
|
// Clear previous
|
||||||
|
for btn in swatchButtons {
|
||||||
|
btn.layer?.borderColor = NSColor.clear.cgColor
|
||||||
|
}
|
||||||
|
// Highlight new
|
||||||
|
if index >= 0 && index < swatchButtons.count {
|
||||||
|
swatchButtons[index].layer?.borderColor = NSColor.white.cgColor
|
||||||
|
selectedIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func highlightClosest(to target: NSColor) {
|
||||||
|
let t = target.usingColorSpace(.sRGB) ?? target
|
||||||
|
var bestIdx = 0
|
||||||
|
var bestDist: CGFloat = .greatestFiniteMagnitude
|
||||||
|
for (i, (_, color)) in Self.presetColors.enumerated() {
|
||||||
|
let c = color.usingColorSpace(.sRGB) ?? color
|
||||||
|
let dr = t.redComponent - c.redComponent
|
||||||
|
let dg = t.greenComponent - c.greenComponent
|
||||||
|
let db = t.blueComponent - c.blueComponent
|
||||||
|
let dist = dr*dr + dg*dg + db*db
|
||||||
|
if dist < bestDist { bestDist = dist; bestIdx = i }
|
||||||
|
}
|
||||||
|
setSelected(bestIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(color: NSColor) {
|
||||||
|
highlightClosest(to: color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Menu Builder
|
||||||
|
class SettingsMenuBuilder {
|
||||||
|
private var intensityView: SliderMenuItemView?
|
||||||
|
private var durationView: SliderMenuItemView?
|
||||||
|
private var colorView: ColorSwatchMenuItemView?
|
||||||
|
|
||||||
|
func buildSettingsSubmenu() -> NSMenuItem {
|
||||||
|
let submenu = NSMenu(title: "Settings")
|
||||||
|
|
||||||
|
// Color swatches
|
||||||
|
let cv = ColorSwatchMenuItemView(title: "Gradient Color:", currentColor: Settings.shared.gradientColor)
|
||||||
|
cv.onChanged = { color in Settings.shared.gradientColor = color }
|
||||||
|
colorView = cv
|
||||||
|
let colorItem = NSMenuItem()
|
||||||
|
colorItem.view = cv
|
||||||
|
submenu.addItem(colorItem)
|
||||||
|
|
||||||
|
submenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
// Intensity
|
||||||
|
let iv = SliderMenuItemView(title: "Intensity", value: Settings.shared.intensityScale,
|
||||||
|
minValue: 0.25, maxValue: 2.0)
|
||||||
|
iv.onChanged = { val in Settings.shared.intensityScale = val }
|
||||||
|
intensityView = iv
|
||||||
|
let intensityItem = NSMenuItem()
|
||||||
|
intensityItem.view = iv
|
||||||
|
submenu.addItem(intensityItem)
|
||||||
|
|
||||||
|
submenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
// Blink Duration
|
||||||
|
let dv = SliderMenuItemView(title: "Blink Duration", value: Settings.shared.blinkDurationScale,
|
||||||
|
minValue: 0.25, maxValue: 3.0)
|
||||||
|
dv.onChanged = { val in Settings.shared.blinkDurationScale = val }
|
||||||
|
durationView = dv
|
||||||
|
let durationItem = NSMenuItem()
|
||||||
|
durationItem.view = dv
|
||||||
|
submenu.addItem(durationItem)
|
||||||
|
|
||||||
|
submenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
let resetItem = NSMenuItem(title: "Reset to Defaults", action: #selector(resetDefaults), keyEquivalent: "")
|
||||||
|
resetItem.target = self
|
||||||
|
submenu.addItem(resetItem)
|
||||||
|
|
||||||
|
let parentItem = NSMenuItem(title: "Settings", action: nil, keyEquivalent: ",")
|
||||||
|
parentItem.submenu = submenu
|
||||||
|
return parentItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshValues() {
|
||||||
|
let s = Settings.shared
|
||||||
|
intensityView?.update(value: s.intensityScale)
|
||||||
|
durationView?.update(value: s.blinkDurationScale)
|
||||||
|
colorView?.update(color: s.gradientColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func resetDefaults() {
|
||||||
|
Settings.shared.resetToDefaults()
|
||||||
|
refreshValues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,12 @@ extension AppDelegate {
|
||||||
menu.addItem(statusMenuItem)
|
menu.addItem(statusMenuItem)
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
menu.addItem(NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p"))
|
menu.addItem(NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p"))
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
// Settings submenu with inline sliders and color picker
|
||||||
|
settingsMenuBuilder = SettingsMenuBuilder()
|
||||||
|
menu.addItem(settingsMenuBuilder.buildSettingsSubmenu())
|
||||||
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d")
|
debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d")
|
||||||
debugMenuItem.state = isDebugMode ? .on : .off
|
debugMenuItem.state = isDebugMode ? .on : .off
|
||||||
|
|
@ -29,8 +35,10 @@ extension AppDelegate {
|
||||||
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
|
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
|
||||||
|
|
||||||
for item in menu.items {
|
for item in menu.items {
|
||||||
|
if item.target == nil && item.action != nil {
|
||||||
item.target = self
|
item.target = self
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
statusItem.menu = menu
|
statusItem.menu = menu
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue