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
@@ -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)
|
||||
Reference in new issue
Block a user