Make sickdays (in kuhyx/utils) text-free (textgat…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run

Todo: 2d0704cd
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 19:07:37 +01:00
parent ae56037e41
commit 9d6b17fd28
17 changed files with 1035 additions and 484 deletions

View File

@ -70,22 +70,35 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
## The picker window (0.3.0) ## The picker window (0.3.0)
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on `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 inert; a free day is refused; each write starts `sync_quietly()` on a
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
just the panel (`import sickdays` never imports tkinter): 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 ```python
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call 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() KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
``` ```
Normal day: five switches, all off (off = stands down), "Mark sick"; sick Normal day: six switches, all off (off = stands down), `actions.mark`; sick
day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable
why and offer no write; a refusal (or a click mid-sync) shows in the panel. 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 `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` mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None`
on an unchanged Save (nothing written). The panel never destroys itself. on an unchanged save (nothing written). The panel never destroys itself.
## API ## API

View File

@ -120,6 +120,9 @@ disable = []
min-public-methods = 0 min-public-methods = 0
max-module-lines = 1000 max-module-lines = 1000
max-attributes = 10 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] [tool.pylint.typecheck]
generated-members = [ generated-members = [

View File

@ -1,29 +1,33 @@
# Copyright (c) 2026 Krzysztof Rudnicki # 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 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 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 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 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 from __future__ import annotations
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Final
from sickdays._day import to_iso 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 _tokens as tok
from sickdays.gui import _widgets as ui from sickdays.gui import _widgets as ui
from sickdays.gui._controls import IconButton, IconSlot
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from datetime import date from datetime import date
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}" #: The weekday marks over the grid, in px.
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}" _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]: 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._is_sick = is_sick
self._has_dot = has_dot self._has_dot = has_dot
self._on_day = on_day 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.month = today.replace(day=1)
self._header = tk.Frame(self, name="header", bg=self.cget("bg")) self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
self._header.pack(fill="x") self._header.pack(fill="x")
@ -66,7 +70,7 @@ class MonthCalendar(tk.Frame):
def step(self, months: int) -> None: def step(self, months: int) -> None:
"""Move ``months`` months (within the phone's range) and redraw.""" """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: if self._first <= target <= self._last:
self.month = target self.month = target
self.redraw() self.redraw()
@ -77,11 +81,12 @@ class MonthCalendar(tk.Frame):
for child in frame.winfo_children(): for child in frame.winfo_children():
child.destroy() child.destroy()
self._draw_header() self._draw_header()
for column, initial in enumerate(_text.WEEKDAY_INITIALS): for column in range(7):
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid( mark = "ring" if _dates.is_weekend_column(column) else "dot"
row=0, column=column 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): for column, day in enumerate(week):
if day is not None: if day is not None:
self._cell(day).grid( self._cell(day).grid(
@ -89,20 +94,20 @@ class MonthCalendar(tk.Frame):
) )
def _draw_header(self) -> None: def _draw_header(self) -> None:
back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev") back = IconButton(self._header, "prev", lambda: self.step(-1), name="prev")
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next") forward = IconButton(self._header, "next", lambda: self.step(1), name="next")
back.configure(state="normal" if self.month > self._first else "disabled") back.set_enabled(self.month > self._first)
forward.configure(state="normal" if self.month < self._last else "disabled") forward.set_enabled(self.month < self._last)
back.pack(side="left") back.pack(side="left")
forward.pack(side="right") 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 side="top", pady=tok.XS
) )
def _cell(self, day: date) -> tk.Button: def _cell(self, day: date) -> tk.Button:
sick = self._is_sick(day) sick = self._is_sick(day)
past = day < self._today 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( cell = tk.Button(
self._grid, self._grid,
name=f"day-{to_iso(day)}", name=f"day-{to_iso(day)}",
@ -124,7 +129,6 @@ class MonthCalendar(tk.Frame):
cursor="arrow" if past else "hand2", cursor="arrow" if past else "hand2",
) )
if sick and self._has_dot(day): if sick and self._has_dot(day):
dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill) dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0) dot.place(relx=1.0, x=-2, y=2, anchor="ne")
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
return cell return cell

View File

@ -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("<Button-1>", 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("<Button-1>", 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

View File

@ -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)

View File

@ -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))

View File

@ -1,5 +1,5 @@
# Copyright (c) 2026 Krzysztof Rudnicki # 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 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, 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). 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 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 -- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A
day, or a free-day pool that cannot be checked shows why and offers no past day (padlock), a free day (no-entry sign) or a free-day pool that cannot
write. A refused write is shown in the panel, never raised: an exception in a be checked (warning) shows its icon and offers no write. A refused write is
Tk callback only reaches stderr, which nobody sees under a lock. 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 from __future__ import annotations
import logging
import threading import threading
import tkinter as tk 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._api import add_sick_day, is_sick_day, remove_sick_day
from sickdays._constants import LOCKERS from sickdays._constants import LOCKERS
from sickdays._day import today from sickdays._day import to_iso, today
from sickdays._errors import SickDayError from sickdays._errors import SickDayError
from sickdays._keep_api import kept_lockers, set_kept_lockers from sickdays._keep_api import kept_lockers, set_kept_lockers
from sickdays._paths import resolve_paths from sickdays._paths import resolve_paths
from sickdays._sync import sync_quietly 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 _tokens as tok
from sickdays.gui import _widgets as ui 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: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -39,6 +44,17 @@ if TYPE_CHECKING:
from sickdays._paths import Paths 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 #: 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 #: 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 #: 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 ``on_saved`` is called once per successful click, after the write and
after the background sync has started: after the background sync has started:
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``); * ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
* "Save" with a changed set -> the stored entry (``is_sick=True``); * ``actions.save`` with a changed set -> the stored entry (``is_sick=True``);
* "Save" with nothing changed -> ``None``: nothing written, no sync; * ``actions.save`` with nothing changed -> ``None``: nothing written, no sync;
* "Clear sick day" -> the cleared entry (``is_sick=False``). * ``actions.clear`` -> the cleared entry (``is_sick=False``).
A refused write (:class:`~sickdays.SickDayError`, or a click while the A refused write (:class:`~sickdays.SickDayError`, or a click while the
previous write's sync is still running) is shown in the panel and previous write's sync is still running) is shown in the panel's
``on_saved`` is not called. ``on_cancel``, when given, adds a ``status`` slot and ``on_saved`` is not called; :attr:`reason` names it
"Cancel" button that calls it. The panel never destroys itself; the (a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an
callbacks decide what happens to it. ``actions.cancel`` cross that calls it. The panel never destroys itself;
the callbacks decide what happens to it.
""" """
def __init__( def __init__(
@ -107,12 +124,15 @@ class KeepPanel(tk.Frame):
} }
#: The last background sync, for callers (and tests) that wait on it. #: The last background sync, for callers (and tests) that wait on it.
self.sync_thread: threading.Thread | None = None 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() blocked = self._blocked_reason()
self._draw_switches(editable=blocked is None) self._draw_switches(editable=blocked is None)
self._status = ui.label( self._status = IconSlot(self, name="status")
self, blocked or "", "label", fg=tok.DANGER, 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) self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
def kept(self) -> frozenset[str]: def kept(self) -> frozenset[str]:
@ -121,49 +141,54 @@ class KeepPanel(tk.Frame):
def _blocked_reason(self) -> str | None: def _blocked_reason(self) -> str | None:
if self._day < today(): if self._day < today():
return _text.PAST_NOTE return "past"
return None if self._sick else _text.free_day_refusal(self._day) 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: def _draw_switches(self, *, editable: bool) -> None:
title = _text.panel_title(self._day, sick=self._sick) head = tk.Frame(self, name="head", bg=self.cget("bg"))
ui.label(self, title, "subtitle", name="title").grid( head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM))
row=0, column=0, columnspan=2, sticky="w" IconSlot(head, name="state").show(
) "sick" if self._sick else "normal", tok.SICK
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid( ).pack(side="left", padx=(0, tok.SM))
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM) ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left")
) for row, locker in enumerate(LOCKERS, start=1):
for row, locker in enumerate(LOCKERS, start=2): IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid(
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
row=row, column=0, sticky="w", pady=tok.XS 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) switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
if not editable: if not editable:
switch.configure(state="disabled") switch.set_enabled(False)
def _draw_actions( def _draw_actions(
self, *, writable: bool, on_cancel: Callable[[], None] | None self, *, writable: bool, on_cancel: Callable[[], None] | None
) -> None: ) -> None:
actions = tk.Frame(self, name="actions", bg=self.cget("bg")) actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
actions.grid( 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: if writable and self._sick:
buttons.append( buttons.append(
ui.button(actions, "Save", self._save, kind="primary", name="save") IconButton(actions, "check", self._save, kind="primary", name="save")
) )
buttons.append( buttons.append(
ui.button( IconButton(actions, "trash", self._clear, kind="danger", name="clear")
actions, "Clear sick day", self._clear, kind="danger", name="clear"
)
) )
elif writable: elif writable:
buttons.append( 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: 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: for button in buttons:
button.pack(side="left", padx=(0, tok.SM)) 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 # Non-blocking on purpose (no ``with``): waiting would freeze Tk for
# the whole network round trip. # the whole network round trip.
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with 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 return
try: try:
entry = action() entry = action()
except SickDayError as exc: 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 return
finally: finally:
_SYNC_LOCK.release() _SYNC_LOCK.release()

View File

@ -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)

View File

@ -1,33 +1,20 @@
# Copyright (c) 2026 Krzysztof Rudnicki # 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 Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and
filled control is picked from its fill (``on-fill`` on the accent, never the icons are word-free Canvases in :mod:`sickdays.gui._controls`.
near-white body text), the bug the design system's README warns about.
""" """
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import font as tkfont from tkinter import font as tkfont
from typing import TYPE_CHECKING, Final, Literal from typing import Final
from sickdays.gui import _tokens as tok 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"}) _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: def family(widget: tk.Misc) -> str:
"""The family Tk's default font actually resolves to on this display.""" """The family Tk's default font actually resolves to on this display."""
@ -42,10 +29,7 @@ def label(
fg: str = tok.TEXT_ON_DARK, fg: str = tok.TEXT_ON_DARK,
name: str = "", name: str = "",
) -> tk.Label: ) -> tk.Label:
"""A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`. """A label on ``parent``'s own background; titles and subtitles are bold."""
Titles and subtitles are bold, everything else regular.
"""
return tk.Label( return tk.Label(
parent, parent,
name=name, name=name,
@ -55,71 +39,4 @@ def label(
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES), font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
justify="left", justify="left",
anchor="w", 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

View File

@ -2,26 +2,33 @@
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk. """``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 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 the clicked day, the sick days still ahead underneath (each an ISO date and
``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 the pictograms of the lockers it keeps). The window class is ``sickdays``
``for_window`` rule can float it; ``Escape`` closes it. (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 from __future__ import annotations
import tkinter as tk 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._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._keep_api import kept_lockers
from sickdays._model import sick_entries from sickdays._model import sick_entries
from sickdays._paths import resolve_paths 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 _tokens as tok
from sickdays.gui import _widgets as ui from sickdays.gui import _widgets as ui
from sickdays.gui._calendar import MonthCalendar 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: if TYPE_CHECKING:
from datetime import date from datetime import date
@ -30,19 +37,18 @@ if TYPE_CHECKING:
from sickdays._paths import Paths from sickdays._paths import Paths
WINDOW_CLASS = "sickdays" 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: def saved_icon(entry: SickDay | None) -> str | None:
"""The status line after the panel closed with ``entry``.""" """The status mark after the panel closed with ``entry``.
label = _text.day_label(day)
if entry is None: A check for any write (the grid and the list show what it was); nothing
return f"{label}: nothing changed" when the panel was saved unchanged.
if not entry.is_sick: """
return f"{label} is a normal day again" return None if entry is None else "check"
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: class SickDaysApp:
@ -52,7 +58,7 @@ class SickDaysApp:
"""Style ``root`` and draw the current pool.""" """Style ``root`` and draw the current pool."""
self._root = root self._root = root
self._paths = resolve_paths(paths) 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.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
root.bind("<Escape>", self.close) root.bind("<Escape>", self.close)
left = tk.Frame(root, bg=tok.INK) left = tk.Frame(root, bg=tok.INK)
@ -60,13 +66,9 @@ class SickDaysApp:
#: Where the clicked day's panel goes. #: Where the clicked day's panel goes.
self.host = tk.Frame(root, name="host", bg=tok.INK) self.host = tk.Frame(root, name="host", bg=tok.INK)
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0)) self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
ui.label(left, "Sick days", "title").pack(anchor="w") #: The last outcome's mark: a check, or the panel's refusal icons.
ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0)) self.status = IconSlot(left, name="status")
ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack( self.status.pack(anchor="w")
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 log = self._paths.log
self.calendar = MonthCalendar( self.calendar = MonthCalendar(
left, left,
@ -75,8 +77,8 @@ class SickDaysApp:
has_dot=lambda day: bool(kept_lockers(day, log_path=log)), has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
on_day=self.select, on_day=self.select,
) )
self.calendar.pack(anchor="w", pady=(tok.MD, 0)) self.calendar.pack(anchor="w", pady=(tok.SM, 0))
ui.label(left, "Marked sick", "subtitle").pack( IconSlot(left, name="ahead").show("sick", tok.SICK).pack(
anchor="w", pady=(tok.LG, tok.SM) anchor="w", pady=(tok.LG, tok.SM)
) )
self._list = tk.Frame(left, name="upcoming", bg=tok.INK) self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
@ -88,25 +90,25 @@ class SickDaysApp:
self._root.destroy() self._root.destroy()
def select(self, day: date) -> None: 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() self._close_panel()
if not is_sick_day(day, log_path=self._paths.log): if not is_sick_day(day, log_path=self._paths.log):
refusal = _text.free_day_refusal(day) block = _dates.free_day_block(day)
if refusal is not None: if block is not None:
self._set_status(refusal, failed=True) self.status.show(*STATUS_ICONS[block])
return return
self._set_status("", failed=False) self.status.show(None)
KeepPanel( KeepPanel(
self.host, self.host,
day, day,
on_saved=lambda entry: self._saved(entry, day), on_saved=self._saved,
on_cancel=self._close_panel, on_cancel=self._close_panel,
paths=self._paths, paths=self._paths,
).pack(anchor="n") ).pack(anchor="n")
def _saved(self, entry: SickDay | None, day: date) -> None: def _saved(self, entry: SickDay | None) -> None:
self._close_panel() self._close_panel()
self._set_status(saved_message(entry, day), failed=False) self.status.show(saved_icon(entry), tok.SUCCESS)
self.calendar.redraw() self.calendar.redraw()
self._draw_upcoming() self._draw_upcoming()
@ -114,29 +116,20 @@ class SickDaysApp:
for child in self.host.winfo_children(): for child in self.host.winfo_children():
child.destroy() 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: def _draw_upcoming(self) -> None:
for child in self._list.winfo_children(): for child in self._list.winfo_children():
child.destroy() child.destroy()
reference = today() reference = today()
upcoming = [ for entry in sick_entries(load(self._paths.log)):
entry if entry.day < reference:
for entry in sick_entries(load(self._paths.log)) continue
if entry.day >= reference row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK)
] row.pack(anchor="w", pady=(0, tok.XS))
if not upcoming: ui.label(row, to_iso(entry.day), "body").pack(side="left")
ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack( for locker in _dates.kept_in_order(entry.keep):
anchor="w" IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show(
) LOCKER_ICONS[locker], tok.MUTED_ON_DARK
for entry in upcoming: ).pack(side="left", padx=(tok.SM, 0))
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: def run(*, paths: Paths | None = None) -> int:

View File

@ -61,7 +61,7 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
for module in _MODULES: for module in _MODULES:
monkeypatch.setattr(f"{module}.resolve_paths", _resolve) monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free) 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) monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)

View File

@ -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("<Button-1>", 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()

View File

@ -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

View File

@ -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)

View File

@ -1,26 +1,27 @@
# Copyright (c) 2026 Krzysztof Rudnicki # 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 __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging
import threading import threading
import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
from sickdays._api import add_sick_day, is_sick_day, load 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._errors import ConflictCheckUnavailableError
from sickdays._keep_api import kept_lockers from sickdays._keep_api import kept_lockers
from sickdays._paths import Paths from sickdays._paths import Paths
from sickdays._store import write_pool from sickdays._store import write_pool
from sickdays.gui import KeepPanel, _text from sickdays.gui import KeepPanel
from sickdays.gui import _tokens as tok from sickdays.gui._controls import IconButton, IconSlot, Toggle
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
import tkinter as tk
from sickdays._model import SickDay from sickdays._model import SickDay
@ -40,20 +41,22 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
return calls return calls
def _button(panel: KeepPanel, name: str) -> tk.Button: def _button(panel: KeepPanel, name: str) -> IconButton:
widget = panel.nametowidget(f"actions.{name}") widget = panel.nametowidget(f"actions.{name}")
assert isinstance(widget, tk.Button) assert isinstance(widget, IconButton)
return widget return widget
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton: def _toggle(panel: KeepPanel, locker: str) -> Toggle:
widget = panel.nametowidget(f"keep-{locker}") widget = panel.nametowidget(f"keep-{locker}")
assert isinstance(widget, tk.Checkbutton) assert isinstance(widget, Toggle)
return widget return widget
def _status(panel: KeepPanel) -> str: def _status(panel: KeepPanel) -> str | None:
return str(panel.nametowidget("status").cget("text")) slot = panel.nametowidget("status")
assert isinstance(slot, IconSlot)
return slot.icon
def _actions(panel: KeepPanel) -> set[str]: 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 panel.kept() == frozenset()
assert _actions(panel) == {"mark"} assert _actions(panel) == {"mark"}
toggle = _toggle(panel, "leetcode") toggle = _toggle(panel, "leetcode")
assert toggle.cget("text") == "Off" assert not toggle.is_on()
toggle.invoke() toggle.invoke()
assert toggle.cget("text") == "On" assert toggle.is_on()
assert str(toggle.cget("bg")).upper() == tok.ACCENT assert _button(panel, "mark").icon == "check"
assert str(toggle.cget("fg")).upper() == tok.ON_FILL assert _status(panel) is None
_button(panel, "mark").invoke() _button(panel, "mark").invoke()
_join(panel) _join(panel)
assert is_sick_day(FUTURE) 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) panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
assert panel.kept() == frozenset({"workout"}) assert panel.kept() == frozenset({"workout"})
assert _actions(panel) == {"save", "clear"} assert _actions(panel) == {"save", "clear"}
title = panel.nametowidget("title").cget("text") assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE)
assert title == f"Sick day: {_text.day_label(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: 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: 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) 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 _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( def test_a_free_day_is_refused_with_the_phones_message(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr( 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) panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
expected = "is a free day — release it in Free days first." assert panel.reason == "free"
assert _status(panel).endswith(expected) assert _status(panel) == "no_entry"
state = panel.nametowidget("head.state")
assert isinstance(state, IconSlot)
assert state.icon == "normal"
assert _actions(panel) == set() assert _actions(panel) == set()
@ -163,20 +179,24 @@ def test_an_uncheckable_free_pool_refuses(
msg = "cannot check the free-day pool" msg = "cannot check the free-day pool"
raise ConflictCheckUnavailableError(msg) 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) 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() assert _actions(panel) == set()
def test_a_refused_write_is_shown_not_raised( 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: ) -> None:
saved: list[SickDay | None] = [] saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append) panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
add_sick_day(FUTURE) # marked elsewhere while the panel was open add_sick_day(FUTURE) # marked elsewhere while the panel was open
with caplog.at_level(logging.WARNING):
_button(panel, "mark").invoke() _button(panel, "mark").invoke()
assert "already a sick day" in _status(panel) assert panel.reason == "refused"
assert _status(panel) == "warning"
assert "already a sick day" in caplog.text
assert saved == [] assert saved == []
assert synced == [] assert synced == []
@ -189,7 +209,9 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
on_saved=lambda _e: None, on_saved=lambda _e: None,
on_cancel=lambda: cancelled.append(True), on_cancel=lambda: cancelled.append(True),
) )
_button(panel, "cancel").invoke() cancel = _button(panel, "cancel")
assert cancel.icon == "cross"
cancel.invoke()
assert cancelled == [True] assert cancelled == [True]
@ -242,4 +264,5 @@ def test_a_write_during_a_sync_is_refused_not_lost(
for entry in saved: for entry in saved:
assert entry is not None assert entry is not None
assert is_sick_day(entry.day), f"{entry.day} was written, then lost" 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"

View File

@ -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

View File

@ -10,8 +10,9 @@ from typing import TYPE_CHECKING
from sickdays._api import add_sick_day from sickdays._api import add_sick_day
from sickdays._day import to_iso, today from sickdays._day import to_iso, today
from sickdays._model import SickDay 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 import _tokens as tok
from sickdays.gui._controls import IconButton, IconSlot
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest import pytest
@ -19,13 +20,19 @@ if TYPE_CHECKING:
FUTURE = today() + timedelta(days=3) FUTURE = today() + timedelta(days=3)
def _texts(widget: tk.Misc) -> list[str]: def _upcoming(app: SickDaysApp) -> dict[str, list[str]]:
found: list[str] = [] """Each listed day's ISO date -> the locker icons it keeps."""
for child in widget.winfo_children(): rows = app.calendar.master.nametowidget("upcoming").winfo_children()
if isinstance(child, tk.Label): listed: dict[str, list[str]] = {}
found.append(str(child.cget("text"))) for row in rows:
found.extend(_texts(child)) icons = [
return found 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: def _cell(app: SickDaysApp, day: date) -> tk.Button:
@ -40,14 +47,17 @@ def _panel(app: SickDaysApp) -> KeepPanel:
return panel 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) app = SickDaysApp(tk_root)
assert tk_root.title() == "Sick days" 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() == [] assert app.host.winfo_children() == []
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None: 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 = SickDaysApp(tk_root)
app.calendar.month = FUTURE.replace(day=1) app.calendar.month = FUTURE.replace(day=1)
app.calendar.redraw() 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 assert panel.sync_thread is not None
panel.sync_thread.join(timeout=5) panel.sync_thread.join(timeout=5)
assert app.host.winfo_children() == [] assert app.host.winfo_children() == []
assert app.status.cget("text") == _window.saved_message( assert app.status.icon == "check"
SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []}
)
assert "Kept: LeetCode" in _texts(tk_root)
cell = _cell(app, FUTURE) cell = _cell(app, FUTURE)
assert str(cell.cget("bg")).upper() == tok.SICK assert str(cell.cget("bg")).upper() == tok.SICK
assert cell.nametowidget("dot").winfo_exists() 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 tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr( 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 = SickDaysApp(tk_root)
app.select(FUTURE) app.select(FUTURE)
assert app.host.winfo_children() == [] assert app.host.winfo_children() == []
assert str(app.status.cget("text")).endswith("release it in Free days first.") assert app.status.icon == "no_entry"
assert str(app.status.cget("fg")).upper() == tok.DANGER
def test_a_sick_day_on_a_free_day_still_opens( 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: ) -> None:
add_sick_day(FUTURE) add_sick_day(FUTURE)
monkeypatch.setattr( 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 = SickDaysApp(tk_root)
app.select(FUTURE) 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: 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, keep=["alarm", "workout"])
add_sick_day(FUTURE + timedelta(days=1)) add_sick_day(FUTURE + timedelta(days=1))
texts = _texts(SickDaysApp(tk_root).calendar.master) past = today() - timedelta(days=1)
assert _text.day_label(FUTURE) in texts add_sick_day(past, now=past)
assert "Kept: Workout lock, Wake alarm" in texts assert _upcoming(SickDaysApp(tk_root)) == {
assert _text.NONE_AHEAD not in texts to_iso(FUTURE): ["workout", "alarm"],
to_iso(FUTURE + timedelta(days=1)): [],
}
def test_saved_message_wording() -> None: def test_saved_icon() -> None:
sick = SickDay(FUTURE, is_sick=True) assert _window.saved_icon(None) is None
label = _text.day_label(FUTURE) assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check"
assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed" assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check"
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_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: 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: def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root) app = SickDaysApp(tk_root)
first, last = _text.month_bounds(today()) first, last = _dates.month_bounds(today())
for _ in range(30): for _ in range(30):
app.calendar.step(1) app.calendar.step(1)
assert app.calendar.month == last 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) app.calendar.step(-1)
assert app.calendar.month == first assert app.calendar.month == first
back = app.calendar.nametowidget("header.prev") back = app.calendar.nametowidget("header.prev")
assert isinstance(back, IconButton)
assert back.icon == "prev"
assert str(back.cget("state")) == "disabled" assert str(back.cget("state")) == "disabled"