diff --git a/.build/Pommedoro.dmg b/.build/Pommedoro.dmg new file mode 100644 index 0000000..c878a0a Binary files /dev/null and b/.build/Pommedoro.dmg differ diff --git a/.gitignore b/.gitignore index bdd6260..45ee5c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -.build/ .DS_Store *.o Package.resolved +.build/* +!.build/Pommedoro.dmg diff --git a/Sources/Pommedoro/AppDelegate.swift b/Sources/Pommedoro/AppDelegate.swift index d36a290..ba55346 100644 --- a/Sources/Pommedoro/AppDelegate.swift +++ b/Sources/Pommedoro/AppDelegate.swift @@ -13,6 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var statusMenuItem: NSMenuItem! var debugMenuItem: NSMenuItem! var launchAtLoginMenuItem: NSMenuItem! + var settingsMenuBuilder: SettingsMenuBuilder! // Overlay windows var overlayWindows: [NSWindow] = [] @@ -38,6 +39,23 @@ class AppDelegate: NSObject, NSApplicationDelegate { startCountdown() NotificationManager.shared.requestPermission() 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() { @@ -53,7 +71,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let s = self.remainingSeconds if s > 60 { - // 5:00 to 3:00 -> every 60 seconds, 3:00 to 1:00 -> every 30 seconds let shouldBlink: Bool if s > 180 { shouldBlink = (s % 60 == 0) @@ -62,7 +79,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } if shouldBlink { self.triggerBlink() } } else if s > 5 { - // Last minute: same as debug mode escalation let shouldBlink: Bool if s > 30 { shouldBlink = (s % 10 == 0) diff --git a/Sources/Pommedoro/BreakScreens.swift b/Sources/Pommedoro/BreakScreens.swift index b11a509..daa303e 100644 --- a/Sources/Pommedoro/BreakScreens.swift +++ b/Sources/Pommedoro/BreakScreens.swift @@ -45,11 +45,15 @@ extension AppDelegate { } } + private var breakScreenColor: NSColor { + Settings.shared.gradientColor.withAlphaComponent(0.85) + } + func buildSuggestionScreen(window: NSWindow, isPrimary: Bool) { let size = window.frame.size let view = NSView(frame: NSRect(origin: .zero, size: size)) view.wantsLayer = true - view.layer?.backgroundColor = NSColor.systemTeal.withAlphaComponent(0.85).cgColor + view.layer?.backgroundColor = breakScreenColor.cgColor let countdownLabel = NSTextField(labelWithString: "05:00") countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 72, weight: .bold) @@ -128,11 +132,13 @@ extension AppDelegate { @objc func didSuccess() { breakCountdownLabels.removeAll() + let bgColor = breakScreenColor + for (i, window) in overlayWindows.enumerated() { let size = window.frame.size let view = NSView(frame: NSRect(origin: .zero, size: size)) 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)) countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold) @@ -215,11 +221,13 @@ extension AppDelegate { @objc func reflectionDone() { breakCountdownLabels.removeAll() + let bgColor = breakScreenColor + for (i, window) in overlayWindows.enumerated() { let size = window.frame.size let view = NSView(frame: NSRect(origin: .zero, size: size)) 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)) countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold) diff --git a/Sources/Pommedoro/EdgeGradientView.swift b/Sources/Pommedoro/EdgeGradientView.swift index 99b0ed9..5c06b24 100644 --- a/Sources/Pommedoro/EdgeGradientView.swift +++ b/Sources/Pommedoro/EdgeGradientView.swift @@ -2,11 +2,11 @@ import AppKit class EdgeGradientView: NSView { var intensity: CGFloat = 0.4 + var color: NSColor = Settings.shared.gradientColor override func draw(_ dirtyRect: NSRect) { guard let ctx = NSGraphicsContext.current?.cgContext else { return } 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 guard let grad = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0, 1]) else { return } diff --git a/Sources/Pommedoro/OverlayWindows.swift b/Sources/Pommedoro/OverlayWindows.swift index 5ad2fb2..195b45a 100644 --- a/Sources/Pommedoro/OverlayWindows.swift +++ b/Sources/Pommedoro/OverlayWindows.swift @@ -52,7 +52,7 @@ extension AppDelegate { let label = NSTextField(labelWithString: "00:10") label.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold) - label.textColor = .systemTeal + label.textColor = Settings.shared.gradientColor label.alignment = .center label.frame = NSRect(x: 0, y: 14, width: pillW, height: 50) pillView.addSubview(label) @@ -88,30 +88,37 @@ extension AppDelegate { } 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) let effectiveCountdown = min(countdownDuration, workTimerDuration) let countdownElapsed = Double(effectiveCountdown - remainingSeconds) let countdownTotal = Double(effectiveCountdown) 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 hold: Double let fadeOut: Double if remainingSeconds > 60 { - // 5:00 to 1:00: gentle, slow flashes - fadeIn = 1.0; hold = 0.8; fadeOut = 1.0 + fadeIn = 1.0 * durationScale; hold = 0.8 * durationScale; fadeOut = 1.0 * durationScale } 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 { - fadeIn = 0.5; hold = 0.4; fadeOut = 0.6 + fadeIn = 0.5 * durationScale; hold = 0.4 * durationScale; fadeOut = 0.6 * durationScale } 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 { guard let view = window.contentView as? EdgeGradientView else { continue } view.intensity = CGFloat(intensity) + view.color = color view.needsDisplay = true NSAnimationContext.runAnimationGroup { ctx in @@ -130,9 +137,14 @@ extension AppDelegate { func makeSolid() { isSolid = true + let settings = Settings.shared + let maxIntensity = min(0.75 * CGFloat(settings.intensityScale), 1.0) + let color = settings.gradientColor + for window in overlayWindows { guard let view = window.contentView as? EdgeGradientView else { continue } - view.intensity = 0.75 + view.intensity = maxIntensity + view.color = color view.needsDisplay = true NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.3 diff --git a/Sources/Pommedoro/Settings.swift b/Sources/Pommedoro/Settings.swift new file mode 100644 index 0000000..a0e16d3 --- /dev/null +++ b/Sources/Pommedoro/Settings.swift @@ -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 { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Sources/Pommedoro/SettingsMenu.swift b/Sources/Pommedoro/SettingsMenu.swift new file mode 100644 index 0000000..792e81b --- /dev/null +++ b/Sources/Pommedoro/SettingsMenu.swift @@ -0,0 +1,149 @@ +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 Menu Item View +class ColorMenuItemView: NSView { + let colorWell: NSColorWell + private let titleLabel: NSTextField + var onChanged: ((NSColor) -> Void)? + + init(title: String, color: NSColor, width: CGFloat = 280) { + titleLabel = NSTextField(labelWithString: title) + colorWell = NSColorWell(frame: .zero) + + super.init(frame: NSRect(x: 0, y: 0, width: width, height: 34)) + + titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium) + titleLabel.textColor = .labelColor + titleLabel.frame = NSRect(x: 16, y: 7, width: 120, height: 16) + addSubview(titleLabel) + + colorWell.frame = NSRect(x: 140, y: 4, width: 40, height: 26) + colorWell.color = color + colorWell.target = self + colorWell.action = #selector(colorPicked) + addSubview(colorWell) + } + + required init?(coder: NSCoder) { fatalError() } + + @objc private func colorPicked() { + onChanged?(colorWell.color) + } + + func update(color: NSColor) { + colorWell.color = color + } +} + +// MARK: - Settings Menu Builder +class SettingsMenuBuilder { + private var intensityView: SliderMenuItemView? + private var durationView: SliderMenuItemView? + private var colorView: ColorMenuItemView? + + /// Build settings items and append them to the given menu as a submenu + func buildSettingsSubmenu() -> NSMenuItem { + let submenu = NSMenu(title: "Settings") + + // Color + let cv = ColorMenuItemView(title: "Gradient Color:", color: 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() + } +} diff --git a/Sources/Pommedoro/StatusBar.swift b/Sources/Pommedoro/StatusBar.swift index 00baf78..2d2c905 100644 --- a/Sources/Pommedoro/StatusBar.swift +++ b/Sources/Pommedoro/StatusBar.swift @@ -17,6 +17,12 @@ extension AppDelegate { menu.addItem(statusMenuItem) menu.addItem(NSMenuItem.separator()) 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()) debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d") debugMenuItem.state = isDebugMode ? .on : .off @@ -29,7 +35,9 @@ extension AppDelegate { menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q")) for item in menu.items { - item.target = self + if item.target == nil && item.action != nil { + item.target = self + } } statusItem.menu = menu diff --git a/releases/Pommedoro.dmg b/releases/Pommedoro.dmg index 5952ff8..c878a0a 100644 Binary files a/releases/Pommedoro.dmg and b/releases/Pommedoro.dmg differ