mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
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
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:
parent
ae56037e41
commit
9d6b17fd28
@ -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
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
|
||||
171
sickdays/sickdays/gui/_controls.py
Normal file
171
sickdays/sickdays/gui/_controls.py
Normal 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
|
||||
86
sickdays/sickdays/gui/_dates.py
Normal file
86
sickdays/sickdays/gui/_dates.py
Normal 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)
|
||||
235
sickdays/sickdays/gui/_icons.py
Normal file
235
sickdays/sickdays/gui/_icons.py
Normal 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))
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
70
sickdays/sickdays/tests/test_gui_controls.py
Normal file
70
sickdays/sickdays/tests/test_gui_controls.py
Normal 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()
|
||||
87
sickdays/sickdays/tests/test_gui_dates.py
Normal file
87
sickdays/sickdays/tests/test_gui_dates.py
Normal 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
|
||||
113
sickdays/sickdays/tests/test_gui_no_words.py
Normal file
113
sickdays/sickdays/tests/test_gui_no_words.py
Normal 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)
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user