DEV Community

amekusa03
amekusa03

Posted on

Building FocusCycle: A Non-Intrusive Qt5/C++ Pomodoro Timer for Linux

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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 β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode
  • WorkWait: Waiting for user to start work (WorkStartWindow popped up).
  • Working: Timer counting down, continuing into negative seconds during idle detection (-00:01, -00:02...).
  • BreakWait: Idle threshold reached; BreakDialog popped 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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Task Dropdown: WorkStartWindow reads the top 5 recent unique task names from SessionLogger and 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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)