Initial release of Pommedoro

A macOS Pomodoro timer built on the thesis that transitions — not hard
cuts — are the only way breaks actually happen. Includes escalating
edge gradients, actionable wellness suggestions, self-reflection, and
a compiled DMG. Licensed CC BY 4.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-06 15:51:14 -05:00
commit cc23058b1d
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
20 changed files with 1204 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.build/
.DS_Store
*.o
Package.resolved

24
LICENSE Normal file
View File

@ -0,0 +1,24 @@
Creative Commons Attribution 4.0 International (CC BY 4.0)
Copyright (c) 2026 Colin Knapp — https://colinknapp.com
You are free to:
Share — copy and redistribute the material in any medium or format for
any purpose, even commercially.
Adapt — remix, transform, and build upon the material for any purpose,
even commercially.
Under the following terms:
Attribution — You must give appropriate credit, provide a link to the
license, and indicate if changes were made. You may do so in any
reasonable manner, but not in any way that suggests the licensor
endorses you or your use.
No additional restrictions — You may not apply legal terms or
technological measures that legally restrict others from doing anything
the license permits.
Full license text: https://creativecommons.org/licenses/by/4.0/legalcode

95
Makefile Normal file
View File

@ -0,0 +1,95 @@
APP_NAME = Pommedoro
BUILD_DIR = .build
RELEASE_BIN = $(BUILD_DIR)/arm64-apple-macosx/release/$(APP_NAME)
APP_BUNDLE = $(BUILD_DIR)/$(APP_NAME).app
CONTENTS = $(APP_BUNDLE)/Contents
MACOS_DIR = $(CONTENTS)/MacOS
RESOURCES_DIR = $(CONTENTS)/Resources
LAUNCH_AGENT_LABEL = com.pommedoro.app
LAUNCH_AGENT_PLIST = $(HOME)/Library/LaunchAgents/$(LAUNCH_AGENT_LABEL).plist
.PHONY: all build bundle install clean run icons install-agent uninstall-agent dmg
all: bundle
build:
swift build -c release
icons:
@echo "Generating icons from SVG..."
@mkdir -p Resources/Pommedoro.iconset
@for size in 16 32 64 128 256 512 1024; do \
rsvg-convert -w $$size -h $$size Resources/boot-logo.svg \
-o Resources/Pommedoro.iconset/icon_$${size}x$${size}.png; \
done
@cp Resources/Pommedoro.iconset/icon_32x32.png Resources/Pommedoro.iconset/icon_16x16@2x.png
@cp Resources/Pommedoro.iconset/icon_64x64.png Resources/Pommedoro.iconset/icon_32x32@2x.png
@cp Resources/Pommedoro.iconset/icon_256x256.png Resources/Pommedoro.iconset/icon_128x128@2x.png
@cp Resources/Pommedoro.iconset/icon_512x512.png Resources/Pommedoro.iconset/icon_256x256@2x.png
@cp Resources/Pommedoro.iconset/icon_1024x1024.png Resources/Pommedoro.iconset/icon_512x512@2x.png
@rm -f Resources/Pommedoro.iconset/icon_64x64.png Resources/Pommedoro.iconset/icon_1024x1024.png
@iconutil -c icns Resources/Pommedoro.iconset -o Resources/Pommedoro.icns
@rm -rf Resources/Pommedoro.iconset
@echo "Icon generated: Resources/Pommedoro.icns"
bundle: build
@echo "Creating $(APP_NAME).app bundle..."
@rm -rf $(APP_BUNDLE)
@mkdir -p $(MACOS_DIR)
@mkdir -p $(RESOURCES_DIR)
@cp $(RELEASE_BIN) $(MACOS_DIR)/$(APP_NAME)
@cp Resources/Info.plist $(CONTENTS)/Info.plist
@cp Resources/$(APP_NAME).icns $(RESOURCES_DIR)/$(APP_NAME).icns
@echo "Built $(APP_BUNDLE)"
install: bundle
@echo "Installing to /Applications..."
@rm -rf /Applications/$(APP_NAME).app
@cp -R $(APP_BUNDLE) /Applications/$(APP_NAME).app
@echo "Installed /Applications/$(APP_NAME).app"
run: bundle
@open $(APP_BUNDLE)
install-agent: install
@echo "Installing LaunchAgent..."
@mkdir -p $(HOME)/Library/LaunchAgents
@/usr/libexec/PlistBuddy -c "Clear dict" /dev/null 2>/dev/null; \
printf '<?xml version="1.0" encoding="UTF-8"?>\n\
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n\
<plist version="1.0">\n\
<dict>\n\
\t<key>Label</key>\n\
\t<string>$(LAUNCH_AGENT_LABEL)</string>\n\
\t<key>Program</key>\n\
\t<string>/Applications/$(APP_NAME).app/Contents/MacOS/$(APP_NAME)</string>\n\
\t<key>RunAtLoad</key>\n\
\t<true/>\n\
\t<key>KeepAlive</key>\n\
\t<false/>\n\
</dict>\n\
</plist>\n' > $(LAUNCH_AGENT_PLIST)
@echo "LaunchAgent installed: $(LAUNCH_AGENT_PLIST)"
uninstall-agent:
@echo "Removing LaunchAgent..."
@rm -f $(LAUNCH_AGENT_PLIST)
@echo "LaunchAgent removed"
dmg: bundle
@echo "Creating DMG..."
@rm -f $(BUILD_DIR)/$(APP_NAME).dmg
@mkdir -p $(BUILD_DIR)/dmg-staging
@cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/
@ln -s /Applications $(BUILD_DIR)/dmg-staging/Applications
@hdiutil create -volname "$(APP_NAME)" \
-srcfolder $(BUILD_DIR)/dmg-staging \
-ov -format UDZO \
$(BUILD_DIR)/$(APP_NAME).dmg
@rm -rf $(BUILD_DIR)/dmg-staging
@echo "Created $(BUILD_DIR)/$(APP_NAME).dmg"
clean:
swift package clean
rm -rf $(APP_BUNDLE)

20
Package.swift Normal file
View File

@ -0,0 +1,20 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Pommedoro",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.56.0")
],
targets: [
.executableTarget(
name: "Pommedoro",
dependencies: [
.product(name: "Sentry", package: "sentry-cocoa")
]
)
]
)

129
README.md Normal file
View File

@ -0,0 +1,129 @@
# Pommedoro
**A Pomodoro timer that respects how your brain actually works.**
[Download the latest DMG](https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg)
---
## The Problem with Traditional Pomodoro Timers
Every Pomodoro app does the same thing: a timer counts down, an alarm fires, and a dialog box demands your attention. You dismiss it. You always dismiss it. The break never happens.
This isn't a willpower failure. It's a design failure.
When you're deep in flow — writing, coding, designing — your mind is in a groove. A sudden alarm is a context switch, and human beings are wired to reject context switches. The instinctive response to an interruption during focus is to kill the interruption, not to obey it. So you click "dismiss" and keep working, and the entire purpose of the break is defeated.
Traditional timers treat breaks as binary events: you're working, then you're not. But that's not how attention works. Attention is a continuum. You can't jump from full focus to full rest without a bridge. **Transitions, not hard cuts, are the only way successful breaks will actually occur.**
## How Pommedoro Works
Pommedoro is built around the idea that your timer should *ease* you toward a break rather than ambush you with one.
### Phase 1: Escalating Awareness
During the last five minutes of a work session, Pommedoro begins painting soft teal gradients along the edges of all your screens. These are gentle — barely noticeable at first. They fade in and out slowly, like breathing.
As time goes on, the blinks become more frequent and more intense:
- **5:00 3:00 remaining**: A brief flash every 60 seconds. Subtle. A whisper.
- **3:00 1:00 remaining**: Every 30 seconds. You start to notice.
- **1:00 0:30 remaining**: Every 10 seconds. The edges are becoming familiar.
- **0:30 0:10 remaining**: Every 5 seconds. Your peripheral vision has accepted what's coming.
- **0:10 0:05 remaining**: Every 2 seconds. A steady pulse.
- **Last 5 seconds**: The edges hold solid. A countdown pill appears at the bottom of the screen.
The intensity of the gradient also escalates over this period — from a faint 15% opacity to a vivid 75%. Your subconscious has been processing this for five full minutes before the break screen ever appears.
### Phase 2: The Break Screen
When the timer reaches zero, the screen transitions to a full teal overlay. This isn't an alert dialog you reflexively dismiss — it *is* your screen now. It carries a simple suggestion:
> *"Stretch your neck and shoulders"*
> *"Step outside for a minute of fresh air"*
> *"Unclench your jaw and relax your shoulders"*
These are real, physical actions. Not platitudes. Not "take a break!" — that's meaningless. Pommedoro tells you *what to do* with your break, and if a suggestion doesn't resonate, you can dismiss it and it won't come back.
A 5-minute break countdown runs in the background. You can mark the suggestion as completed ("Success!"), skip it ("Next Time"), or permanently dismiss suggestions that don't suit you ("Don't Suggest").
### Phase 3: Reflection
After completing a suggestion, Pommedoro asks one question:
> *"How did that impact your readiness for the day?"*
Three options: **Feeling Great**, **About the Same**, **Not Really**. No journaling. No friction. Just a moment of honest self-assessment before you return to work.
### Phase 4: Ready When You Are
The final screen says "Ready when you are" and offers **Pause** or **Continue**. There's no urgency. The break timer is still visible but the message is clear: *you* decide when to go back. The next 25-minute cycle starts when you press Continue.
## Why This Works
The escalating gradient approach works because it operates on the same channel as your focus: your visual field. Rather than competing with your attention via an auditory alarm or a modal dialog, Pommedoro gradually *joins* your visual environment. By the time the break arrives, your brain has already been transitioning for five minutes. The break screen isn't an interruption — it's the natural conclusion of a process you've been subconsciously participating in.
This is the difference between someone tapping you on the shoulder while you're reading versus slowly turning up the lights in the room. One triggers a startle response and gets dismissed. The other lets your eyes adjust naturally.
The actionable suggestions matter too. "Take a break" is an instruction with no substance. "Do some pushups" or "Drink some water" gives your body and mind a concrete thing to switch to. The context switch succeeds because there's a real context to switch *into*.
## Features
- **25-minute work sessions** with 5-minute escalating visual transitions
- **Edge gradient warnings** that increase in frequency and intensity
- **Full-screen break overlay** with actionable wellness suggestions
- **5-minute break timer** with self-assessment reflection
- **Menu bar timer** with pause/resume support
- **Debug mode** for testing (1-minute cycles)
- **Launch at Login** via macOS LaunchAgent
- **Native macOS app** — no Electron, no web views, just AppKit
## Install
Download the latest DMG directly:
**[Pommedoro.dmg](https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg)**
Open the DMG and drag Pommedoro to your Applications folder.
## Build from Source
Requires macOS 13+ and Swift 5.9+.
```bash
make bundle
```
To install to `/Applications`:
```bash
make install
```
To build the DMG:
```bash
make dmg
```
To run directly:
```bash
make run
```
## Requirements
- macOS 13+
- Swift 5.9+
- Apple Silicon or Intel Mac
## Author
**Colin Knapp** — [colinknapp.com](https://colinknapp.com)
## License
This work is licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
You are free to share and adapt this work for any purpose, including commercially, as long as you give appropriate credit.

34
Resources/Info.plist Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>Pommedoro</string>
<key>CFBundleIconFile</key>
<string>Pommedoro</string>
<key>CFBundleIdentifier</key>
<string>com.pommedoro.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Pommedoro</string>
<key>CFBundleDisplayName</key>
<string>Pommedoro</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

BIN
Resources/Pommedoro.icns Normal file

Binary file not shown.

2
Resources/boot-logo.svg Normal file
View File

@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 218.21 218.21"><defs><style>.cls-1{fill:#1d3557;}.cls-2{fill:#457b9d;}.cls-3{fill:#e63946;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M137.19,18.06A3.74,3.74,0,0,0,140.86,15l-3.47-3.46c-.3-.3-.61-.58-.91-.87a3.73,3.73,0,0,0,.71,7.4Z"/><path class="cls-1" d="M140.48,53.17h7.24a17.34,17.34,0,0,1,17.33,17.32v7.25H175a7.24,7.24,0,0,0,5.12-2.12l10.7-10.7-45-45a10.37,10.37,0,0,1-5.33,4.14Z"/><path class="cls-1" d="M175,84.31h-9.91v7.47h14.27a3.29,3.29,0,0,1,0,6.57H165.05v7.47h52.88a2.4,2.4,0,0,1,.28,0,39.79,39.79,0,0,0-11.52-25L195.43,69.56l-10.7,10.7A13.73,13.73,0,0,1,175,84.31Z"/><path class="cls-1" d="M112.39,53.16h7.48V38.9a3.28,3.28,0,1,1,6.56,0V53.16h7.48V24.09a10.3,10.3,0,0,1-3.08-17.86A40,40,0,0,0,112.36,0a2.4,2.4,0,0,1,0,.28Z"/><path class="cls-2" d="M14.32,84.76A3.74,3.74,0,0,0,15,77.35l-3.47,3.47c-.3.3-.58.61-.87.92A3.73,3.73,0,0,0,14.32,84.76Z"/><path class="cls-2" d="M53.16,105.82V98.35H38.9a3.29,3.29,0,1,1,0-6.57H53.16V84.31H24.09A10.3,10.3,0,0,1,6.23,87.39,40,40,0,0,0,0,105.85a2.4,2.4,0,0,1,.28,0Z"/><path class="cls-1" d="M84.31,43.26v9.9h7.47V38.9a3.29,3.29,0,1,1,6.57,0V53.16h7.47V.28a2.4,2.4,0,0,1,0-.28,39.8,39.8,0,0,0-25,11.53L69.56,22.79l10.7,10.7A13.73,13.73,0,0,1,84.31,43.26Z"/><path class="cls-2" d="M81,200.16a3.74,3.74,0,0,0-3.67,3.06l3.47,3.47c.3.3.61.58.92.87a3.73,3.73,0,0,0-.72-7.4Z"/><path class="cls-2" d="M105.82,165.05H98.35v14.27a3.29,3.29,0,0,1-6.57,0V165.05H84.31v29.08A10.29,10.29,0,0,1,87.39,212a39.84,39.84,0,0,0,18.46,6.23,2.4,2.4,0,0,1,0-.28Z"/><path class="cls-1" d="M203.89,133.46a3.73,3.73,0,0,0-.67,7.4l3.47-3.47c.3-.3.58-.61.87-.91A3.74,3.74,0,0,0,203.89,133.46Z"/><path class="cls-3" d="M194.13,140.48H165.05v7.24a17,17,0,0,1-.55,4.33,3.28,3.28,0,0,1-3.17,2.47,3.24,3.24,0,0,1-.82-.11,3.29,3.29,0,0,1-2.37-4,10.5,10.5,0,0,0,.34-2.69V70.49a10.78,10.78,0,0,0-10.76-10.76H70.49a11,11,0,0,0-2.69.34,3.28,3.28,0,0,1-1.64-6.36,17.54,17.54,0,0,1,4.33-.55h7.25v-9.9a7.24,7.24,0,0,0-2.12-5.13l-10.7-10.7-45,45a10.43,10.43,0,0,1,4.14,5.34H53.17V70.49a17.53,17.53,0,0,1,.54-4.33,3.28,3.28,0,1,1,6.36,1.64,11,11,0,0,0-.34,2.69v77.23a10.78,10.78,0,0,0,10.76,10.76h77.23a10.63,10.63,0,0,0,2.7-.34,3.28,3.28,0,0,1,1.63,6.36,17,17,0,0,1-4.33.55h-7.24V175a7.19,7.19,0,0,0,2.12,5.12l10.7,10.7,45-45A10.27,10.27,0,0,1,194.13,140.48Zm-46.46-30.77L140.21,117l-4.5-1.94,1.83-4.75Zm-14.46,4.84-10.43-3.84,2.34-2.67-3.84-10.13L147,85.49Zm-.8,1.54-2.58,5.71H112l9.64-9.63Zm-4.08-38.27-.46,14.71-6.34,2.71-3.25-6.58ZM119,96.5l-9.59,4.41-9.5-4.41,9.5-26.94Zm-10.26,5.75v18.84l-13-13.42,3.41-9.76Zm-8.08-13.43-2.92,6.42L91.26,92l-.41-14Zm-3.34,8.43-4.17,10.92,2.59,2.42L85.59,114,71.92,85.24ZM81.67,110.09l1.67,4.54-4.71,2-7.46-7.13Zm15.68,23.3-30-10.67,17.3-6.09,3.73,7,18.58.07Zm-8-11.59-3-6,10.68-3.84,10,9.88Zm21,26.85h-1.59V123.3h1.59Zm0-46.48,9.42-3.84,3.5,9.34-12.92,13.42Zm11.42,31.22-10.09-10,19.29.17,3.16-6.74,16.74,5.9Z"/><path class="cls-2" d="M133.91,175v-9.91h-7.48v14.27a3.29,3.29,0,0,1-6.57,0V165.05h-7.47v52.88a2.4,2.4,0,0,1,0,.28,39.76,39.76,0,0,0,25-11.52l11.26-11.26L138,184.73A13.72,13.72,0,0,1,133.91,175Z"/><path class="cls-2" d="M43.26,133.91h9.9v-7.48H38.9a3.29,3.29,0,1,1,0-6.57H53.16v-7.47H.28a2.4,2.4,0,0,1-.28,0,39.75,39.75,0,0,0,11.53,25l11.26,11.26L33.49,138A13.72,13.72,0,0,1,43.26,133.91Z"/><path class="cls-1" d="M165.05,112.39v7.47h14.27a3.29,3.29,0,0,1,0,6.57H165.05v7.48h29.08A10.29,10.29,0,0,1,212,130.83a39.88,39.88,0,0,0,6.23-18.47,2.4,2.4,0,0,1-.28,0Z"/><path class="cls-2" d="M77.74,165.05H70.49a17.34,17.34,0,0,1-17.32-17.33v-7.24H43.26a7.2,7.2,0,0,0-5.13,2.12l-10.7,10.7,45,45a10.33,10.33,0,0,1,5.34-4.14Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,163 @@
import AppKit
class AppDelegate: NSObject, NSApplicationDelegate {
// Configuration
var isDebugMode: Bool = false
var workTimerDuration: Int {
return isDebugMode ? 60 : 1500 // Debug: 1 min, Normal: 25 min
}
var countdownDuration: Int = 300 // 5 minutes of escalating flashes
// Status bar
var statusItem: NSStatusItem!
var statusMenuItem: NSMenuItem!
var debugMenuItem: NSMenuItem!
var launchAtLoginMenuItem: NSMenuItem!
// Overlay windows
var overlayWindows: [NSWindow] = []
var countdownWindows: [NSWindow] = []
var countdownLabels: [NSTextField] = []
// Timers
var countdownTimer: Timer?
var breakTimer: Timer?
// State
var remainingSeconds: Int = 0
var isSolid = false
var isPaused = false
var currentSuggestion = ""
var breakRemainingSeconds: Int = 300
var breakCountdownLabels: [NSTextField] = []
func applicationDidFinishLaunching(_ notification: Notification) {
remainingSeconds = workTimerDuration
setupStatusBar()
setupWindows()
startCountdown()
NotificationManager.shared.requestPermission()
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
}
func startCountdown() {
updateStatusBar()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self = self else { timer.invalidate(); return }
if self.isPaused { return }
self.remainingSeconds -= 1
self.updateStatusBar()
if self.remainingSeconds <= self.countdownDuration && self.remainingSeconds > 0 {
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)
} else {
shouldBlink = (s % 30 == 0)
}
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)
} else if s > 10 {
shouldBlink = (s % 5 == 0)
} else {
shouldBlink = (s % 2 == 0)
}
if shouldBlink { self.triggerBlink() }
} else {
if !self.isSolid { self.makeSolid() }
}
if s <= 10 {
self.showCountdownPills()
self.updateCountdownLabels()
}
}
if self.remainingSeconds <= 0 {
self.showFullOverlay()
}
}
}
@objc func togglePause() {
isPaused.toggle()
updateStatusBar()
}
@objc func pauseFromOverlay() {
isPaused = true
breakTimer?.invalidate()
updateStatusBar()
resetOverlayToGradient()
breakCountdownLabels.removeAll()
}
@objc func toggleDebugMode() {
isDebugMode.toggle()
debugMenuItem.state = isDebugMode ? .on : .off
countdownTimer?.invalidate()
breakTimer?.invalidate()
breakCountdownLabels.removeAll()
remainingSeconds = workTimerDuration
isSolid = false
isPaused = false
resetOverlayToGradient()
hideCountdownPills()
for window in countdownWindows { window.orderFrontRegardless() }
startCountdown()
}
@objc func toggleLaunchAtLogin() {
if LaunchAgent.isInstalled() {
LaunchAgent.uninstall()
launchAtLoginMenuItem.state = .off
} else {
LaunchAgent.install()
launchAtLoginMenuItem.state = .on
}
}
@objc func closePommedoro() {
countdownTimer?.invalidate()
breakTimer?.invalidate()
for window in overlayWindows { window.orderOut(nil) }
for window in countdownWindows { window.orderOut(nil) }
NSApp.terminate(nil)
}
@objc func resumePommedoro() {
isPaused = false
remainingSeconds = workTimerDuration
isSolid = false
breakTimer?.invalidate()
breakCountdownLabels.removeAll()
resetOverlayToGradient()
hideCountdownPills()
for window in countdownWindows { window.orderFrontRegardless() }
startCountdown()
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
}
@objc func dismissSuggestion() {
SuggestionManager.shared.dismiss(suggestion: currentSuggestion)
resumePommedoro()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return false
}
}

View File

@ -0,0 +1,283 @@
import AppKit
extension AppDelegate {
func showFullOverlay() {
countdownTimer?.invalidate()
currentSuggestion = SuggestionManager.shared.next()
statusItem.button?.title = "🍅 Time's Up!"
for window in countdownWindows { window.orderOut(nil) }
breakCountdownLabels.removeAll()
breakRemainingSeconds = 300
startBreakTimer()
for (i, window) in overlayWindows.enumerated() {
window.ignoresMouseEvents = false
buildSuggestionScreen(window: window, isPrimary: i == 0)
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.3
window.animator().alphaValue = 1.0
}
}
}
func startBreakTimer() {
breakTimer?.invalidate()
breakTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self = self else { timer.invalidate(); return }
self.breakRemainingSeconds -= 1
self.updateBreakCountdownLabels()
if self.breakRemainingSeconds <= 0 {
timer.invalidate()
}
}
}
func updateBreakCountdownLabels() {
let minutes = breakRemainingSeconds / 60
let seconds = breakRemainingSeconds % 60
let text = String(format: "%02d:%02d", minutes, seconds)
for label in breakCountdownLabels {
label.stringValue = text
}
}
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
let countdownLabel = NSTextField(labelWithString: "05:00")
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 72, weight: .bold)
countdownLabel.textColor = .white
countdownLabel.alignment = .center
countdownLabel.sizeToFit()
countdownLabel.frame.origin = CGPoint(
x: (size.width - countdownLabel.frame.width) / 2,
y: (size.height / 2) + 80
)
view.addSubview(countdownLabel)
breakCountdownLabels.append(countdownLabel)
let suggestion = NSTextField(labelWithString: currentSuggestion)
suggestion.font = NSFont.systemFont(ofSize: 48, weight: .medium)
suggestion.textColor = NSColor.white.withAlphaComponent(0.9)
suggestion.alignment = .center
suggestion.sizeToFit()
suggestion.frame.origin = CGPoint(
x: (size.width - suggestion.frame.width) / 2,
y: (size.height / 2) - 10
)
view.addSubview(suggestion)
if isPrimary {
let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36))
closeBtn.title = "Close"
closeBtn.bezelStyle = .rounded
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
closeBtn.target = self
closeBtn.action = #selector(closePommedoro)
view.addSubview(closeBtn)
let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50))
continueWorkingBtn.title = "Continue Working"
continueWorkingBtn.bezelStyle = .rounded
continueWorkingBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
continueWorkingBtn.target = self
continueWorkingBtn.action = #selector(resumePommedoro)
view.addSubview(continueWorkingBtn)
let btnY = size.height / 2 - 110
let btnW: CGFloat = 160
let btnH: CGFloat = 44
let gap: CGFloat = 16
let totalW = btnW * 3 + gap * 2
let startX = (size.width - totalW) / 2
let successBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
successBtn.title = "Success!"
successBtn.bezelStyle = .rounded
successBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
successBtn.target = self
successBtn.action = #selector(didSuccess)
view.addSubview(successBtn)
let nextTimeBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
nextTimeBtn.title = "Next Time"
nextTimeBtn.bezelStyle = .rounded
nextTimeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
nextTimeBtn.target = self
nextTimeBtn.action = #selector(resumePommedoro)
view.addSubview(nextTimeBtn)
let dontSuggestBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH))
dontSuggestBtn.title = "Don't Suggest"
dontSuggestBtn.bezelStyle = .rounded
dontSuggestBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
dontSuggestBtn.target = self
dontSuggestBtn.action = #selector(dismissSuggestion)
view.addSubview(dontSuggestBtn)
}
window.contentView = view
}
@objc func didSuccess() {
breakCountdownLabels.removeAll()
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
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
countdownLabel.textColor = NSColor.white.withAlphaComponent(0.7)
countdownLabel.alignment = .center
countdownLabel.sizeToFit()
countdownLabel.frame.origin = CGPoint(
x: (size.width - countdownLabel.frame.width) / 2,
y: size.height - 80
)
view.addSubview(countdownLabel)
breakCountdownLabels.append(countdownLabel)
let congrats = NSTextField(labelWithString: "Nice work!")
congrats.font = NSFont.systemFont(ofSize: 72, weight: .bold)
congrats.textColor = .white
congrats.alignment = .center
congrats.sizeToFit()
congrats.frame.origin = CGPoint(
x: (size.width - congrats.frame.width) / 2,
y: (size.height / 2) + 80
)
view.addSubview(congrats)
let prompt = NSTextField(labelWithString: "How did that impact your readiness for the day?")
prompt.font = NSFont.systemFont(ofSize: 36, weight: .medium)
prompt.textColor = NSColor.white.withAlphaComponent(0.9)
prompt.alignment = .center
prompt.sizeToFit()
prompt.frame.origin = CGPoint(
x: (size.width - prompt.frame.width) / 2,
y: (size.height / 2) + 10
)
view.addSubview(prompt)
if i == 0 {
let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36))
closeBtn.title = "Close"
closeBtn.bezelStyle = .rounded
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
closeBtn.target = self
closeBtn.action = #selector(closePommedoro)
view.addSubview(closeBtn)
let reflY = size.height / 2 - 60
let btnW: CGFloat = 180
let btnH: CGFloat = 44
let gap: CGFloat = 16
let totalW = btnW * 3 + gap * 2
let startX = (size.width - totalW) / 2
let greatBtn = NSButton(frame: NSRect(x: startX, y: reflY, width: btnW, height: btnH))
greatBtn.title = "Feeling Great"
greatBtn.bezelStyle = .rounded
greatBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
greatBtn.target = self
greatBtn.action = #selector(reflectionDone)
view.addSubview(greatBtn)
let okBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: reflY, width: btnW, height: btnH))
okBtn.title = "About the Same"
okBtn.bezelStyle = .rounded
okBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
okBtn.target = self
okBtn.action = #selector(reflectionDone)
view.addSubview(okBtn)
let notBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: reflY, width: btnW, height: btnH))
notBtn.title = "Not Really"
notBtn.bezelStyle = .rounded
notBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
notBtn.target = self
notBtn.action = #selector(reflectionDone)
view.addSubview(notBtn)
}
window.contentView = view
}
}
@objc func reflectionDone() {
breakCountdownLabels.removeAll()
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
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
countdownLabel.textColor = NSColor.white.withAlphaComponent(0.7)
countdownLabel.alignment = .center
countdownLabel.sizeToFit()
countdownLabel.frame.origin = CGPoint(
x: (size.width - countdownLabel.frame.width) / 2,
y: size.height - 80
)
view.addSubview(countdownLabel)
breakCountdownLabels.append(countdownLabel)
let msg = NSTextField(labelWithString: "Ready when you are")
msg.font = NSFont.systemFont(ofSize: 56, weight: .bold)
msg.textColor = .white
msg.alignment = .center
msg.sizeToFit()
msg.frame.origin = CGPoint(
x: (size.width - msg.frame.width) / 2,
y: (size.height / 2) + 40
)
view.addSubview(msg)
if i == 0 {
let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36))
closeBtn.title = "Close"
closeBtn.bezelStyle = .rounded
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
closeBtn.target = self
closeBtn.action = #selector(closePommedoro)
view.addSubview(closeBtn)
let btnW: CGFloat = 180
let btnH: CGFloat = 50
let gap: CGFloat = 24
let totalW = btnW * 2 + gap
let startX = (size.width - totalW) / 2
let btnY = size.height / 2 - 50
let pauseBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
pauseBtn.title = "Pause"
pauseBtn.bezelStyle = .rounded
pauseBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
pauseBtn.target = self
pauseBtn.action = #selector(pauseFromOverlay)
view.addSubview(pauseBtn)
let continueBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
continueBtn.title = "Continue"
continueBtn.bezelStyle = .rounded
continueBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
continueBtn.target = self
continueBtn.action = #selector(resumePommedoro)
view.addSubview(continueBtn)
}
window.contentView = view
}
}
}

View File

@ -0,0 +1,32 @@
import Foundation
import Sentry
enum BugReporting {
static let dsn = "https://5ef8c516e95244bc88e859fc428ec5a0@bugsink.nixc.us/5"
static func start() {
SentrySDK.start { options in
options.dsn = dsn
options.debug = false
options.enableCrashHandler = true
options.enableAutoSessionTracking = true
options.sendDefaultPii = false
// Attach stack traces to all logged errors
options.attachStacktrace = true
// Sample rate for regular events (1.0 = send everything)
options.sampleRate = 1.0
}
}
/// Capture a non-fatal error manually
static func capture(_ error: Error) {
SentrySDK.capture(error: error)
}
/// Capture a message manually
static func capture(message: String) {
SentrySDK.capture(message: message)
}
}

View File

@ -0,0 +1,25 @@
import AppKit
class EdgeGradientView: NSView {
var intensity: CGFloat = 0.4
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 }
ctx.saveGState(); ctx.clip(to: CGRect(x: 0, y: h - edge, width: w, height: edge))
ctx.drawLinearGradient(grad, start: CGPoint(x: w/2, y: h), end: CGPoint(x: w/2, y: h - edge), options: []); ctx.restoreGState()
ctx.saveGState(); ctx.clip(to: CGRect(x: 0, y: 0, width: w, height: edge))
ctx.drawLinearGradient(grad, start: CGPoint(x: w/2, y: 0), end: CGPoint(x: w/2, y: edge), options: []); ctx.restoreGState()
ctx.saveGState(); ctx.clip(to: CGRect(x: 0, y: 0, width: edge, height: h))
ctx.drawLinearGradient(grad, start: CGPoint(x: 0, y: h/2), end: CGPoint(x: edge, y: h/2), options: []); ctx.restoreGState()
ctx.saveGState(); ctx.clip(to: CGRect(x: w - edge, y: 0, width: edge, height: h))
ctx.drawLinearGradient(grad, start: CGPoint(x: w, y: h/2), end: CGPoint(x: w - edge, y: h/2), options: []); ctx.restoreGState()
}
}

View File

@ -0,0 +1,47 @@
import Foundation
enum LaunchAgent {
static let label = "com.pommedoro.app"
static let plistName = "\(label).plist"
static var plistURL: URL {
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent("Library/LaunchAgents/\(plistName)")
}
static var appPath: String {
return "/Applications/Pommedoro.app/Contents/MacOS/Pommedoro"
}
static func isInstalled() -> Bool {
return FileManager.default.fileExists(atPath: plistURL.path)
}
static func install() {
let plist: [String: Any] = [
"Label": label,
"Program": appPath,
"RunAtLoad": true,
"KeepAlive": false
]
let launchAgentsDir = plistURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: launchAgentsDir, withIntermediateDirectories: true)
let data = try? PropertyListSerialization.data(
fromPropertyList: plist,
format: .xml,
options: 0
)
if let data = data {
try? data.write(to: plistURL)
NSLog("Pommedoro: LaunchAgent installed at \(plistURL.path)")
}
}
static func uninstall() {
try? FileManager.default.removeItem(at: plistURL)
NSLog("Pommedoro: LaunchAgent removed")
}
}

View File

@ -0,0 +1,61 @@
import Foundation
import UserNotifications
class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationManager()
/// UNUserNotificationCenter requires a valid .app bundle.
/// When running the bare binary (e.g. via build-test.sh) we skip notifications.
private var isInsideBundle: Bool {
return Bundle.main.bundleIdentifier != nil
}
private override init() {
super.init()
if isInsideBundle {
UNUserNotificationCenter.current().delegate = self
}
}
func requestPermission() {
guard isInsideBundle else {
NSLog("Pommedoro: skipping notification permission (not running from .app bundle)")
return
}
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
if let error = error {
NSLog("Pommedoro: notification permission error: \(error.localizedDescription)")
}
}
}
func postCycleStarted(durationMinutes: Int) {
guard isInsideBundle else { return }
let content = UNMutableNotificationContent()
content.title = "Pommedoro"
content.body = "Cycle started — \(durationMinutes) minutes"
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: "pommedoro-cycle-start-\(UUID().uuidString)",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
NSLog("Pommedoro: failed to post notification: \(error.localizedDescription)")
}
}
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
}

View File

@ -0,0 +1,154 @@
import AppKit
extension AppDelegate {
func setupWindows() {
for screen in NSScreen.screens {
let window = NSWindow(
contentRect: screen.frame,
styleMask: .borderless,
backing: .buffered,
defer: false
)
window.level = .screenSaver
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = false
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.ignoresMouseEvents = true
window.isReleasedWhenClosed = false
let gradientView = EdgeGradientView(frame: NSRect(origin: .zero, size: screen.frame.size))
gradientView.autoresizingMask = [.width, .height]
window.contentView = gradientView
window.alphaValue = 0.0
window.orderFrontRegardless()
overlayWindows.append(window)
let pillW: CGFloat = 200
let pillH: CGFloat = 80
let pillX = screen.frame.origin.x + (screen.frame.width - pillW) / 2
let pillY = screen.frame.origin.y + 40
let pillWindow = NSWindow(
contentRect: NSRect(x: pillX, y: pillY, width: pillW, height: pillH),
styleMask: .borderless,
backing: .buffered,
defer: false
)
pillWindow.level = .screenSaver
pillWindow.backgroundColor = .clear
pillWindow.isOpaque = false
pillWindow.hasShadow = false
pillWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
pillWindow.ignoresMouseEvents = true
pillWindow.isReleasedWhenClosed = false
let pillView = NSView(frame: NSRect(x: 0, y: 0, width: pillW, height: pillH))
pillView.wantsLayer = true
pillView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.6).cgColor
pillView.layer?.cornerRadius = pillH / 2
let label = NSTextField(labelWithString: "00:10")
label.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
label.textColor = .systemTeal
label.alignment = .center
label.frame = NSRect(x: 0, y: 14, width: pillW, height: 50)
pillView.addSubview(label)
pillWindow.contentView = pillView
pillWindow.alphaValue = 0.0
pillWindow.orderFrontRegardless()
countdownWindows.append(pillWindow)
countdownLabels.append(label)
}
}
func showCountdownPills() {
for window in countdownWindows {
if window.alphaValue < 1.0 {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.2
window.animator().alphaValue = 1.0
}
}
}
}
func hideCountdownPills() {
for window in countdownWindows { window.alphaValue = 0.0 }
}
func updateCountdownLabels() {
let seconds = max(remainingSeconds, 0)
let text = String(format: "00:%02d", seconds)
for label in countdownLabels { label.stringValue = text }
}
func triggerBlink() {
// 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 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
} else if remainingSeconds > 30 {
fadeIn = 0.8; hold = 0.6; fadeOut = 0.8
} else if remainingSeconds > 10 {
fadeIn = 0.5; hold = 0.4; fadeOut = 0.6
} else {
fadeIn = 0.3; hold = 0.3; fadeOut = 0.4
}
for window in overlayWindows {
guard let view = window.contentView as? EdgeGradientView else { continue }
view.intensity = CGFloat(intensity)
view.needsDisplay = true
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = fadeIn
window.animator().alphaValue = 1.0
} completionHandler: {
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = fadeOut
window.animator().alphaValue = 0.0
})
}
}
}
}
func makeSolid() {
isSolid = true
for window in overlayWindows {
guard let view = window.contentView as? EdgeGradientView else { continue }
view.intensity = 0.75
view.needsDisplay = true
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.3
window.animator().alphaValue = 1.0
}
}
}
func resetOverlayToGradient() {
for window in overlayWindows {
window.ignoresMouseEvents = true
let size = window.frame.size
let gradientView = EdgeGradientView(frame: NSRect(origin: .zero, size: size))
gradientView.autoresizingMask = [.width, .height]
window.contentView = gradientView
window.alphaValue = 0.0
}
}
}

View File

@ -0,0 +1,46 @@
import AppKit
extension AppDelegate {
func setupStatusBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
let minutes = workTimerDuration / 60
let seconds = workTimerDuration % 60
button.title = String(format: "🍅 %02d:%02d", minutes, seconds)
button.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
}
let menu = NSMenu()
statusMenuItem = NSMenuItem(title: "Pommedoro: Running", action: nil, keyEquivalent: "")
statusMenuItem.isEnabled = false
menu.addItem(statusMenuItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p"))
menu.addItem(NSMenuItem.separator())
debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d")
debugMenuItem.state = isDebugMode ? .on : .off
menu.addItem(debugMenuItem)
menu.addItem(NSMenuItem.separator())
launchAtLoginMenuItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "l")
launchAtLoginMenuItem.state = LaunchAgent.isInstalled() ? .on : .off
menu.addItem(launchAtLoginMenuItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
for item in menu.items {
item.target = self
}
statusItem.menu = menu
}
func updateStatusBar() {
let minutes = remainingSeconds / 60
let seconds = remainingSeconds % 60
let time = String(format: "%02d:%02d", minutes, seconds)
let prefix = isPaused ? "" : "🍅"
statusItem.button?.title = "\(prefix) \(time)"
statusMenuItem.title = isPaused ? "Pommedoro: Paused" : "Pommedoro: Running"
}
}

View File

@ -0,0 +1,53 @@
import Foundation
class SuggestionManager {
static let shared = SuggestionManager()
private var suggestions: [String] = [
"Breathe for a few",
"Get up and walk around a minute",
"Do some pushups or something",
"Bodyweight squats, let's go",
"Maybe some crunches",
"Drink some water",
"Stretch your neck and shoulders",
"Rest your eyes, look at something far away",
"Stand up and touch your toes",
"Roll your wrists and ankles",
"Take a few deep breaths",
"Go get some water",
"Do some lunges",
"Stretch your hip flexors",
"Give your eyes a break",
"Straighten your posture, your back will thank you",
"Smile — it actually helps your mood",
"Clear your desk, clear your mind",
"Take a moment to appreciate how far you've come",
"Eat something fresh and nourishing today",
"Step outside for a minute of fresh air",
"Unclench your jaw and relax your shoulders"
]
private var dismissed: Set<Int> = []
private var currentIndex: Int = 0
func next() -> String {
var attempts = 0
while dismissed.contains(currentIndex) && attempts < suggestions.count {
currentIndex = (currentIndex + 1) % suggestions.count
attempts += 1
}
if attempts >= suggestions.count {
dismissed.removeAll()
}
let suggestion = suggestions[currentIndex]
currentIndex = (currentIndex + 1) % suggestions.count
return suggestion
}
func dismiss(suggestion: String) {
if let idx = suggestions.firstIndex(of: suggestion) {
dismissed.insert(idx)
}
}
}

View File

@ -0,0 +1,9 @@
import AppKit
BugReporting.start()
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let delegate = AppDelegate()
app.delegate = delegate
app.run()

23
build-test.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Stopping any running Pommedoro..."
pkill -9 -f Pommedoro 2>/dev/null || true
sleep 0.5
echo "==> Cleaning previous build..."
rm -rf .build/Pommedoro.app .build/Pommedoro.dmg
echo "==> Building DMG..."
make dmg
echo "==> Installing to /Applications..."
make install
echo "==> Launching Pommedoro.app..."
open /Applications/Pommedoro.app
echo ""
echo "==> Done. DMG at: .build/Pommedoro.dmg"

BIN
releases/Pommedoro.dmg Normal file

Binary file not shown.