refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency

- Refactored TimerPage component to improve layout and user experience.
- Updated theme configuration to utilize design tokens for better maintainability.
- Introduced new tokens for colors and typography to align with design principles.
- Simplified state management and improved timer functionality.
- Enhanced accessibility and responsiveness of UI elements.
This commit is contained in:
timka123 committed 2026-10-18 23:57:36 +03:00
1 parent 17a0a3c4c9
commit 77b26757bf
84 files changed
+4501 -2682

No files matched your search

+6 -1
View File
@@ -5,6 +5,7 @@ talk to a *running* instance over D-Bus instead of starting a second one, which
is what makes them usable as global shortcuts:
kadence-desktop --toggle # bind this to a key in System Settings
kadence-desktop --mini # show/hide the floating mini timer
kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today"
"""
@@ -27,6 +28,7 @@ REMOTE_VERBS = {
"interrupt": "Interrupt",
"status": "Status",
"report": "SendReport",
"mini": "ToggleMini",
"show": "Raise",
}
@@ -53,6 +55,9 @@ def _remote(verb: str) -> int:
def main() -> int:
parser = argparse.ArgumentParser(prog="kadence-desktop", description=__doc__)
parser.add_argument("--minimized", action="store_true", help="start in the tray only")
parser.add_argument(
"--view-only", action="store_true", help="start in view-only mode with timer turned off"
)
parser.add_argument("--verbose", action="store_true")
for verb in REMOTE_VERBS:
parser.add_argument(
@@ -77,7 +82,7 @@ def main() -> int:
print("Kadence is already running — raised its window.")
return 0
return KadenceApp(sys.argv, minimized=args.minimized).run()
return KadenceApp(sys.argv, minimized=args.minimized, view_only=args.view_only).run()
if __name__ == "__main__":
+3 -2
View File
@@ -100,8 +100,9 @@ class Api:
def refresh_tasks(self) -> dict:
return self._request("POST", "/api/tasks/refresh", {})
def task_done(self, task_id: int, done: bool = True) -> dict:
return self._request("POST", f"/api/tasks/{task_id}/done", {"done": done})
def patch_task(self, task_id: int, patch: dict) -> dict:
"""Edit a task's local fields (pinning, estimate). Completion is not ours."""
return self._request("PATCH", f"/api/tasks/{task_id}", patch)
def create_task(self, title: str, due_date: str | None = None) -> dict:
return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date})
+88 -2
View File
@@ -20,7 +20,7 @@ os.environ.setdefault("QT_QUICK_CONTROLS_MATERIAL_VARIANT", "Normal")
from PyQt6.QtCore import QObject, QTimer, QUrl
from PyQt6.QtGui import QIcon
from PyQt6.QtQml import QQmlApplicationEngine
from PyQt6.QtQml import QQmlApplicationEngine, QQmlComponent
from PyQt6.QtQuick import QQuickWindow
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
@@ -28,6 +28,7 @@ from kadence_desktop import APP_ID, __version__
from kadence_desktop.config import DesktopConfig
from kadence_desktop.controller import Controller
from kadence_desktop.integrations import dbus_service
from kadence_desktop.integrations.placement import Rect, point_for
from kadence_desktop.tray import Tray, render_icon
log = logging.getLogger(__name__)
@@ -36,7 +37,9 @@ QML_DIR = Path(__file__).resolve().parent / "qml"
class KadenceApp(QObject):
def __init__(self, argv: list[str], *, minimized: bool = False) -> None:
def __init__(
self, argv: list[str], *, minimized: bool = False, view_only: bool = False
) -> None:
super().__init__()
# QApplication (not QGuiApplication): the tray icon and its menu are
# widgets, and they are what makes the app usable with no window open.
@@ -52,6 +55,8 @@ class KadenceApp(QObject):
self._app.setQuitOnLastWindowClosed(False)
self._config = DesktopConfig.load()
if view_only:
self._config.view_only = True
self._controller = Controller(self._config)
self._engine = QQmlApplicationEngine()
@@ -63,6 +68,8 @@ class KadenceApp(QObject):
raise SystemExit("Could not load the QML interface.")
self._window: QQuickWindow = self._engine.rootObjects()[0]
self._mini = self._create_mini_window()
self._tray: Tray | None = None
if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable():
self._tray = Tray(self._controller, self)
@@ -73,6 +80,15 @@ class KadenceApp(QObject):
self._controller.showWindowRequested.connect(self.show_window)
self._app.aboutToQuit.connect(self._controller.shutdown)
# Anything that can change "should the mini timer be up?" lands here.
self._controller.miniChanged.connect(self._update_mini)
self._controller.miniPlacementChanged.connect(self._place_mini)
self._controller.stateChanged.connect(self._update_mini)
self._controller.configChanged.connect(self._update_mini)
self._window.visibleChanged.connect(self._update_mini)
self._update_mini()
self._place_mini()
if self._config.dbus_service:
self._register_dbus()
@@ -88,6 +104,73 @@ class KadenceApp(QObject):
self._wakeup.start(500)
self._wakeup.timeout.connect(lambda: None)
# ---- the floating mini timer --------------------------------------------
def _create_mini_window(self) -> QObject | None:
"""Build MiniTimer.qml as a top-level window of its own."""
component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / "MiniTimer.qml")))
if component.isError(): # pragma: no cover - a QML typo, caught in dev
for error in component.errors():
log.error("mini timer: %s", error.toString())
return None
# No parent on purpose: a parented QML Window becomes transient and
# cannot outlive — or out-show — its parent. The Python reference on
# `self._mini` is what keeps it alive.
window = component.create()
if window is not None:
window.setProperty("objectName", "miniTimer")
return window
def _update_mini(self) -> None:
"""Show it when pinned, or when an interval runs with the window away."""
if self._mini is None:
return
config = self._config
wanted = bool(config.mini_timer) and (
self._controller.miniVisible
or (config.mini_timer_auto and self._controller.running and not self._window.isVisible())
)
if bool(self._mini.property("visible")) != wanted:
if wanted:
# Each time it comes up it lands where you asked for, even if it
# was dragged elsewhere during the last interval.
self._place_mini()
self._mini.setProperty("visible", wanted)
def _mini_screen_rect(self) -> Rect:
"""The usable area of the screen the app is on — panels excluded."""
screen = self._window.screen() or self._app.primaryScreen()
area = screen.availableGeometry()
return Rect(area.x(), area.y(), area.width(), area.height())
def _place_mini(self) -> None:
"""Put the mini timer in the configured corner.
Setting `x`/`y` is what works on X11; on Wayland the client may not
place itself, so the same point goes into the KWin rule, which is
honoured when the window is mapped.
"""
if self._mini is None:
return
config = self._config
if config.mini_timer_corner == "remember":
self._controller.ensure_keep_above(None)
if config.mini_timer_x >= 0 and config.mini_timer_y >= 0:
self._mini.setProperty("x", config.mini_timer_x)
self._mini.setProperty("y", config.mini_timer_y)
return
size = (
int(self._mini.property("width") or 252),
int(self._mini.property("height") or 152),
)
point = point_for(
config.mini_timer_corner, self._mini_screen_rect(), size, config.mini_timer_margin
)
self._mini.setProperty("x", point[0])
self._mini.setProperty("y", point[1])
self._controller.ensure_keep_above(point)
# ---- window ------------------------------------------------------------
def show_window(self) -> None:
@@ -117,6 +200,7 @@ class KadenceApp(QObject):
"interrupt": lambda: (controller.interrupt(), "noted")[1],
"status": controller.statusLine,
"send_report": lambda: (controller.sendReport(), "sending")[1],
"toggle_mini": controller.toggleMini,
"raise_window": lambda: (self.show_window(), "shown")[1],
}
self._dbus = dbus_service.TimerDBusService(actions, self)
@@ -126,6 +210,8 @@ class KadenceApp(QObject):
# ---- lifecycle ---------------------------------------------------------
def quit(self) -> None:
if self._mini is not None:
self._mini.setProperty("visible", False)
self._controller.shutdown()
if self._tray:
self._tray.hide()
+24 -6
View File
@@ -5,9 +5,11 @@ the cmdline, matches against the configured names, and either terminates the
process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending
on the setting.
Matching is deliberately forgiving — `steam` should catch `steamwebhelper`, and
`telegram` should catch `Telegram` — but it never matches ourselves, PID 1, or
anything outside the current user's processes.
Matching is deliberately forgiving — `steam` catches `steamwebhelper`, and
`telegram` catches `Telegram` — and an entry containing `*` or `?` is treated as
a glob (`steam*`, `*discord*`, `telegram-?`), matched against both the process
name and its command line. It never matches ourselves, PID 1, or anything
outside the current user's processes.
"""
from __future__ import annotations
@@ -16,6 +18,7 @@ import logging
import os
import signal
import time
from fnmatch import fnmatch
from dataclasses import dataclass
from pathlib import Path
@@ -24,6 +27,21 @@ log = logging.getLogger(__name__)
_SELF_MARKERS = ("kadence", "python3 -m kadence_desktop")
def matches(pattern: str, name: str, cmdline: str) -> bool:
"""Whether one block-list entry matches a process.
A mask is anchored to the process *name* (so `steam*` does not match every
command that merely mentions steam) but also tried against the whole command
line, which is how `*discord*` catches an Electron app launched by path.
"""
pattern = pattern.strip().lower()
if not pattern:
return False
if any(char in pattern for char in "*?["):
return fnmatch(name, pattern) or fnmatch(cmdline, pattern)
return pattern in name or pattern in cmdline
@dataclass(slots=True)
class RunningApp:
pid: int
@@ -65,11 +83,11 @@ def scan(patterns: list[str]) -> list[RunningApp]:
if not comm:
continue
cmdline = _read(entry / "cmdline").replace("\x00", " ").strip()
haystack = f"{comm} {cmdline}".lower()
if any(marker in haystack for marker in _SELF_MARKERS):
name, command = comm.lower(), cmdline.lower()
if any(marker in f"{name} {command}" for marker in _SELF_MARKERS):
continue
match = next((pattern for pattern in wanted if pattern in haystack), None)
match = next((pattern for pattern in wanted if matches(pattern, name, command)), None)
if match:
found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match))
return found
+32
View File
@@ -38,6 +38,38 @@ class DesktopConfig:
start_minimized: bool = False
close_to_tray: bool = True
view_only: bool = False
"""When true, the app runs in view-only mode with the timer turned off."""
# ---- the floating mini timer ------------------------------------------
mini_timer: bool = True
"""Allow the small always-on-top countdown window at all."""
mini_timer_auto: bool = True
"""Show it by itself while an interval runs and the main window is away."""
mini_timer_keep_above: bool = True
"""On KDE, install a KWin rule so it really floats above every window.
Wayland gives an application no way to ask for "always on top", so this is
the only honest route; see `integrations.keep_above`. Switching it off
removes the rule again.
"""
mini_timer_corner: str = "bottom-right"
"""Where it appears: a screen corner, an edge centre, or `remember`.
One of: top-left, top-centre, top-right, bottom-left, bottom-centre,
bottom-right, centre, remember. `remember` puts it back where you last
dragged it instead.
"""
mini_timer_margin: int = 24
"""Gap between the mini timer and the screen edges, in pixels."""
mini_timer_x: int = -1
mini_timer_y: int = -1
"""Where it was last dragged to. -1 = let the compositor place it."""
# ---- system integration (each one is a real desktop feature) -----------
tray_icon: bool = True
+103 -6
View File
@@ -25,7 +25,7 @@ from PyQt6.QtGui import QDesktopServices
from kadence_desktop.api import Api
from kadence_desktop.blocking.manager import BlockerManager
from kadence_desktop.config import DesktopConfig
from kadence_desktop.integrations import autostart
from kadence_desktop.integrations import autostart, keep_above
from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor
from kadence_desktop.integrations.launcher import LauncherEntry
from kadence_desktop.integrations.mpris import MediaController
@@ -53,6 +53,8 @@ class Controller(QObject):
blockerChanged = pyqtSignal()
connectionChanged = pyqtSignal()
configChanged = pyqtSignal()
miniChanged = pyqtSignal()
miniPlacementChanged = pyqtSignal()
toast = pyqtSignal(str)
showWindowRequested = pyqtSignal()
@@ -77,6 +79,7 @@ class Controller(QObject):
self._last_status = ""
self._completing = False
self._paused_by_lock = False
self._mini_visible = False
# ---- system integrations -------------------------------------------
self._notifier = Notifier()
@@ -119,6 +122,9 @@ class Controller(QObject):
self._tick.timeout.connect(self._on_tick)
self._tick.start()
# A Wayland compositor ignores "stay on top", so ask KWin properly.
self.ensure_keep_above()
self._idle_check = QTimer(self)
self._idle_check.setInterval(30_000)
self._idle_check.timeout.connect(self._check_idle)
@@ -219,6 +225,14 @@ class Controller(QObject):
def nextKindLabel(self) -> str:
return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus")
@pyqtProperty(bool, notify=settingsChanged)
def timerEnabled(self) -> bool:
"""When False the app is in view-only mode — no timer controls."""
if self._config.view_only:
return False
pomodoro = self._settings.get("pomodoro", {})
return bool(pomodoro.get("timer_enabled", True))
@pyqtProperty("QVariantList", notify=tasksChanged)
def tasks(self) -> list[dict[str, Any]]:
return self._tasks
@@ -263,6 +277,11 @@ class Controller(QObject):
def blockerError(self) -> str:
return self._blocker.last_error or ""
@pyqtProperty(bool, notify=miniChanged)
def miniVisible(self) -> bool:
"""Whether the floating mini timer is pinned open by the user."""
return self._mini_visible
@pyqtProperty("QVariantMap", notify=configChanged)
def config(self) -> dict[str, Any]:
from dataclasses import asdict
@@ -270,6 +289,8 @@ class Controller(QObject):
data = asdict(self._config)
data["autostart"] = autostart.autostart_enabled()
data["service_enabled"] = autostart.service_enabled()
data["keep_above_supported"] = keep_above.supported()
data["keep_above_installed"] = keep_above.installed()
return data
# ======================================================================
@@ -289,6 +310,8 @@ class Controller(QObject):
@pyqtSlot()
@pyqtSlot(int)
def startWork(self, minutes: int = 0) -> None:
if not self.timerEnabled:
return
args: dict[str, Any] = {"kind": "work"}
if minutes:
args["minutes"] = minutes
@@ -301,12 +324,16 @@ class Controller(QObject):
@pyqtSlot()
def startBreak(self) -> None:
if not self.timerEnabled:
return
kind = str(self._state.get("next_kind", "short_break"))
self._send("start", {"kind": "long_break" if kind == "long_break" else "short_break"})
@pyqtSlot()
def toggle(self) -> None:
"""One verb for a global hotkey: start → pause → resume."""
if not self.timerEnabled:
return
if not self.running:
self.startWork()
elif self.status == "paused":
@@ -314,6 +341,14 @@ class Controller(QObject):
else:
self.pause()
@pyqtSlot(bool)
def setTimerEnabled(self, enabled: bool) -> None:
"""Toggle view-only mode on or off."""
self._config.update(view_only=not enabled)
self.configChanged.emit()
self.settingsChanged.emit()
self.patchSettings({"pomodoro": {"timer_enabled": enabled}})
@pyqtSlot()
def pause(self) -> None:
self._send("pause")
@@ -345,8 +380,9 @@ class Controller(QObject):
self._send("attach_task", {"task_id": task_id if task_id >= 0 else None})
@pyqtSlot(int, bool)
def setTaskDone(self, task_id: int, done: bool) -> None:
self._send("task_done", {"task_id": task_id, "done": done})
def setTaskPinned(self, task_id: int, pinned: bool) -> None:
"""Pinning is the only task edit this app makes — closing one is not."""
self._send("patch_task", {"task_id": task_id, "patch": {"pinned": pinned}})
@pyqtSlot(str, str)
def createTask(self, title: str, due_date: str) -> None:
@@ -413,10 +449,66 @@ class Controller(QObject):
self._blocker.enforce_hosts = bool(value)
elif key == "block_apps":
self._blocker.enforce_apps = bool(value)
elif key == "mini_timer_keep_above":
ok, message = keep_above.apply() if value else keep_above.remove()
self.toast.emit(message)
if not ok:
self._config.update(mini_timer_keep_above=not value)
elif key in ("mini_timer_corner", "mini_timer_margin"):
self.miniPlacementChanged.emit()
elif key == "enforce_blocking" and not value:
self._blocker.release()
self.configChanged.emit()
@pyqtSlot()
@pyqtSlot(bool)
def setMiniVisible(self, visible: bool = True) -> None:
if visible == self._mini_visible:
return
self._mini_visible = visible
self.miniChanged.emit()
@pyqtSlot(result=str)
def toggleMini(self) -> str:
"""Tray menu, Ctrl+M and the D-Bus verb all land here."""
if not self._config.mini_timer:
self._config.update(mini_timer=True)
self.configChanged.emit()
self.setMiniVisible(not self._mini_visible)
if self._mini_visible:
self.ensure_keep_above()
return "shown" if self._mini_visible else "hidden"
def ensure_keep_above(
self, position: tuple[int, int] | None = None, *, announce: bool = False
) -> None:
"""Install or refresh the KWin rule, including where to place the window.
A no-op when the rule already says exactly this, so it can be called
freely — it only ever touches KWin when something really changed.
"""
config = self._config
if not (config.mini_timer and config.mini_timer_keep_above):
return
if not keep_above.supported() or keep_above.up_to_date(position):
return
ok, message = keep_above.apply(position)
log.info("keep-above rule: %s", message)
if announce or not ok:
self.toast.emit(message)
self.configChanged.emit()
@pyqtSlot(int, int)
def saveMiniPosition(self, x: int, y: int) -> None:
"""Remember where it was dragged to — only in "remember" mode.
In corner mode the app places the window itself, and recording that
placement would quietly overwrite the position you had dragged it to.
"""
if self._config.mini_timer_corner != "remember":
return
self._config.update(mini_timer_x=int(x), mini_timer_y=int(y))
@pyqtSlot()
def openWebUi(self) -> None:
QDesktopServices.openUrl(QUrl(self._config.server_url))
@@ -431,6 +523,8 @@ class Controller(QObject):
"""One line for the tray tooltip and the D-Bus `Status` method."""
if not self._connected:
return "Kadence — backend unreachable"
if not self.timerEnabled:
return f"Kadence — view mode · {self.completedToday}/{self.goal} today"
if not self.running:
return f"Kadence — idle · {self.completedToday}/{self.goal} today"
return (
@@ -503,11 +597,14 @@ class Controller(QObject):
self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings")
elif name == "refresh_sources":
failed = [item for item in result.get("sources", []) if not item.get("ok")]
closed = result.get("closed", 0)
self.toast.emit(
f"{len(failed)} source(s) failed" if failed else f"{result.get('total', 0)} tasks pulled"
f"{len(failed)} source(s) failed"
if failed
else f"{result.get('total', 0)} open · {closed} closed since last refresh"
)
elif name == "task_done" and result.get("warning"):
self.toast.emit(str(result["warning"]))
elif name == "refresh_sources" and result.get("closed"):
self.toast.emit(f"{result['closed']} task(s) closed in your tools")
# ======================================================================
# Transitions → the system integrations
@@ -8,6 +8,12 @@ hotkey for "start a pomodoro" needs no X11 key-grabbing hack:
Owning the name also gives single-instance behaviour for free: a second launch
sees the name is taken, calls `Raise()` on the first, and exits.
The verbs live on a **QDBusAbstractAdaptor**, not on the exported object
itself. Qt only honours the "D-Bus Interface" class info on an adaptor; export
a plain QObject and the interface is named after its class
(`local.kadence.TimerDBusService`), which is exactly the "No such interface"
every documented command then hits.
"""
from __future__ import annotations
@@ -15,8 +21,8 @@ from __future__ import annotations
import logging
from typing import Callable
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtDBus import QDBusConnection
from PyQt6.QtCore import QObject, pyqtClassInfo, pyqtSlot
from PyQt6.QtDBus import QDBusAbstractAdaptor, QDBusConnection
log = logging.getLogger(__name__)
@@ -26,13 +32,14 @@ INTERFACE = "org.kadence.Timer"
class TimerDBusService(QObject):
"""The exported object. Every slot is a verb you can bind to a key."""
"""Holds the actions; the adaptor below is what the bus sees."""
def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None:
super().__init__(parent)
self._actions = actions
self.adaptor = TimerAdaptor(self)
def _run(self, name: str) -> str:
def run(self, name: str) -> str:
action = self._actions.get(name)
if action is None:
return f"unknown action: {name}"
@@ -43,71 +50,113 @@ class TimerDBusService(QObject):
log.exception("D-Bus action %s failed", name)
return f"error: {exc}"
# Qt generates no introspection XML for a Python adaptor, and without it
# `busctl introspect` and KDE's D-Bus shortcut browser have nothing to list —
# so the interface describes itself. Every verb takes no arguments and returns
# one string.
VERBS = (
"StartWork",
"StartBreak",
"Toggle",
"Pause",
"Resume",
"Complete",
"Stop",
"Extend",
"Interrupt",
"Status",
"SendReport",
"ToggleMini",
"Raise",
)
INTROSPECTION = "\n".join(
[f'<interface name="{INTERFACE}">']
+ [
f' <method name="{verb}">\n <arg type="s" direction="out"/>\n </method>'
for verb in VERBS
]
+ ["</interface>"]
)
@pyqtClassInfo("D-Bus Interface", INTERFACE)
@pyqtClassInfo("D-Bus Introspection", INTROSPECTION)
class TimerAdaptor(QDBusAbstractAdaptor):
"""The published interface. Every slot is a verb you can bind to a key."""
def __init__(self, parent: TimerDBusService) -> None:
super().__init__(parent)
self._service = parent
# D-Bus methods are PascalCase by convention.
@pyqtSlot(result=str)
def StartWork(self) -> str: # noqa: N802 - D-Bus methods are PascalCase
def StartWork(self) -> str: # noqa: N802
"""Start a focus interval."""
return self._run("start_work")
return self._service.run("start_work")
@pyqtSlot(result=str)
def StartBreak(self) -> str: # noqa: N802
return self._run("start_break")
return self._service.run("start_break")
@pyqtSlot(result=str)
def Toggle(self) -> str: # noqa: N802
"""Start if idle, pause if running, resume if paused — one hotkey."""
return self._run("toggle")
return self._service.run("toggle")
@pyqtSlot(result=str)
def Pause(self) -> str: # noqa: N802
return self._run("pause")
return self._service.run("pause")
@pyqtSlot(result=str)
def Resume(self) -> str: # noqa: N802
return self._run("resume")
return self._service.run("resume")
@pyqtSlot(result=str)
def Complete(self) -> str: # noqa: N802
return self._run("complete")
return self._service.run("complete")
@pyqtSlot(result=str)
def Stop(self) -> str: # noqa: N802
return self._run("stop")
return self._service.run("stop")
@pyqtSlot(result=str)
def Extend(self) -> str: # noqa: N802
return self._run("extend")
return self._service.run("extend")
@pyqtSlot(result=str)
def Interrupt(self) -> str: # noqa: N802
"""Count a distraction without stopping the clock."""
return self._run("interrupt")
return self._service.run("interrupt")
@pyqtSlot(result=str)
def Status(self) -> str: # noqa: N802
"""One line: what is running and how much is left."""
return self._run("status")
return self._service.run("status")
@pyqtSlot(result=str)
def SendReport(self) -> str: # noqa: N802
return self._run("send_report")
return self._service.run("send_report")
@pyqtSlot(result=str)
def ToggleMini(self) -> str: # noqa: N802
"""Show or hide the floating mini timer."""
return self._service.run("toggle_mini")
@pyqtSlot(result=str)
def Raise(self) -> str: # noqa: N802
"""Show the window (used by the second instance)."""
return self._run("raise_window")
"""Show the main window (used by a second instance)."""
return self._service.run("raise_window")
def register(service: TimerDBusService) -> bool:
"""Own the name and export the object. False means another instance has it."""
"""Own the name and export the adaptor. False means another instance has it."""
bus = QDBusConnection.sessionBus()
if not bus.registerService(SERVICE_NAME):
return False
bus.registerObject(
OBJECT_PATH,
service,
QDBusConnection.RegisterOption.ExportAllSlots | QDBusConnection.RegisterOption.ExportAllInvokables,
)
log.info("D-Bus service %s%s is up", SERVICE_NAME, OBJECT_PATH)
bus.registerObject(OBJECT_PATH, service, QDBusConnection.RegisterOption.ExportAdaptors)
log.info("D-Bus service %s%s is up as %s", SERVICE_NAME, OBJECT_PATH, INTERFACE)
return True
@@ -0,0 +1,215 @@
"""Keeping the mini timer above everything else — on Wayland, for real.
Wayland has no "always on top" an ordinary application can ask for: the
`Qt.WindowStaysOnTopHint` flag is honoured on X11 and quietly ignored by a
Wayland compositor. KWin does have the mechanism, but it lives in *window
rules*, so the only honest way to float above other applications on KDE is to
install one — which is what this module does, reversibly and with a backup.
The rule matches this one window (title `Kadence mini`, app id `kadence`) and
forces: keep above, skip the task bar, skip the switcher, skip the pager. KWin
is asked to re-read its configuration over D-Bus, so it takes effect at once
without logging out.
Everywhere else this is a no-op and the Qt flag does the job.
"""
from __future__ import annotations
import configparser
import logging
import os
import shutil
import uuid
from pathlib import Path
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
DESCRIPTION = "Kadence mini timer"
WINDOW_TITLE = "Kadence mini"
WINDOW_CLASS = "kadence"
# KWin's rule-policy codes: 2 is "Force".
_FORCE = "2"
# KWin's string-match codes: 1 is "Exact Match", 2 is "Substring Match".
_EXACT = "1"
_SUBSTRING = "2"
# Matched on the window title, as a **substring**.
#
# Two traps here, both found by asking KWin what it actually sees:
# * Qt appends the application's display name, so the window KWin knows is
# "Kadence mini — Kadence", and an exact match on "Kadence mini" never
# fired;
# * the app id depends on how the app was started (`kadence` from the desktop
# entry, `python3` from a bare `python3 -m ...`), so requiring the class as
# well would make the rule stop matching for some launches.
RULE = {
"Description": DESCRIPTION,
"above": "true",
"aboverule": _FORCE,
"skiptaskbar": "true",
"skiptaskbarrule": _FORCE,
"skipswitcher": "true",
"skipswitcherrule": _FORCE,
"skippager": "true",
"skippagerrule": _FORCE,
"title": WINDOW_TITLE,
"titlematch": _SUBSTRING,
"wmclassmatch": "0",
}
def rules_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "kwinrulesrc"
def supported() -> bool:
"""Only KDE has this mechanism; elsewhere the Qt flag is all there is."""
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
return "KDE" in desktop.upper()
def _parser() -> configparser.RawConfigParser:
parser = configparser.RawConfigParser(strict=False)
# KDE keys are case-sensitive ("Description" must not become "description").
parser.optionxform = str # type: ignore[assignment]
return parser
def _load() -> configparser.RawConfigParser:
parser = _parser()
path = rules_path()
if path.exists():
parser.read(path, encoding="utf-8")
if not parser.has_section("General"):
parser.add_section("General")
return parser
def _our_section(parser: configparser.RawConfigParser) -> str | None:
for section in parser.sections():
if section == "General":
continue
if parser.get(section, "Description", fallback="") == DESCRIPTION:
return section
return None
def _save(parser: configparser.RawConfigParser) -> None:
path = rules_path()
path.parent.mkdir(parents=True, exist_ok=True)
# One backup, before the first time we ever touch the file.
backup = path.with_suffix(path.suffix + ".kadence-backup")
if path.exists() and not backup.exists():
shutil.copy2(path, backup)
temp = path.with_suffix(path.suffix + ".kadence-tmp")
with temp.open("w", encoding="utf-8") as handle:
parser.write(handle, space_around_delimiters=False)
temp.replace(path)
def _reconfigure() -> None:
"""Ask KWin to re-read its rules; without it the change waits for a relogin."""
interface = QDBusInterface("org.kde.KWin", "/KWin", "org.kde.KWin", QDBusConnection.sessionBus())
if interface.isValid():
interface.call("reconfigure")
def _wanted(position: tuple[int, int] | None) -> dict[str, str]:
"""The rule as it should be, with the forced position when there is one."""
rule = dict(RULE)
if position is not None:
# KWin stores a forced position as "x,y" — this is what places the
# window on Wayland, where a client may not place itself.
rule["position"] = f"{position[0]},{position[1]}"
rule["positionrule"] = _FORCE
return rule
def installed() -> bool:
try:
return _our_section(_load()) is not None
except (configparser.Error, OSError):
return False
def up_to_date(position: tuple[int, int] | None = None) -> bool:
"""Whether the installed rule matches what this version would write.
An older build wrote an exact title match that never fired; without this
check an upgrade would leave the broken rule in place forever, because the
rule *exists*. The position is part of the comparison, so moving the mini
timer to another corner rewrites the rule — and nothing else does.
"""
try:
parser = _load()
section = _our_section(parser)
if section is None:
return False
wanted = _wanted(position)
if any(parser.get(section, key, fallback=None) != value for key, value in wanted.items()):
return False
# A position left over from a previous corner must not linger.
if position is None and parser.has_option(section, "position"):
return False
return True
except (configparser.Error, OSError):
return False
def apply(position: tuple[int, int] | None = None) -> tuple[bool, str]:
"""Install (or refresh) the rule, optionally forcing a position."""
if not supported():
return False, "Keeping a window above others this way needs KWin (KDE)."
try:
parser = _load()
section = _our_section(parser) or str(uuid.uuid4())
if not parser.has_section(section):
parser.add_section(section)
for key in ("position", "positionrule"):
parser.remove_option(section, key)
for key, value in _wanted(position).items():
parser.set(section, key, value)
listed = [item for item in parser.get("General", "rules", fallback="").split(",") if item]
if section not in listed:
listed.append(section)
parser.set("General", "rules", ",".join(listed))
parser.set("General", "count", str(len(listed)))
_save(parser)
except (configparser.Error, OSError) as exc:
log.warning("could not write the KWin rule: %s", exc)
return False, f"Could not write {rules_path()}: {exc}"
_reconfigure()
if position is not None:
return True, "KWin keeps the mini timer above other windows, in the corner you chose."
return True, "KWin will now keep the mini timer above other windows."
def remove() -> tuple[bool, str]:
"""Take the rule back out, leaving the rest of the file alone."""
try:
parser = _load()
section = _our_section(parser)
if section is None:
return True, "No KWin rule to remove."
parser.remove_section(section)
listed = [
item
for item in parser.get("General", "rules", fallback="").split(",")
if item and item != section
]
parser.set("General", "rules", ",".join(listed))
parser.set("General", "count", str(len(listed)))
_save(parser)
except (configparser.Error, OSError) as exc:
return False, f"Could not update {rules_path()}: {exc}"
_reconfigure()
return True, "Removed the KWin rule; the mini timer floats only as far as Qt can ask."
@@ -0,0 +1,78 @@
"""Where the mini timer appears.
Two mechanisms, because one is not enough:
* **X11** — the application may place its own window, so the computed point is
applied directly.
* **Wayland** — it may not: a client cannot position an xdg-toplevel, and
setting `x`/`y` is quietly ignored. KWin *can* place it, through the same
window rule that already forces "keep above", so the point is written there.
The geometry maths is a pure function so it can be checked without a screen.
"""
from __future__ import annotations
from dataclasses import dataclass
CORNERS = (
"top-left",
"top-centre",
"top-right",
"bottom-left",
"bottom-centre",
"bottom-right",
"centre",
"remember",
)
# What the settings page shows, in the order it shows them.
CORNER_LABELS = {
"top-left": "Top left",
"top-centre": "Top centre",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-centre": "Bottom centre",
"bottom-right": "Bottom right",
"centre": "Centre of the screen",
"remember": "Where I last dragged it",
}
@dataclass(frozen=True, slots=True)
class Rect:
"""A screen's usable area — panels and docks already taken off."""
x: int
y: int
width: int
height: int
def point_for(corner: str, screen: Rect, size: tuple[int, int], margin: int = 24) -> tuple[int, int]:
"""Top-left point that puts a `size` window in `corner` of `screen`."""
width, height = size
margin = max(0, margin)
vertical, _, horizontal = corner.partition("-")
if corner == "centre":
vertical, horizontal = "centre", "centre"
if horizontal == "left":
x = screen.x + margin
elif horizontal == "right":
x = screen.x + screen.width - width - margin
else: # centre
x = screen.x + (screen.width - width) // 2
if vertical == "top":
y = screen.y + margin
elif vertical == "bottom":
y = screen.y + screen.height - height - margin
else: # centre
y = screen.y + (screen.height - height) // 2
# Never place it off-screen, however small the screen or large the margin.
x = max(screen.x, min(x, screen.x + max(screen.width - width, 0)))
y = max(screen.y, min(y, screen.y + max(screen.height - height, 0)))
return int(x), int(y)
+292 -159
View File
@@ -1,12 +1,13 @@
// What the blocker is doing right now, and what it will do.
// The focus guard: what is blocked, when, and what it caught.
//
// The rules themselves are server-side (edit them in the web UI or below); this
// page is about *enforcement on this machine* — the helper, the hosts file and
// the process watcher.
// Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
// expanded server-side into real hostnames; this page shows that count and says
// plainly when a pattern cannot be expressed that way.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
@@ -15,176 +16,308 @@ ScrollView {
clip: true
ColumnLayout {
width: page.availableWidth
spacing: 16
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
Banner {
Layout.fillWidth: true
visible: !controller.helperInstalled
text: "The privileged helper is not installed, so websites cannot be blocked. "
+ "Run: sudo desktop/helper/install.sh"
actionText: ""
PageHeader {
eyebrow: controller.blockerReason
title: controller.blockerActive ? "Blocking is on" : "Blocking is off"
caption: "Rules are shared with the web app; this machine enforces them."
Switch {
text: "Enabled"
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
}
}
Banner {
Layout.fillWidth: true
visible: !controller.helperInstalled
text: "The privileged helper is not installed, so websites cannot be blocked. "
+ "Run: sudo desktop/helper/install.sh"
}
Banner {
visible: controller.blockerError !== ""
text: controller.blockerError
}
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
Card {
title: controller.blockerActive ? "🛡 Blocking is ON" : "Blocking is off"
subtitle: controller.blockerReason
RowLayout {
Layout.fillWidth: true
spacing: 20
StatTile {
label: "Domains"
value: controller.blockedDomains.length
caption: "redirected to 127.0.0.1"
}
StatTile {
label: "Apps"
value: controller.blockedApps.length
caption: "closed on sight"
}
StatTile {
label: "Attempts today"
value: controller.stats.today ? controller.stats.today.blocked_attempts : 0
caption: "and counting"
}
}
RowLayout {
Layout.fillWidth: true
Switch {
text: "Enforce on this machine"
checked: controller.config.enforce_blocking
onToggled: controller.setConfig("enforce_blocking", checked)
}
Switch {
text: "Websites"
checked: controller.config.block_hosts_file
onToggled: controller.setConfig("block_hosts_file", checked)
}
Switch {
text: "Apps"
checked: controller.config.block_apps
onToggled: controller.setConfig("block_apps", checked)
}
}
}
Card {
title: "Blocked websites"
subtitle: "One hostname per line. `www.` and `m.` are added automatically."
TextArea {
id: domainsArea
Layout.fillWidth: true
Layout.minimumHeight: 140
wrapMode: TextArea.NoWrap
font.family: "Roboto Mono, monospace"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("\n")
placeholderText: "youtube.com\nreddit.com\ntwitter.com"
}
RowLayout {
PrimaryButton {
text: "Save websites"
onClicked: {
var list = domainsArea.text.split("\n").map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
controller.patchSettings({ blocker: { blocked_domains: list } })
}
}
Item { Layout.fillWidth: true }
}
}
Card {
title: "Blocked applications"
subtitle: "Process names, matched loosely: `steam` also catches `steamwebhelper`."
TextArea {
id: appsArea
Layout.fillWidth: true
Layout.minimumHeight: 100
wrapMode: TextArea.NoWrap
font.family: "Roboto Mono, monospace"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("\n")
placeholderText: "steam\ndiscord\ntelegram-desktop"
}
RowLayout {
PrimaryButton {
text: "Save applications"
onClicked: {
var list = appsArea.text.split("\n").map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
controller.patchSettings({ blocker: { blocked_apps: list } })
}
}
Item { Layout.fillWidth: true }
}
}
Card {
title: "When to block"
subtitle: "Server-side, so every device agrees."
RowLayout {
Layout.fillWidth: true
Switch {
text: "Blocking enabled"
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
}
ComboBox {
Layout.preferredWidth: 220
model: ["During focus intervals", "Always", "On a schedule"]
currentIndex: {
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
}
onActivated: function (index) {
var modes = ["during_work", "always", "scheduled"]
controller.patchSettings({ blocker: { mode: modes[index] } })
}
}
Switch {
text: "Keep blocking during breaks"
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
}
}
}
Card {
title: "Today's temptations"
subtitle: "Every attempt the blocker turned away — this is what the daily report jokes about."
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: controller.stats.today ? controller.stats.today.blocked_top : []
model: [
{ label: "Hostnames", value: "" + controller.blockedDomains.length, caption: "after expansion" },
{ label: "Apps", value: "" + controller.blockedApps.length, caption: "closed on sight" },
{ label: "Attempts today",
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0",
caption: "turned away" },
{ label: "Enforcing",
value: controller.config.enforce_blocking ? "Yes" : "No",
caption: "on this computer" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
Label { text: modelData.target; Layout.fillWidth: true }
Label { text: "× " + modelData.count; font.weight: Font.DemiBold }
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 70
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
caption: modelData.caption
}
}
}
Label {
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
text: "Nothing yet today. Either the blocker is off, or you are behaving."
opacity: 0.6
font.pixelSize: 12
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "When" }
RowLayout {
Layout.fillWidth: true
spacing: 16
ComboBox {
Layout.preferredWidth: 240
font.pixelSize: 13
model: ["While a focus interval runs", "Always", "On a schedule"]
currentIndex: {
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
}
onActivated: function (index) {
var modes = ["during_work", "always", "scheduled"]
controller.patchSettings({ blocker: { mode: modes[index] } })
}
}
Switch {
text: "Through breaks"
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
}
Switch {
text: "Close blocked apps"
checked: controller.settings.blocker ? controller.settings.blocker.kill_blocked_apps : true
onToggled: controller.patchSettings({ blocker: { kill_blocked_apps: checked } })
}
Item { Layout.fillWidth: true }
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Masks" }
Panel {
spacing: 0
Repeater {
model: [
{ pattern: "youtube.com", meaning: "the site itself, plus www. and m." },
{ pattern: "*.google.com", meaning: "every subdomain in the expansion list" },
{ pattern: "*gram*", meaning: "matches apps; a hosts file cannot express it" }
]
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 16
Label {
text: modelData.pattern
color: Theme.ink
font.pixelSize: 12
font.family: Theme.monoFamily
Layout.preferredWidth: 140
}
Label {
text: modelData.meaning
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
}
}
}
}
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 900 ? 3 : 1
columnSpacing: 18
rowSpacing: 18
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Blocked websites" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: domainsArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: domainsArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "youtube.com\n*.reddit.com"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("
")
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Exceptions" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: allowArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: allowArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "music.youtube.com\n*.docs.google.com"
text: (controller.settings.blocker ? controller.settings.blocker.allowed_domains : []).join("
")
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Blocked applications" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: appsArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: appsArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "steam*\n*discord*"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("
")
}
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 12
TextField {
id: subdomainField
Layout.preferredWidth: 420
placeholderText: "*.site expands to: www, m, app…"
text: (controller.settings.blocker && controller.settings.blocker.subdomain_expansions
? controller.settings.blocker.subdomain_expansions : []).join(", ")
}
Item { Layout.fillWidth: true }
PrimaryButton {
text: "Save lists"
onClicked: {
function split(text, sep) {
return text.split(sep).map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
}
controller.patchSettings({
blocker: {
blocked_domains: split(domainsArea.text, "\n"),
allowed_domains: split(allowArea.text, "\n"),
blocked_apps: split(appsArea.text, "\n"),
subdomain_expansions: split(subdomainField.text, ",")
}
})
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Today's temptations" }
Repeater {
model: controller.stats.today ? controller.stats.today.blocked_top : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label {
text: modelData.target
color: Theme.ink
font.pixelSize: 12
font.family: Theme.monoFamily
Layout.fillWidth: true
}
Label {
text: "× " + modelData.count
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
}
}
}
Label {
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
text: "Nothing yet — either the blocker is off, or you are behaving."
color: Theme.faint
font.pixelSize: 12
font.family: Theme.sansFamily
}
}
Item { Layout.preferredHeight: 10 }
}
Component.onCompleted: controller.loadSettings()
+161 -135
View File
@@ -1,46 +1,40 @@
// The window.
//
// Qt Quick Controls' Material style, following the session's own light/dark
// preference, with a navigation rail on the left and the pages stacked on the
// right. Everything it shows comes from `controller`, the Python object bound
// into the QML context — there is no second copy of the state here.
// A quiet left column — wordmark, navigation as words, and the two things worth
// knowing from anywhere (what the timer is doing, whether the blocker is on) —
// with the pages on the right. Qt Quick Controls supply the behaviour; the
// chrome comes from `Theme`, so the desktop app and the web app are the same
// product rather than two takes on it.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQuick.Layouts
import QtQuick.Window
import "."
import "components"
ApplicationWindow {
id: window
width: 1100
height: 760
minimumWidth: 420
minimumHeight: 560
width: 1180
height: 820
minimumWidth: 460
minimumHeight: 600
visible: false
color: Theme.paper
title: controller.running
? controller.clockText + " · " + controller.kindLabel + " — Kadence"
: "Kadence"
Material.theme: Material.System
Material.primary: "#6750A4"
Material.accent: "#6750A4"
Material.theme: Theme.dark ? Material.Dark : Material.Light
Material.primary: Theme.ember
Material.accent: Theme.ember
Material.background: Theme.paper
Material.foreground: Theme.ink
readonly property color workColor: "#2a78d6"
readonly property color shortBreakColor: "#eb6834"
readonly property color longBreakColor: "#1baf7a"
readonly property bool wide: width >= 820
property int page: 0
function kindColor(kind) {
if (kind === "short_break") return shortBreakColor
if (kind === "long_break") return longBreakColor
return workColor
}
// Closing the window keeps the app alive in the tray — the timer is still
// running, and that is the whole point of a tray icon.
onClosing: function (close) {
if (controller.config.close_to_tray && controller.config.tray_icon) {
close.accepted = false
@@ -54,10 +48,9 @@ ApplicationWindow {
function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() }
}
// ---- shortcuts ---------------------------------------------------------
Shortcut { sequence: "Space"; onActivated: controller.toggle() }
Shortcut { sequence: "Ctrl+Return"; onActivated: controller.complete() }
Shortcut { sequence: "Ctrl+E"; onActivated: controller.extend(5) }
Shortcut { sequence: "Space"; onActivated: if (controller.timerEnabled) controller.toggle() }
Shortcut { sequence: "Ctrl+Return"; onActivated: if (controller.timerEnabled) controller.complete() }
Shortcut { sequence: "Ctrl+E"; onActivated: if (controller.timerEnabled) controller.extend(5) }
Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() }
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
@@ -65,143 +58,167 @@ ApplicationWindow {
Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 }
Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 }
Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() }
header: ToolBar {
visible: !window.wide
Material.background: Material.backgroundColor
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
Label {
text: ["Timer", "Tasks", "Blocker", "Report", "Settings"][window.page]
font.pixelSize: 18
font.weight: Font.Medium
Layout.fillWidth: true
}
StatusPill {
running: controller.running
paused: controller.status === "paused"
connected: controller.connected
text: controller.connected ? controller.clockText : "offline"
accent: window.kindColor(controller.kind)
}
}
}
Shortcut { sequence: "Ctrl+M"; onActivated: controller.toggleMini() }
RowLayout {
anchors.fill: parent
spacing: 0
// ---- navigation rail ------------------------------------------------
Pane {
// ---- the rail --------------------------------------------------------
Item {
visible: window.wide
Layout.fillHeight: true
Layout.preferredWidth: 212
Material.elevation: 0
padding: 12
Rectangle {
anchors.right: parent.right
width: 1
height: parent.height
color: Theme.rule
}
ColumnLayout {
anchors.fill: parent
spacing: 4
anchors.margins: 22
spacing: 2
RowLayout {
Layout.bottomMargin: 16
Layout.leftMargin: 8
spacing: 10
Rectangle {
width: 36; height: 36; radius: 18
color: Material.accent
Label { anchors.centerIn: parent; text: "🍅"; font.pixelSize: 18 }
ColumnLayout {
Layout.bottomMargin: 26
spacing: 0
Label {
text: "Kadence"
color: Theme.ink
font.pixelSize: 23
font.family: Theme.displayFamily
font.letterSpacing: -0.5
}
ColumnLayout {
spacing: 0
Label { text: "Kadence"; font.pixelSize: 17; font.weight: Font.DemiBold }
Label {
text: controller.connected ? "connected" : "offline"
font.pixelSize: 11
opacity: 0.6
}
Label {
text: "find your rhythm"
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
Repeater {
model: [
{ label: "Timer", icon: "⏱" },
{ label: "Tasks", icon: "✓" },
{ label: "Blocker", icon: "🛡" },
{ label: "Report", icon: "📊" },
{ label: "Settings", icon: "⚙" }
]
delegate: ItemDelegate {
model: ["Timer", "Tasks", "Blocker", "Report", "Settings"]
delegate: Item {
required property int index
required property var modelData
required property string modelData
Layout.fillWidth: true
height: 44
highlighted: window.page === index
onClicked: window.page = index
implicitHeight: 34
background: Rectangle {
radius: 22
color: parent.highlighted ? Material.accent
: (parent.hovered ? Material.listHighlightColor : "transparent")
opacity: parent.highlighted ? 0.18 : 1
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 2
height: 16
radius: 1
color: Theme.ember
visible: window.page === index
}
contentItem: RowLayout {
spacing: 12
Label { text: modelData.icon; font.pixelSize: 16 }
Label {
text: modelData.label
font.weight: parent.parent.highlighted ? Font.DemiBold : Font.Normal
Layout.fillWidth: true
}
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: modelData
color: window.page === index ? Theme.ink : (mouse.containsMouse ? Theme.ink : Theme.muted)
font.pixelSize: 14
font.weight: window.page === index ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: window.page = index
}
}
}
Item { Layout.fillHeight: true }
StatusPill {
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule; Layout.bottomMargin: 10 }
RowLayout {
Layout.fillWidth: true
running: controller.running
paused: controller.status === "paused"
connected: controller.connected
text: controller.connected ? (controller.running ? controller.clockText : "idle") : "offline"
accent: window.kindColor(controller.kind)
spacing: 8
Rectangle {
width: 7; height: 7; radius: 3.5
color: controller.running ? Theme.kindColor(controller.kind) : Theme.faint
SequentialAnimation on opacity {
running: controller.running && controller.status === "running"
loops: Animation.Infinite
NumberAnimation { to: 0.35; duration: 900 }
NumberAnimation { to: 1.0; duration: 900 }
}
}
Label {
text: controller.connected
? (controller.timerEnabled
? (controller.running ? controller.clockText : "Idle")
: "View mode")
: "Offline"
color: Theme.ink
font.pixelSize: 13
font.weight: Font.Medium
font.family: Theme.monoFamily
}
Item { Layout.fillWidth: true }
Label {
text: controller.completedToday + "/" + controller.goal
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
}
}
Label {
Layout.fillWidth: true
Layout.topMargin: 6
text: controller.completedToday + "/" + controller.goal + " today"
font.pixelSize: 12
opacity: 0.7
horizontalAlignment: Text.AlignHCenter
}
Label {
Layout.fillWidth: true
visible: controller.blockerActive
text: "🛡 blocking is on"
font.pixelSize: 12
color: "#e34948"
horizontalAlignment: Text.AlignHCenter
text: "● BLOCKING"
color: Theme.clay
font.pixelSize: 10
font.letterSpacing: 1.1
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
}
}
// ---- pages ----------------------------------------------------------
// ---- pages -----------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
Banner {
ToolBar {
visible: !window.wide
Layout.fillWidth: true
visible: !controller.connected
text: controller.connectionError !== ""
? controller.connectionError
: "Cannot reach the backend. Start it with `docker compose up`."
actionText: "Settings"
onActionClicked: window.page = 4
background: Rectangle {
color: Theme.paper
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: Theme.rule }
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
Label {
text: "Kadence"
color: Theme.ink
font.pixelSize: 18
font.family: Theme.displayFamily
Layout.fillWidth: true
}
Label {
text: controller.connected
? (controller.timerEnabled
? (controller.running ? controller.clockText : "Idle")
: "View mode")
: "Offline"
color: Theme.ink
font.pixelSize: 13
font.family: Theme.monoFamily
}
}
}
StackLayout {
@@ -215,20 +232,29 @@ ApplicationWindow {
ReportPage {}
SettingsPage {}
}
TabBar {
visible: !window.wide
Layout.fillWidth: true
currentIndex: window.page
onCurrentIndexChanged: window.page = currentIndex
background: Rectangle {
color: Theme.paper
Rectangle { width: parent.width; height: 1; color: Theme.rule }
}
TabButton { text: "Timer" }
TabButton { text: "Tasks" }
TabButton { text: "Blocker" }
TabButton { text: "Report" }
TabButton { text: "Settings" }
}
}
}
// ---- footer: bottom navigation on a narrow window ----------------------
footer: TabBar {
visible: !window.wide
currentIndex: window.page
onCurrentIndexChanged: window.page = currentIndex
TabButton { text: "⏱" }
TabButton { text: "✓" }
TabButton { text: "🛡" }
TabButton { text: "📊" }
TabButton { text: "⚙" }
}
Snackbar { id: snackbar }
// The floating mini timer is created by app.py as its own top-level window:
// nested inside this one it would be transient, and a transient window
// cannot be shown while its parent is hidden — which is precisely when the
// mini timer earns its keep.
}
+241
View File
@@ -0,0 +1,241 @@
// The floating mini timer.
//
// A small pill you keep in a corner while you work: the countdown, what you are
// working on, and the three controls you actually reach for. It appears on
// demand (tray, Ctrl+M, the D-Bus verb) and — if you let it — by itself
// whenever an interval is running and the main window is out of the way.
//
// Everything has its own row. An earlier version saved space by swapping the
// task line for the controls on hover and by drawing the progress bar over the
// bottom edge; both of those put things on top of each other, and a timer you
// have to hover to operate is a timer you cannot operate at a glance.
//
// Dragging uses the compositor's own move (`startSystemMove`), the only thing
// that works on Wayland, and the window refuses keyboard focus rather than
// being a `Qt.Tool` — Qt hides tool windows whenever the application is
// deactivated, which made this one vanish on every click in another app.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import "."
Window {
id: mini
width: 252
height: card.implicitHeight + 12
color: "transparent"
// KWin's rule matches a substring of this, so it must stay distinct from
// the main window's caption. Qt appends " — Kadence" to it.
title: "Kadence mini"
flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
| Qt.WindowDoesNotAcceptFocus
readonly property color accent: Theme.kindColor(controller.kind)
readonly property bool paused: controller.status === "paused"
// Placement is decided in app.py: it is the only piece that knows about
// screens, and on Wayland it has to go through a KWin rule anyway.
onXChanged: saveTimer.restart()
onYChanged: saveTimer.restart()
Timer {
id: saveTimer
interval: 800
onTriggered: if (mini.visible) controller.saveMiniPosition(mini.x, mini.y)
}
// A small pill button. `filled` marks the one action you want by default —
// Resume when paused, Start when idle.
component MiniButton: Rectangle {
id: button
property string label: ""
property bool filled: false
signal clicked()
implicitWidth: buttonText.implicitWidth + 18
implicitHeight: 24
radius: 7
color: filled
? (buttonHover.hovered ? Qt.lighter(mini.accent, 1.08) : mini.accent)
: (buttonHover.hovered ? Qt.alpha(mini.accent, 0.12) : "transparent")
border.width: filled ? 0 : 1
border.color: buttonHover.hovered ? mini.accent : Theme.rule
Behavior on color { ColorAnimation { duration: 110 } }
Label {
id: buttonText
anchors.fill: parent
text: button.label
color: button.filled ? "white" : Theme.ink
font.pixelSize: 11
font.weight: button.filled ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
HoverHandler { id: buttonHover; cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: button.clicked() }
}
Rectangle {
id: card
anchors.fill: parent
anchors.margins: 6
radius: 14
color: Theme.surface
border.width: 1
border.color: Theme.rule
implicitHeight: body.implicitHeight + 24 + progressLane.height + 5
HoverHandler { id: hover }
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) mini.startSystemMove()
}
TapHandler {
acceptedButtons: Qt.LeftButton
onDoubleTapped: controller.showWindowRequested()
}
ColumnLayout {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 3
// ---- state -------------------------------------------------------
RowLayout {
Layout.fillWidth: true
spacing: 7
Rectangle {
width: 6; height: 6; radius: 3
color: controller.running ? mini.accent : Theme.faint
SequentialAnimation on opacity {
running: controller.running && !mini.paused
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 900 }
NumberAnimation { to: 1.0; duration: 900 }
}
}
Label {
text: (controller.running ? controller.kindLabel : "Idle").toUpperCase()
+ (mini.paused ? " · PAUSED" : "")
color: mini.paused ? Theme.amber : (controller.running ? mini.accent : Theme.muted)
font.pixelSize: 9
font.letterSpacing: 1.2
font.weight: Font.DemiBold
font.family: Theme.sansFamily
Layout.fillWidth: true
elide: Text.ElideRight
}
Label {
text: controller.completedToday + "/" + controller.goal
color: Theme.faint
font.pixelSize: 10
font.family: Theme.sansFamily
}
Label {
text: "✕"
color: closeHover.hovered ? Theme.ink : Theme.faint
font.pixelSize: 11
opacity: hover.hovered ? 1 : 0.35
Behavior on opacity { NumberAnimation { duration: 120 } }
HoverHandler { id: closeHover; cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: controller.setMiniVisible(false) }
}
}
// ---- the time ----------------------------------------------------
Label {
text: controller.clockText
color: Theme.ink
opacity: mini.paused ? 0.55 : 1
font.pixelSize: 32
font.family: Theme.displayFamily
font.letterSpacing: -1
Layout.topMargin: -2
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// ---- what you are on ---------------------------------------------
Label {
Layout.fillWidth: true
text: controller.taskTitle !== ""
? controller.taskTitle
: (controller.running ? "no task attached" : "double-click to open Kadence")
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
elide: Text.ElideRight
}
// ---- controls, always visible --------------------------------------
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 4
spacing: 6
MiniButton {
label: mini.paused ? "Resume" : "Pause"
filled: mini.paused
visible: controller.running
onClicked: mini.paused ? controller.resume() : controller.pause()
}
MiniButton {
label: "Finish"
visible: controller.running
onClicked: controller.complete()
}
MiniButton {
label: "+5"
visible: controller.running
onClicked: controller.extend(5)
}
MiniButton {
label: "Start focus"
filled: true
visible: !controller.running
onClicked: controller.startWork()
}
MiniButton {
label: "Break"
visible: !controller.running
onClicked: controller.startBreak()
}
Item { Layout.fillWidth: true }
}
}
// ---- progress, in a lane of its own ----------------------------------
Item {
id: progressLane
objectName: "progressLane"
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 1
anchors.rightMargin: 1
anchors.bottomMargin: 1
height: 3
clip: true
Rectangle {
width: parent.width * Math.max(0, Math.min(1, controller.progress))
height: parent.height
color: mini.accent
opacity: controller.running ? (mini.paused ? 0.45 : 1) : 0.25
Behavior on width { NumberAnimation { duration: 400 } }
}
}
}
}
+190 -141
View File
@@ -1,8 +1,9 @@
// The day, summarised — the same payload the Telegram message is built from.
// The day in numbers — the same payload the Telegram message is built from.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
@@ -14,166 +15,214 @@ ScrollView {
readonly property var metrics: payload ? payload.metrics : null
ColumnLayout {
width: page.availableWidth
spacing: 16
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: controller.report.sent_at ? "sent to Telegram" : "not sent yet"
title: page.payload ? "Report · " + page.payload.date : "Report"
caption: page.payload ? page.payload.verdict : ""
GhostButton { text: "Reload"; onClicked: controller.loadReport() }
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
}
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ label: "Pomodoros",
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—",
caption: page.metrics && page.metrics.goal_met ? "goal met" : "goal not met yet",
accent: true },
{ label: "Focus",
value: page.metrics ? page.metrics.focus_minutes + "m" : "—",
caption: page.metrics ? page.metrics.break_minutes + "m resting" : "" },
{ label: "Score",
value: page.payload ? "" + Math.round(page.payload.score) : "—",
caption: "out of 100" },
{ label: "Streak",
value: page.payload ? page.payload.streak.current + "d" : "—",
caption: page.payload ? "best " + page.payload.streak.best : "" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 84
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 20
label: modelData.label
value: modelData.value
caption: modelData.caption
size: 38
accent: modelData.accent ? Theme.work : Theme.ink
}
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
RowLayout {
Layout.fillWidth: true
Label {
text: page.payload ? "Report for " + page.payload.date : "Report"
font.pixelSize: 20
font.weight: Font.Medium
Layout.fillWidth: true
}
Button { text: "⟳"; flat: true; onClicked: controller.loadReport() }
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 760 ? 4 : 2
columnSpacing: 12
rowSpacing: 12
StatTile {
label: "Pomodoros"
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—"
caption: page.metrics && page.metrics.goal_met ? "goal met 🎯" : "keep going"
accent: window.workColor
}
StatTile {
label: "Focus"
value: page.metrics ? page.metrics.focus_minutes + "m" : "—"
caption: page.metrics ? page.metrics.break_minutes + "m resting" : ""
}
StatTile {
label: "Score"
value: page.payload ? Math.round(page.payload.score) : "—"
caption: "out of 100"
}
StatTile {
label: "Streak"
value: page.payload ? page.payload.streak.current + "d" : "—"
caption: page.payload ? "best " + page.payload.streak.best : ""
}
}
Card {
title: "Last 30 days"
subtitle: "Goal line is the dashed row; darker bars cleared it."
spacing: 10
SectionTitle { text: "Last 30 days"; trailing: "goal line dashed" }
Panel {
BarRow {
Layout.fillWidth: true
days: controller.stats.range ? controller.stats.range : []
goal: controller.goal
}
}
}
Card {
visible: page.payload && page.payload.achievements.length > 0
title: "Achievements"
Flow {
Layout.fillWidth: true
spacing: 8
Repeater {
model: page.payload ? page.payload.achievements : []
delegate: Pane {
required property var modelData
Material.elevation: 0
Material.roundedScale: Material.SmallScale
padding: 10
background: Rectangle {
radius: 10
color: Material.accent
opacity: 0.14
ColumnLayout {
Layout.fillWidth: true
visible: page.payload && page.payload.achievements.length > 0
spacing: 10
SectionTitle { text: "Achievements" }
Flow {
Layout.fillWidth: true
spacing: 8
Repeater {
model: page.payload ? page.payload.achievements : []
delegate: Rectangle {
required property var modelData
implicitWidth: badge.implicitWidth + 24
implicitHeight: badge.implicitHeight + 18
radius: 10
color: Qt.alpha(Theme.ember, 0.08)
border.width: 1
border.color: Qt.alpha(Theme.ember, 0.22)
ColumnLayout {
id: badge
anchors.centerIn: parent
spacing: 1
Label {
text: modelData.emoji + " " + modelData.title
color: Theme.ink
font.pixelSize: 13
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
ColumnLayout {
spacing: 1
Label {
text: modelData.emoji + " " + modelData.title
font.weight: Font.DemiBold
font.pixelSize: 13
}
Label {
text: modelData.detail
visible: modelData.detail !== ""
font.pixelSize: 11
opacity: 0.75
}
Label {
visible: modelData.detail !== ""
text: modelData.detail
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
}
}
}
}
Card {
visible: page.payload && page.payload.fun_facts.length > 0
title: "The funny part"
Repeater {
model: page.payload ? page.payload.fun_facts : []
delegate: Label {
required property var modelData
text: modelData
wrapMode: Text.WordWrap
Layout.fillWidth: true
font.pixelSize: 13
}
}
}
Card {
visible: page.metrics && page.metrics.tasks_completed.length > 0
title: "Closed today"
Repeater {
model: page.metrics ? page.metrics.tasks_completed : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label { text: "✅"; font.pixelSize: 12 }
Label { text: modelData.title; Layout.fillWidth: true; elide: Text.ElideRight }
Label { text: modelData.source; font.pixelSize: 11; opacity: 0.6 }
}
}
}
Card {
title: "All time"
RowLayout {
ColumnLayout {
Layout.fillWidth: true
visible: page.payload && page.payload.fun_facts.length > 0
spacing: 8
SectionTitle { text: "The funny part" }
Repeater {
model: page.payload ? page.payload.fun_facts : []
delegate: Label {
required property var modelData
text: modelData
color: Theme.ink
font.pixelSize: 13
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
Layout.fillWidth: true
spacing: 12
StatTile {
label: "Pomodoros"
value: controller.stats.lifetime ? controller.stats.lifetime.pomodoros : "—"
}
StatTile {
label: "Hours focused"
value: controller.stats.lifetime ? controller.stats.lifetime.focus_hours : "—"
}
StatTile {
label: "Tasks closed"
value: controller.stats.lifetime ? controller.stats.lifetime.tasks_completed : "—"
}
StatTile {
label: "Blocked"
value: controller.stats.lifetime ? controller.stats.lifetime.blocked_attempts : "—"
}
}
}
Label {
visible: page.payload && page.payload.verdict !== ""
text: page.payload ? page.payload.verdict : ""
font.italic: true
font.pixelSize: 14
opacity: 0.85
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
ColumnLayout {
Layout.fillWidth: true
visible: page.metrics && page.metrics.tasks_completed.length > 0
spacing: 6
SectionTitle { text: "Closed today" }
Repeater {
model: page.metrics ? page.metrics.tasks_completed : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 12
Label {
text: modelData.title
color: Theme.ink
font.pixelSize: 13
font.family: Theme.sansFamily
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: modelData.source
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "All time" }
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ label: "Pomodoros",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.pomodoros : "—" },
{ label: "Hours focused",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.focus_hours : "—" },
{ label: "Tasks closed",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.tasks_completed : "—" },
{ label: "Blocked",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.blocked_attempts : "—" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 64
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
}
}
}
}
}
}
Item { Layout.preferredHeight: 10 }
}
Component.onCompleted: controller.loadReport()
+446 -324
View File
@@ -1,12 +1,12 @@
// Settings, split by where they live.
//
// Anything under "This computer" is local to this machine (and saved in
// ~/.config/kadence/desktop.json). Everything else is server-side, shared with
// the web app and the Telegram bot.
// "This computer" is local to this machine (~/.config/kadence/desktop.json).
// Everything else is server-side, shared with the web app and the Telegram bot.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
@@ -29,362 +29,484 @@ ScrollView {
controller.patchSettings({ notifications: { telegram: patch } })
}
ColumnLayout {
width: page.availableWidth
spacing: 16
component Field: ColumnLayout {
property string label: ""
default property alias content: holder.data
spacing: 4
Label {
text: parent.label
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
ColumnLayout { id: holder; spacing: 0 }
}
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 26
PageHeader {
eyebrow: "Shared with the web app and the Telegram bot"
title: "Settings"
caption: "Secrets are masked once stored — leave the dots alone to keep the saved value."
GhostButton { text: "Open the web app"; onClicked: controller.openWebUi() }
}
// ---- backend ----------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
// ---- connection --------------------------------------------------
Card {
title: "Backend"
subtitle: controller.connected ? "Connected to " + controller.config.server_url
: controller.connectionError
RowLayout {
spacing: 12
SectionTitle {
text: "Backend"
trailing: controller.connected ? "connected" : "offline"
}
RowLayout {
Layout.fillWidth: true
spacing: 10
TextField {
id: serverField
Layout.fillWidth: true
TextField {
id: serverField
Layout.fillWidth: true
text: controller.config.server_url
placeholderText: "http://localhost:8000"
}
TextField {
id: tokenField
Layout.preferredWidth: 180
text: controller.config.api_token
placeholderText: "API token (optional)"
echoMode: TextInput.Password
}
PrimaryButton {
text: "Connect"
onClicked: {
controller.setConfig("server_url", serverField.text.trim())
controller.setConfig("api_token", tokenField.text)
}
text: controller.config.server_url
placeholderText: "http://localhost:8000"
}
TextField {
id: tokenField
Layout.preferredWidth: 180
text: controller.config.api_token
placeholderText: "API token (optional)"
echoMode: TextInput.Password
}
PrimaryButton {
text: "Connect"
onClicked: {
controller.setConfig("server_url", serverField.text.trim())
controller.setConfig("api_token", tokenField.text)
}
}
RowLayout {
Label { text: "Poll every"; font.pixelSize: 12 }
}
Label {
visible: !controller.connected && controller.connectionError !== ""
text: controller.connectionError
color: Theme.clay
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
// ---- timer -------------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Timer" }
Flow {
Layout.fillWidth: true
spacing: 20
Field {
label: "Focus (min)"
SpinBox {
from: 1; to: 60
value: controller.config.poll_seconds
onValueModified: controller.setConfig("poll_seconds", value)
}
Label { text: "seconds"; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
Button { text: "Open the web app"; flat: true; onClicked: controller.openWebUi() }
}
}
// ---- timer -------------------------------------------------------
Card {
title: "Timer"
subtitle: "Shared with the web app and the Telegram bot."
GridLayout {
Layout.fillWidth: true
columns: page.width > 700 ? 4 : 2
columnSpacing: 16
rowSpacing: 8
ColumnLayout {
Label { text: "Focus (min)"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.work_minutes : 25
onValueModified: page.patchPomodoro("work_minutes", value)
}
}
ColumnLayout {
Label { text: "Short break"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 120
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
onValueModified: page.patchPomodoro("short_break_minutes", value)
}
}
ColumnLayout {
Label { text: "Long break"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
onValueModified: page.patchPomodoro("long_break_minutes", value)
}
}
ColumnLayout {
Label { text: "Long break every"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 12
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
}
}
ColumnLayout {
Label { text: "Daily goal"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 40
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
}
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.work_minutes : 25
onValueModified: page.patchPomodoro("work_minutes", value)
}
}
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Auto-start breaks"
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
onToggled: page.patchPomodoro("auto_start_breaks", checked)
Field {
label: "Short break"
SpinBox {
from: 1; to: 120
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
onValueModified: page.patchPomodoro("short_break_minutes", value)
}
Switch {
text: "Auto-start next focus"
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
onToggled: page.patchPomodoro("auto_start_work", checked)
}
Field {
label: "Long break"
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
onValueModified: page.patchPomodoro("long_break_minutes", value)
}
Switch {
text: "Allow pausing"
checked: page.pomodoro ? page.pomodoro.allow_pause : true
onToggled: page.patchPomodoro("allow_pause", checked)
}
Field {
label: "Long break every"
SpinBox {
from: 1; to: 12
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
}
Switch {
text: "Strict mode"
checked: page.pomodoro ? page.pomodoro.strict_mode : false
onToggled: page.patchPomodoro("strict_mode", checked)
ToolTip.visible: hovered
ToolTip.text: "Abandoning a focus interval requires a reason."
}
Field {
label: "Daily goal"
SpinBox {
from: 1; to: 40
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
}
}
}
// ---- desktop integration ----------------------------------------
Card {
title: "This computer"
subtitle: "Native desktop features. Stored locally, not on the server."
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Timer enabled (off = view-only mode)"
checked: controller.timerEnabled
onToggled: controller.setTimerEnabled(checked)
}
Switch {
text: "Auto-start breaks"
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
onToggled: page.patchPomodoro("auto_start_breaks", checked)
}
Switch {
text: "Auto-start next focus"
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
onToggled: page.patchPomodoro("auto_start_work", checked)
}
Switch {
text: "Allow pausing"
checked: page.pomodoro ? page.pomodoro.allow_pause : true
onToggled: page.patchPomodoro("allow_pause", checked)
}
Switch {
text: "Strict mode"
checked: page.pomodoro ? page.pomodoro.strict_mode : false
onToggled: page.patchPomodoro("strict_mode", checked)
ToolTip.visible: hovered
ToolTip.text: "Abandoning a focus interval requires a reason."
}
}
}
Flow {
Layout.fillWidth: true
spacing: 16
// ---- this computer ------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "This computer"; trailing: "native desktop features" }
Switch {
text: "Tray icon"
checked: controller.config.tray_icon
onToggled: controller.setConfig("tray_icon", checked)
}
Switch {
text: "Close to tray"
checked: controller.config.close_to_tray
onToggled: controller.setConfig("close_to_tray", checked)
}
Switch {
text: "Notifications"
checked: controller.config.notifications
onToggled: controller.setConfig("notifications", checked)
}
Switch {
text: "Notification buttons"
checked: controller.config.notification_actions
onToggled: controller.setConfig("notification_actions", checked)
ToolTip.visible: hovered
ToolTip.text: "“Start break” / “Skip” directly on the notification."
}
Switch {
text: "Keep the screen awake"
checked: controller.config.inhibit_screensaver
onToggled: controller.setConfig("inhibit_screensaver", checked)
}
Switch {
text: "Do Not Disturb while focusing"
checked: controller.config.do_not_disturb
onToggled: controller.setConfig("do_not_disturb", checked)
}
Switch {
text: "Pause music when focus starts"
checked: controller.config.pause_media
onToggled: controller.setConfig("pause_media", checked)
ToolTip.visible: hovered
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
}
Switch {
text: "Taskbar progress"
checked: controller.config.taskbar_progress
onToggled: controller.setConfig("taskbar_progress", checked)
}
Switch {
text: "Pause when the screen locks"
checked: controller.config.pause_on_lock
onToggled: controller.setConfig("pause_on_lock", checked)
}
Switch {
text: "D-Bus control"
checked: controller.config.dbus_service
onToggled: controller.setConfig("dbus_service", checked)
ToolTip.visible: hovered
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Start in view-only mode"
checked: controller.config.view_only
onToggled: controller.setConfig("view_only", checked)
}
Switch {
text: "Tray icon"
checked: controller.config.tray_icon
onToggled: controller.setConfig("tray_icon", checked)
}
Switch {
text: "Close to tray"
checked: controller.config.close_to_tray
onToggled: controller.setConfig("close_to_tray", checked)
}
Switch {
text: "Notifications"
checked: controller.config.notifications
onToggled: controller.setConfig("notifications", checked)
}
Switch {
text: "Notification buttons"
checked: controller.config.notification_actions
onToggled: controller.setConfig("notification_actions", checked)
}
Switch {
text: "Keep the screen awake"
checked: controller.config.inhibit_screensaver
onToggled: controller.setConfig("inhibit_screensaver", checked)
}
Switch {
text: "Do Not Disturb while focusing"
checked: controller.config.do_not_disturb
onToggled: controller.setConfig("do_not_disturb", checked)
}
Switch {
text: "Pause music on focus"
checked: controller.config.pause_media
onToggled: controller.setConfig("pause_media", checked)
ToolTip.visible: hovered
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
}
Switch {
text: "Mini timer"
checked: controller.config.mini_timer
onToggled: controller.setConfig("mini_timer", checked)
ToolTip.visible: hovered
ToolTip.text: "A small always-on-top countdown. Ctrl+M, or the tray menu."
}
Switch {
text: "Mini timer follows the window"
checked: controller.config.mini_timer_auto
onToggled: controller.setConfig("mini_timer_auto", checked)
ToolTip.visible: hovered
ToolTip.text: "Show it while an interval runs and the main window is hidden."
}
Switch {
text: "Keep the mini timer above everything"
enabled: controller.config.keep_above_supported
checked: controller.config.mini_timer_keep_above
onToggled: controller.setConfig("mini_timer_keep_above", checked)
ToolTip.visible: hovered
ToolTip.text: controller.config.keep_above_supported
? "Installs a KWin window rule — the only way to float above "
+ "other apps on Wayland. Switching this off removes it again."
: "Needs KWin (KDE). Elsewhere the Qt window flag is used."
}
Switch {
text: "Taskbar progress"
checked: controller.config.taskbar_progress
onToggled: controller.setConfig("taskbar_progress", checked)
}
Switch {
text: "Pause when the screen locks"
checked: controller.config.pause_on_lock
onToggled: controller.setConfig("pause_on_lock", checked)
}
Switch {
text: "D-Bus control"
checked: controller.config.dbus_service
onToggled: controller.setConfig("dbus_service", checked)
ToolTip.visible: hovered
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
}
}
RowLayout {
Layout.fillWidth: true
spacing: 20
Field {
label: "Mini timer appears"
ComboBox {
implicitWidth: 210
textRole: "label"
valueRole: "key"
model: [
{ key: "top-left", label: "Top left" },
{ key: "top-centre", label: "Top centre" },
{ key: "top-right", label: "Top right" },
{ key: "bottom-left", label: "Bottom left" },
{ key: "bottom-centre", label: "Bottom centre" },
{ key: "bottom-right", label: "Bottom right" },
{ key: "centre", label: "Centre of the screen" },
{ key: "remember", label: "Where I last dragged it" }
]
currentIndex: {
var corner = controller.config.mini_timer_corner
for (var i = 0; i < model.length; i++)
if (model[i].key === corner) return i
return 5
}
onActivated: function (index) {
controller.setConfig("mini_timer_corner", model[index].key)
}
}
}
Field {
label: "Gap from the edges (px)"
SpinBox {
from: 0; to: 400; stepSize: 4
value: controller.config.mini_timer_margin
onValueModified: controller.setConfig("mini_timer_margin", value)
}
}
Item { Layout.fillWidth: true }
}
RowLayout {
Layout.fillWidth: true
Label { text: "Pause after"; font.pixelSize: 12 }
RowLayout {
Layout.fillWidth: true
spacing: 20
Field {
label: "Pause after idle (min, 0 = never)"
SpinBox {
from: 0; to: 120
value: controller.config.pause_on_idle_minutes
onValueModified: controller.setConfig("pause_on_idle_minutes", value)
}
Label { text: "idle minutes (0 = never)"; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
}
RowLayout {
Layout.fillWidth: true
Switch {
text: "Start with the session"
checked: controller.config.autostart
onToggled: controller.setConfig("autostart", checked)
}
Switch {
text: "systemd user service"
checked: controller.config.service_enabled
onToggled: controller.setConfig("service_enabled", checked)
ToolTip.visible: hovered
ToolTip.text: "Restarts the app if it dies; logs to the journal."
}
Switch {
text: "Start minimised"
checked: controller.config.start_minimized
onToggled: controller.setConfig("start_minimized", checked)
Field {
label: "Poll the backend every (s)"
SpinBox {
from: 1; to: 60
value: controller.config.poll_seconds
onValueModified: controller.setConfig("poll_seconds", value)
}
}
Item { Layout.fillWidth: true }
}
// ---- telegram ----------------------------------------------------
Card {
title: "Telegram"
subtitle: "Daily report, morning briefing and the nudges that get you back to work."
RowLayout {
Layout.fillWidth: true
Switch {
text: "Enabled"
checked: page.telegram ? page.telegram.enabled : false
onToggled: page.patchTelegram("enabled", checked)
}
TextField {
Layout.fillWidth: true
placeholderText: "Bot token from @BotFather"
text: page.telegram ? page.telegram.bot_token : ""
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
}
TextField {
Layout.preferredWidth: 160
placeholderText: "Chat id"
text: page.telegram ? page.telegram.chat_id : ""
onEditingFinished: page.patchTelegram("chat_id", text)
}
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Start with the session"
checked: controller.config.autostart
onToggled: controller.setConfig("autostart", checked)
}
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Daily report"
checked: page.telegram ? page.telegram.daily_report : true
onToggled: page.patchTelegram("daily_report", checked)
}
Switch {
text: "Morning briefing"
checked: page.telegram ? page.telegram.morning_briefing : true
onToggled: page.patchTelegram("morning_briefing", checked)
}
Switch {
text: "Weekly review"
checked: page.telegram ? page.telegram.weekly_report : true
onToggled: page.patchTelegram("weekly_report", checked)
}
Switch {
text: "Nudge me when idle"
checked: page.telegram ? page.telegram.nudge_enabled : true
onToggled: page.patchTelegram("nudge_enabled", checked)
}
Switch {
text: "Chat commands"
checked: page.telegram ? page.telegram.commands_enabled : true
onToggled: page.patchTelegram("commands_enabled", checked)
}
Switch {
text: "systemd user service"
checked: controller.config.service_enabled
onToggled: controller.setConfig("service_enabled", checked)
ToolTip.visible: hovered
ToolTip.text: "Restarts the app if it dies; logs to the journal."
}
RowLayout {
Layout.fillWidth: true
ColumnLayout {
Label { text: "Report at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
TextField {
text: page.telegram ? page.telegram.daily_report_time : "21:00"
onEditingFinished: page.patchTelegram("daily_report_time", text)
}
}
ColumnLayout {
Label { text: "Briefing at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
TextField {
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
}
}
ColumnLayout {
Label { text: "Nudge after (min idle)"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 10; to: 600
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
}
}
ColumnLayout {
Label { text: "Tone"; font.pixelSize: 11; opacity: 0.7 }
ComboBox {
model: ["friendly", "coach", "drill_sergeant", "dry"]
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
}
}
}
ColumnLayout {
Label { text: "Language"; font.pixelSize: 11; opacity: 0.7 }
ComboBox {
model: ["en", "ru"]
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { language: model[index] } } })
}
}
}
}
}
Card {
title: "Shortcuts and scripting"
subtitle: "The app exposes org.kadence.Desktop on the session bus."
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.family: "Roboto Mono, monospace"
font.pixelSize: 11
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
+ "kadence-desktop --toggle # same thing from a terminal\n\n"
+ "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
}
Label {
text: "Kadence " + appVersion + " · sources, blocking rules and reports live on the backend."
font.pixelSize: 11
opacity: 0.6
Switch {
text: "Start minimised"
checked: controller.config.start_minimized
onToggled: controller.setConfig("start_minimized", checked)
}
}
}
// ---- telegram -------------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Telegram"; trailing: "reports, briefings and nudges" }
RowLayout {
Layout.fillWidth: true
spacing: 10
Switch {
text: "Enabled"
checked: page.telegram ? page.telegram.enabled : false
onToggled: page.patchTelegram("enabled", checked)
}
TextField {
Layout.fillWidth: true
placeholderText: "Bot token from @BotFather"
text: page.telegram ? page.telegram.bot_token : ""
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
}
TextField {
Layout.preferredWidth: 160
placeholderText: "Chat id"
text: page.telegram ? page.telegram.chat_id : ""
onEditingFinished: page.patchTelegram("chat_id", text)
}
}
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Daily report"
checked: page.telegram ? page.telegram.daily_report : true
onToggled: page.patchTelegram("daily_report", checked)
}
Switch {
text: "Morning briefing"
checked: page.telegram ? page.telegram.morning_briefing : true
onToggled: page.patchTelegram("morning_briefing", checked)
}
Switch {
text: "Weekly review"
checked: page.telegram ? page.telegram.weekly_report : true
onToggled: page.patchTelegram("weekly_report", checked)
}
Switch {
text: "Nudge me when idle"
checked: page.telegram ? page.telegram.nudge_enabled : true
onToggled: page.patchTelegram("nudge_enabled", checked)
}
Switch {
text: "Chat commands"
checked: page.telegram ? page.telegram.commands_enabled : true
onToggled: page.patchTelegram("commands_enabled", checked)
}
}
Flow {
Layout.fillWidth: true
spacing: 20
Field {
label: "Report at (HH:MM)"
TextField {
implicitWidth: 110
text: page.telegram ? page.telegram.daily_report_time : "21:00"
onEditingFinished: page.patchTelegram("daily_report_time", text)
}
}
Field {
label: "Briefing at (HH:MM)"
TextField {
implicitWidth: 110
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
}
}
Field {
label: "Nudge after (min idle)"
SpinBox {
from: 10; to: 600
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
}
}
Field {
label: "Tone"
ComboBox {
implicitWidth: 170
model: ["friendly", "coach", "drill_sergeant", "dry"]
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
}
}
}
Field {
label: "Language"
ComboBox {
implicitWidth: 110
model: ["en", "ru"]
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { language: model[index] } } })
}
}
}
}
}
// ---- scripting -------------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Shortcuts and scripting" }
Panel {
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Theme.muted
font.pixelSize: 11
font.family: Theme.monoFamily
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
+ "kadence-desktop --toggle"
}
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
text: "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
}
}
Label {
text: "Kadence " + appVersion
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
Item { Layout.preferredHeight: 10 }
}
Component.onCompleted: controller.loadSettings()
+163 -109
View File
@@ -1,12 +1,19 @@
// Everything you owe, from every source, grouped by deadline.
// Everything you owe, grouped by deadline.
//
// There is no checkbox: a task is closed in Jira, Todoist or Google Tasks, and
// the next refresh notices. Two places to tick something off is two places to
// be wrong.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
Item {
ScrollView {
id: page
contentWidth: availableWidth
clip: true
function bucketTasks(bucket) {
var out = []
@@ -17,130 +24,170 @@ Item {
return out
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 12
function bucketColor(bucket) {
if (bucket === "overdue") return Theme.clay
if (bucket === "today") return Theme.amber
if (bucket === "active") return Theme.pine
if (bucket === "upcoming") return Theme.faint
return Theme.rule
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: controller.tasks.length + " open"
font.pixelSize: 13
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button { text: "+ Add"; onClicked: addDialog.open() }
PrimaryButton { text: "⟳ Refresh sources"; onClicked: controller.refreshSources() }
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: "From Jira, Todoist, Google Tasks and your own list"
title: "Tasks"
caption: "Close them where they live — the next refresh credits them to today."
GhostButton { text: "Add"; onClicked: addDialog.open() }
PrimaryButton { text: "Refresh sources"; onClicked: controller.refreshSources() }
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
contentWidth: availableWidth
clip: true
Repeater {
model: [
{ key: "overdue", label: "Overdue" },
{ key: "today", label: "Due today" },
{ key: "active", label: "In progress" },
{ key: "upcoming", label: "Coming up" },
{ key: "someday", label: "No deadline" }
]
delegate: ColumnLayout {
required property var modelData
property var items: page.bucketTasks(modelData.key)
visible: items.length > 0
Layout.fillWidth: true
spacing: 2
ColumnLayout {
width: parent.parent.availableWidth
spacing: 12
SectionTitle { text: modelData.label; trailing: "" + parent.items.length }
Repeater {
model: [
{ key: "overdue", label: "Overdue", mark: "🔴" },
{ key: "today", label: "Due today", mark: "🟡" },
{ key: "upcoming", label: "Coming up", mark: "🔵" },
{ key: "someday", label: "No deadline", mark: "⚪" }
]
delegate: Card {
model: parent.items
delegate: Item {
required property var modelData
property var items: page.bucketTasks(modelData.key)
visible: items.length > 0
title: modelData.mark + " " + modelData.label + " (" + items.length + ")"
Layout.fillWidth: true
implicitHeight: row.implicitHeight + 20
Repeater {
model: parent.parent.items
delegate: RowLayout {
required property var modelData
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.rule
}
RowLayout {
id: row
anchors.fill: parent
anchors.topMargin: 10
anchors.bottomMargin: 10
spacing: 14
Rectangle {
width: 2
Layout.fillHeight: true
radius: 1
color: page.bucketColor(modelData.bucket)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 10
CheckBox {
checked: modelData.is_done
onToggled: controller.setTaskDone(modelData.id, checked)
}
ColumnLayout {
spacing: 3
Label {
text: modelData.title
color: Theme.ink
font.pixelSize: 14
font.weight: modelData.pinned ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
elide: Text.ElideRight
Layout.fillWidth: true
spacing: 2
}
RowLayout {
spacing: 12
Label {
text: modelData.title
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
text: modelData.source_label
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
RowLayout {
spacing: 6
Label {
text: modelData.source_label
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: !!modelData.project
text: "· " + modelData.project
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: modelData.days_overdue > 0
text: "· " + modelData.days_overdue + "d overdue"
font.pixelSize: 10
color: "#e34948"
}
Label {
visible: !!modelData.due_date && modelData.days_overdue === 0
text: "· " + modelData.due_date
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: !!modelData.priority
text: "· " + modelData.priority
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: !!modelData.project && modelData.project !== modelData.source_label
text: modelData.project || ""
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
visible: !!modelData.status
text: modelData.status || ""
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
visible: modelData.days_overdue > 0
text: modelData.days_overdue + "d overdue"
color: Theme.clay; font.pixelSize: 11
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
visible: !!modelData.due_date && modelData.days_overdue === 0
text: "due " + (modelData.due_date || "")
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
}
ToolButton {
text: "↗"
visible: !!modelData.url
onClicked: controller.openUrl(modelData.url)
ToolTip.visible: hovered
ToolTip.text: "Open in " + modelData.source_label
}
ToolButton {
text: "▶"
onClicked: { controller.attachTask(modelData.id); controller.startWork(); window.page = 0 }
ToolTip.visible: hovered
ToolTip.text: "Start a pomodoro on this"
}
GhostButton {
text: modelData.pinned ? "Unpin" : "Pin"
implicitHeight: 28
onClicked: controller.setTaskPinned(modelData.id, !modelData.pinned)
}
GhostButton {
visible: !!modelData.url
text: "Open"
implicitHeight: 28
onClicked: controller.openUrl(modelData.url)
}
GhostButton {
text: "Focus"
implicitHeight: 28
onClicked: {
controller.attachTask(modelData.id)
controller.startWork()
window.page = 0
}
}
}
}
}
Card {
visible: controller.tasks.length === 0
title: "Nothing on the list"
subtitle: "Connect Jira, Todoist or Google Tasks in Settings → Sources, "
+ "or add something here yourself."
Button {
text: "Open settings"
onClicked: window.page = 4
}
}
}
}
Panel {
visible: controller.tasks.length === 0
ColumnLayout {
spacing: 8
Label {
text: "Nothing on the list"
color: Theme.ink
font.pixelSize: 17
font.family: Theme.displayFamily
}
Label {
text: "Connect Jira, Todoist or Google Tasks in Settings, or add something yourself."
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
GhostButton { text: "Open settings"; onClicked: window.page = 4 }
}
}
Item { Layout.preferredHeight: 10 }
}
Dialog {
@@ -153,7 +200,7 @@ Item {
ColumnLayout {
anchors.fill: parent
spacing: 10
spacing: 12
TextField {
id: titleField
Layout.fillWidth: true
@@ -162,8 +209,15 @@ Item {
TextField {
id: dueField
Layout.fillWidth: true
placeholderText: "Due date (YYYY-MM-DD, optional)"
inputMask: ""
placeholderText: "Due date — YYYY-MM-DD (optional)"
}
Label {
text: "Local tasks live only here, so they are the one kind you can delete."
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
PrimaryButton {
text: "Add"
+73
View File
@@ -0,0 +1,73 @@
pragma Singleton
import QtQuick
/**
* The design tokens, shared with the web app's `tokens.ts`.
*
* Kadence reads like a well-set page rather than a dashboard: warm paper, ink
* type, hairline rules instead of boxes and shadows, one warm accent. Light and
* dark follow the desktop's own colour scheme.
*/
QtObject {
id: theme
property SystemPalette system: SystemPalette { colorGroup: SystemPalette.Active }
// The session decides; we only read it.
readonly property bool dark: system.window.hslLightness < 0.5
readonly property color paper: dark ? "#121110" : "#FAF7F2"
readonly property color surface: dark ? "#1A1917" : "#FFFFFF"
readonly property color sunken: dark ? "#201E1B" : "#F3EFE8"
readonly property color ink: dark ? "#F4F1EA" : "#17150F"
readonly property color muted: dark ? "#A79F93" : "#6E675B"
readonly property color faint: dark ? "#776F64" : "#9A9388"
readonly property color rule: dark ? Qt.rgba(0.96, 0.95, 0.92, 0.14) : Qt.rgba(0.09, 0.08, 0.06, 0.12)
readonly property color ember: dark ? "#E0724A" : "#B4441E"
readonly property color pine: dark ? "#6FB394" : "#2F6B55"
readonly property color amber: dark ? "#D9A441" : "#8A6200"
readonly property color clay: dark ? "#E07A6E" : "#A33A2E"
// Chart colours are the validated data palette, not the brand accent —
// a chart has to stay readable for colour-blind readers in both themes.
readonly property color work: dark ? "#d95926" : "#eb6834"
readonly property color shortBreak: dark ? "#3987e5" : "#2a78d6"
readonly property color longBreak: dark ? "#199e70" : "#1baf7a"
readonly property var sequential: dark
? ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"]
: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"]
// QML's `font.family` takes one name, so the preference list is resolved
// once against what is actually installed on this machine.
function pickFamily(candidates) {
var available = Qt.fontFamilies()
for (var i = 0; i < candidates.length; i++)
if (available.indexOf(candidates[i]) >= 0)
return candidates[i]
return candidates[candidates.length - 1]
}
readonly property string displayFamily: pickFamily(
["Fraunces", "Noto Serif", "DejaVu Serif", "Liberation Serif", "Georgia", "serif"])
readonly property string sansFamily: pickFamily(
["Inter", "Noto Sans", "DejaVu Sans", "Liberation Sans", "sans-serif"])
readonly property string monoFamily: pickFamily(
["JetBrains Mono", "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "monospace"])
readonly property int radius: 12
readonly property int gap: 12
readonly property int pad: 18
function kindColor(kind) {
if (kind === "short_break") return shortBreak
if (kind === "long_break") return longBreak
return work
}
function kindLabel(kind) {
if (kind === "short_break") return "Short break"
if (kind === "long_break") return "Long break"
return "Focus"
}
}
+194 -147
View File
@@ -1,8 +1,9 @@
// The timer page: the dial, the controls, and what you are working on.
// The timer: the dial, the controls, and what you are working on.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
@@ -10,149 +11,165 @@ ScrollView {
contentWidth: availableWidth
clip: true
property var openTasks: {
readonly property var openTasks: {
var list = []
for (var i = 0; i < controller.tasks.length; i++)
if (!controller.tasks[i].is_done) list.push(controller.tasks[i])
return list
}
function suggestion() {
var buckets = ["overdue", "today", "upcoming", "someday"]
for (var b = 0; b < buckets.length; b++)
for (var i = 0; i < openTasks.length; i++)
if (openTasks[i].pinned || openTasks[i].bucket === buckets[b]) return openTasks[i]
return null
}
ColumnLayout {
width: page.availableWidth
spacing: 16
anchors.margins: 20
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: Qt.formatDate(new Date(), "dddd, d MMMM")
title: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Ready when you are")
caption: !controller.timerEnabled
? "Timer is disabled · view-only mode"
: (controller.taskTitle !== ""
? "Tracking “" + controller.taskTitle + "”"
: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
+ " before a long break")
GhostButton {
visible: controller.timerEnabled && controller.running && controller.status !== "paused"
text: "Pause"
onClicked: controller.pause()
}
GhostButton {
visible: controller.timerEnabled && controller.running && controller.status === "paused"
text: "Resume"
onClicked: controller.resume()
}
PrimaryButton {
visible: controller.timerEnabled && controller.running
text: "Finish"
onClicked: controller.complete()
}
PrimaryButton {
visible: controller.timerEnabled && !controller.running
text: "Start focus"
onClicked: controller.startWork()
}
GhostButton {
visible: controller.timerEnabled && !controller.running
text: controller.nextKindLabel
onClicked: controller.startBreak()
}
GhostButton {
text: controller.timerEnabled ? "Turn off timer" : "Turn on timer"
onClicked: controller.setTimerEnabled(!controller.timerEnabled)
}
}
GridLayout {
Layout.fillWidth: true
Layout.margins: 20
columns: page.width > 900 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
columns: page.width > 940 ? 2 : 1
columnSpacing: 22
rowSpacing: 22
// ---- dial + controls --------------------------------------------
Card {
Panel {
Layout.fillWidth: true
Layout.preferredWidth: 1
padding: 28
TimerDial {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Math.min(300, page.availableWidth * 0.7)
Layout.preferredWidth: Math.min(290, page.availableWidth * 0.62)
Layout.preferredHeight: Layout.preferredWidth
progress: controller.progress
clockText: controller.clockText
label: controller.running ? controller.kindLabel : "Next: " + controller.nextKindLabel
sublabel: controller.running
? (controller.taskTitle !== "" ? controller.taskTitle : " ")
: "Press start when you are ready"
accent: window.kindColor(controller.kind)
progress: controller.timerEnabled ? controller.progress : 0
clockText: controller.timerEnabled ? controller.clockText : "—"
minutes: Math.max(1, Math.round(controller.plannedSeconds / 60))
label: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Next · " + controller.nextKindLabel)
sublabel: !controller.timerEnabled
? "Timer disabled"
: (controller.taskTitle !== ""
? controller.taskTitle
: (controller.running ? "" : "Pick something below, or just begin"))
accent: controller.timerEnabled ? Theme.kindColor(controller.kind) : Theme.muted
paused: controller.status === "paused"
idle: !controller.timerEnabled || !controller.running
}
Flow {
RowLayout {
visible: controller.timerEnabled && controller.running
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 8
spacing: 8
Layout.topMargin: 14
spacing: 6
PrimaryButton {
visible: !controller.running
text: "▶ Start focus"
onClicked: controller.startWork()
}
Button {
visible: !controller.running
text: "☕ " + controller.nextKindLabel
onClicked: controller.startBreak()
}
Button {
visible: controller.running && controller.status !== "paused"
text: "⏸ Pause"
onClicked: controller.pause()
}
PrimaryButton {
visible: controller.running && controller.status === "paused"
text: "▶ Resume"
onClicked: controller.resume()
}
PrimaryButton {
visible: controller.running
text: "✓ Done"
onClicked: controller.complete()
}
Button {
visible: controller.running
text: "+5"
GhostButton {
text: "+5 min"
onClicked: controller.extend(5)
ToolTip.visible: hovered
ToolTip.text: "Add five minutes"
}
Button {
visible: controller.running
text: "🔔"
GhostButton {
text: "Interruption"
onClicked: controller.interrupt()
ToolTip.visible: hovered
ToolTip.text: "Count an interruption without stopping"
ToolTip.text: "Count a distraction without stopping the clock"
}
Button {
visible: controller.running
text: "⏹"
flat: true
Material.foreground: "#e34948"
GhostButton {
text: "Abandon"
onClicked: abandonDialog.open()
ToolTip.visible: hovered
ToolTip.text: "Abandon this interval"
}
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: controller.interruptions > 0
text: controller.interruptions + " interruption(s) this interval"
visible: controller.timerEnabled && controller.interruptions > 0
text: controller.interruptions + " interruption(s) so far"
color: Theme.faint
font.pixelSize: 11
opacity: 0.6
font.family: Theme.sansFamily
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
visible: controller.timerEnabled
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
color: Theme.faint
font.pixelSize: 10
opacity: 0.45
font.family: Theme.sansFamily
}
}
// ---- today + task picker ----------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
spacing: 16
spacing: 20
Card {
title: "Today"
subtitle: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
+ " before a long break"
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "Today" }
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: controller.completedToday
font.pixelSize: 34
font.weight: Font.Light
color: Theme.ink
font.pixelSize: 40
font.family: Theme.displayFamily
font.letterSpacing: -1
}
Label {
text: "/ " + controller.goal + " pomodoros"
font.pixelSize: 16
opacity: 0.7
text: "of " + controller.goal + " pomodoros"
color: Theme.muted
font.pixelSize: 14
font.family: Theme.sansFamily
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 6
Layout.bottomMargin: 7
}
Item { Layout.fillWidth: true }
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: Math.max(controller.goal, 1)
value: Math.min(controller.completedToday, controller.goal)
}
Flow {
@@ -162,27 +179,45 @@ ScrollView {
model: Math.max(controller.goal, controller.completedToday)
delegate: Rectangle {
required property int index
width: 14; height: 14; radius: 7
color: index < controller.completedToday ? window.workColor : "transparent"
width: 11; height: 11; radius: 5.5
color: index < controller.completedToday ? Theme.work : "transparent"
border.width: index < controller.completedToday ? 0 : 1
border.color: Material.hintTextColor
border.color: Theme.rule
}
}
}
Label {
Layout.fillWidth: true
text: controller.stats.today
? controller.stats.today.focus_minutes + "m focused · "
+ controller.stats.today.break_minutes + "m rested"
+ (controller.stats.today.longest_stretch_minutes > 0
? " · longest run " + controller.stats.today.longest_stretch_minutes + "m" : "")
: ""
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
}
}
Card {
title: "What are you working on?"
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "Working on" }
ComboBox {
id: taskPicker
Layout.fillWidth: true
font.pixelSize: 13
font.family: Theme.sansFamily
model: {
var names = ["No task"]
var names = ["Nothing in particular"]
for (var i = 0; i < page.openTasks.length; i++)
names.push((page.openTasks[i].pinned ? "📌 " : "")
names.push((page.openTasks[i].pinned ? "★ " : "")
+ page.openTasks[i].title
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
return names
}
onActivated: function (index) {
@@ -190,65 +225,76 @@ ScrollView {
}
}
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.pixelSize: 12
opacity: 0.7
text: controller.taskTitle !== ""
? "This interval is tracked against “" + controller.taskTitle + "”."
: "Pick a task so the daily report knows where the time went."
}
RowLayout {
Layout.fillWidth: true
spacing: 8
spacing: 14
Label {
text: "🔴 " + (controller.taskCounts.overdue || 0) + " overdue"
font.pixelSize: 12
text: (controller.taskCounts.overdue || 0) + " overdue"
color: (controller.taskCounts.overdue || 0) > 0 ? Theme.clay : Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
text: "🟡 " + (controller.taskCounts.today || 0) + " due today"
font.pixelSize: 12
text: (controller.taskCounts.today || 0) + " due today"
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
Item { Layout.fillWidth: true }
Button { text: "Tasks"; flat: true; onClicked: window.page = 1 }
GhostButton { text: "All tasks"; onClicked: window.page = 1 }
}
}
}
}
// ---- the day's numbers ------------------------------------------------
GridLayout {
Layout.fillWidth: true
Layout.leftMargin: 20
Layout.rightMargin: 20
Layout.bottomMargin: 20
columns: page.width > 760 ? 4 : 2
columnSpacing: 12
rowSpacing: 12
// ---- the day's numbers -------------------------------------------------
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
StatTile {
label: "Focus today"
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—"
caption: controller.completedToday + " pomodoros"
}
StatTile {
label: "Longest stretch"
value: controller.stats.today ? controller.stats.today.longest_stretch_minutes + "m" : "—"
caption: "unbroken focus"
}
StatTile {
label: "Streak"
value: controller.stats.streak ? controller.stats.streak.current + "d" : "—"
caption: controller.stats.streak ? "best " + controller.stats.streak.best : ""
}
StatTile {
label: "Blocked"
value: controller.stats.today ? controller.stats.today.blocked_attempts : "—"
caption: "temptations turned away"
Repeater {
model: [
{ label: "Focus today",
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—",
caption: controller.completedToday + " intervals" },
{ label: "Interruptions",
value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",
caption: controller.stats.today && controller.stats.today.abandoned > 0
? controller.stats.today.abandoned + " abandoned" : "none dropped" },
{ label: "Closed today",
value: controller.stats.today ? "" + controller.stats.today.tasks_completed_count : "—",
caption: "in your own tools" },
{ label: "Blocked",
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "—",
caption: "temptations turned away" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.fillHeight: true
Layout.preferredHeight: 70
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
caption: modelData.caption
}
}
}
}
}
Item { Layout.preferredHeight: 10 }
}
Dialog {
@@ -261,9 +307,12 @@ ScrollView {
ColumnLayout {
anchors.fill: parent
spacing: 10
spacing: 12
Label {
text: controller.clockText + " left. The interval is recorded as abandoned."
text: controller.clockText + " left. It will be recorded as abandoned."
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
@@ -272,10 +321,8 @@ ScrollView {
Layout.fillWidth: true
placeholderText: "What happened? (optional)"
}
Button {
PrimaryButton {
text: "Abandon"
Material.background: "#e34948"
Material.foreground: "white"
Layout.alignment: Qt.AlignRight
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
}
@@ -1,27 +1,34 @@
// An inline warning strip — used for "the backend is gone" and "the helper is
// not installed", the two states where the app cannot do its job.
// An inline warning strip for the two states where the app cannot do its job:
// the backend is unreachable, or the privileged helper is missing.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import ".."
Rectangle {
id: banner
property string text: ""
property string actionText: ""
signal actionClicked()
Layout.fillWidth: true
implicitHeight: visible ? row.implicitHeight + 20 : 0
color: "#e34948"
opacity: 0.92
radius: Theme.radius
color: Qt.alpha(Theme.clay, 0.12)
border.width: 1
border.color: Qt.alpha(Theme.clay, 0.4)
RowLayout {
id: row
anchors.fill: parent
anchors.margins: 10
anchors.margins: 12
spacing: 12
Label {
text: banner.text
color: "white"
color: Theme.ink
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
@@ -29,7 +36,6 @@ Rectangle {
visible: banner.actionText !== ""
text: banner.actionText
flat: true
Material.foreground: "white"
onClicked: banner.actionClicked()
}
}
@@ -1,18 +1,21 @@
// A tiny bar chart: magnitude over time, one hue, baseline at zero.
//
// The ramp step says "did this day clear the goal" without adding a second
// colour, and every bar carries its own tooltip, so no bar needs a label.
// Pomodoros per day: magnitude over time, so bars, one hue, baseline at zero.
// The goal line is the only reference mark; days that cleared it take a darker
// step of the same ramp, which keeps "did I hit it" readable without a second
// colour.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import ".."
Item {
id: chart
property var days: [] // [{date, pomodoros, focus_minutes, goal_met}]
property var days: []
property int goal: 8
property int barHeight: 110
property int barHeight: 108
implicitHeight: barHeight + 18
Layout.fillWidth: true
implicitHeight: barHeight + 16
readonly property int maxValue: {
var top = goal
@@ -25,8 +28,7 @@ Item {
anchors.right: parent.right
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
height: 1
color: Material.hintTextColor
opacity: 0.45
color: Theme.rule
visible: chart.goal > 0
}
@@ -48,15 +50,16 @@ Item {
width: parent.width
height: Math.max(modelData.pomodoros > 0
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
radius: 4
color: modelData.pomodoros === 0 ? Material.dividerColor
: (modelData.goal_met ? "#256abf" : "#6da7ec")
radius: 3
color: modelData.pomodoros === 0
? Theme.rule
: (modelData.goal_met ? Theme.sequential[4] : Theme.sequential[2])
ToolTip.visible: hover.hovered
ToolTip.text: modelData.date + ": " + modelData.pomodoros
ToolTip.text: modelData.date + " · " + modelData.pomodoros
+ " pomodoros · " + modelData.focus_minutes + " min"
HoverHandler { id: hover }
Behavior on height { NumberAnimation { duration: 250 } }
Behavior on height { NumberAnimation { duration: 220 } }
}
}
}
@@ -68,12 +71,12 @@ Item {
anchors.right: parent.right
Label {
text: chart.days.length ? chart.days[0].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
}
Item { Layout.fillWidth: true }
Label {
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
}
}
}
@@ -1,51 +0,0 @@
// A Material surface with a title. Used everywhere instead of bare Panes so the
// pages share one radius, one padding and one elevation.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Pane {
id: card
property string title: ""
property string subtitle: ""
default property alias content: inner.data
Material.elevation: 1
Material.roundedScale: Material.LargeScale
padding: 18
Layout.fillWidth: true
background: Rectangle {
radius: 18
color: Material.dialogColor
border.width: 1
border.color: Material.dividerColor
}
ColumnLayout {
anchors.fill: parent
spacing: 10
ColumnLayout {
spacing: 2
visible: card.title !== ""
Layout.fillWidth: true
Label { text: card.title; font.pixelSize: 16; font.weight: Font.DemiBold }
Label {
text: card.subtitle
visible: card.subtitle !== ""
font.pixelSize: 12
opacity: 0.7
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
ColumnLayout {
id: inner
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 10
}
}
}
@@ -0,0 +1,29 @@
// The outlined button, for everything that is not the primary action.
import QtQuick
import QtQuick.Controls
import ".."
Button {
id: control
implicitHeight: 34
leftPadding: 14
rightPadding: 14
background: Rectangle {
radius: 8
color: control.hovered ? Qt.alpha(Theme.ember, 0.07) : "transparent"
border.width: 1
border.color: control.hovered ? Theme.ember : Theme.rule
Behavior on color { ColorAnimation { duration: 110 } }
}
contentItem: Label {
text: control.text
color: control.enabled ? Theme.ink : Theme.faint
font.pixelSize: 13
font.weight: Font.Medium
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
@@ -0,0 +1,55 @@
// Eyebrow, title, actions, rule — the same opening every page uses.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string eyebrow: ""
property string title: ""
property string caption: ""
default property alias actions: actionRow.data
Layout.fillWidth: true
spacing: 10
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
visible: eyebrow !== ""
text: eyebrow.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.3
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: title
color: Theme.ink
font.pixelSize: 27
font.family: Theme.displayFamily
font.letterSpacing: -0.6
}
Label {
visible: caption !== ""
text: caption
color: Theme.muted
font.pixelSize: 13
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
RowLayout { id: actionRow; spacing: 8; Layout.alignment: Qt.AlignRight | Qt.AlignVCenter }
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
}
@@ -0,0 +1,28 @@
// A surface with a hairline border. No shadow, no tint — the border does the
// separating, which is what keeps a page of panels calm.
import QtQuick
import QtQuick.Layouts
import ".."
Rectangle {
id: panel
default property alias content: inner.data
property int padding: Theme.pad
property alias spacing: inner.spacing
color: Theme.surface
radius: Theme.radius
border.width: 1
border.color: Theme.rule
implicitHeight: inner.implicitHeight + padding * 2
implicitWidth: inner.implicitWidth + padding * 2
Layout.fillWidth: true
ColumnLayout {
id: inner
anchors.fill: parent
anchors.margins: panel.padding
spacing: 10
}
}
@@ -1,34 +1,28 @@
// The filled Material button, painted explicitly.
//
// Relying on `highlighted: true` leaves the fill to the style, and under some
// renderers that fill never appears — white text on a white surface. The
// primary action on a page is exactly the thing that must never be invisible,
// so it draws its own pill.
// The filled button: one per view, painted explicitly so no style can lose it.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import ".."
Button {
id: control
Material.foreground: "white"
font.weight: Font.Medium
leftPadding: 20
rightPadding: 20
implicitHeight: 40
implicitHeight: 34
leftPadding: 16
rightPadding: 16
background: Rectangle {
radius: height / 2
color: control.enabled ? Material.accent : Material.buttonDisabledColor
radius: 8
color: control.enabled ? Theme.ember : Theme.rule
opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1)
Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on opacity { NumberAnimation { duration: 110 } }
}
contentItem: Label {
text: control.text
font: control.font
color: control.enabled ? "white" : Material.hintTextColor
color: control.enabled ? "white" : Theme.faint
font.pixelSize: 13
font.weight: Font.Medium
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
@@ -0,0 +1,31 @@
// An eyebrow label with a rule running to the right edge.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
RowLayout {
property string text: ""
property string trailing: ""
Layout.fillWidth: true
spacing: 10
Label {
text: parent.text.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.3
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
Label {
visible: parent.trailing !== ""
text: parent.trailing
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
@@ -1,6 +1,8 @@
// Transient messages. Material's own popup, anchored to the bottom.
// Transient messages, bottom centre, on an ink slab.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import ".."
Popup {
id: snackbar
@@ -13,19 +15,23 @@ Popup {
}
x: Math.round((parent.width - width) / 2)
y: parent.height - height - 72
width: Math.min(parent.width - 48, Math.max(260, label.implicitWidth + 48))
padding: 16
y: parent.height - height - 28
width: Math.min(parent.width - 48, Math.max(240, label.implicitWidth + 36))
padding: 14
modal: false
focus: false
closePolicy: Popup.NoAutoClose
Material.elevation: 6
Material.roundedScale: Material.SmallScale
Label {
background: Rectangle {
color: Theme.ink
radius: 10
}
contentItem: Label {
id: label
anchors.fill: parent
text: snackbar.message
color: Theme.paper
font.pixelSize: 13
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
}
@@ -0,0 +1,44 @@
// A number and its name. The figure is set in the display serif, which is what
// makes a screen of statistics read like a page rather than a readout.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string label: ""
property string value: "—"
property string caption: ""
property color accent: Theme.ink
property int size: 30
spacing: 2
Layout.fillWidth: true
Label {
text: label.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.2
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: value
color: accent
font.pixelSize: size
font.family: Theme.displayFamily
font.letterSpacing: -0.5
}
Label {
visible: caption !== ""
text: caption
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
Layout.fillWidth: true
elide: Text.ElideRight
wrapMode: Text.WordWrap
}
}
@@ -1,38 +0,0 @@
// One number, read at a glance. Not a chart.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Pane {
property string label: ""
property string value: "—"
property string caption: ""
property color accent: Material.foreground
Material.elevation: 1
Material.roundedScale: Material.LargeScale
padding: 16
Layout.fillWidth: true
background: Rectangle {
radius: 16
color: Material.dialogColor
border.width: 1
border.color: Material.dividerColor
}
ColumnLayout {
anchors.fill: parent
spacing: 2
Label { text: label.toUpperCase(); font.pixelSize: 10; font.letterSpacing: 1; opacity: 0.6 }
Label { text: value; font.pixelSize: 26; font.weight: Font.Light; color: accent }
Label {
text: caption
visible: caption !== ""
font.pixelSize: 11
opacity: 0.7
Layout.fillWidth: true
elide: Text.ElideRight
}
}
}
@@ -1,29 +0,0 @@
// The little "what is the timer doing" chip, shown in the rail and the toolbar.
import QtQuick
import QtQuick.Controls.Material
Rectangle {
property bool running: false
property bool paused: false
property bool connected: true
property string text: ""
property color accent: "#2a78d6"
implicitWidth: Math.max(78, label.implicitWidth + 28)
implicitHeight: 32
radius: 16
color: !connected ? "#e34948" : (running ? accent : "transparent")
opacity: paused ? 0.6 : 1
border.width: running || !connected ? 0 : 1
border.color: Material.hintTextColor
Label {
id: label
anchors.centerIn: parent
text: parent.text
font.pixelSize: 13
font.weight: Font.DemiBold
font.family: "Roboto Mono, monospace"
color: (parent.running || !parent.connected) ? "white" : Material.foreground
}
}
@@ -1,25 +1,32 @@
// The countdown ring, drawn with Canvas.
// The dial.
//
// A hero number inside a progress arc: the number is what you read, the arc is
// the glance. The arc colour is the only thing that says focus vs break.
// A hairline ring with one tick per minute inside it: the ticks light up as the
// minutes are spent, so the dial reads as "how much is left" at a glance and as
// "how long was this?" on a second look. The time is the hero, set in the
// display serif.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import ".."
Item {
id: dial
property real progress: 0 // 0..1 elapsed
property real progress: 0 // 0..1 elapsed
property string clockText: "00:00"
property string label: ""
property string sublabel: ""
property color accent: "#2a78d6"
property color accent: Theme.work
property bool paused: false
property bool idle: false
property int minutes: 25
implicitWidth: 260
implicitHeight: 260
implicitWidth: 280
implicitHeight: 280
onProgressChanged: canvas.requestPaint()
onAccentChanged: canvas.requestPaint()
onPausedChanged: canvas.requestPaint()
onIdleChanged: canvas.requestPaint()
Canvas {
id: canvas
@@ -29,59 +36,85 @@ Item {
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var centre = Math.min(width, height) / 2
var stroke = Math.max(10, centre * 0.11)
var radius = centre - stroke
ctx.lineCap = "round"
ctx.lineWidth = stroke
var c = Math.min(width, height) / 2
var radius = c - 26
// The ring.
ctx.lineWidth = 1.5
ctx.strokeStyle = Theme.rule
ctx.beginPath()
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.18)
ctx.arc(centre, centre, radius, 0, Math.PI * 2)
ctx.arc(c, c, radius, 0, Math.PI * 2)
ctx.stroke()
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) {
// Minute ticks, inside the ring.
var count = dial.minutes <= 60 ? Math.max(1, dial.minutes) : Math.round(dial.minutes / 5)
var spent = Math.floor(count * Math.max(0, Math.min(1, dial.progress)))
for (var i = 0; i < count; i++) {
var angle = (i / count) * Math.PI * 2 - Math.PI / 2
var major = i % 5 === 0
var outer = radius - 7
var inner = outer - (major ? 9 : 5)
ctx.beginPath()
ctx.globalAlpha = dial.paused ? 0.45 : 1
ctx.strokeStyle = dial.accent
ctx.arc(centre, centre, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
ctx.lineWidth = major ? 1.4 : 1
ctx.strokeStyle = (i < spent && !dial.idle) ? dial.accent : Theme.rule
ctx.moveTo(c + inner * Math.cos(angle), c + inner * Math.sin(angle))
ctx.lineTo(c + outer * Math.cos(angle), c + outer * Math.sin(angle))
ctx.stroke()
}
// The remaining arc.
if (!dial.idle) {
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) {
ctx.beginPath()
ctx.lineWidth = 2.5
ctx.lineCap = "round"
ctx.globalAlpha = dial.paused ? 0.4 : 1
ctx.strokeStyle = dial.accent
ctx.arc(c, c, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
ctx.stroke()
}
}
}
}
Column {
anchors.centerIn: parent
spacing: 4
width: parent.width * 0.72
spacing: 6
width: parent.width * 0.7
Label {
width: parent.width
text: dial.label.toUpperCase()
font.pixelSize: 11
font.letterSpacing: 1.5
opacity: 0.65
color: dial.idle ? Theme.muted : dial.accent
font.pixelSize: 10
font.letterSpacing: 1.4
font.weight: Font.DemiBold
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.clockText
font.pixelSize: Math.round(dial.width * 0.2)
font.weight: Font.Light
font.family: "Roboto Mono, monospace"
opacity: dial.paused ? 0.6 : 1
color: Theme.ink
opacity: dial.paused ? 0.55 : 1
font.pixelSize: Math.round(dial.width * 0.21)
font.family: Theme.displayFamily
font.letterSpacing: -1.5
horizontalAlignment: Text.AlignHCenter
Behavior on opacity { NumberAnimation { duration: 180 } }
}
Label {
width: parent.width
text: dial.sublabel
visible: dial.sublabel !== ""
font.pixelSize: 13
opacity: 0.75
elide: Text.ElideRight
text: dial.sublabel
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WordWrap
}
}
}
+2
View File
@@ -0,0 +1,2 @@
# Makes `Theme` a singleton for every file that does `import "."`.
singleton Theme 1.0 Theme.qml
+33 -8
View File
@@ -82,6 +82,9 @@ class Tray(QObject):
controller.stateChanged.connect(self.refresh)
controller.connectionChanged.connect(self.refresh)
controller.blockerChanged.connect(self.refresh)
controller.miniChanged.connect(self.refresh)
controller.settingsChanged.connect(self.refresh)
controller.configChanged.connect(self.refresh)
self.refresh()
def show(self) -> None:
@@ -140,6 +143,18 @@ class Tray(QObject):
self._menu.addAction(refresh)
self._menu.addSeparator()
self._action_mini = QAction("Mini timer", self._menu)
self._action_mini.setCheckable(True)
self._action_mini.triggered.connect(lambda: controller.toggleMini())
self._menu.addAction(self._action_mini)
self._action_view_mode = QAction("View-only mode (timer off)", self._menu)
self._action_view_mode.setCheckable(True)
self._action_view_mode.triggered.connect(
lambda: controller.setTimerEnabled(not controller.timerEnabled)
)
self._menu.addAction(self._action_view_mode)
show = QAction("Open Kadence", self._menu)
show.triggered.connect(self.showRequested.emit)
self._menu.addAction(show)
@@ -157,22 +172,32 @@ class Tray(QObject):
controller = self._controller
running = controller.running
paused = controller.status == "paused"
timer_enabled = controller.timerEnabled
self._icon.setIcon(
render_icon(controller.progress, controller.kind, running, controller.connected, paused)
render_icon(
controller.progress,
controller.kind,
running if timer_enabled else False,
controller.connected,
paused if timer_enabled else False,
)
)
status = controller.statusLine()
blocker = " · 🛡 blocking" if controller.blockerActive else ""
self._icon.setToolTip(f"{status}{blocker}")
self._action_status.setText(status)
self._action_start.setVisible(not running)
self._action_break.setVisible(not running)
self._action_pause.setVisible(running and not paused)
self._action_resume.setVisible(running and paused)
self._action_done.setVisible(running)
self._action_extend.setVisible(running)
self._action_abandon.setVisible(running)
self._action_start.setVisible(timer_enabled and not running)
self._action_break.setVisible(timer_enabled and not running)
self._action_pause.setVisible(timer_enabled and running and not paused)
self._action_resume.setVisible(timer_enabled and running and paused)
self._action_done.setVisible(timer_enabled and running)
self._action_extend.setVisible(timer_enabled and running)
self._action_abandon.setVisible(timer_enabled and running)
self._action_mini.setVisible(timer_enabled)
self._action_mini.setChecked(controller.miniVisible)
self._action_view_mode.setChecked(not timer_enabled)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
# Left click toggles the window; middle click toggles the timer, which is
+2 -2
View File
@@ -129,7 +129,7 @@ class ApiWorker(QObject):
"extend": lambda: self._api.extend(int(args.get("minutes", 5))),
"interrupt": self._api.interrupt,
"attach_task": lambda: self._api.attach_task(args.get("task_id")),
"task_done": lambda: self._api.task_done(int(args["task_id"]), bool(args.get("done", True))),
"patch_task": lambda: self._api.patch_task(int(args["task_id"]), dict(args.get("patch", {}))),
"create_task": lambda: self._api.create_task(str(args["title"]), args.get("due_date")),
"refresh_sources": self._api.refresh_tasks,
"send_report": self._api.send_report,
@@ -156,7 +156,7 @@ class ApiWorker(QObject):
return
self.commandDone.emit(name, result if isinstance(result, dict) else {})
if name in ("refresh_sources", "task_done", "create_task"):
if name in ("refresh_sources", "patch_task", "create_task"):
self.refresh_tasks()
if name == "patch_settings":
self.refresh_settings()