I have a bad habit with traditional Pomodoro apps: when the 25-minute timer finishes, a loud bell rings or a massive popup interrupts me mid-thought. That's the opposite of focus.
I also work in an office with fixed break times (lunch at 12:00, afternoon break at 15:00).
Thatβs why I developed FocusCycleβa Qt5/C++ Pomodoro timer for Linux.
What FocusCycle Does Differently
1. π± Smart Idle Detection (Wayland & X11)
The most important feature: FocusCycle won't interrupt you until you stop typing.
When the work timer reaches 0:00, instead of immediately popping up a break dialog, it keeps counting negative into overtime (-00:01, -00:02...) while monitoring your keyboard/mouse activity in the background. Only once you've been idle for a configurable number of seconds (0β30) does the break prompt appear.
On Wayland (GNOME), this uses the org.gnome.Mutter.IdleMonitor D-Bus API:
// IdleMonitor.cpp β cached D-Bus interface, queried every second
IdleMonitor::IdleMonitor(QObject* parent) : QObject(parent)
{
QString waylandDisplay =
QProcessEnvironment::systemEnvironment().value("WAYLAND_DISPLAY");
m_waylandMode = !waylandDisplay.isEmpty();
if (m_waylandMode) {
m_dbusIface = new QDBusInterface(
"org.gnome.Mutter.IdleMonitor",
"/org/gnome/Mutter/IdleMonitor/Core",
"org.gnome.Mutter.IdleMonitor",
QDBusConnection::sessionBus(),
this);
}
m_timer.setInterval(1000);
connect(&m_timer, &QTimer::timeout, this, &IdleMonitor::poll);
}
qint64 IdleMonitor::getIdleTimeWayland() const
{
if (!m_dbusIface || !m_dbusIface->isValid()) return -1;
QDBusReply<qulonglong> reply = m_dbusIface->call("GetIdletime");
if (!reply.isValid()) return -1;
return static_cast<qint64>(reply.value()); // milliseconds
}
On X11, it falls back to the XScreenSaver extension (libxscrnsaver). The .pro file detects it automatically:
unix:!macx {
packagesExist(xscrnsaver) {
DEFINES += HAVE_X11SS
CONFIG += link_pkgconfig
PKGCONFIG += xscrnsaver
QT += x11extras
}
}
Note: The D-Bus interface is created once in the constructor and cached β not reconstructed every polling tick. This was a bug I caught in code review and fixed before shipping.
2. π Zero-Distraction UI & System Color Palette
Focus is about eliminating distractions. When you click "Start Work", the window completely closes and hides (hide()). There are no floating timers, badges, or widgets on your screen to pull your eyes away from your work.
-
System Tray Tooltip: Status and time (
FocusCycle | Working 24:59) are accessible via hover tooltip. -
Monochrome System Colors: The tray icon uses standard system palette colors (
QPalette::WindowText) during work so it blends in with OS panel icons (Wi-Fi, sound) without bright distracting colors. -
No Red Accent Colors: All red font and button highlights have been removed in favor of white (
#FFFFFF) and system blue (#4a90e2), creating a calm, focused environment.
3. π’ Scheduled Company Breaks
You can configure up to 10 daily break slots with both a start time and an optional end time:
Break 1 [12:00] β End [13:00]
Break 2 [15:00] β‘ End
- When the clock hits 12:00, the company break dialog appears and work stops.
- At 13:00, the app automatically resumes the previous state β no manual action needed.
- Break 2 has no end time, so it stays up until you dismiss it manually.
The data is stored in INI format:
[CompanyBreaks]
Count=2
Break0=12:00-13:00
Break1=15:00
Reading it back handles both old (start-only) and new (start-end) formats for backward compatibility:
if (val.contains('-')) {
QStringList parts = val.split('-');
cb.startTime = QTime::fromString(parts[0].trimmed(), "HH:mm");
cb.endTime = QTime::fromString(parts[1].trimmed(), "HH:mm");
} else {
cb.startTime = QTime::fromString(val.trimmed(), "HH:mm");
}
4. π― The State Machine
The core of FocusCycle is CycleController β a clean state machine with 5 explicit states:
WorkWait β Working (Counts 25:00 β 0:00 β -00:01...) β BreakWait β Break β WorkWait (loop)
β β
βββββββββββββββββββ CompanyBreak βββββββββββββββββββββββ
-
WorkWait: Waiting for user to start work (WorkStartWindowpopped up). -
Working: Timer counting down, continuing into negative seconds during idle detection (-00:01,-00:02...). -
BreakWait: Idle threshold reached;BreakDialogpopped up with options to Continue (reset timer) or Complete Work (take break). -
Break: Break timer counting down. -
CompanyBreak: Scheduled company break active.
Each state transition emits a stateChanged(AppState) signal. The UI layer (Application) connects to these to show/hide windows and send desktop notifications. The state machine itself has zero UI dependencies β it only knows about timers and signals.
// CycleController.h
signals:
void stateChanged(AppState state);
void workTick(int remainingSeconds);
void breakTick(int remainingSeconds);
void workTimeFinished();
void breakTimeFinished();
void showBreakDialog();
void showCompanyBreakDialog();
void showWorkStartWindow();
5. π CSV Session Logging & Quick Task Dropdown
Every session gets logged automatically to ~/.local/share/FocusCycle/history.csv:
Start Time,End Time,Task Name,Status
2025-08-21T09:00:00,2025-08-21T09:25:00,Write blog post,Completed
2025-08-21T09:30:00,2025-08-21T09:45:00,Code review,Incomplete
-
Task Dropdown:
WorkStartWindowreads the top 5 recent unique task names fromSessionLoggerand populates the task QComboBox, defaulting to your last worked task for 1-click startup. - Commas in Task Names: Task names containing commas are safely parsed by reading from both ends of each line:
int first = line.indexOf(',');
int second = line.indexOf(',', first + 1);
int last = line.lastIndexOf(',');
// Middle section between second and last comma = task name (may contain commas)
r.taskName = line.mid(second + 1, last - second - 1);
6. π English / Japanese UI
The app ships with English as default and Japanese as an option, switchable at runtime without restarting. Every widget implements retranslateUi() which Application calls whenever the language setting changes:
void Application::updateAllUiTranslations()
{
m_workStartWindow->retranslateUi();
m_breakDialog->retranslateUi();
m_companyBreakDialog->retranslateUi();
m_logWindow->retranslateUi();
m_settingsDialog->retranslateUi();
m_tray->retranslateUi();
}
Architecture Overview
src/
βββ app/
β βββ Application # Orchestrator: connects signals, owns all windows
βββ core/
β βββ Settings # Singleton, INI persistence
β βββ SessionLogger # Singleton, CSV read/write
β βββ IdleMonitor # Wayland D-Bus / X11 XScreenSaver
β βββ AppState.h # State enum (WorkWait, Working, BreakWait, Break, CompanyBreak)
βββ timer/
β βββ CycleController # State machine, QTimer management, stopAll() cleanup
βββ ui/
βββ WorkStartWindow # Start window with 5-task dropdown
βββ BreakDialog # Break prompt (Continue / Complete Work)
βββ CompanyBreakDialog
βββ SettingsDialog
βββ LogWindow
βββ TrayIcon # Monochrome system tray icon
The separation is intentional: CycleController never touches Qt UI classes. Application bridges the core and UI layers through signals and slots.
Building It
sudo apt install qtbase5-dev libqt5dbus5 libnotify-bin build-essential
git clone https://github.com/YOUR_USERNAME/FocusCycle.git
cd FocusCycle
qmake FocusCycle.pro
make -j$(nproc)
./FocusCycle
Build outputs (*.o, moc_*.cpp, etc.) go to build/obj, build/moc, and build/rcc to keep the source tree clean.
What I Learned
Wayland is tricky for idle detection. There's no direct equivalent of XScreenSaverQueryInfo on Wayland. The org.gnome.Mutter.IdleMonitor D-Bus API works well on GNOME/Mutter, but it's GNOME-specific. KDE Plasma has org.kde.KIdleTime, which would need a separate implementation. For now, this app targets Ubuntu with GNOME.
Frameless windows need careful event filter design. Making a draggable frameless window in Qt requires handling QEvent::MouseButtonPress and QEvent::MouseMove β either via event filters or by overriding mousePressEvent/mouseMoveEvent. I ended up doing both (event filter on child widgets + override on the dialog itself) to catch drags initiated anywhere on the card.
Clean Application Quit & Timer Cleanup. Qt singletons created with bare new and background QTimer instances won't stop automatically on exit if not handled. Connecting QCoreApplication::aboutToQuit to stopAll() in CycleController ensures all timers (m_workTimer, m_breakTimer, m_companyCheckTimer, IdleMonitor) stop cleanly and the process exits without thread leaks.
qAddPostRoutine for singleton cleanup. Adding qAddPostRoutine([]{ delete m_instance; m_instance = nullptr; }) on first creation ensures proper singleton cleanup on exit.
Links
- π GitHub: [repository link]
- π License: MIT
- π Language docs: README.md (English) / README.ja.md (ζ₯ζ¬θͺ)
If you're building Linux desktop apps with Qt5 and have wrestled with Wayland compatibility or idle detection, I'd love to hear how you approached it β drop a comment below.
Top comments (0)