From bd64b27109e422acbd50c7612de59117d15aaee5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 17 Oct 2026 13:47:26 +0200 Subject: [PATCH] sickdays 0.3.0: desktop sick-day picker and embeddable KeepPanel `sickdays gui` opens a Tk window modelled on the phone's Sick days screen: a Monday-first month calendar (cat-2 fill, a dot for kept lockers, past days faded and inert), a five-switch panel for the clicked day, and the "Marked sick" list with "Kept: ..." lines. A free day is refused with the phone's wording. Each write starts sync_quietly() on a non-daemon thread. The window class is "sickdays" and Escape closes it. sickdays.gui.KeepPanel is the panel on its own, a plain tk.Frame with no root, mainloop, grab or wm_* call, so screen-locker can embed it under its lock-screen grab. `import sickdays` and the rest of the CLI never import tkinter: the CLI loads the gui module by name only for `gui`, and a subprocess test checks this. The tests drive real Tk on a withdrawn root, and CI now runs them under xvfb-run, as gatelock's CI does. Adds a sickdays.desktop launcher, which install.sh installs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp --- .github/workflows/sickdays-tests.yml | 11 +- sickdays/README.md | 24 ++- sickdays/install.sh | 14 ++ sickdays/pyproject.toml | 2 +- sickdays/sickdays.desktop | 9 + sickdays/sickdays/_cli.py | 13 ++ sickdays/sickdays/gui/__init__.py | 24 +++ sickdays/sickdays/gui/_calendar.py | 130 +++++++++++++ sickdays/sickdays/gui/_panel.py | 181 ++++++++++++++++++ sickdays/sickdays/gui/_text.py | 122 ++++++++++++ sickdays/sickdays/gui/_tokens.py | 70 +++++++ sickdays/sickdays/gui/_widgets.py | 125 +++++++++++++ sickdays/sickdays/gui/_window.py | 147 +++++++++++++++ sickdays/sickdays/tests/conftest.py | 33 ++++ sickdays/sickdays/tests/test_cli.py | 28 +++ sickdays/sickdays/tests/test_gui_panel.py | 205 +++++++++++++++++++++ sickdays/sickdays/tests/test_gui_text.py | 85 +++++++++ sickdays/sickdays/tests/test_gui_window.py | 167 +++++++++++++++++ sickdays/sickdays/tests/test_paths.py | 22 +++ 19 files changed, 1406 insertions(+), 6 deletions(-) create mode 100644 sickdays/sickdays.desktop create mode 100644 sickdays/sickdays/gui/__init__.py create mode 100644 sickdays/sickdays/gui/_calendar.py create mode 100644 sickdays/sickdays/gui/_panel.py create mode 100644 sickdays/sickdays/gui/_text.py create mode 100644 sickdays/sickdays/gui/_tokens.py create mode 100644 sickdays/sickdays/gui/_widgets.py create mode 100644 sickdays/sickdays/gui/_window.py create mode 100644 sickdays/sickdays/tests/test_gui_panel.py create mode 100644 sickdays/sickdays/tests/test_gui_text.py create mode 100644 sickdays/sickdays/tests/test_gui_window.py diff --git a/.github/workflows/sickdays-tests.yml b/.github/workflows/sickdays-tests.yml index 9cfcbf0..914c376 100644 --- a/.github/workflows/sickdays-tests.yml +++ b/.github/workflows/sickdays-tests.yml @@ -38,7 +38,12 @@ jobs: - name: Install dependencies run: pip install -r requirements.txt && pip install --no-deps -e . - # No display needed: sickdays draws nothing. It is policy, a JSON file - # and a CLI. + # The library draws nothing, but sickdays.gui (0.3.0) does, and its + # tests drive real Tk: option names and Checkbutton/variable wiring are + # Tk's own behaviour. Headless they skip, taking coverage with them -- + # so the runner gets a virtual display, as gatelock's does. + - name: Install a virtual X display + run: sudo apt-get update && sudo apt-get install -y xvfb + - name: Run tests with coverage - run: python -m pytest -v + run: xvfb-run -a -s "-screen 0 1600x1200x24" python -m pytest -v diff --git a/sickdays/README.md b/sickdays/README.md index 827be8c..2d9abc8 100644 --- a/sickdays/README.md +++ b/sickdays/README.md @@ -67,6 +67,26 @@ refused (past day, already sick, not sick, a free day, sync unavailable), `2` input not understood (a bad date, or a locker id not in `LOCKERS` -- `--keep`, `keep` and `--locker` only accept the five ids). +## The picker window (0.3.0) + +`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on +Tk -- calendar, a five-switch panel per day, the days ahead. Past days are +inert; a free day is refused; each write starts `sync_quietly()` on a +non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed +just the panel (`import sickdays` never imports tkinter): + +```python +from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call +KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack() +``` + +Normal day: five switches, all off (off = stands down), "Mark sick"; sick +day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show +why and offer no write; a refused write shows in the panel, never raised. +`on_saved(SickDay | None)` fires after the write and sync start: the entry on +Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None` +on an unchanged Save (nothing written). The panel never destroys itself. + ## API | call | does | @@ -216,7 +236,7 @@ Don't hand-roll this on Dart: use `crdt_sync_dart` (`syncLog`, `logToJson` ## Install ```console -$ ./install.sh # own venv (crdt-sync + freedays + sickdays), CLI on PATH, 15-min sync timer +$ ./install.sh # own venv (crdt-sync + freedays + sickdays), CLI on PATH, launcher, 15-min sync timer $ ./install.sh --no-timer ``` @@ -225,6 +245,6 @@ $ ./install.sh --no-timer ```console $ pip install ../crdt-sync && pip install --no-deps ../freedays $ pip install -r requirements.txt && pip install --no-deps -e . -$ python -m pytest # 100% branch coverage is enforced +$ xvfb-run -a python -m pytest # 100% branch coverage; the GUI tests need a display $ pre-commit run --config sickdays/.pre-commit-config.yaml --all-files # from the repo root ``` diff --git a/sickdays/install.sh b/sickdays/install.sh index 096c76c..a1a9b7e 100755 --- a/sickdays/install.sh +++ b/sickdays/install.sh @@ -20,6 +20,7 @@ readonly STATE_DIR="${HOME}/.local/share/sickdays" readonly VENV_DIR="${STATE_DIR}/venv" readonly BIN_DIR="${HOME}/.local/bin" readonly UNIT_DIR="${HOME}/.config/systemd/user" +readonly APPS_DIR="${HOME}/.local/share/applications" readonly SYSTEM_PYTHON="/usr/bin/python3" log() { printf 'install: %s\n' "$1" >&2; } @@ -57,6 +58,16 @@ link_cli() { ln -sf "$VENV_DIR/bin/sickdays" "$BIN_DIR/sickdays" } +install_launcher() { + # Exec is the bare command, so the launcher follows the $BIN_DIR symlink. + log "installing the 'Sick days' launcher into $APPS_DIR" + mkdir -p "$APPS_DIR" + install -m 644 "$REPO_DIR/sickdays.desktop" "$APPS_DIR/" + if command -v update-desktop-database >/dev/null; then + update-desktop-database "$APPS_DIR" || log "NOTE: update-desktop-database failed" + fi +} + install_timer() { log "installing the sync timer" mkdir -p "$UNIT_DIR" @@ -73,6 +84,8 @@ verify() { "$BIN_DIR/sickdays" list >/dev/null || fail "the sickdays CLI does not run" "$VENV_DIR/bin/python" -c "import freedays" \ || fail "freedays is missing from the venv; adding a sick day would refuse" + "$VENV_DIR/bin/python" -c "import tkinter" \ + || fail "the venv's python has no tkinter; 'sickdays gui' would not open" "$SYSTEM_PYTHON" -c "import sickdays" 2>/dev/null || { log "NOTE: the system python cannot import sickdays yet." log " The gate apps need it there; their own install.sh does that." @@ -86,6 +99,7 @@ verify() { main() { build_venv link_cli + install_launcher if [[ "$INSTALL_TIMER" -eq 1 ]]; then install_timer fi diff --git a/sickdays/pyproject.toml b/sickdays/pyproject.toml index fae7c55..a777569 100644 --- a/sickdays/pyproject.toml +++ b/sickdays/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sickdays" -version = "0.2.0" +version = "0.3.0" description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it" requires-python = ">=3.14" # Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in diff --git a/sickdays/sickdays.desktop b/sickdays/sickdays.desktop new file mode 100644 index 0000000..a20f9b8 --- /dev/null +++ b/sickdays/sickdays.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Sick days +Comment=Mark sick days and choose which lockers still apply +Exec=sickdays gui +Icon=face-sick +Terminal=false +Categories=Utility; +StartupWMClass=sickdays diff --git a/sickdays/sickdays/_cli.py b/sickdays/sickdays/_cli.py index 8634adc..c6d84ad 100644 --- a/sickdays/sickdays/_cli.py +++ b/sickdays/sickdays/_cli.py @@ -9,6 +9,10 @@ A sick day stands every locker down unless it *keeps* some (``--keep`` on ``add``, or ``keep`` afterwards). Locker ids are argparse ``choices``, so a typo exits 2 instead of silently keeping nothing. +``gui`` opens the Tk picker. It is imported only when asked for, so every +other subcommand -- and every gate importing the library -- stays free of +tkinter. + ``sync-quiet`` is what the timer runs: it reports success even with no network, because a unit that goes red whenever the laptop is offline is a unit you learn to ignore. @@ -17,6 +21,7 @@ unit you learn to ignore. from __future__ import annotations import argparse +import importlib import sys from typing import TYPE_CHECKING @@ -91,6 +96,7 @@ def _build_parser() -> argparse.ArgumentParser: metavar=_LOCKER_METAVAR, help="instead: exit 0 iff this locker stands down on the day, 1 if not", ) + sub.add_parser("gui", help="open the sick-day picker window") sub.add_parser("sync", help="pull and push the pool now") sub.add_parser( "sync-quiet", @@ -143,6 +149,12 @@ def _do_check(raw_day: str, locker: str | None) -> int: return _EXIT_OK if sick else _EXIT_REFUSED +def _do_gui() -> int: + # By name, at call time: the library and the rest of the CLI never load Tk. + gui = importlib.import_module("sickdays.gui") + return int(gui.run()) + + def _do_sync() -> int: sync() emit("pool synced") @@ -160,6 +172,7 @@ _HANDLERS: dict[str, Callable[[argparse.Namespace], int]] = { "remove": lambda args: _do_remove(args.day), "list": lambda _args: _do_list(), "check": lambda args: _do_check(args.day, args.locker), + "gui": lambda _args: _do_gui(), "sync": lambda _args: _do_sync(), "sync-quiet": lambda _args: _do_sync_quiet(), } diff --git a/sickdays/sickdays/gui/__init__.py b/sickdays/sickdays/gui/__init__.py new file mode 100644 index 0000000..445d1e6 --- /dev/null +++ b/sickdays/sickdays/gui/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The Tk sick-day picker, and the switch panel other apps embed. + +Kept out of :mod:`sickdays` on purpose: every headless gate imports +``sickdays`` once a minute, and none of them may pull in tkinter. Import +this module only from the ``sickdays gui`` command or an app that already +draws with Tk:: + + from sickdays.gui import KeepPanel + + panel = KeepPanel(surface, today, on_saved=lambda entry: panel.destroy()) + panel.pack() + +:class:`KeepPanel` is a plain ``tk.Frame`` (no root, no mainloop, no grab), +so it works in-process under another window's X grab. See its docstring for +what ``on_saved`` receives. :func:`run` opens the whole window. +""" + +from __future__ import annotations + +from sickdays.gui._panel import KeepPanel +from sickdays.gui._window import SickDaysApp, run + +__all__ = ["KeepPanel", "SickDaysApp", "run"] diff --git a/sickdays/sickdays/gui/_calendar.py b/sickdays/sickdays/gui/_calendar.py new file mode 100644 index 0000000..c16903a --- /dev/null +++ b/sickdays/sickdays/gui/_calendar.py @@ -0,0 +1,130 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The month grid: Monday-first, one button per day, prev/next month arrows. + +The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a +sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past +days keep their mark but faded and inert. Today stays clickable -- a sick +day can be marked or cleared for today. Deciding what a click *means* stays +with the window; this only reports the date. +""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING + +from sickdays._day import to_iso +from sickdays.gui import _text +from sickdays.gui import _tokens as tok +from sickdays.gui import _widgets as ui + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import date + +_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}" +_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}" + + +def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]: + """(fill, text) for one day, faded when past, as ``DayCell`` draws it.""" + if sick: + fill = tok.blend(tok.SICK, tok.INK, tok.PAST_ALPHA) if past else tok.SICK + return fill, tok.ON_FILL + fill = tok.INK_RAISED_1 if workday else tok.INK + text = ( + tok.blend(tok.TEXT_ON_DARK, fill, tok.PAST_ALPHA) if past else tok.TEXT_ON_DARK + ) + return fill, text + + +class MonthCalendar(tk.Frame): + """One month of one pool; :meth:`redraw` repaints it from the callbacks.""" + + def __init__( + self, + parent: tk.Misc, + *, + today: date, + is_sick: Callable[[date], bool], + has_dot: Callable[[date], bool], + on_day: Callable[[date], None], + ) -> None: + """Show the month holding ``today``.""" + super().__init__(parent, bg=parent.cget("bg")) + self._today = today + self._is_sick = is_sick + self._has_dot = has_dot + self._on_day = on_day + self._first, self._last = _text.month_bounds(today) + self.month = today.replace(day=1) + self._header = tk.Frame(self, name="header", bg=self.cget("bg")) + self._header.pack(fill="x") + self._grid = tk.Frame(self, name="days", bg=self.cget("bg")) + self._grid.pack(pady=(tok.XS, 0)) + self.redraw() + + def step(self, months: int) -> None: + """Move ``months`` months (within the phone's range) and redraw.""" + target = _text.shift_month(self.month, months) + if self._first <= target <= self._last: + self.month = target + self.redraw() + + def redraw(self) -> None: + """Rebuild the header and every day from the current pool.""" + for frame in (self._header, self._grid): + for child in frame.winfo_children(): + child.destroy() + self._draw_header() + for column, initial in enumerate(_text.WEEKDAY_INITIALS): + ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid( + row=0, column=column + ) + for row, week in enumerate(_text.month_weeks(self.month), start=1): + for column, day in enumerate(week): + if day is not None: + self._cell(day).grid( + row=row, column=column, padx=tok.XS // 2, pady=tok.XS // 2 + ) + + def _draw_header(self) -> None: + back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev") + forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next") + back.configure(state="normal" if self.month > self._first else "disabled") + forward.configure(state="normal" if self.month < self._last else "disabled") + back.pack(side="left") + forward.pack(side="right") + ui.label(self._header, _text.month_title(self.month), "subtitle").pack( + side="top", pady=tok.XS + ) + + def _cell(self, day: date) -> tk.Button: + sick = self._is_sick(day) + past = day < self._today + fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day)) + cell = tk.Button( + self._grid, + name=f"day-{to_iso(day)}", + text=str(day.day), + command=lambda: self._on_day(day), + state="disabled" if past else "normal", + bg=fill, + fg=text, + activebackground=fill, + activeforeground=text, + disabledforeground=text, + font=tok.font(ui.family(self), "body", bold=sick), + width=3, + relief="flat", + borderwidth=0, + highlightthickness=0, + padx=tok.SM, + pady=tok.SM, + cursor="arrow" if past else "hand2", + ) + if sick and self._has_dot(day): + dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill) + dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0) + dot.place(relx=1.0, x=-2, y=0, anchor="ne") + return cell diff --git a/sickdays/sickdays/gui/_panel.py b/sickdays/sickdays/gui/_panel.py new file mode 100644 index 0000000..e56720a --- /dev/null +++ b/sickdays/sickdays/gui/_panel.py @@ -0,0 +1,181 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""KeepPanel: the five switches for one day, and the buttons that write them. + +The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no +mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call, +so screen-locker can drop it onto its fullscreen surface in-process, under +its own X grab (a separate window could not get input there). + +On is "this locker still applies", off is "stands down", all off by default +-- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day +offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free +day, or a free-day pool that cannot be checked shows why and offers no +write. A refused write is shown in the panel, never raised: an exception in a +Tk callback only reaches stderr, which nobody sees under a lock. +""" + +from __future__ import annotations + +import threading +import tkinter as tk +from typing import TYPE_CHECKING + +from sickdays._api import add_sick_day, is_sick_day, remove_sick_day +from sickdays._constants import LOCKERS +from sickdays._day import today +from sickdays._errors import SickDayError +from sickdays._keep_api import kept_lockers, set_kept_lockers +from sickdays._paths import resolve_paths +from sickdays._sync import sync_quietly +from sickdays.gui import _text +from sickdays.gui import _tokens as tok +from sickdays.gui import _widgets as ui + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import date + + from sickdays._model import SickDay + from sickdays._paths import Paths + + +def start_sync(paths: Paths) -> threading.Thread: + """Push the write in the background so the phone sees it; never raises. + + Not a daemon: a window closed right after "Mark sick" must not kill the + push half-way. The thread touches no widget. + """ + thread = threading.Thread( + target=sync_quietly, + kwargs={"paths": paths}, + name="sickdays-sync", + daemon=False, + ) + thread.start() + return thread + + +class KeepPanel(tk.Frame): + """Mark ``day`` sick with some lockers kept, or edit / clear a sick day. + + ``on_saved`` is called once per successful click, after the write and + after the background sync has started: + + * "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``); + * "Save" with a changed set -> the stored entry (``is_sick=True``); + * "Save" with nothing changed -> ``None``: nothing written, no sync; + * "Clear sick day" -> the cleared entry (``is_sick=False``). + + A refused write (:class:`~sickdays.SickDayError`) is shown in the panel + and ``on_saved`` is not called. ``on_cancel``, when given, adds a + "Cancel" button that calls it. The panel never destroys itself; the + callbacks decide what happens to it. + """ + + def __init__( + self, + parent: tk.Misc, + day: date, + *, + on_saved: Callable[[SickDay | None], None], + on_cancel: Callable[[], None] | None = None, + paths: Paths | None = None, + ) -> None: + """Read ``day``'s current state from the pool and draw the panel.""" + super().__init__(parent, bg=tok.INK_RAISED_1, padx=tok.MD, pady=tok.MD) + self._day = day + self._paths = resolve_paths(paths) + self._on_saved = on_saved + self._sick = is_sick_day(day, log_path=self._paths.log) + self._kept = kept_lockers(day, log_path=self._paths.log) + self._vars = { + locker: tk.BooleanVar(self, value=locker in self._kept) + for locker in LOCKERS + } + #: The last background sync, for callers (and tests) that wait on it. + self.sync_thread: threading.Thread | None = None + blocked = self._blocked_reason() + self._draw_switches(editable=blocked is None) + self._status = ui.label( + self, blocked or "", "label", fg=tok.DANGER, name="status" + ) + self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w") + self._draw_actions(writable=blocked is None, on_cancel=on_cancel) + + def kept(self) -> frozenset[str]: + """The lockers currently switched on.""" + return frozenset(locker for locker, var in self._vars.items() if var.get()) + + def _blocked_reason(self) -> str | None: + if self._day < today(): + return _text.PAST_NOTE + return None if self._sick else _text.free_day_refusal(self._day) + + def _draw_switches(self, *, editable: bool) -> None: + title = _text.panel_title(self._day, sick=self._sick) + ui.label(self, title, "subtitle", name="title").grid( + row=0, column=0, columnspan=2, sticky="w" + ) + ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid( + row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM) + ) + for row, locker in enumerate(LOCKERS, start=2): + ui.label(self, _text.LOCKER_LABELS[locker], "body").grid( + row=row, column=0, sticky="w", pady=tok.XS + ) + switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}") + switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS) + if not editable: + switch.configure(state="disabled") + + def _draw_actions( + self, *, writable: bool, on_cancel: Callable[[], None] | None + ) -> None: + actions = tk.Frame(self, name="actions", bg=self.cget("bg")) + actions.grid( + row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0) + ) + buttons: list[tk.Button] = [] + if writable and self._sick: + buttons.append( + ui.button(actions, "Save", self._save, kind="primary", name="save") + ) + buttons.append( + ui.button( + actions, "Clear sick day", self._clear, kind="danger", name="clear" + ) + ) + elif writable: + buttons.append( + ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark") + ) + if on_cancel is not None: + buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel")) + for button in buttons: + button.pack(side="left", padx=(0, tok.SM)) + + def _mark(self) -> None: + self._write( + lambda: add_sick_day(self._day, keep=self.kept(), paths=self._paths) + ) + + def _save(self) -> None: + keep = self.kept() + if keep == self._kept: + # A fresh tick on an unchanged set would only churn the sync. + self._on_saved(None) + return + self._write(lambda: set_kept_lockers(self._day, keep, paths=self._paths)) + + def _clear(self) -> None: + self._write(lambda: remove_sick_day(self._day, paths=self._paths)) + + def _write(self, action: Callable[[], SickDay]) -> None: + try: + entry = action() + except SickDayError as exc: + self._status.configure(text=str(exc), fg=tok.DANGER) + return + self.sync_thread = start_sync(self._paths) + # Last: the callback may destroy this panel. + self._on_saved(entry) diff --git a/sickdays/sickdays/gui/_text.py b/sickdays/sickdays/gui/_text.py new file mode 100644 index 0000000..079ba12 --- /dev/null +++ b/sickdays/sickdays/gui/_text.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Wording and calendar arithmetic for the picker -- no Tk in here. + +Every string is the phone's (``wake-alarm/phone_app/lib/screens/ +sick_day_sheet.dart`` and ``sick_days_body.dart``), so the two pickers read +the same. Kept here, apart from the widgets, so it is testable without a +display. +""" + +from __future__ import annotations + +import calendar +from datetime import date +from typing import TYPE_CHECKING, Final + +from sickdays._conflict import free_day_predicate +from sickdays._constants import ( + LOCKER_ALARM, + LOCKER_BOOK, + LOCKER_DIET, + LOCKER_LEETCODE, + LOCKER_WORKOUT, +) +from sickdays._day import to_iso +from sickdays._errors import ConflictCheckUnavailableError + +if TYPE_CHECKING: + from collections.abc import Iterable + +#: What each locker is called on screen, in ``sickdays.LOCKERS`` order. +LOCKER_LABELS: Final = { + LOCKER_WORKOUT: "Workout lock", + LOCKER_LEETCODE: "LeetCode", + LOCKER_BOOK: "Book", + LOCKER_DIET: "Diet", + LOCKER_ALARM: "Wake alarm", +} + +SHEET_HELP: Final = ( + "Off = stands down for the day. Switch on any locker that should still " + "apply; it then behaves exactly as on a normal day." +) +INTRO: Final = ( + "Click a day to mark it sick and choose which lockers still apply; click " + "a sick day to change them or clear it. Today or later only. No limit and " + "no reason needed." +) +INTRO_DETAIL: Final = ( + "On a sick day every locker left off stands down: no alarm, no lock, and " + "work left undone is dropped rather than carried forward. A locker " + "switched on behaves as on a normal day; a dot marks a sick day that " + "keeps one." +) +PAST_NOTE: Final = "This day has passed; it can no longer be changed." +NONE_AHEAD: Final = "None ahead." +WEEKDAY_INITIALS: Final = ("M", "T", "W", "T", "F", "S", "S") + +_WEEKDAYS: Final = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``). +_WORKDAYS: Final = frozenset({1, 2, 3}) + + +def day_label(day: date) -> str: + """``"Tue 2026-10-20"``.""" + return f"{_WEEKDAYS[day.weekday()]} {to_iso(day)}" + + +def kept_label(kept: Iterable[str]) -> str: + """The kept lockers as one line, in :data:`LOCKER_LABELS` order.""" + chosen = set(kept) + return ", ".join(label for key, label in LOCKER_LABELS.items() if key in chosen) + + +def panel_title(day: date, *, sick: bool) -> str: + """``"Mark sick: Tue 2026-10-20"``, or ``"Sick day: ..."`` when editing.""" + return f"{'Sick day' if sick else 'Mark sick'}: {day_label(day)}" + + +def free_day_refusal(day: date) -> str | None: + """Why ``day`` cannot be marked sick for the free-day pool, or None. + + "Could not check" refuses too, with the library's own message: it must + never count as "not a free day". + """ + try: + is_free = free_day_predicate() + except ConflictCheckUnavailableError as exc: + return str(exc) + if not is_free(day): + return None + return f"{to_iso(day)} is a free day — release it in Free days first." + + +def is_workday(day: date) -> bool: + """Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid.""" + return day.weekday() in _WORKDAYS + + +def month_title(month: date) -> str: + """``"October 2026"``.""" + return f"{calendar.month_name[month.month]} {month.year}" + + +def month_weeks(month: date) -> list[list[date | None]]: + """The month as Monday-first weeks, ``None`` padding either end.""" + return [ + [None if not cell else date(month.year, month.month, cell) for cell in week] + for week in calendar.Calendar(firstweekday=0).monthdayscalendar( + month.year, month.month + ) + ] + + +def shift_month(month: date, step: int) -> date: + """The first of the month ``step`` months after ``month``.""" + index = month.year * 12 + month.month - 1 + step + return date(index // 12, index % 12 + 1, 1) + + +def month_bounds(reference: date) -> tuple[date, date]: + """January this year to December next year, the phone's range.""" + return date(reference.year, 1, 1), date(reference.year + 1, 12, 1) diff --git a/sickdays/sickdays/gui/_tokens.py b/sickdays/sickdays/gui/_tokens.py new file mode 100644 index 0000000..20c19fe --- /dev/null +++ b/sickdays/sickdays/gui/_tokens.py @@ -0,0 +1,70 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Design tokens for the Tk picker, copied from the unified design system. + +Source: ``~/src/utils/unified-design-system/DOCS-tokens.md`` -- the same +values as daily-limits' ``_tokens.py`` and gatelock's ``LockPalette``. +gatelock is not imported: its ``__init__`` pulls in the lock machinery, and +this package must stay importable by every headless gate. The hexes are the +frozen shared palette, so a copy cannot drift unnoticed. + +A sick day is filled ``cat-2``, as on the phone (``DayMarkColors.sick``): +the categorical ramp leaves the warm wedge, so a sick day can never be read +as the free days' accent or as success/warning/danger. + +Font sizes are **pixels**, so :func:`font` returns Tk's negative form (a +positive size means points and renders about a third larger). +""" + +from __future__ import annotations + +from typing import Final, Literal + +# Neutrals (dark theme) + semantic roles. +INK: Final = "#211D1B" +INK_RAISED_1: Final = "#2B2624" +INK_RAISED_2: Final = "#38312E" +LINE_DARK: Final = "#463E3A" +TEXT_ON_DARK: Final = "#ECEAE9" +MUTED_ON_DARK: Final = "#AAA09A" +ACCENT: Final = "#B8862E" +SUCCESS: Final = "#8A9A3C" +DANGER: Final = "#E2585F" +ON_FILL: Final = INK +#: ``cat-2``: a sick day's fill. +SICK: Final = "#398FC0" + +#: The phone fades a past day to 38 % opacity; Tk has no alpha, so blend. +PAST_ALPHA: Final = 0.38 + +# Spacing scale (4px base). +XS: Final = 4 +SM: Final = 8 +MD: Final = 16 +LG: Final = 24 + +#: Width, in px, that wrapped prose lines break at. +WRAP: Final = 420 + +TypeRole = Literal["title", "subtitle", "body", "label", "caption"] +_TYPE_PX: Final[dict[TypeRole, int]] = { + "title": 24, + "subtitle": 20, + "body": 16, + "label": 14, + "caption": 12, +} + + +def font(family: str, role: TypeRole, *, bold: bool = False) -> tuple[str, int, str]: + """A Tk font tuple for ``role`` in ``family``, sized in pixels (negative).""" + return (family, -_TYPE_PX[role], "bold" if bold else "normal") + + +def blend(colour: str, onto: str, alpha: float) -> str: + """``colour`` at ``alpha`` opacity over ``onto``, as an opaque ``#RRGGBB``.""" + top = [int(colour[i : i + 2], 16) for i in (1, 3, 5)] + under = [int(onto[i : i + 2], 16) for i in (1, 3, 5)] + mixed = ( + round(t * alpha + u * (1 - alpha)) for t, u in zip(top, under, strict=True) + ) + return "#" + "".join(f"{channel:02X}" for channel in mixed) diff --git a/sickdays/sickdays/gui/_widgets.py b/sickdays/sickdays/gui/_widgets.py new file mode 100644 index 0000000..0d3d261 --- /dev/null +++ b/sickdays/sickdays/gui/_widgets.py @@ -0,0 +1,125 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Token-styled Tk building blocks shared by the panel and the window. + +Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a +filled control is picked from its fill (``on-fill`` on the accent, never the +near-white body text), the bug the design system's README warns about. +""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import font as tkfont +from typing import TYPE_CHECKING, Final, Literal + +from sickdays.gui import _tokens as tok + +if TYPE_CHECKING: + from collections.abc import Callable + +ButtonKind = Literal["primary", "danger", "plain"] +_BOLD_ROLES: Final = frozenset({"title", "subtitle"}) + +#: (background, foreground) per button kind. Danger is a text button, as on +#: the phone, so its red sits on the dark surface rather than under ink. +_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = { + "primary": (tok.ACCENT, tok.ON_FILL), + "danger": (tok.INK_RAISED_2, tok.DANGER), + "plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK), +} + + +def family(widget: tk.Misc) -> str: + """The family Tk's default font actually resolves to on this display.""" + return str(tkfont.nametofont("TkDefaultFont", root=widget).actual("family")) + + +def label( + parent: tk.Misc, + text: str, + role: tok.TypeRole, + *, + fg: str = tok.TEXT_ON_DARK, + name: str = "", +) -> tk.Label: + """A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`. + + Titles and subtitles are bold, everything else regular. + """ + return tk.Label( + parent, + name=name, + text=text, + fg=fg, + bg=parent.cget("bg"), + font=tok.font(family(parent), role, bold=role in _BOLD_ROLES), + justify="left", + anchor="w", + wraplength=tok.WRAP, + ) + + +def button( + parent: tk.Misc, + text: str, + command: Callable[[], object], + *, + kind: ButtonKind = "plain", + name: str = "", +) -> tk.Button: + """A flat button whose text colour follows its fill.""" + bg, fg = _BUTTON_COLOURS[kind] + return tk.Button( + parent, + name=name, + text=text, + command=command, + bg=bg, + fg=fg, + activebackground=bg, + activeforeground=fg, + font=tok.font(family(parent), "body", bold=kind == "primary"), + relief="flat", + borderwidth=0, + highlightthickness=0, + padx=tok.MD, + pady=tok.SM, + cursor="hand2", + ) + + +def paint_toggle(switch: tk.Checkbutton, *, on: bool) -> None: + """Draw ``switch`` as a toggle: accent fill reading "On", or a dark "Off".""" + bg, fg = (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.TEXT_ON_DARK) + switch.configure( + text="On" if on else "Off", + bg=bg, + selectcolor=bg, + activebackground=bg, + fg=fg, + activeforeground=fg, + ) + + +def toggle(parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> tk.Checkbutton: + """A Checkbutton drawn as an On/Off switch, repainted on every change.""" + switch = tk.Checkbutton( + parent, + name=name, + variable=variable, + onvalue=True, + offvalue=False, + indicatoron=False, + width=4, + font=tok.font(family(parent), "label", bold=True), + relief="flat", + borderwidth=0, + highlightthickness=0, + padx=tok.SM, + pady=tok.XS, + disabledforeground=tok.MUTED_ON_DARK, + cursor="hand2", + ) + switch.configure(command=lambda: paint_toggle(switch, on=variable.get())) + paint_toggle(switch, on=variable.get()) + return switch diff --git a/sickdays/sickdays/gui/_window.py b/sickdays/sickdays/gui/_window.py new file mode 100644 index 0000000..5f767fc --- /dev/null +++ b/sickdays/sickdays/gui/_window.py @@ -0,0 +1,147 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk. + +The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for +the clicked day, the sick days still ahead underneath. The window class is +``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 +``for_window`` rule can float it; ``Escape`` closes it. +""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING + +from sickdays._api import is_sick_day, load +from sickdays._day import today +from sickdays._keep_api import kept_lockers +from sickdays._model import sick_entries +from sickdays._paths import resolve_paths +from sickdays.gui import _text +from sickdays.gui import _tokens as tok +from sickdays.gui import _widgets as ui +from sickdays.gui._calendar import MonthCalendar +from sickdays.gui._panel import KeepPanel + +if TYPE_CHECKING: + from datetime import date + + from sickdays._model import SickDay + from sickdays._paths import Paths + +WINDOW_CLASS = "sickdays" + + +def saved_message(entry: SickDay | None, day: date) -> str: + """The status line after the panel closed with ``entry``.""" + label = _text.day_label(day) + if entry is None: + return f"{label}: nothing changed" + if not entry.is_sick: + return f"{label} is a normal day again" + kept = _text.kept_label(entry.keep) + return f"{label} is a sick day — " + ( + f"kept: {kept}" if kept else "every locker stands down" + ) + + +class SickDaysApp: + """The whole window, drawn onto ``root``.""" + + def __init__(self, root: tk.Tk, *, paths: Paths | None = None) -> None: + """Style ``root`` and draw the current pool.""" + self._root = root + self._paths = resolve_paths(paths) + root.title("Sick days") + root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG) + root.bind("", self.close) + left = tk.Frame(root, bg=tok.INK) + left.grid(row=0, column=0, sticky="n") + #: Where the clicked day's panel goes. + self.host = tk.Frame(root, name="host", bg=tok.INK) + self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0)) + ui.label(left, "Sick days", "title").pack(anchor="w") + ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0)) + ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack( + anchor="w", pady=(tok.SM, 0) + ) + self.status = ui.label(left, "", "label", name="status") + self.status.pack(anchor="w", pady=(tok.SM, 0)) + log = self._paths.log + self.calendar = MonthCalendar( + left, + today=today(), + is_sick=lambda day: is_sick_day(day, log_path=log), + has_dot=lambda day: bool(kept_lockers(day, log_path=log)), + on_day=self.select, + ) + self.calendar.pack(anchor="w", pady=(tok.MD, 0)) + ui.label(left, "Marked sick", "subtitle").pack( + anchor="w", pady=(tok.LG, tok.SM) + ) + self._list = tk.Frame(left, name="upcoming", bg=tok.INK) + self._list.pack(anchor="w", fill="x") + self._draw_upcoming() + + def close(self, _event: object = None) -> None: + """Close the window (bound to ``Escape``).""" + self._root.destroy() + + def select(self, day: date) -> None: + """Open the panel for ``day``, or refuse a free day in the status line.""" + self._close_panel() + if not is_sick_day(day, log_path=self._paths.log): + refusal = _text.free_day_refusal(day) + if refusal is not None: + self._set_status(refusal, failed=True) + return + self._set_status("", failed=False) + KeepPanel( + self.host, + day, + on_saved=lambda entry: self._saved(entry, day), + on_cancel=self._close_panel, + paths=self._paths, + ).pack(anchor="n") + + def _saved(self, entry: SickDay | None, day: date) -> None: + self._close_panel() + self._set_status(saved_message(entry, day), failed=False) + self.calendar.redraw() + self._draw_upcoming() + + def _close_panel(self) -> None: + for child in self.host.winfo_children(): + child.destroy() + + def _set_status(self, text: str, *, failed: bool) -> None: + self.status.configure(text=text, fg=tok.DANGER if failed else tok.SUCCESS) + + def _draw_upcoming(self) -> None: + for child in self._list.winfo_children(): + child.destroy() + reference = today() + upcoming = [ + entry + for entry in sick_entries(load(self._paths.log)) + if entry.day >= reference + ] + if not upcoming: + ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack( + anchor="w" + ) + for entry in upcoming: + ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w") + kept = _text.kept_label(entry.keep) + if kept: + ui.label( + self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK + ).pack(anchor="w", pady=(0, tok.XS)) + + +def run(*, paths: Paths | None = None) -> int: + """Open the window and block until it is closed.""" + root = tk.Tk(className=WINDOW_CLASS) + SickDaysApp(root, paths=paths) + root.mainloop() + return 0 diff --git a/sickdays/sickdays/tests/conftest.py b/sickdays/sickdays/tests/conftest.py index f0f6dde..1c44b9a 100644 --- a/sickdays/sickdays/tests/conftest.py +++ b/sickdays/sickdays/tests/conftest.py @@ -5,6 +5,9 @@ Every writer resolves its paths through :func:`sickdays._paths.resolve_paths`, so redirecting that one function in each module that imports it moves all of them, including any file added later. +The GUI modules are in the list too, and their background sync is stubbed: +a test thread must never find the real crdt-sync credential. + The cross-pool check would otherwise import the real freedays and read the real ``~/.local/share/freedays`` pool, so it is stubbed to "nothing is free" for every test; tests of the check itself pass their own predicate. @@ -19,8 +22,10 @@ import pytest from sickdays._paths import Paths if TYPE_CHECKING: + from collections.abc import Iterator from datetime import date from pathlib import Path + import tkinter as tk _MODULES = ( "sickdays._api", @@ -28,6 +33,8 @@ _MODULES = ( "sickdays._conflict", "sickdays._keep_api", "sickdays._sync", + "sickdays.gui._panel", + "sickdays.gui._window", ) @@ -35,6 +42,12 @@ def _never_free(_day: date) -> bool: return False +def _no_sync(*, paths: Paths | None = None) -> bool: + """The GUI's background sync, stubbed: no test may reach Firebase.""" + del paths + return False + + @pytest.fixture(autouse=True) def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Point every default path at ``tmp_path`` before any test runs.""" @@ -48,9 +61,29 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: for module in _MODULES: monkeypatch.setattr(f"{module}.resolve_paths", _resolve) monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free) + monkeypatch.setattr("sickdays.gui._text.free_day_predicate", lambda: _never_free) + monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync) @pytest.fixture def state_dir(tmp_path: Path) -> Path: """The redirected state directory, for tests that assert on files.""" return tmp_path / "state" + + +@pytest.fixture +def tk_root() -> Iterator[tk.Tk]: + """A real, never-mapped Tk root; skips when there is no X display. + + Real Tk rather than a fake: option names and Checkbutton/variable wiring + are Tk's own behaviour. CI runs the suite under ``xvfb-run``. + """ + import tkinter as tk + + try: + root = tk.Tk(className="sickdays-test") + except tk.TclError as exc: + pytest.skip(f"no X display ({exc}); run the suite under xvfb-run") + root.withdraw() + yield root + root.destroy() diff --git a/sickdays/sickdays/tests/test_cli.py b/sickdays/sickdays/tests/test_cli.py index 0be2e07..8d239d6 100644 --- a/sickdays/sickdays/tests/test_cli.py +++ b/sickdays/sickdays/tests/test_cli.py @@ -5,6 +5,9 @@ from __future__ import annotations from datetime import timedelta import runpy +import subprocess +import sys +import types import pytest @@ -168,3 +171,28 @@ def test_check_with_a_locker_on_a_normal_day_exits_one( ) -> None: assert main(["check", "--locker", "alarm"]) == 1 assert "alarm applies" in capsys.readouterr().out + + +def test_gui_opens_the_window_through_a_lazy_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + opened: list[bool] = [] + fake = types.ModuleType("sickdays.gui") + + def run() -> int: + opened.append(True) + return 0 + + fake.__dict__["run"] = run + monkeypatch.setitem(sys.modules, "sickdays.gui", fake) + assert main(["gui"]) == 0 + assert opened == [True] + + +def test_importing_the_library_and_cli_never_imports_tkinter() -> None: + probe = ( + "import sys, sickdays, sickdays._cli; " + "sys.exit(1 if 'tkinter' in sys.modules else 0)" + ) + done = subprocess.run([sys.executable, "-c", probe], check=False) + assert done.returncode == 0 diff --git a/sickdays/sickdays/tests/test_gui_panel.py b/sickdays/sickdays/tests/test_gui_panel.py new file mode 100644 index 0000000..e74cdc9 --- /dev/null +++ b/sickdays/sickdays/tests/test_gui_panel.py @@ -0,0 +1,205 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""KeepPanel: the five switches for one day, against a real (unmapped) Tk.""" + +from __future__ import annotations + +from datetime import timedelta +import tkinter as tk +from typing import TYPE_CHECKING + +import pytest + +from sickdays._api import add_sick_day, is_sick_day +from sickdays._day import today +from sickdays._errors import ConflictCheckUnavailableError +from sickdays._keep_api import kept_lockers +from sickdays._paths import Paths +from sickdays.gui import KeepPanel, _text +from sickdays.gui import _tokens as tok + +if TYPE_CHECKING: + from pathlib import Path + + from sickdays._model import SickDay + +FUTURE = today() + timedelta(days=3) + + +@pytest.fixture +def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]: + """Record every background sync the panel starts.""" + calls: list[Paths | None] = [] + + def record(*, paths: Paths | None = None) -> bool: + calls.append(paths) + return True + + monkeypatch.setattr("sickdays.gui._panel.sync_quietly", record) + return calls + + +def _button(panel: KeepPanel, name: str) -> tk.Button: + widget = panel.nametowidget(f"actions.{name}") + assert isinstance(widget, tk.Button) + return widget + + +def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton: + widget = panel.nametowidget(f"keep-{locker}") + assert isinstance(widget, tk.Checkbutton) + return widget + + +def _status(panel: KeepPanel) -> str: + return str(panel.nametowidget("status").cget("text")) + + +def _actions(panel: KeepPanel) -> set[str]: + actions = panel.nametowidget("actions") + return {child.winfo_name() for child in actions.winfo_children()} + + +def _join(panel: KeepPanel) -> None: + assert panel.sync_thread is not None + panel.sync_thread.join(timeout=5) + + +def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None]) -> None: + saved: list[SickDay | None] = [] + panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append) + assert panel.kept() == frozenset() + assert _actions(panel) == {"mark"} + toggle = _toggle(panel, "leetcode") + assert toggle.cget("text") == "Off" + toggle.invoke() + assert toggle.cget("text") == "On" + assert str(toggle.cget("bg")).upper() == tok.ACCENT + assert str(toggle.cget("fg")).upper() == tok.ON_FILL + _button(panel, "mark").invoke() + _join(panel) + assert is_sick_day(FUTURE) + assert kept_lockers(FUTURE) == frozenset({"leetcode"}) + assert len(saved) == 1 + assert saved[0] is not None + assert saved[0].is_sick + assert saved[0].keep == frozenset({"leetcode"}) + assert len(synced) == 1 + + +def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None: + add_sick_day(FUTURE, keep=["workout"]) + panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None) + assert panel.kept() == frozenset({"workout"}) + assert _actions(panel) == {"save", "clear"} + title = panel.nametowidget("title").cget("text") + assert title == f"Sick day: {_text.day_label(FUTURE)}" + + +def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None: + add_sick_day(FUTURE, keep=["workout"]) + saved: list[SickDay | None] = [] + panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append) + _toggle(panel, "workout").invoke() + _toggle(panel, "diet").invoke() + _button(panel, "save").invoke() + _join(panel) + assert kept_lockers(FUTURE) == frozenset({"diet"}) + assert saved[0] is not None + assert saved[0].keep == frozenset({"diet"}) + assert len(synced) == 1 + + +def test_an_unchanged_save_writes_nothing( + tk_root: tk.Tk, synced: list[Paths | None], state_dir: Path +) -> None: + add_sick_day(FUTURE, keep=["book"]) + before = (state_dir / "audit.jsonl").read_text(encoding="utf-8") + saved: list[SickDay | None] = [] + panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append) + _button(panel, "save").invoke() + assert saved == [None] + assert panel.sync_thread is None + assert synced == [] + assert (state_dir / "audit.jsonl").read_text(encoding="utf-8") == before + + +def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None]) -> None: + add_sick_day(FUTURE, keep=["alarm"]) + saved: list[SickDay | None] = [] + panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append) + _button(panel, "clear").invoke() + _join(panel) + assert not is_sick_day(FUTURE) + assert saved[0] is not None + assert not saved[0].is_sick + assert len(synced) == 1 + + +def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None: + panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None) + assert _status(panel) == _text.PAST_NOTE + assert _actions(panel) == set() + assert str(_toggle(panel, "diet").cget("state")) == "disabled" + + +def test_a_free_day_is_refused_with_the_phones_message( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True + ) + panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None) + expected = "is a free day — release it in Free days first." + assert _status(panel).endswith(expected) + assert _actions(panel) == set() + + +def test_an_uncheckable_free_pool_refuses( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + def unavailable() -> None: + msg = "cannot check the free-day pool" + raise ConflictCheckUnavailableError(msg) + + monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable) + panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None) + assert _status(panel) == "cannot check the free-day pool" + assert _actions(panel) == set() + + +def test_a_refused_write_is_shown_not_raised( + tk_root: tk.Tk, synced: list[Paths | None] +) -> None: + saved: list[SickDay | None] = [] + panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append) + add_sick_day(FUTURE) # marked elsewhere while the panel was open + _button(panel, "mark").invoke() + assert "already a sick day" in _status(panel) + assert saved == [] + assert synced == [] + + +def test_cancel_calls_back(tk_root: tk.Tk) -> None: + cancelled: list[bool] = [] + panel = KeepPanel( + tk_root, + FUTURE, + on_saved=lambda _e: None, + on_cancel=lambda: cancelled.append(True), + ) + _button(panel, "cancel").invoke() + assert cancelled == [True] + + +def test_explicit_paths_are_used_for_every_read_write_and_sync( + tk_root: tk.Tk, synced: list[Paths | None], tmp_path: Path +) -> None: + other = Paths.under(tmp_path / "other") + add_sick_day(FUTURE, keep=["book"], paths=other) + panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None, paths=other) + assert panel.kept() == frozenset({"book"}) + _button(panel, "clear").invoke() + _join(panel) + assert not is_sick_day(FUTURE, log_path=other.log) + assert synced == [other] + assert not (tmp_path / "state" / "sick_days.json").exists() diff --git a/sickdays/sickdays/tests/test_gui_text.py b/sickdays/sickdays/tests/test_gui_text.py new file mode 100644 index 0000000..43a08a9 --- /dev/null +++ b/sickdays/sickdays/tests/test_gui_text.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The picker's wording, calendar arithmetic and tokens -- no display needed.""" + +from __future__ import annotations + +from datetime import date +from typing import TYPE_CHECKING + +from sickdays._errors import ConflictCheckUnavailableError +from sickdays.gui import _text +from sickdays.gui import _tokens as tok + +if TYPE_CHECKING: + import pytest + +DAY = date(2026, 10, 20) + + +def test_labels_match_the_phone() -> None: + assert _text.day_label(DAY) == "Tue 2026-10-20" + assert _text.panel_title(DAY, sick=False) == "Mark sick: Tue 2026-10-20" + assert _text.panel_title(DAY, sick=True) == "Sick day: Tue 2026-10-20" + assert list(_text.LOCKER_LABELS.values()) == [ + "Workout lock", + "LeetCode", + "Book", + "Diet", + "Wake alarm", + ] + + +def test_kept_label_follows_locker_order_not_the_alphabet() -> None: + assert _text.kept_label({"alarm", "book", "workout"}) == ( + "Workout lock, Book, Wake alarm" + ) + assert _text.kept_label(frozenset()) == "" + + +def test_free_day_refusal(monkeypatch: pytest.MonkeyPatch) -> None: + assert _text.free_day_refusal(DAY) is None + monkeypatch.setattr( + "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True + ) + assert _text.free_day_refusal(DAY) == ( + "2026-10-20 is a free day — release it in Free days first." + ) + + def unavailable() -> None: + msg = "freedays is not installed" + raise ConflictCheckUnavailableError(msg) + + monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable) + assert _text.free_day_refusal(DAY) == "freedays is not installed" + + +def test_month_grid_is_monday_first() -> None: + weeks = _text.month_weeks(date(2026, 10, 1)) + assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))] + assert weeks[-1][-1] is None + assert _text.month_title(date(2026, 10, 1)) == "October 2026" + + +def test_month_arithmetic_and_range() -> None: + assert _text.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1) + assert _text.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1) + assert _text.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1)) + + +def test_workdays_are_tuesday_to_thursday() -> None: + assert [_text.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [ + False, + True, + True, + True, + False, + False, + False, + ] + + +def test_tokens() -> None: + assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal") + assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold") + assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080" + assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK diff --git a/sickdays/sickdays/tests/test_gui_window.py b/sickdays/sickdays/tests/test_gui_window.py new file mode 100644 index 0000000..0244e31 --- /dev/null +++ b/sickdays/sickdays/tests/test_gui_window.py @@ -0,0 +1,167 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The picker window and its month grid, against a real (unmapped) Tk.""" + +from __future__ import annotations + +from datetime import date, timedelta +import tkinter as tk +from typing import TYPE_CHECKING + +from sickdays._api import add_sick_day +from sickdays._day import to_iso, today +from sickdays._model import SickDay +from sickdays.gui import KeepPanel, SickDaysApp, _text, _window +from sickdays.gui import _tokens as tok + +if TYPE_CHECKING: + import pytest + +FUTURE = today() + timedelta(days=3) + + +def _texts(widget: tk.Misc) -> list[str]: + found: list[str] = [] + for child in widget.winfo_children(): + if isinstance(child, tk.Label): + found.append(str(child.cget("text"))) + found.extend(_texts(child)) + return found + + +def _cell(app: SickDaysApp, day: date) -> tk.Button: + widget = app.calendar.nametowidget(f"days.day-{to_iso(day)}") + assert isinstance(widget, tk.Button) + return widget + + +def _panel(app: SickDaysApp) -> KeepPanel: + (panel,) = app.host.winfo_children() + assert isinstance(panel, KeepPanel) + return panel + + +def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None: + app = SickDaysApp(tk_root) + assert tk_root.title() == "Sick days" + assert _text.NONE_AHEAD in _texts(tk_root) + assert app.host.winfo_children() == [] + + +def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None: + app = SickDaysApp(tk_root) + app.calendar.month = FUTURE.replace(day=1) + app.calendar.redraw() + _cell(app, FUTURE).invoke() + panel = _panel(app) + panel.nametowidget("keep-leetcode").invoke() + panel.nametowidget("actions.mark").invoke() + assert panel.sync_thread is not None + panel.sync_thread.join(timeout=5) + assert app.host.winfo_children() == [] + assert app.status.cget("text") == _window.saved_message( + SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE + ) + assert "Kept: LeetCode" in _texts(tk_root) + cell = _cell(app, FUTURE) + assert str(cell.cget("bg")).upper() == tok.SICK + assert cell.nametowidget("dot").winfo_exists() + + +def test_cancel_closes_the_panel(tk_root: tk.Tk) -> None: + app = SickDaysApp(tk_root) + app.select(FUTURE) + _panel(app).nametowidget("actions.cancel").invoke() + assert app.host.winfo_children() == [] + + +def test_a_free_day_is_refused_before_the_panel_opens( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True + ) + app = SickDaysApp(tk_root) + app.select(FUTURE) + assert app.host.winfo_children() == [] + assert str(app.status.cget("text")).endswith("release it in Free days first.") + assert str(app.status.cget("fg")).upper() == tok.DANGER + + +def test_a_sick_day_on_a_free_day_still_opens( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + add_sick_day(FUTURE) + monkeypatch.setattr( + "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True + ) + app = SickDaysApp(tk_root) + app.select(FUTURE) + assert _panel(app).nametowidget("actions.clear").winfo_exists() + + +def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None: + add_sick_day(FUTURE, keep=["alarm", "workout"]) + add_sick_day(FUTURE + timedelta(days=1)) + texts = _texts(SickDaysApp(tk_root).calendar.master) + assert _text.day_label(FUTURE) in texts + assert "Kept: Workout lock, Wake alarm" in texts + assert _text.NONE_AHEAD not in texts + + +def test_saved_message_wording() -> None: + sick = SickDay(FUTURE, is_sick=True) + label = _text.day_label(FUTURE) + assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed" + assert _window.saved_message(sick, FUTURE).endswith("every locker stands down") + cleared = SickDay(FUTURE, is_sick=False) + assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again" + + +def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None: + app = SickDaysApp(tk_root) + first = today().replace(day=1) + app.calendar.month = first - timedelta(days=1) + app.calendar.redraw() + cell = _cell(app, app.calendar.month.replace(day=1)) + assert str(cell.cget("state")) == "disabled" + + +def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None: + app = SickDaysApp(tk_root) + first, last = _text.month_bounds(today()) + for _ in range(30): + app.calendar.step(1) + assert app.calendar.month == last + for _ in range(30): + app.calendar.step(-1) + assert app.calendar.month == first + back = app.calendar.nametowidget("header.prev") + assert str(back.cget("state")) == "disabled" + + +def test_escape_closes_the_window(monkeypatch: pytest.MonkeyPatch) -> None: + closed: list[bool] = [] + root = tk.Tk(className="sickdays-test") + root.withdraw() + app = SickDaysApp(root) + assert root.bind("") + real_destroy = root.destroy + monkeypatch.setattr(root, "destroy", lambda: closed.append(True)) + app.close() + assert closed == [True] + real_destroy() + + +def test_run_opens_the_window_and_blocks( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + classes: list[str] = [] + + def make_root(**options: str) -> tk.Tk: + classes.append(options["className"]) + return tk_root + + monkeypatch.setattr(tk, "Tk", make_root) + monkeypatch.setattr(tk_root, "mainloop", lambda: None) + assert _window.run() == 0 + assert classes == ["sickdays"] diff --git a/sickdays/sickdays/tests/test_paths.py b/sickdays/sickdays/tests/test_paths.py index de5484f..901db20 100644 --- a/sickdays/sickdays/tests/test_paths.py +++ b/sickdays/sickdays/tests/test_paths.py @@ -58,3 +58,25 @@ def test_resolve_returns_the_bundle_it_was_given(tmp_path: Path) -> None: def test_resolve_falls_back_to_the_defaults() -> None: assert resolve_paths(None) == Paths.default() + + +def test_every_module_resolving_paths_is_redirected_by_conftest() -> None: + """A module that resolves the real paths but is not redirected would let + a test write the real pool.""" + import importlib + import pkgutil + + import sickdays + from sickdays.tests import conftest + + names = [ + info.name + for info in pkgutil.walk_packages(sickdays.__path__, "sickdays.") + if ".tests" not in info.name and info.name != "sickdays.__main__" + ] + resolving = { + name + for name in names + if hasattr(importlib.import_module(name), "resolve_paths") + } - {"sickdays._paths"} + assert resolving == set(conftest._MODULES)