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:
84 files changed
+4501
-2682
No files matched your search
+71
-3
@@ -7,7 +7,9 @@ system package `python-pyqt6`; everything else — HTTP, config, process scannin
|
||||
is the Python standard library.
|
||||
|
||||
It is also the piece that *enforces* things, because it is the only part running
|
||||
on your machine.
|
||||
on your machine. The interface is the same warm, hairline-ruled design as the
|
||||
web app — the `Theme` singleton in `qml/` holds the same tokens as the web
|
||||
app's `tokens.ts`.
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed python-pyqt6 qt6-declarative # Arch
|
||||
@@ -24,6 +26,9 @@ Run it straight from the checkout instead: `python3 -m kadence_desktop`.
|
||||
|
||||
| Feature | How |
|
||||
| --- | --- |
|
||||
| **A floating mini timer** | A small pill with the countdown, the task you are on, and the controls — *Pause / Resume / Finish / +5*, or *Start focus / Break* when idle — all on their own rows and all visible without hovering. Open it with **Ctrl+M**, the tray menu, or `kadence-desktop --mini`; it also appears by itself while an interval runs and the main window is away. Dragging moves it (through the compositor, so it works on Wayland) and double-clicking opens the main window. It is **not** a `Qt.Tool` window — Qt hides those whenever the application is deactivated, which made it vanish on every click in another app — and it refuses keyboard focus instead. |
|
||||
| **Where it appears** | Settings → *Mini timer appears*: any of the four corners, the top/bottom edge centres, the centre of the screen, or *where I last dragged it*, with a configurable gap from the edges. It is placed on the screen the app is on, inside the usable area (panels excluded), and it returns to that spot every time it comes up. On X11 the app positions the window; on Wayland — where a client may not — the point is written into the same KWin rule. |
|
||||
| **Above everything, on Wayland** | Wayland gives an application no way to ask for "always on top", so on KDE the app installs a **KWin window rule** (keep above, skip task bar, skip switcher, skip pager) and asks KWin to re-read its config over D-Bus. The rule matches the window title as a **substring**: Qt appends the application's display name, so KWin sees `Kadence mini — Kadence`, and an exact match silently never fires. The app backs the file up first, leaves your other rules alone, re-writes the rule when an upgrade changes it, and *Settings → Keep the mini timer above everything* removes it. Verify with `busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting loadScript …` or simply: the mini timer should stay on top of a maximised window. |
|
||||
| **Tray icon that *is* the timer** | `QSystemTrayIcon` (a StatusNotifierItem on KDE). The icon is painted at runtime: a ring that drains as the interval runs, tinted by focus / break / idle, with a pause glyph when held. Middle-click toggles the timer. |
|
||||
| **Real notifications with buttons** | `org.freedesktop.Notifications` over D-Bus, with actions: *Start break* · *Another pomodoro* · *+5 min*. Replaced in place, so a day of pomodoros never stacks a column of toasts. |
|
||||
| **Scriptable over D-Bus** | `org.kadence.Desktop` exports `StartWork`, `Toggle`, `Pause`, `Complete`, `Extend`, `Interrupt`, `Status`, `SendReport`, `Raise`. Bind any of them to a global shortcut in System Settings — no key-grabbing hacks. |
|
||||
@@ -52,6 +57,8 @@ edit them and a second machine obeys the same list. This app enforces them:
|
||||
* **Applications** — `/proc` is scanned every few seconds for your own processes
|
||||
matching the list; they are terminated (SIGTERM, then SIGKILL) or merely
|
||||
reported, and every attempt is sent to the backend for the evening report.
|
||||
List entries may be globs — `steam*`, `*discord*`, `telegram-?` — matched
|
||||
against both the process name and its command line.
|
||||
* Everything is released when the interval ends — and on exit, including a crash,
|
||||
because `shutdown()` clears the block and the inhibits.
|
||||
|
||||
@@ -76,6 +83,45 @@ sudo ./helper/install.sh --uninstall # unblocks first, then removes itself
|
||||
|
||||
---
|
||||
|
||||
## Application menu and rofi
|
||||
|
||||
```bash
|
||||
./packaging/install-desktop.sh
|
||||
```
|
||||
|
||||
That writes `~/.local/share/applications/kadence.desktop`, the icon, and a
|
||||
`~/.local/bin/kadence-desktop` launcher, then refreshes the caches each
|
||||
launcher keeps (`update-desktop-database`, `gtk-update-icon-cache`,
|
||||
`kbuildsycoca6` for KDE's menu).
|
||||
|
||||
Two details that decide whether an entry shows up at all:
|
||||
|
||||
* **`Exec`/`TryExec` are written as absolute paths.** Launchers run with the
|
||||
*session* PATH, which usually does not include `~/.local/bin`; an entry whose
|
||||
`TryExec` cannot be resolved is silently hidden. Absolute paths cannot be.
|
||||
* **One main category.** `Categories=Office;ProjectManagement;` — listing both
|
||||
`Utility` and `Office` makes the app appear twice in some menus.
|
||||
|
||||
It then shows up in KDE's launcher under *Office* and in `rofi -show drun`
|
||||
(search for *pomodoro*, *timer* or *focus*), with the right-click actions
|
||||
*Start a pomodoro* / *Start a break* / *Start / pause* / *Send today's report*.
|
||||
|
||||
If your rofi runs in **`run` mode** rather than `drun`, it lists binaries from
|
||||
`PATH`, so `~/.local/bin` has to be on the session PATH:
|
||||
|
||||
```bash
|
||||
printf 'PATH=$HOME/.local/bin:$PATH\n' > ~/.config/environment.d/10-local-bin.conf
|
||||
# for the session you are already in:
|
||||
systemctl --user set-environment PATH="$HOME/.local/bin:$PATH"
|
||||
dbus-update-activation-environment --systemd PATH
|
||||
```
|
||||
|
||||
To see the entry (and its actions) in rofi with its icon instead, add `drun`:
|
||||
`modes: "drun,run";` in `~/.config/rofi/config.rasi`, plus
|
||||
`drun-show-actions: true;` if you want the actions listed too.
|
||||
|
||||
---
|
||||
|
||||
## Global shortcuts
|
||||
|
||||
System Settings → Shortcuts → Custom Shortcuts → New → D-Bus command:
|
||||
@@ -94,13 +140,35 @@ kadence-desktop --toggle # start → pause → resume
|
||||
kadence-desktop --start # start a focus interval
|
||||
kadence-desktop --break
|
||||
kadence-desktop --complete
|
||||
kadence-desktop --mini # show/hide the floating mini timer
|
||||
kadence-desktop --status # "Focus 18:42 · Write the docs · 3/8 today"
|
||||
kadence-desktop --report # send today's report to Telegram
|
||||
```
|
||||
|
||||
On Qt 6 the command-line D-Bus tool is **`qdbus6`** (plain `qdbus` is the Qt 5
|
||||
one and may not be installed):
|
||||
|
||||
```bash
|
||||
qdbus6 org.kadence.Desktop /Timer org.kadence.Timer.Status
|
||||
busctl --user introspect org.kadence.Desktop /Timer # lists every verb
|
||||
```
|
||||
|
||||
The verbs live on a `QDBusAbstractAdaptor` carrying
|
||||
`pyqtClassInfo("D-Bus Interface", "org.kadence.Timer")`. That detail matters:
|
||||
export a plain `QObject` instead and Qt names the interface after the class
|
||||
(`local.kadence.TimerDBusService`), so every documented command fails with
|
||||
*"No such interface"*.
|
||||
|
||||
In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish ·
|
||||
**Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+1…5** pages ·
|
||||
**Ctrl+W** hide to tray.
|
||||
**Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+M** mini timer ·
|
||||
**Ctrl+1…5** pages · **Ctrl+W** hide to tray.
|
||||
|
||||
> **Wayland note.** The mini timer's placement and "always on top" are a KWin rule written to
|
||||
> `~/.config/kwinrulesrc` (backed up once as `kwinrulesrc.kadence-backup`); you
|
||||
> can see it in System Settings → Window Management → Window Rules as *Kadence
|
||||
> mini timer*, and the switch in Settings removes it. Its position is remembered
|
||||
> on X11; on Wayland the compositor places it and dragging goes through
|
||||
> `startSystemMove()`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Makes `Theme` a singleton for every file that does `import "."`.
|
||||
singleton Theme 1.0 Theme.qml
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -13,7 +13,8 @@ BIN="${HOME}/.local/bin"
|
||||
|
||||
mkdir -p "$APPS" "$ICONS" "$BIN"
|
||||
|
||||
# A launcher that works whether or not the package is pip-installed.
|
||||
# A launcher that works whether or not the package is pip-installed. The
|
||||
# desktop entry points at this absolute path, so nothing depends on PATH.
|
||||
cat > "$BIN/kadence-desktop" <<LAUNCHER
|
||||
#!/usr/bin/env bash
|
||||
exec python3 -m kadence_desktop "\$@"
|
||||
@@ -25,11 +26,36 @@ SITE_PTH="$(python3 -c 'import site; print(site.getusersitepackages())')"
|
||||
mkdir -p "$SITE_PTH"
|
||||
echo "$(dirname "$HERE")" > "$SITE_PTH/kadence-desktop.pth"
|
||||
|
||||
install -m 0644 "$HERE/kadence.desktop" "$APPS/kadence.desktop"
|
||||
# Exec/TryExec are written as absolute paths on purpose.
|
||||
#
|
||||
# Application launchers (KDE's Kickoff, rofi -show drun, GNOME's overview) run
|
||||
# with the *session* PATH, which on most setups does not include ~/.local/bin —
|
||||
# and an entry whose TryExec cannot be resolved is silently hidden. An absolute
|
||||
# path cannot be hidden by a PATH that differs from your shell's.
|
||||
sed -e "s|^Exec=kadence-desktop|Exec=$BIN/kadence-desktop|" \
|
||||
-e "s|^TryExec=kadence-desktop|TryExec=$BIN/kadence-desktop|" \
|
||||
-e "s|^Exec=kadence-desktop |Exec=$BIN/kadence-desktop |" \
|
||||
"$HERE/kadence.desktop" > "$APPS/kadence.desktop"
|
||||
chmod 0644 "$APPS/kadence.desktop"
|
||||
install -m 0644 "$HERE/kadence.svg" "$ICONS/kadence.svg"
|
||||
|
||||
# Each launcher keeps its own cache; refresh all of the ones that are present.
|
||||
command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" || true
|
||||
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || true
|
||||
command -v gtk-update-icon-cache >/dev/null && \
|
||||
gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || true
|
||||
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 --noincremental >/dev/null 2>&1 || true
|
||||
# rofi rebuilds its drun cache when the directory changes; nudge the timestamp.
|
||||
touch "$APPS" 2>/dev/null || true
|
||||
|
||||
echo "Installed. Launch it from the menu, or run: kadence-desktop"
|
||||
echo "Make sure ~/.local/bin is on your PATH."
|
||||
if command -v desktop-file-validate >/dev/null; then
|
||||
desktop-file-validate "$APPS/kadence.desktop" && echo "Desktop entry validates."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Installed:"
|
||||
echo " launcher $BIN/kadence-desktop"
|
||||
echo " menu entry $APPS/kadence.desktop"
|
||||
echo " icon $ICONS/kadence.svg"
|
||||
echo
|
||||
echo "It should now appear in the application menu and in \`rofi -show drun\`"
|
||||
echo "as \"Kadence\" (search: pomodoro, timer, focus)."
|
||||
@@ -7,7 +7,7 @@ Exec=kadence-desktop
|
||||
TryExec=kadence-desktop
|
||||
Icon=kadence
|
||||
Terminal=false
|
||||
Categories=Utility;Office;ProjectManagement;
|
||||
Categories=Office;ProjectManagement;
|
||||
Keywords=pomodoro;timer;focus;tasks;productivity;
|
||||
StartupNotify=true
|
||||
StartupWMClass=kadence
|
||||
|
||||
@@ -8,6 +8,9 @@ requires-python = ">=3.11"
|
||||
# so the app starts with the system Python and no virtualenv.
|
||||
dependencies = ["PyQt6>=6.6"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0"]
|
||||
|
||||
[project.scripts]
|
||||
kadence-desktop = "kadence_desktop.__main__:main"
|
||||
|
||||
@@ -17,3 +20,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["kadence_desktop"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""The mini timer's corner geometry.
|
||||
|
||||
A pure function, so it is worth pinning down: this is the sort of arithmetic
|
||||
that silently puts a window half off the screen on somebody else's monitor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from kadence_desktop.integrations.placement import CORNER_LABELS, CORNERS, Rect, point_for
|
||||
|
||||
SCREEN = Rect(0, 0, 1536, 960)
|
||||
SIZE = (252, 152)
|
||||
|
||||
|
||||
def test_each_corner_lands_where_its_name_says():
|
||||
assert point_for("top-left", SCREEN, SIZE, 24) == (24, 24)
|
||||
assert point_for("top-right", SCREEN, SIZE, 24) == (1260, 24)
|
||||
assert point_for("bottom-left", SCREEN, SIZE, 24) == (24, 784)
|
||||
assert point_for("bottom-right", SCREEN, SIZE, 24) == (1260, 784)
|
||||
|
||||
|
||||
def test_edge_centres_and_centre_are_centred():
|
||||
centre_x = (1536 - 252) // 2
|
||||
assert point_for("top-centre", SCREEN, SIZE, 24) == (centre_x, 24)
|
||||
assert point_for("bottom-centre", SCREEN, SIZE, 24) == (centre_x, 784)
|
||||
assert point_for("centre", SCREEN, SIZE, 24) == (centre_x, (960 - 152) // 2)
|
||||
|
||||
|
||||
def test_a_margin_of_zero_hugs_the_edge():
|
||||
assert point_for("bottom-right", SCREEN, SIZE, 0) == (1536 - 252, 960 - 152)
|
||||
|
||||
|
||||
def test_the_screen_offset_is_respected_on_a_second_monitor():
|
||||
"""A monitor to the right of the primary one starts at a non-zero x."""
|
||||
second = Rect(1536, 0, 1920, 1080)
|
||||
assert point_for("top-right", second, SIZE, 24) == (1536 + 1920 - 252 - 24, 24)
|
||||
assert point_for("bottom-left", second, SIZE, 24) == (1536 + 24, 1080 - 152 - 24)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corner", [c for c in CORNERS if c != "remember"])
|
||||
def test_the_window_never_leaves_the_screen(corner):
|
||||
"""Even on a screen smaller than the window, or with a silly margin."""
|
||||
tiny = Rect(0, 0, 200, 120)
|
||||
x, y = point_for(corner, tiny, SIZE, 400)
|
||||
assert x >= tiny.x and y >= tiny.y
|
||||
assert x <= tiny.x + max(tiny.width - SIZE[0], 0)
|
||||
assert y <= tiny.y + max(tiny.height - SIZE[1], 0)
|
||||
|
||||
|
||||
def test_every_corner_has_a_label_for_the_settings_page():
|
||||
assert set(CORNER_LABELS) == set(CORNERS)
|
||||
Reference in new issue
Block a user