From 9d6b17fd2845c7282d1ae3cccee248310d1ecbad Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 19:07:37 +0100 Subject: [PATCH] =?UTF-8?q?Make=20sickdays=20(in=20kuhyx/utils)=20text-fre?= =?UTF-8?q?e=20(textgat=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todo: 2d0704cd --- sickdays/README.md | 25 +- sickdays/pyproject.toml | 3 + sickdays/sickdays/gui/_calendar.py | 46 ++-- sickdays/sickdays/gui/_controls.py | 171 ++++++++++++++ sickdays/sickdays/gui/_dates.py | 86 +++++++ sickdays/sickdays/gui/_icons.py | 235 +++++++++++++++++++ sickdays/sickdays/gui/_panel.py | 112 +++++---- sickdays/sickdays/gui/_text.py | 125 ---------- sickdays/sickdays/gui/_widgets.py | 93 +------- sickdays/sickdays/gui/_window.py | 103 ++++---- sickdays/sickdays/tests/conftest.py | 2 +- sickdays/sickdays/tests/test_gui_controls.py | 70 ++++++ sickdays/sickdays/tests/test_gui_dates.py | 87 +++++++ sickdays/sickdays/tests/test_gui_no_words.py | 113 +++++++++ sickdays/sickdays/tests/test_gui_panel.py | 81 ++++--- sickdays/sickdays/tests/test_gui_text.py | 86 ------- sickdays/sickdays/tests/test_gui_window.py | 81 ++++--- 17 files changed, 1035 insertions(+), 484 deletions(-) create mode 100644 sickdays/sickdays/gui/_controls.py create mode 100644 sickdays/sickdays/gui/_dates.py create mode 100644 sickdays/sickdays/gui/_icons.py delete mode 100644 sickdays/sickdays/gui/_text.py create mode 100644 sickdays/sickdays/tests/test_gui_controls.py create mode 100644 sickdays/sickdays/tests/test_gui_dates.py create mode 100644 sickdays/sickdays/tests/test_gui_no_words.py delete mode 100644 sickdays/sickdays/tests/test_gui_text.py diff --git a/sickdays/README.md b/sickdays/README.md index 500041d..858e97a 100644 --- a/sickdays/README.md +++ b/sickdays/README.md @@ -70,22 +70,35 @@ refused (past day, already sick, not sick, a free day, sync unavailable), ## 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 +Tk -- calendar, a six-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): +No words inside the window (textgate): dates are ISO numerals (`2026-11`, +`2026-11-27`), each locker is a drawn pictogram (dumbbell = workout, `` = +LeetCode, open book, apple = diet, alarm clock, target = focus), switches are +knob-right-on-accent / knob-left-on-dark, buttons are check (mark / save), +red bin (clear) and cross (cancel). A refusal is an icon in the status slot: +padlock (past), no-entry (free day), warning (cannot check, or a refused +write; the detail goes to the log), hourglass (a sync is still running). +Icons are Canvas geometry, not font glyphs: the venv's Tk uses core X fonts, +where non-ASCII glyphs render as nothing. The title bar keeps "Sick days" +(an app's name, textgate settled decision 4). The guard is +`tests/test_gui_no_words.py`: no letter on any widget, in any state. + ```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 refusal (or a click mid-sync) shows in the panel. +Normal day: six switches, all off (off = stands down), `actions.mark`; sick +day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable +days show their icon and offer no write; a refusal (or a click mid-sync) +shows in the panel's `status` slot, named by `panel.reason`. `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. +mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None` +on an unchanged save (nothing written). The panel never destroys itself. ## API diff --git a/sickdays/pyproject.toml b/sickdays/pyproject.toml index a9ce88f..ddec4b6 100644 --- a/sickdays/pyproject.toml +++ b/sickdays/pyproject.toml @@ -120,6 +120,9 @@ disable = [] min-public-methods = 0 max-module-lines = 1000 max-attributes = 10 +# A tk.Canvas subclass (gui/_controls.py) has 9 ancestors by construction: +# Canvas mixes in XView and YView on top of Widget's chain. +max-parents = 9 [tool.pylint.typecheck] generated-members = [ diff --git a/sickdays/sickdays/gui/_calendar.py b/sickdays/sickdays/gui/_calendar.py index c16903a..28a5b4e 100644 --- a/sickdays/sickdays/gui/_calendar.py +++ b/sickdays/sickdays/gui/_calendar.py @@ -1,29 +1,33 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""The month grid: Monday-first, one button per day, prev/next month arrows. +"""The month grid: Monday-first, one button per day, prev/next month chevrons. 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. +with the window; this only reports the date. No words: the month reads +``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday. """ from __future__ import annotations import tkinter as tk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from sickdays._day import to_iso -from sickdays.gui import _text +from sickdays.gui import _dates from sickdays.gui import _tokens as tok from sickdays.gui import _widgets as ui +from sickdays.gui._controls import IconButton, IconSlot 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}" +#: The weekday marks over the grid, in px. +_MARK_PX: Final = 10 +#: The keep-a-locker dot inside a sick day's cell, in px. +_DOT_PX: Final = 8 def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]: @@ -56,7 +60,7 @@ class MonthCalendar(tk.Frame): self._is_sick = is_sick self._has_dot = has_dot self._on_day = on_day - self._first, self._last = _text.month_bounds(today) + self._first, self._last = _dates.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") @@ -66,7 +70,7 @@ class MonthCalendar(tk.Frame): def step(self, months: int) -> None: """Move ``months`` months (within the phone's range) and redraw.""" - target = _text.shift_month(self.month, months) + target = _dates.shift_month(self.month, months) if self._first <= target <= self._last: self.month = target self.redraw() @@ -77,11 +81,12 @@ class MonthCalendar(tk.Frame): 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 column in range(7): + mark = "ring" if _dates.is_weekend_column(column) else "dot" + IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid( + row=0, column=column, pady=(0, tok.XS) ) - for row, week in enumerate(_text.month_weeks(self.month), start=1): + for row, week in enumerate(_dates.month_weeks(self.month), start=1): for column, day in enumerate(week): if day is not None: self._cell(day).grid( @@ -89,20 +94,20 @@ class MonthCalendar(tk.Frame): ) 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 = IconButton(self._header, "prev", lambda: self.step(-1), name="prev") + forward = IconButton(self._header, "next", lambda: self.step(1), name="next") + back.set_enabled(self.month > self._first) + forward.set_enabled(self.month < self._last) back.pack(side="left") forward.pack(side="right") - ui.label(self._header, _text.month_title(self.month), "subtitle").pack( + ui.label(self._header, _dates.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)) + fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day)) cell = tk.Button( self._grid, name=f"day-{to_iso(day)}", @@ -124,7 +129,6 @@ class MonthCalendar(tk.Frame): 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") + dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text) + dot.place(relx=1.0, x=-2, y=2, anchor="ne") return cell diff --git a/sickdays/sickdays/gui/_controls.py b/sickdays/sickdays/gui/_controls.py new file mode 100644 index 0000000..e949db0 --- /dev/null +++ b/sickdays/sickdays/gui/_controls.py @@ -0,0 +1,171 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Word-free controls: an icon button, an on/off switch and an icon slot. + +Canvases, because a Tk Button or Checkbutton can only show its state as a +word or a font glyph (see :mod:`sickdays.gui._icons`). Each keeps Tk's own +``state`` option and offers ``invoke()``, so callers and tests drive them +like the widgets they replace. Every state has two channels: shape (icon, +knob position) plus colour. +""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING, Final, Literal + +from sickdays.gui import _tokens as tok +from sickdays.gui._icons import TAG, draw + +if TYPE_CHECKING: + from collections.abc import Callable + +ButtonKind = Literal["primary", "danger", "plain"] + +#: (fill, icon colour) per button kind; danger keeps its red on a dark fill. +_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), +} +ICON_PX: Final = 20 +_TRACK_W: Final = 44 +_TRACK_H: Final = 24 + + +def _enabled(widget: tk.Canvas) -> bool: + return str(widget.cget("state")) != "disabled" + + +class IconButton(tk.Canvas): + """A flat button showing ``icon``; ``invoke()`` runs ``command`` unless disabled.""" + + def __init__( + self, + parent: tk.Misc, + icon: str, + command: Callable[[], object], + *, + name: str, + kind: ButtonKind = "plain", + ) -> None: + """Draw the button; a click anywhere on it invokes it.""" + fill, self._colour = _BUTTON_COLOURS[kind] + super().__init__( + parent, + name=name, + width=ICON_PX + 2 * tok.MD, + height=ICON_PX + 2 * tok.SM, + bg=fill, + highlightthickness=0, + borderwidth=0, + cursor="hand2", + ) + #: Which pictogram this button shows. + self.icon = icon + self._command = command + self.bind("", lambda _event: self.invoke()) + self._paint() + + def set_enabled(self, enabled: bool) -> None: # noqa: FBT001 + """Grey the icon out and ignore clicks while disabled.""" + self.configure( + state="normal" if enabled else "disabled", + cursor="hand2" if enabled else "arrow", + ) + self._paint() + + def invoke(self) -> None: + """Run the command, as a click would.""" + if _enabled(self): + self._command() + + def _paint(self) -> None: + self.delete(TAG) + colour = self._colour if _enabled(self) else tok.MUTED_ON_DARK + draw(self, self.icon, size=ICON_PX, colour=colour, at=(tok.MD, tok.SM)) + + +class Toggle(tk.Canvas): + """An on/off switch for ``variable``: knob right on accent, left on dark.""" + + def __init__(self, parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> None: + """Draw the switch in ``variable``'s current state.""" + super().__init__( + parent, + name=name, + width=_TRACK_W, + height=_TRACK_H, + bg=parent.cget("bg"), + highlightthickness=0, + borderwidth=0, + cursor="hand2", + ) + self._variable = variable + self.bind("", lambda _event: self.invoke()) + self.paint() + + def set_enabled(self, enabled: bool) -> None: # noqa: FBT001 + """Fade the switch and ignore clicks while disabled.""" + self.configure( + state="normal" if enabled else "disabled", + cursor="hand2" if enabled else "arrow", + ) + self.paint() + + def invoke(self) -> None: + """Flip the switch, as a click would.""" + if _enabled(self): + self._variable.set(not self._variable.get()) + self.paint() + + def is_on(self) -> bool: + """Whether the locker this switch stands for still applies.""" + return bool(self._variable.get()) + + def paint(self) -> None: + """Redraw the track and knob from the variable.""" + self.delete(TAG) + on = self.is_on() + track, knob = ( + (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.MUTED_ON_DARK) + ) + if not _enabled(self): + track = tok.blend(track, tok.INK, tok.PAST_ALPHA) + knob = tok.blend(knob, tok.INK, tok.PAST_ALPHA) + h, w = _TRACK_H, _TRACK_W + self.create_oval(0, 0, h, h, fill=track, outline=track, tags=TAG) + self.create_oval(w - h, 0, w, h, fill=track, outline=track, tags=TAG) + self.create_rectangle( + h // 2, 0, w - h // 2, h, fill=track, outline=track, tags=TAG + ) + left = w - h + 3 if on else 3 + self.create_oval( + left, 3, left + h - 6, h - 3, fill=knob, outline=knob, tags=TAG + ) + + +class IconSlot(tk.Canvas): + """One icon, or nothing: a status mark, a locker's pictogram, a marker.""" + + def __init__(self, parent: tk.Misc, *, name: str = "", size: int = ICON_PX) -> None: + """An empty slot ``size`` px square on ``parent``'s background.""" + super().__init__( + parent, + name=name, + width=size, + height=size, + bg=parent.cget("bg"), + highlightthickness=0, + borderwidth=0, + ) + self._size = size + #: The pictogram shown, or ``None`` when the slot is empty. + self.icon: str | None = None + + def show(self, icon: str | None, colour: str = tok.TEXT_ON_DARK) -> IconSlot: + """Show ``icon`` in ``colour`` (``None`` clears the slot).""" + self.delete(TAG) + self.icon = icon + if icon is not None: + draw(self, icon, size=self._size, colour=colour) + return self diff --git a/sickdays/sickdays/gui/_dates.py b/sickdays/sickdays/gui/_dates.py new file mode 100644 index 0000000..dc62bd8 --- /dev/null +++ b/sickdays/sickdays/gui/_dates.py @@ -0,0 +1,86 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Calendar arithmetic and the free-day check for the picker -- no Tk in here. + +The picker shows no words (kuhy's textgate rule): dates are ISO numerals and +a refusal is a reason *code* the widgets turn into an icon. The library's +own message for an unavailable check goes to the log, not the screen. +""" + +from __future__ import annotations + +import calendar +from datetime import date +import logging +from typing import TYPE_CHECKING, Final, Literal + +from sickdays._conflict import free_day_predicate +from sickdays._constants import LOCKERS +from sickdays._errors import ConflictCheckUnavailableError + +if TYPE_CHECKING: + from collections.abc import Iterable + +_LOG = logging.getLogger(__name__) + +#: Why a day cannot be marked sick: it is a free day, or that cannot be checked. +Block = Literal["free", "unavailable"] + +#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``). +_WORKDAYS: Final = frozenset({1, 2, 3}) +#: Sat/Sun, marked with a ring instead of a dot over the grid. +_WEEKEND: Final = frozenset({5, 6}) + + +def kept_in_order(kept: Iterable[str]) -> tuple[str, ...]: + """The kept lockers in ``sickdays.LOCKERS`` order, not the set's.""" + chosen = set(kept) + return tuple(locker for locker in LOCKERS if locker in chosen) + + +def free_day_block(day: date) -> Block | None: + """Why ``day`` cannot be marked sick for the free-day pool, or None. + + "Could not check" refuses too: it must never count as "not a free day". + """ + try: + is_free = free_day_predicate() + except ConflictCheckUnavailableError as exc: + _LOG.warning("free-day check unavailable: %s", exc) + return "unavailable" + return "free" if is_free(day) else None + + +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 is_weekend_column(column: int) -> bool: + """Whether grid ``column`` (0 = Monday) is Saturday or Sunday.""" + return column in _WEEKEND + + +def month_title(month: date) -> str: + """``"2026-10"``: the month as numerals, never a month name.""" + return f"{month.year}-{month.month:02d}" + + +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/_icons.py b/sickdays/sickdays/gui/_icons.py new file mode 100644 index 0000000..ab0602f --- /dev/null +++ b/sickdays/sickdays/gui/_icons.py @@ -0,0 +1,235 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Pictograms drawn as Canvas geometry, so the picker needs no words. + +Drawn rather than typed: the venv's Tk falls back to core X fonts, where +every non-ASCII glyph (angle quotes, check marks) renders as nothing; the +old month arrows were blank buttons. Each icon is plotted on a unit square, +scaled to ``size`` pixels, and tagged ``icon`` so a repaint can drop it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from sickdays._constants import ( + LOCKER_ALARM, + LOCKER_BOOK, + LOCKER_DIET, + LOCKER_FOCUS, + LOCKER_LEETCODE, + LOCKER_WORKOUT, +) + +if TYPE_CHECKING: + from collections.abc import Callable + import tkinter as tk + +TAG: Final = "icon" + + +class Pen: + """Draws unit-square coordinates onto ``canvas`` at ``(x, y)``, ``size`` wide.""" + + def __init__( + self, canvas: tk.Canvas, *, size: int, colour: str, at: tuple[int, int] + ) -> None: + """Remember where and how large to draw.""" + self._canvas = canvas + self._size = size + self._colour = colour + self._x, self._y = at + self.width = max(2, round(size / 10)) + + def _xy(self, points: tuple[float, ...]) -> list[float]: + return [ + (self._y if i % 2 else self._x) + value * self._size + for i, value in enumerate(points) + ] + + def line(self, *points: float) -> None: + """A stroke through ``points`` (x0, y0, x1, y1, ...).""" + self._canvas.create_line( + *self._xy(points), + fill=self._colour, + width=self.width, + capstyle="round", + joinstyle="round", + tags=TAG, + ) + + def oval(self, *box: float, filled: bool = False) -> None: + """An ellipse in ``box`` (x0, y0, x1, y1), outlined or filled.""" + self._canvas.create_oval( + *self._xy(box), + outline=self._colour, + fill=self._colour if filled else "", + width=self.width, + tags=TAG, + ) + + def shape(self, *points: float, filled: bool = False) -> None: + """A closed polygon through ``points``, outlined or filled.""" + self._canvas.create_polygon( + *self._xy(points), + outline=self._colour, + fill=self._colour if filled else "", + width=self.width, + joinstyle="round", + tags=TAG, + ) + + def arch(self, *box: float) -> None: + """The top half of the ellipse in ``box`` (a padlock's shackle).""" + self._canvas.create_arc( + *self._xy(box), + start=0, + extent=180, + style="arc", + outline=self._colour, + width=self.width, + tags=TAG, + ) + + +def _check(pen: Pen) -> None: + pen.line(0.2, 0.55, 0.42, 0.76, 0.82, 0.28) + + +def _cross(pen: Pen) -> None: + pen.line(0.25, 0.25, 0.75, 0.75) + pen.line(0.75, 0.25, 0.25, 0.75) + + +def _trash(pen: Pen) -> None: + pen.line(0.18, 0.26, 0.82, 0.26) + pen.line(0.4, 0.26, 0.42, 0.14, 0.58, 0.14, 0.6, 0.26) + pen.shape(0.27, 0.34, 0.73, 0.34, 0.68, 0.88, 0.32, 0.88) + + +def _prev(pen: Pen) -> None: + pen.line(0.62, 0.22, 0.36, 0.5, 0.62, 0.78) + + +def _next(pen: Pen) -> None: + pen.line(0.38, 0.22, 0.64, 0.5, 0.38, 0.78) + + +def _workout(pen: Pen) -> None: + pen.line(0.2, 0.5, 0.8, 0.5) + pen.shape(0.17, 0.28, 0.29, 0.28, 0.29, 0.72, 0.17, 0.72, filled=True) + pen.shape(0.71, 0.28, 0.83, 0.28, 0.83, 0.72, 0.71, 0.72, filled=True) + pen.line(0.08, 0.5, 0.17, 0.5) + pen.line(0.83, 0.5, 0.92, 0.5) + + +def _leetcode(pen: Pen) -> None: + pen.line(0.36, 0.28, 0.12, 0.5, 0.36, 0.72) + pen.line(0.64, 0.28, 0.88, 0.5, 0.64, 0.72) + pen.line(0.57, 0.2, 0.43, 0.8) + + +def _book(pen: Pen) -> None: + pen.shape(0.5, 0.3, 0.1, 0.2, 0.1, 0.76, 0.5, 0.86) + pen.shape(0.5, 0.3, 0.9, 0.2, 0.9, 0.76, 0.5, 0.86) + + +def _diet(pen: Pen) -> None: + pen.oval(0.2, 0.34, 0.8, 0.9, filled=True) + pen.line(0.5, 0.34, 0.54, 0.14) + pen.oval(0.56, 0.12, 0.78, 0.26, filled=True) + + +def _alarm(pen: Pen) -> None: + pen.oval(0.18, 0.24, 0.82, 0.88) + pen.line(0.5, 0.38, 0.5, 0.56, 0.63, 0.64) + pen.oval(0.08, 0.1, 0.3, 0.3, filled=True) + pen.oval(0.7, 0.1, 0.92, 0.3, filled=True) + + +def _focus(pen: Pen) -> None: + pen.oval(0.1, 0.1, 0.9, 0.9) + pen.oval(0.3, 0.3, 0.7, 0.7) + pen.oval(0.44, 0.44, 0.56, 0.56, filled=True) + + +def _padlock(pen: Pen) -> None: + pen.arch(0.3, 0.12, 0.7, 0.62) + pen.shape(0.22, 0.44, 0.78, 0.44, 0.78, 0.88, 0.22, 0.88, filled=True) + + +def _no_entry(pen: Pen) -> None: + pen.oval(0.14, 0.14, 0.86, 0.86) + pen.line(0.28, 0.28, 0.72, 0.72) + + +def _warning(pen: Pen) -> None: + pen.shape(0.5, 0.12, 0.9, 0.86, 0.1, 0.86) + pen.line(0.5, 0.38, 0.5, 0.6) + pen.oval(0.47, 0.71, 0.53, 0.77, filled=True) + + +def _hourglass(pen: Pen) -> None: + pen.line(0.22, 0.12, 0.78, 0.12) + pen.line(0.22, 0.88, 0.78, 0.88) + pen.shape(0.28, 0.12, 0.72, 0.12, 0.28, 0.88, 0.72, 0.88) + + +def _sick(pen: Pen) -> None: + pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85, filled=True) + + +def _normal(pen: Pen) -> None: + pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85) + + +def _dot(pen: Pen) -> None: + pen.oval(0.3, 0.3, 0.7, 0.7, filled=True) + + +def _ring(pen: Pen) -> None: + pen.oval(0.25, 0.25, 0.75, 0.75) + + +ICONS: Final[dict[str, Callable[[Pen], None]]] = { + "check": _check, + "cross": _cross, + "trash": _trash, + "prev": _prev, + "next": _next, + "workout": _workout, + "leetcode": _leetcode, + "book": _book, + "diet": _diet, + "alarm": _alarm, + "focus": _focus, + "padlock": _padlock, + "no_entry": _no_entry, + "warning": _warning, + "hourglass": _hourglass, + "sick": _sick, + "normal": _normal, + "dot": _dot, + "ring": _ring, +} + +#: The pictogram each locker is shown as, in place of its name. +LOCKER_ICONS: Final = { + LOCKER_WORKOUT: "workout", + LOCKER_LEETCODE: "leetcode", + LOCKER_BOOK: "book", + LOCKER_DIET: "diet", + LOCKER_ALARM: "alarm", + LOCKER_FOCUS: "focus", +} + + +def draw( + canvas: tk.Canvas, + icon: str, + *, + size: int, + colour: str, + at: tuple[int, int] = (0, 0), +) -> None: + """Draw ``icon`` on ``canvas`` in a ``size`` px square whose corner is ``at``.""" + ICONS[icon](Pen(canvas, size=size, colour=colour, at=at)) diff --git a/sickdays/sickdays/gui/_panel.py b/sickdays/sickdays/gui/_panel.py index 428152c..c0283fc 100644 --- a/sickdays/sickdays/gui/_panel.py +++ b/sickdays/sickdays/gui/_panel.py @@ -1,5 +1,5 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""KeepPanel: the five switches for one day, and the buttons that write them. +"""KeepPanel: a switch per locker 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, @@ -7,29 +7,34 @@ 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. +-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a +sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A +past day (padlock), a free day (no-entry sign) or a free-day pool that cannot +be checked (warning) shows its icon and offers no write. A refused write is +shown in the panel as an icon, never raised: an exception in a Tk callback +only reaches stderr, which nobody sees under a lock. The panel shows no +words (kuhy's textgate rule): a locker is its pictogram, the day its ISO date. """ from __future__ import annotations +import logging import threading import tkinter as tk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final 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._day import to_iso, 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 _dates from sickdays.gui import _tokens as tok from sickdays.gui import _widgets as ui +from sickdays.gui._controls import IconButton, IconSlot, Toggle +from sickdays.gui._icons import LOCKER_ICONS if TYPE_CHECKING: from collections.abc import Callable @@ -39,6 +44,17 @@ if TYPE_CHECKING: from sickdays._paths import Paths +_LOG = logging.getLogger(__name__) + +#: (icon, colour) for each reason the panel shows, keyed by that reason. +STATUS_ICONS: Final = { + "past": ("padlock", tok.MUTED_ON_DARK), + "free": ("no_entry", tok.DANGER), + "unavailable": ("warning", tok.DANGER), + "refused": ("warning", tok.DANGER), + "busy": ("hourglass", tok.DANGER), +} + #: Held by every background sync, and tried by every write. ``sync()`` reads #: the pool, waits on the network, then writes its merged copy back, so a #: write landing in between would be silently overwritten. A write that finds @@ -73,16 +89,17 @@ class KeepPanel(tk.Frame): ``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``). + * ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``); + * ``actions.save`` with a changed set -> the stored entry (``is_sick=True``); + * ``actions.save`` with nothing changed -> ``None``: nothing written, no sync; + * ``actions.clear`` -> the cleared entry (``is_sick=False``). A refused write (:class:`~sickdays.SickDayError`, or a click while the - previous write's sync is still running) 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. + previous write's sync is still running) is shown in the panel's + ``status`` slot and ``on_saved`` is not called; :attr:`reason` names it + (a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an + ``actions.cancel`` cross that calls it. The panel never destroys itself; + the callbacks decide what happens to it. """ def __init__( @@ -107,12 +124,15 @@ class KeepPanel(tk.Frame): } #: The last background sync, for callers (and tests) that wait on it. self.sync_thread: threading.Thread | None = None + #: Why the panel refuses, a :data:`STATUS_ICONS` key, or ``None``. + self.reason: str | 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 = IconSlot(self, name="status") + self._status.grid( + row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0) ) - self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w") + self._show(blocked) self._draw_actions(writable=blocked is None, on_cancel=on_cancel) def kept(self) -> frozenset[str]: @@ -121,49 +141,54 @@ class KeepPanel(tk.Frame): 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) + return "past" + return None if self._sick else _dates.free_day_block(self._day) + + def _show(self, reason: str | None) -> None: + self.reason = reason + if reason is None: + self._status.show(None) + return + icon, colour = STATUS_ICONS[reason] + self._status.show(icon, colour) 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( + head = tk.Frame(self, name="head", bg=self.cget("bg")) + head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM)) + IconSlot(head, name="state").show( + "sick" if self._sick else "normal", tok.SICK + ).pack(side="left", padx=(0, tok.SM)) + ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left") + for row, locker in enumerate(LOCKERS, start=1): + IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid( row=row, column=0, sticky="w", pady=tok.XS ) - switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}") + switch = 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") + switch.set_enabled(False) 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) + row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0) ) - buttons: list[tk.Button] = [] + buttons: list[IconButton] = [] if writable and self._sick: buttons.append( - ui.button(actions, "Save", self._save, kind="primary", name="save") + IconButton(actions, "check", self._save, kind="primary", name="save") ) buttons.append( - ui.button( - actions, "Clear sick day", self._clear, kind="danger", name="clear" - ) + IconButton(actions, "trash", self._clear, kind="danger", name="clear") ) elif writable: buttons.append( - ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark") + IconButton(actions, "check", self._mark, kind="primary", name="mark") ) if on_cancel is not None: - buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel")) + buttons.append(IconButton(actions, "cross", on_cancel, name="cancel")) for button in buttons: button.pack(side="left", padx=(0, tok.SM)) @@ -187,12 +212,13 @@ class KeepPanel(tk.Frame): # Non-blocking on purpose (no ``with``): waiting would freeze Tk for # the whole network round trip. if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with - self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER) + self._show("busy") return try: entry = action() except SickDayError as exc: - self._status.configure(text=str(exc), fg=tok.DANGER) + _LOG.warning("sick-day write refused: %s", exc) + self._show("refused") return finally: _SYNC_LOCK.release() diff --git a/sickdays/sickdays/gui/_text.py b/sickdays/sickdays/gui/_text.py deleted file mode 100644 index 5b13780..0000000 --- a/sickdays/sickdays/gui/_text.py +++ /dev/null @@ -1,125 +0,0 @@ -# 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_FOCUS, - 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", - LOCKER_FOCUS: "Focus", -} - -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." -BUSY_NOTE: Final = "Syncing \u2014 try again in a moment." -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/_widgets.py b/sickdays/sickdays/gui/_widgets.py index 0d3d261..cac8c13 100644 --- a/sickdays/sickdays/gui/_widgets.py +++ b/sickdays/sickdays/gui/_widgets.py @@ -1,33 +1,20 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""Token-styled Tk building blocks shared by the panel and the window. +"""Token-styled Tk labels for the numerals the picker shows (dates, months). -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. +Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and +icons are word-free Canvases in :mod:`sickdays.gui._controls`. """ from __future__ import annotations import tkinter as tk from tkinter import font as tkfont -from typing import TYPE_CHECKING, Final, Literal +from typing import Final 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.""" @@ -42,10 +29,7 @@ def label( 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. - """ + """A label on ``parent``'s own background; titles and subtitles are bold.""" return tk.Label( parent, name=name, @@ -55,71 +39,4 @@ def label( 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 index 5f767fc..6a8d6b4 100644 --- a/sickdays/sickdays/gui/_window.py +++ b/sickdays/sickdays/gui/_window.py @@ -2,26 +2,33 @@ """``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. +the clicked day, the sick days still ahead underneath (each an ISO date and +the pictograms of the lockers it keeps). The window class is ``sickdays`` +(Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 ``for_window`` +rule can float it; ``Escape`` closes it. + +No words inside the window (kuhy's textgate rule). The title bar keeps the +app's name: an OS-level app name is a proper noun (textgate settled +decision 4), the same as ``sickdays.desktop``'s ``Name=``. """ from __future__ import annotations import tkinter as tk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from sickdays._api import is_sick_day, load -from sickdays._day import today +from sickdays._day import to_iso, 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 _dates 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 +from sickdays.gui._controls import IconSlot +from sickdays.gui._icons import LOCKER_ICONS +from sickdays.gui._panel import STATUS_ICONS, KeepPanel if TYPE_CHECKING: from datetime import date @@ -30,19 +37,18 @@ if TYPE_CHECKING: from sickdays._paths import Paths WINDOW_CLASS = "sickdays" +WINDOW_TITLE = "Sick days" +#: A kept locker's pictogram in the list of days ahead, in px. +_KEPT_PX: Final = 16 -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" - ) +def saved_icon(entry: SickDay | None) -> str | None: + """The status mark after the panel closed with ``entry``. + + A check for any write (the grid and the list show what it was); nothing + when the panel was saved unchanged. + """ + return None if entry is None else "check" class SickDaysApp: @@ -52,7 +58,7 @@ class SickDaysApp: """Style ``root`` and draw the current pool.""" self._root = root self._paths = resolve_paths(paths) - root.title("Sick days") + root.title(WINDOW_TITLE) root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG) root.bind("", self.close) left = tk.Frame(root, bg=tok.INK) @@ -60,13 +66,9 @@ class SickDaysApp: #: 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)) + #: The last outcome's mark: a check, or the panel's refusal icons. + self.status = IconSlot(left, name="status") + self.status.pack(anchor="w") log = self._paths.log self.calendar = MonthCalendar( left, @@ -75,8 +77,8 @@ class SickDaysApp: 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( + self.calendar.pack(anchor="w", pady=(tok.SM, 0)) + IconSlot(left, name="ahead").show("sick", tok.SICK).pack( anchor="w", pady=(tok.LG, tok.SM) ) self._list = tk.Frame(left, name="upcoming", bg=tok.INK) @@ -88,25 +90,25 @@ class SickDaysApp: self._root.destroy() def select(self, day: date) -> None: - """Open the panel for ``day``, or refuse a free day in the status line.""" + """Open the panel for ``day``, or refuse a free day in the status mark.""" 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) + block = _dates.free_day_block(day) + if block is not None: + self.status.show(*STATUS_ICONS[block]) return - self._set_status("", failed=False) + self.status.show(None) KeepPanel( self.host, day, - on_saved=lambda entry: self._saved(entry, day), + on_saved=self._saved, on_cancel=self._close_panel, paths=self._paths, ).pack(anchor="n") - def _saved(self, entry: SickDay | None, day: date) -> None: + def _saved(self, entry: SickDay | None) -> None: self._close_panel() - self._set_status(saved_message(entry, day), failed=False) + self.status.show(saved_icon(entry), tok.SUCCESS) self.calendar.redraw() self._draw_upcoming() @@ -114,29 +116,20 @@ class SickDaysApp: 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)) + for entry in sick_entries(load(self._paths.log)): + if entry.day < reference: + continue + row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK) + row.pack(anchor="w", pady=(0, tok.XS)) + ui.label(row, to_iso(entry.day), "body").pack(side="left") + for locker in _dates.kept_in_order(entry.keep): + IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show( + LOCKER_ICONS[locker], tok.MUTED_ON_DARK + ).pack(side="left", padx=(tok.SM, 0)) def run(*, paths: Paths | None = None) -> int: diff --git a/sickdays/sickdays/tests/conftest.py b/sickdays/sickdays/tests/conftest.py index 1c44b9a..55fa105 100644 --- a/sickdays/sickdays/tests/conftest.py +++ b/sickdays/sickdays/tests/conftest.py @@ -61,7 +61,7 @@ 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._dates.free_day_predicate", lambda: _never_free) monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync) diff --git a/sickdays/sickdays/tests/test_gui_controls.py b/sickdays/sickdays/tests/test_gui_controls.py new file mode 100644 index 0000000..233ba0f --- /dev/null +++ b/sickdays/sickdays/tests/test_gui_controls.py @@ -0,0 +1,70 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The word-free controls and the drawn icons, against a real Tk.""" + +from __future__ import annotations + +import tkinter as tk + +import pytest + +from sickdays._constants import LOCKERS +from sickdays.gui import _tokens as tok +from sickdays.gui._controls import IconButton, IconSlot, Toggle +from sickdays.gui._icons import ICONS, LOCKER_ICONS, TAG + + +@pytest.fixture +def shown(tk_root: tk.Tk) -> tk.Tk: + """The root mapped, so real clicks reach the canvases.""" + tk_root.deiconify() + tk_root.update() + return tk_root + + +def _click(widget: tk.Canvas) -> None: + widget.pack() + widget.update() + widget.event_generate("", x=2, y=2) + widget.update() + + +def test_every_icon_draws_something(tk_root: tk.Tk) -> None: + for icon in ICONS: + slot = IconSlot(tk_root, size=32).show(icon, tok.DANGER) + assert slot.icon == icon + assert slot.find_withtag(TAG), icon + assert slot.show(None).icon is None + assert slot.find_withtag(TAG) == () + + +def test_every_locker_has_an_icon() -> None: + assert set(LOCKER_ICONS) == set(LOCKERS) + assert set(LOCKER_ICONS.values()) <= set(ICONS) + + +def test_a_click_invokes_the_button_unless_disabled(shown: tk.Tk) -> None: + calls: list[int] = [] + button = IconButton(shown, "check", lambda: calls.append(1), name="ok") + button.set_enabled(False) + assert str(button.cget("cursor")) == "arrow" + _click(button) + button.invoke() + assert calls == [] + button.set_enabled(True) + _click(button) + assert calls == [1] + + +def test_a_click_flips_the_switch_unless_disabled(shown: tk.Tk) -> None: + on = tk.BooleanVar(shown, value=False) + switch = Toggle(shown, on, name="switch") + _click(switch) + assert switch.is_on() + assert on.get() + switch.set_enabled(False) + _click(switch) + switch.invoke() + assert switch.is_on() + switch.set_enabled(True) + switch.invoke() + assert not switch.is_on() diff --git a/sickdays/sickdays/tests/test_gui_dates.py b/sickdays/sickdays/tests/test_gui_dates.py new file mode 100644 index 0000000..ff2137c --- /dev/null +++ b/sickdays/sickdays/tests/test_gui_dates.py @@ -0,0 +1,87 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The picker's calendar arithmetic, free-day check and tokens -- no display.""" + +from __future__ import annotations + +from datetime import date +import logging +from typing import TYPE_CHECKING + +from sickdays._errors import ConflictCheckUnavailableError +from sickdays.gui import _dates +from sickdays.gui import _tokens as tok + +if TYPE_CHECKING: + import pytest + +DAY = date(2026, 10, 20) + + +def test_kept_lockers_follow_locker_order_not_the_alphabet() -> None: + assert _dates.kept_in_order({"alarm", "book", "workout"}) == ( + "workout", + "book", + "alarm", + ) + assert _dates.kept_in_order(frozenset()) == () + + +def test_free_day_block( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + assert _dates.free_day_block(DAY) is None + monkeypatch.setattr( + "sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True + ) + assert _dates.free_day_block(DAY) == "free" + + def unavailable() -> None: + msg = "freedays is not installed" + raise ConflictCheckUnavailableError(msg) + + monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable) + with caplog.at_level(logging.WARNING): + assert _dates.free_day_block(DAY) == "unavailable" + assert "freedays is not installed" in caplog.text + + +def test_month_grid_is_monday_first() -> None: + weeks = _dates.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 _dates.month_title(date(2026, 10, 1)) == "2026-10" + assert _dates.month_title(date(2027, 3, 1)) == "2027-03" + assert [_dates.is_weekend_column(c) for c in range(7)] == [ + False, + False, + False, + False, + False, + True, + True, + ] + + +def test_month_arithmetic_and_range() -> None: + assert _dates.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1) + assert _dates.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1) + assert _dates.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1)) + + +def test_workdays_are_tuesday_to_thursday() -> None: + assert [_dates.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_no_words.py b/sickdays/sickdays/tests/test_gui_no_words.py new file mode 100644 index 0000000..888aa7b --- /dev/null +++ b/sickdays/sickdays/tests/test_gui_no_words.py @@ -0,0 +1,113 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The textgate guard: no letter on any widget of the picker, in any state. + +Everything this window shows is chrome except numerals and ISO dates (day +numbers, ``2026-11``, ``2026-11-13``), so the guard bans every letter, not +just words. The window title is the app's proper noun (textgate settled +decision 4) and is checked on its own. +""" + +from __future__ import annotations + +from datetime import timedelta +import re +import tkinter as tk +from typing import TYPE_CHECKING + +from sickdays._api import add_sick_day +from sickdays._day import today +from sickdays._errors import ConflictCheckUnavailableError +from sickdays.gui import KeepPanel, SickDaysApp, _panel + +if TYPE_CHECKING: + import pytest + +FUTURE = today() + timedelta(days=3) +LETTER = re.compile(r"[^\W\d_]") + + +def _shown(widget: tk.Misc) -> list[str]: + """Every string ``widget`` and its descendants paint.""" + found: list[str] = [] + for child in widget.winfo_children(): + if isinstance(child, tk.Canvas): + # Raw Tcl: typeshed types Canvas.type()/itemcget() too loosely. + path = str(child) + found.extend( + str(child.tk.call(path, "itemcget", item, "-text")) + for item in child.find_all() + if str(child.tk.call(path, "type", item)) == "text" + ) + elif isinstance(child, (tk.Label, tk.Button, tk.Checkbutton)): + found.append(str(child.cget("text"))) + found.extend(_shown(child)) + return found + + +def _assert_no_letters(widget: tk.Misc) -> None: + shown = _shown(widget) + assert shown, "the guard walked no widget" + worded = [text for text in shown if LETTER.search(text)] + assert worded == [] + + +def test_the_window_title_is_the_app_name(tk_root: tk.Tk) -> None: + SickDaysApp(tk_root) + assert tk_root.title() == "Sick days" + + +def test_the_window_shows_no_letters(tk_root: tk.Tk) -> None: + add_sick_day(FUTURE, keep=["alarm", "workout"]) + app = SickDaysApp(tk_root) + _assert_no_letters(tk_root) + app.select(FUTURE + timedelta(days=1)) + _assert_no_letters(tk_root) + + +def test_the_window_after_a_refusal_shows_no_letters( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + "sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True + ) + SickDaysApp(tk_root).select(FUTURE) + _assert_no_letters(tk_root) + + +def test_the_window_after_a_save_shows_no_letters(tk_root: tk.Tk) -> None: + app = SickDaysApp(tk_root) + app.select(FUTURE) + (panel,) = app.host.winfo_children() + panel.nametowidget("actions.mark").invoke() + assert isinstance(panel, KeepPanel) + assert panel.sync_thread is not None + panel.sync_thread.join(timeout=5) + _assert_no_letters(tk_root) + + +def test_every_panel_state_shows_no_letters( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + add_sick_day(FUTURE, keep=["book"]) + for day in (FUTURE, FUTURE + timedelta(days=1), today() - timedelta(days=1)): + _assert_no_letters( + KeepPanel(tk_root, day, on_saved=lambda _e: None, on_cancel=lambda: None) + ) + + def unavailable() -> None: + msg = "cannot check the free-day pool" + raise ConflictCheckUnavailableError(msg) + + monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable) + _assert_no_letters(KeepPanel(tk_root, FUTURE + timedelta(days=2), on_saved=print)) + + +def test_a_refused_or_busy_write_shows_no_letters(tk_root: tk.Tk) -> None: + refused = KeepPanel(tk_root, FUTURE + timedelta(days=4), on_saved=print) + add_sick_day(FUTURE + timedelta(days=4)) + refused.nametowidget("actions.mark").invoke() + _assert_no_letters(refused) + busy = KeepPanel(tk_root, FUTURE + timedelta(days=5), on_saved=print) + with _panel._SYNC_LOCK: + busy.nametowidget("actions.mark").invoke() + _assert_no_letters(busy) diff --git a/sickdays/sickdays/tests/test_gui_panel.py b/sickdays/sickdays/tests/test_gui_panel.py index e6425d9..6c9d804 100644 --- a/sickdays/sickdays/tests/test_gui_panel.py +++ b/sickdays/sickdays/tests/test_gui_panel.py @@ -1,26 +1,27 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""KeepPanel: the five switches for one day, against a real (unmapped) Tk.""" +"""KeepPanel: the six switches for one day, against a real (unmapped) Tk.""" from __future__ import annotations from datetime import timedelta +import logging import threading -import tkinter as tk from typing import TYPE_CHECKING import pytest from sickdays._api import add_sick_day, is_sick_day, load -from sickdays._day import today +from sickdays._day import to_iso, today from sickdays._errors import ConflictCheckUnavailableError from sickdays._keep_api import kept_lockers from sickdays._paths import Paths from sickdays._store import write_pool -from sickdays.gui import KeepPanel, _text -from sickdays.gui import _tokens as tok +from sickdays.gui import KeepPanel +from sickdays.gui._controls import IconButton, IconSlot, Toggle if TYPE_CHECKING: from pathlib import Path + import tkinter as tk from sickdays._model import SickDay @@ -40,20 +41,22 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]: return calls -def _button(panel: KeepPanel, name: str) -> tk.Button: +def _button(panel: KeepPanel, name: str) -> IconButton: widget = panel.nametowidget(f"actions.{name}") - assert isinstance(widget, tk.Button) + assert isinstance(widget, IconButton) return widget -def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton: +def _toggle(panel: KeepPanel, locker: str) -> Toggle: widget = panel.nametowidget(f"keep-{locker}") - assert isinstance(widget, tk.Checkbutton) + assert isinstance(widget, Toggle) return widget -def _status(panel: KeepPanel) -> str: - return str(panel.nametowidget("status").cget("text")) +def _status(panel: KeepPanel) -> str | None: + slot = panel.nametowidget("status") + assert isinstance(slot, IconSlot) + return slot.icon def _actions(panel: KeepPanel) -> set[str]: @@ -72,11 +75,11 @@ def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None]) assert panel.kept() == frozenset() assert _actions(panel) == {"mark"} toggle = _toggle(panel, "leetcode") - assert toggle.cget("text") == "Off" + assert not toggle.is_on() toggle.invoke() - assert toggle.cget("text") == "On" - assert str(toggle.cget("bg")).upper() == tok.ACCENT - assert str(toggle.cget("fg")).upper() == tok.ON_FILL + assert toggle.is_on() + assert _button(panel, "mark").icon == "check" + assert _status(panel) is None _button(panel, "mark").invoke() _join(panel) assert is_sick_day(FUTURE) @@ -93,8 +96,14 @@ def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None: 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)}" + assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE) + state = panel.nametowidget("head.state") + assert isinstance(state, IconSlot) + assert state.icon == "sick" + assert _button(panel, "clear").icon == "trash" + icon = panel.nametowidget("icon-workout") + assert isinstance(icon, IconSlot) + assert icon.icon == "workout" def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None: @@ -139,20 +148,27 @@ def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None]) 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 panel.reason == "past" + assert _status(panel) == "padlock" assert _actions(panel) == set() - assert str(_toggle(panel, "diet").cget("state")) == "disabled" + diet = _toggle(panel, "diet") + assert str(diet.cget("state")) == "disabled" + diet.invoke() + assert not diet.is_on() 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 + "sickdays.gui._dates.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 panel.reason == "free" + assert _status(panel) == "no_entry" + state = panel.nametowidget("head.state") + assert isinstance(state, IconSlot) + assert state.icon == "normal" assert _actions(panel) == set() @@ -163,20 +179,24 @@ def test_an_uncheckable_free_pool_refuses( msg = "cannot check the free-day pool" raise ConflictCheckUnavailableError(msg) - monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable) + monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable) panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None) - assert _status(panel) == "cannot check the free-day pool" + assert panel.reason == "unavailable" + assert _status(panel) == "warning" assert _actions(panel) == set() def test_a_refused_write_is_shown_not_raised( - tk_root: tk.Tk, synced: list[Paths | None] + tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture ) -> 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) + with caplog.at_level(logging.WARNING): + _button(panel, "mark").invoke() + assert panel.reason == "refused" + assert _status(panel) == "warning" + assert "already a sick day" in caplog.text assert saved == [] assert synced == [] @@ -189,7 +209,9 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None: on_saved=lambda _e: None, on_cancel=lambda: cancelled.append(True), ) - _button(panel, "cancel").invoke() + cancel = _button(panel, "cancel") + assert cancel.icon == "cross" + cancel.invoke() assert cancelled == [True] @@ -242,4 +264,5 @@ def test_a_write_during_a_sync_is_refused_not_lost( for entry in saved: assert entry is not None assert is_sick_day(entry.day), f"{entry.day} was written, then lost" - assert _status(second) == _text.BUSY_NOTE + assert second.reason == "busy" + assert _status(second) == "hourglass" diff --git a/sickdays/sickdays/tests/test_gui_text.py b/sickdays/sickdays/tests/test_gui_text.py deleted file mode 100644 index d9a3ca9..0000000 --- a/sickdays/sickdays/tests/test_gui_text.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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", - "Focus", - ] - - -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 index 0244e31..8ea43bb 100644 --- a/sickdays/sickdays/tests/test_gui_window.py +++ b/sickdays/sickdays/tests/test_gui_window.py @@ -10,8 +10,9 @@ 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 KeepPanel, SickDaysApp, _dates, _window from sickdays.gui import _tokens as tok +from sickdays.gui._controls import IconButton, IconSlot if TYPE_CHECKING: import pytest @@ -19,13 +20,19 @@ if TYPE_CHECKING: 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 _upcoming(app: SickDaysApp) -> dict[str, list[str]]: + """Each listed day's ISO date -> the locker icons it keeps.""" + rows = app.calendar.master.nametowidget("upcoming").winfo_children() + listed: dict[str, list[str]] = {} + for row in rows: + icons = [ + str(slot.icon) + for slot in row.winfo_children() + if isinstance(slot, IconSlot) + ] + (date_label,) = [c for c in row.winfo_children() if isinstance(c, tk.Label)] + listed[str(date_label.cget("text"))] = icons + return listed def _cell(app: SickDaysApp, day: date) -> tk.Button: @@ -40,14 +47,17 @@ def _panel(app: SickDaysApp) -> KeepPanel: return panel -def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None: +def test_an_empty_pool_lists_nothing_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 _upcoming(app) == {} + assert app.status.icon is None assert app.host.winfo_children() == [] def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None: + later = FUTURE + timedelta(days=1) + add_sick_day(later) app = SickDaysApp(tk_root) app.calendar.month = FUTURE.replace(day=1) app.calendar.redraw() @@ -58,10 +68,8 @@ def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None: 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) + assert app.status.icon == "check" + assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []} cell = _cell(app, FUTURE) assert str(cell.cget("bg")).upper() == tok.SICK assert cell.nametowidget("dot").winfo_exists() @@ -78,13 +86,12 @@ 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 + "sickdays.gui._dates.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 + assert app.status.icon == "no_entry" def test_a_sick_day_on_a_free_day_still_opens( @@ -92,7 +99,7 @@ def test_a_sick_day_on_a_free_day_still_opens( ) -> None: add_sick_day(FUTURE) monkeypatch.setattr( - "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True + "sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True ) app = SickDaysApp(tk_root) app.select(FUTURE) @@ -102,19 +109,31 @@ def test_a_sick_day_on_a_free_day_still_opens( 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 + past = today() - timedelta(days=1) + add_sick_day(past, now=past) + assert _upcoming(SickDaysApp(tk_root)) == { + to_iso(FUTURE): ["workout", "alarm"], + to_iso(FUTURE + timedelta(days=1)): [], + } -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_saved_icon() -> None: + assert _window.saved_icon(None) is None + assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check" + assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check" + + +def test_the_month_reads_as_numerals_with_weekday_marks(tk_root: tk.Tk) -> None: + app = SickDaysApp(tk_root) + title = app.calendar.nametowidget("header").winfo_children()[-1] + assert isinstance(title, tk.Label) + assert title.cget("text") == _dates.month_title(today().replace(day=1)) + marks = [ + child.icon + for child in app.calendar.nametowidget("days").winfo_children() + if isinstance(child, IconSlot) + ] + assert marks == ["dot"] * 5 + ["ring"] * 2 def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None: @@ -128,7 +147,7 @@ def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None: def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None: app = SickDaysApp(tk_root) - first, last = _text.month_bounds(today()) + first, last = _dates.month_bounds(today()) for _ in range(30): app.calendar.step(1) assert app.calendar.month == last @@ -136,6 +155,8 @@ def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None: app.calendar.step(-1) assert app.calendar.month == first back = app.calendar.nametowidget("header.prev") + assert isinstance(back, IconButton) + assert back.icon == "prev" assert str(back.cget("state")) == "disabled"