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

View File

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

View File

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

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
"""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()

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

View File

@ -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("<Escape>", 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:

View File

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

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

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._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"