diff --git a/.gitignore b/.gitignore index bdd6260..cc43ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ +# Swift / SPM build .build/ + +# macOS .DS_Store -*.o -Package.resolved diff --git a/Makefile b/Makefile index b03dd41..1fdff7a 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,8 @@ dmg: bundle @mkdir -p $(BUILD_DIR)/dmg-staging @cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/ @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)" \ -srcfolder $(BUILD_DIR)/dmg-staging \ -ov -format UDZO \ diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..df62545 --- /dev/null +++ b/Package.resolved @@ -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 +} 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..c64c65a --- /dev/null +++ b/Sources/Pommedoro/SettingsMenu.swift @@ -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() + } +} 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..90949db 100644 Binary files a/releases/Pommedoro.dmg and b/releases/Pommedoro.dmg differ