mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 20:53:59 +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)
|
## The picker window (0.3.0)
|
||||||
|
|
||||||
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
|
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
|
||||||
Tk -- calendar, a five-switch panel per day, the days ahead. Past days are
|
Tk -- calendar, a six-switch panel per day, the days ahead. Past days are
|
||||||
inert; a free day is refused; each write starts `sync_quietly()` on a
|
inert; a free day is refused; each write starts `sync_quietly()` on a
|
||||||
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
|
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
|
||||||
just the panel (`import sickdays` never imports tkinter):
|
just the panel (`import sickdays` never imports tkinter):
|
||||||
|
|
||||||
|
No words inside the window (textgate): dates are ISO numerals (`2026-11`,
|
||||||
|
`2026-11-27`), each locker is a drawn pictogram (dumbbell = workout, `</>` =
|
||||||
|
LeetCode, open book, apple = diet, alarm clock, target = focus), switches are
|
||||||
|
knob-right-on-accent / knob-left-on-dark, buttons are check (mark / save),
|
||||||
|
red bin (clear) and cross (cancel). A refusal is an icon in the status slot:
|
||||||
|
padlock (past), no-entry (free day), warning (cannot check, or a refused
|
||||||
|
write; the detail goes to the log), hourglass (a sync is still running).
|
||||||
|
Icons are Canvas geometry, not font glyphs: the venv's Tk uses core X fonts,
|
||||||
|
where non-ASCII glyphs render as nothing. The title bar keeps "Sick days"
|
||||||
|
(an app's name, textgate settled decision 4). The guard is
|
||||||
|
`tests/test_gui_no_words.py`: no letter on any widget, in any state.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
|
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
|
||||||
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
|
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
|
||||||
```
|
```
|
||||||
|
|
||||||
Normal day: five switches, all off (off = stands down), "Mark sick"; sick
|
Normal day: six switches, all off (off = stands down), `actions.mark`; sick
|
||||||
day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show
|
day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable
|
||||||
why and offer no write; a refusal (or a click mid-sync) shows in the panel.
|
days show their icon and offer no write; a refusal (or a click mid-sync)
|
||||||
|
shows in the panel's `status` slot, named by `panel.reason`.
|
||||||
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
|
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
|
||||||
Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
|
mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None`
|
||||||
on an unchanged Save (nothing written). The panel never destroys itself.
|
on an unchanged save (nothing written). The panel never destroys itself.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@ -120,6 +120,9 @@ disable = []
|
|||||||
min-public-methods = 0
|
min-public-methods = 0
|
||||||
max-module-lines = 1000
|
max-module-lines = 1000
|
||||||
max-attributes = 10
|
max-attributes = 10
|
||||||
|
# A tk.Canvas subclass (gui/_controls.py) has 9 ancestors by construction:
|
||||||
|
# Canvas mixes in XView and YView on top of Widget's chain.
|
||||||
|
max-parents = 9
|
||||||
|
|
||||||
[tool.pylint.typecheck]
|
[tool.pylint.typecheck]
|
||||||
generated-members = [
|
generated-members = [
|
||||||
|
|||||||
@ -1,29 +1,33 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""The month grid: Monday-first, one button per day, prev/next month arrows.
|
"""The month grid: Monday-first, one button per day, prev/next month chevrons.
|
||||||
|
|
||||||
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
|
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
|
||||||
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
|
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
|
||||||
days keep their mark but faded and inert. Today stays clickable -- a sick
|
days keep their mark but faded and inert. Today stays clickable -- a sick
|
||||||
day can be marked or cleared for today. Deciding what a click *means* stays
|
day can be marked or cleared for today. Deciding what a click *means* stays
|
||||||
with the window; this only reports the date.
|
with the window; this only reports the date. No words: the month reads
|
||||||
|
``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from sickdays._day import to_iso
|
from sickdays._day import to_iso
|
||||||
from sickdays.gui import _text
|
from sickdays.gui import _dates
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
|
from sickdays.gui._controls import IconButton, IconSlot
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
|
#: The weekday marks over the grid, in px.
|
||||||
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
|
_MARK_PX: Final = 10
|
||||||
|
#: The keep-a-locker dot inside a sick day's cell, in px.
|
||||||
|
_DOT_PX: Final = 8
|
||||||
|
|
||||||
|
|
||||||
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
||||||
@ -56,7 +60,7 @@ class MonthCalendar(tk.Frame):
|
|||||||
self._is_sick = is_sick
|
self._is_sick = is_sick
|
||||||
self._has_dot = has_dot
|
self._has_dot = has_dot
|
||||||
self._on_day = on_day
|
self._on_day = on_day
|
||||||
self._first, self._last = _text.month_bounds(today)
|
self._first, self._last = _dates.month_bounds(today)
|
||||||
self.month = today.replace(day=1)
|
self.month = today.replace(day=1)
|
||||||
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
|
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
|
||||||
self._header.pack(fill="x")
|
self._header.pack(fill="x")
|
||||||
@ -66,7 +70,7 @@ class MonthCalendar(tk.Frame):
|
|||||||
|
|
||||||
def step(self, months: int) -> None:
|
def step(self, months: int) -> None:
|
||||||
"""Move ``months`` months (within the phone's range) and redraw."""
|
"""Move ``months`` months (within the phone's range) and redraw."""
|
||||||
target = _text.shift_month(self.month, months)
|
target = _dates.shift_month(self.month, months)
|
||||||
if self._first <= target <= self._last:
|
if self._first <= target <= self._last:
|
||||||
self.month = target
|
self.month = target
|
||||||
self.redraw()
|
self.redraw()
|
||||||
@ -77,11 +81,12 @@ class MonthCalendar(tk.Frame):
|
|||||||
for child in frame.winfo_children():
|
for child in frame.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
self._draw_header()
|
self._draw_header()
|
||||||
for column, initial in enumerate(_text.WEEKDAY_INITIALS):
|
for column in range(7):
|
||||||
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
|
mark = "ring" if _dates.is_weekend_column(column) else "dot"
|
||||||
row=0, column=column
|
IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid(
|
||||||
|
row=0, column=column, pady=(0, tok.XS)
|
||||||
)
|
)
|
||||||
for row, week in enumerate(_text.month_weeks(self.month), start=1):
|
for row, week in enumerate(_dates.month_weeks(self.month), start=1):
|
||||||
for column, day in enumerate(week):
|
for column, day in enumerate(week):
|
||||||
if day is not None:
|
if day is not None:
|
||||||
self._cell(day).grid(
|
self._cell(day).grid(
|
||||||
@ -89,20 +94,20 @@ class MonthCalendar(tk.Frame):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _draw_header(self) -> None:
|
def _draw_header(self) -> None:
|
||||||
back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
|
back = IconButton(self._header, "prev", lambda: self.step(-1), name="prev")
|
||||||
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
|
forward = IconButton(self._header, "next", lambda: self.step(1), name="next")
|
||||||
back.configure(state="normal" if self.month > self._first else "disabled")
|
back.set_enabled(self.month > self._first)
|
||||||
forward.configure(state="normal" if self.month < self._last else "disabled")
|
forward.set_enabled(self.month < self._last)
|
||||||
back.pack(side="left")
|
back.pack(side="left")
|
||||||
forward.pack(side="right")
|
forward.pack(side="right")
|
||||||
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
|
ui.label(self._header, _dates.month_title(self.month), "subtitle").pack(
|
||||||
side="top", pady=tok.XS
|
side="top", pady=tok.XS
|
||||||
)
|
)
|
||||||
|
|
||||||
def _cell(self, day: date) -> tk.Button:
|
def _cell(self, day: date) -> tk.Button:
|
||||||
sick = self._is_sick(day)
|
sick = self._is_sick(day)
|
||||||
past = day < self._today
|
past = day < self._today
|
||||||
fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day))
|
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day))
|
||||||
cell = tk.Button(
|
cell = tk.Button(
|
||||||
self._grid,
|
self._grid,
|
||||||
name=f"day-{to_iso(day)}",
|
name=f"day-{to_iso(day)}",
|
||||||
@ -124,7 +129,6 @@ class MonthCalendar(tk.Frame):
|
|||||||
cursor="arrow" if past else "hand2",
|
cursor="arrow" if past else "hand2",
|
||||||
)
|
)
|
||||||
if sick and self._has_dot(day):
|
if sick and self._has_dot(day):
|
||||||
dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
|
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
|
||||||
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
|
dot.place(relx=1.0, x=-2, y=2, anchor="ne")
|
||||||
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
|
|
||||||
return cell
|
return cell
|
||||||
|
|||||||
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
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""KeepPanel: the five switches for one day, and the buttons that write them.
|
"""KeepPanel: a switch per locker for one day, and the buttons that write them.
|
||||||
|
|
||||||
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
|
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
|
||||||
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
|
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
|
||||||
@ -7,29 +7,34 @@ so screen-locker can drop it onto its fullscreen surface in-process, under
|
|||||||
its own X grab (a separate window could not get input there).
|
its own X grab (a separate window could not get input there).
|
||||||
|
|
||||||
On is "this locker still applies", off is "stands down", all off by default
|
On is "this locker still applies", off is "stands down", all off by default
|
||||||
-- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day
|
-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a
|
||||||
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
|
sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A
|
||||||
day, or a free-day pool that cannot be checked shows why and offers no
|
past day (padlock), a free day (no-entry sign) or a free-day pool that cannot
|
||||||
write. A refused write is shown in the panel, never raised: an exception in a
|
be checked (warning) shows its icon and offers no write. A refused write is
|
||||||
Tk callback only reaches stderr, which nobody sees under a lock.
|
shown in the panel as an icon, never raised: an exception in a Tk callback
|
||||||
|
only reaches stderr, which nobody sees under a lock. The panel shows no
|
||||||
|
words (kuhy's textgate rule): a locker is its pictogram, the day its ISO date.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
|
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
|
||||||
from sickdays._constants import LOCKERS
|
from sickdays._constants import LOCKERS
|
||||||
from sickdays._day import today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._errors import SickDayError
|
from sickdays._errors import SickDayError
|
||||||
from sickdays._keep_api import kept_lockers, set_kept_lockers
|
from sickdays._keep_api import kept_lockers, set_kept_lockers
|
||||||
from sickdays._paths import resolve_paths
|
from sickdays._paths import resolve_paths
|
||||||
from sickdays._sync import sync_quietly
|
from sickdays._sync import sync_quietly
|
||||||
from sickdays.gui import _text
|
from sickdays.gui import _dates
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
|
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||||
|
from sickdays.gui._icons import LOCKER_ICONS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -39,6 +44,17 @@ if TYPE_CHECKING:
|
|||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: (icon, colour) for each reason the panel shows, keyed by that reason.
|
||||||
|
STATUS_ICONS: Final = {
|
||||||
|
"past": ("padlock", tok.MUTED_ON_DARK),
|
||||||
|
"free": ("no_entry", tok.DANGER),
|
||||||
|
"unavailable": ("warning", tok.DANGER),
|
||||||
|
"refused": ("warning", tok.DANGER),
|
||||||
|
"busy": ("hourglass", tok.DANGER),
|
||||||
|
}
|
||||||
|
|
||||||
#: Held by every background sync, and tried by every write. ``sync()`` reads
|
#: Held by every background sync, and tried by every write. ``sync()`` reads
|
||||||
#: the pool, waits on the network, then writes its merged copy back, so a
|
#: the pool, waits on the network, then writes its merged copy back, so a
|
||||||
#: write landing in between would be silently overwritten. A write that finds
|
#: write landing in between would be silently overwritten. A write that finds
|
||||||
@ -73,16 +89,17 @@ class KeepPanel(tk.Frame):
|
|||||||
``on_saved`` is called once per successful click, after the write and
|
``on_saved`` is called once per successful click, after the write and
|
||||||
after the background sync has started:
|
after the background sync has started:
|
||||||
|
|
||||||
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
* ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
||||||
* "Save" with a changed set -> the stored entry (``is_sick=True``);
|
* ``actions.save`` with a changed set -> the stored entry (``is_sick=True``);
|
||||||
* "Save" with nothing changed -> ``None``: nothing written, no sync;
|
* ``actions.save`` with nothing changed -> ``None``: nothing written, no sync;
|
||||||
* "Clear sick day" -> the cleared entry (``is_sick=False``).
|
* ``actions.clear`` -> the cleared entry (``is_sick=False``).
|
||||||
|
|
||||||
A refused write (:class:`~sickdays.SickDayError`, or a click while the
|
A refused write (:class:`~sickdays.SickDayError`, or a click while the
|
||||||
previous write's sync is still running) is shown in the panel and
|
previous write's sync is still running) is shown in the panel's
|
||||||
``on_saved`` is not called. ``on_cancel``, when given, adds a
|
``status`` slot and ``on_saved`` is not called; :attr:`reason` names it
|
||||||
"Cancel" button that calls it. The panel never destroys itself; the
|
(a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an
|
||||||
callbacks decide what happens to it.
|
``actions.cancel`` cross that calls it. The panel never destroys itself;
|
||||||
|
the callbacks decide what happens to it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -107,12 +124,15 @@ class KeepPanel(tk.Frame):
|
|||||||
}
|
}
|
||||||
#: The last background sync, for callers (and tests) that wait on it.
|
#: The last background sync, for callers (and tests) that wait on it.
|
||||||
self.sync_thread: threading.Thread | None = None
|
self.sync_thread: threading.Thread | None = None
|
||||||
|
#: Why the panel refuses, a :data:`STATUS_ICONS` key, or ``None``.
|
||||||
|
self.reason: str | None = None
|
||||||
blocked = self._blocked_reason()
|
blocked = self._blocked_reason()
|
||||||
self._draw_switches(editable=blocked is None)
|
self._draw_switches(editable=blocked is None)
|
||||||
self._status = ui.label(
|
self._status = IconSlot(self, name="status")
|
||||||
self, blocked or "", "label", fg=tok.DANGER, name="status"
|
self._status.grid(
|
||||||
|
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
|
||||||
)
|
)
|
||||||
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
|
self._show(blocked)
|
||||||
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
||||||
|
|
||||||
def kept(self) -> frozenset[str]:
|
def kept(self) -> frozenset[str]:
|
||||||
@ -121,49 +141,54 @@ class KeepPanel(tk.Frame):
|
|||||||
|
|
||||||
def _blocked_reason(self) -> str | None:
|
def _blocked_reason(self) -> str | None:
|
||||||
if self._day < today():
|
if self._day < today():
|
||||||
return _text.PAST_NOTE
|
return "past"
|
||||||
return None if self._sick else _text.free_day_refusal(self._day)
|
return None if self._sick else _dates.free_day_block(self._day)
|
||||||
|
|
||||||
|
def _show(self, reason: str | None) -> None:
|
||||||
|
self.reason = reason
|
||||||
|
if reason is None:
|
||||||
|
self._status.show(None)
|
||||||
|
return
|
||||||
|
icon, colour = STATUS_ICONS[reason]
|
||||||
|
self._status.show(icon, colour)
|
||||||
|
|
||||||
def _draw_switches(self, *, editable: bool) -> None:
|
def _draw_switches(self, *, editable: bool) -> None:
|
||||||
title = _text.panel_title(self._day, sick=self._sick)
|
head = tk.Frame(self, name="head", bg=self.cget("bg"))
|
||||||
ui.label(self, title, "subtitle", name="title").grid(
|
head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM))
|
||||||
row=0, column=0, columnspan=2, sticky="w"
|
IconSlot(head, name="state").show(
|
||||||
)
|
"sick" if self._sick else "normal", tok.SICK
|
||||||
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
|
).pack(side="left", padx=(0, tok.SM))
|
||||||
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
|
ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left")
|
||||||
)
|
for row, locker in enumerate(LOCKERS, start=1):
|
||||||
for row, locker in enumerate(LOCKERS, start=2):
|
IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid(
|
||||||
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
|
|
||||||
row=row, column=0, sticky="w", pady=tok.XS
|
row=row, column=0, sticky="w", pady=tok.XS
|
||||||
)
|
)
|
||||||
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
|
switch = Toggle(self, self._vars[locker], name=f"keep-{locker}")
|
||||||
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
|
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
|
||||||
if not editable:
|
if not editable:
|
||||||
switch.configure(state="disabled")
|
switch.set_enabled(False)
|
||||||
|
|
||||||
def _draw_actions(
|
def _draw_actions(
|
||||||
self, *, writable: bool, on_cancel: Callable[[], None] | None
|
self, *, writable: bool, on_cancel: Callable[[], None] | None
|
||||||
) -> None:
|
) -> None:
|
||||||
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
|
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
|
||||||
actions.grid(
|
actions.grid(
|
||||||
row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
||||||
)
|
)
|
||||||
buttons: list[tk.Button] = []
|
buttons: list[IconButton] = []
|
||||||
if writable and self._sick:
|
if writable and self._sick:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(actions, "Save", self._save, kind="primary", name="save")
|
IconButton(actions, "check", self._save, kind="primary", name="save")
|
||||||
)
|
)
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(
|
IconButton(actions, "trash", self._clear, kind="danger", name="clear")
|
||||||
actions, "Clear sick day", self._clear, kind="danger", name="clear"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
elif writable:
|
elif writable:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
|
IconButton(actions, "check", self._mark, kind="primary", name="mark")
|
||||||
)
|
)
|
||||||
if on_cancel is not None:
|
if on_cancel is not None:
|
||||||
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
|
buttons.append(IconButton(actions, "cross", on_cancel, name="cancel"))
|
||||||
for button in buttons:
|
for button in buttons:
|
||||||
button.pack(side="left", padx=(0, tok.SM))
|
button.pack(side="left", padx=(0, tok.SM))
|
||||||
|
|
||||||
@ -187,12 +212,13 @@ class KeepPanel(tk.Frame):
|
|||||||
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
|
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
|
||||||
# the whole network round trip.
|
# the whole network round trip.
|
||||||
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
|
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
|
||||||
self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
|
self._show("busy")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
entry = action()
|
entry = action()
|
||||||
except SickDayError as exc:
|
except SickDayError as exc:
|
||||||
self._status.configure(text=str(exc), fg=tok.DANGER)
|
_LOG.warning("sick-day write refused: %s", exc)
|
||||||
|
self._show("refused")
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
_SYNC_LOCK.release()
|
_SYNC_LOCK.release()
|
||||||
|
|||||||
@ -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
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""Token-styled Tk building blocks shared by the panel and the window.
|
"""Token-styled Tk labels for the numerals the picker shows (dates, months).
|
||||||
|
|
||||||
Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a
|
Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and
|
||||||
filled control is picked from its fill (``on-fill`` on the accent, never the
|
icons are word-free Canvases in :mod:`sickdays.gui._controls`.
|
||||||
near-white body text), the bug the design system's README warns about.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import font as tkfont
|
from tkinter import font as tkfont
|
||||||
from typing import TYPE_CHECKING, Final, Literal
|
from typing import Final
|
||||||
|
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
ButtonKind = Literal["primary", "danger", "plain"]
|
|
||||||
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
|
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
|
||||||
|
|
||||||
#: (background, foreground) per button kind. Danger is a text button, as on
|
|
||||||
#: the phone, so its red sits on the dark surface rather than under ink.
|
|
||||||
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
|
|
||||||
"primary": (tok.ACCENT, tok.ON_FILL),
|
|
||||||
"danger": (tok.INK_RAISED_2, tok.DANGER),
|
|
||||||
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def family(widget: tk.Misc) -> str:
|
def family(widget: tk.Misc) -> str:
|
||||||
"""The family Tk's default font actually resolves to on this display."""
|
"""The family Tk's default font actually resolves to on this display."""
|
||||||
@ -42,10 +29,7 @@ def label(
|
|||||||
fg: str = tok.TEXT_ON_DARK,
|
fg: str = tok.TEXT_ON_DARK,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
) -> tk.Label:
|
) -> tk.Label:
|
||||||
"""A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`.
|
"""A label on ``parent``'s own background; titles and subtitles are bold."""
|
||||||
|
|
||||||
Titles and subtitles are bold, everything else regular.
|
|
||||||
"""
|
|
||||||
return tk.Label(
|
return tk.Label(
|
||||||
parent,
|
parent,
|
||||||
name=name,
|
name=name,
|
||||||
@ -55,71 +39,4 @@ def label(
|
|||||||
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
|
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
|
||||||
justify="left",
|
justify="left",
|
||||||
anchor="w",
|
anchor="w",
|
||||||
wraplength=tok.WRAP,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def button(
|
|
||||||
parent: tk.Misc,
|
|
||||||
text: str,
|
|
||||||
command: Callable[[], object],
|
|
||||||
*,
|
|
||||||
kind: ButtonKind = "plain",
|
|
||||||
name: str = "",
|
|
||||||
) -> tk.Button:
|
|
||||||
"""A flat button whose text colour follows its fill."""
|
|
||||||
bg, fg = _BUTTON_COLOURS[kind]
|
|
||||||
return tk.Button(
|
|
||||||
parent,
|
|
||||||
name=name,
|
|
||||||
text=text,
|
|
||||||
command=command,
|
|
||||||
bg=bg,
|
|
||||||
fg=fg,
|
|
||||||
activebackground=bg,
|
|
||||||
activeforeground=fg,
|
|
||||||
font=tok.font(family(parent), "body", bold=kind == "primary"),
|
|
||||||
relief="flat",
|
|
||||||
borderwidth=0,
|
|
||||||
highlightthickness=0,
|
|
||||||
padx=tok.MD,
|
|
||||||
pady=tok.SM,
|
|
||||||
cursor="hand2",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def paint_toggle(switch: tk.Checkbutton, *, on: bool) -> None:
|
|
||||||
"""Draw ``switch`` as a toggle: accent fill reading "On", or a dark "Off"."""
|
|
||||||
bg, fg = (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.TEXT_ON_DARK)
|
|
||||||
switch.configure(
|
|
||||||
text="On" if on else "Off",
|
|
||||||
bg=bg,
|
|
||||||
selectcolor=bg,
|
|
||||||
activebackground=bg,
|
|
||||||
fg=fg,
|
|
||||||
activeforeground=fg,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def toggle(parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> tk.Checkbutton:
|
|
||||||
"""A Checkbutton drawn as an On/Off switch, repainted on every change."""
|
|
||||||
switch = tk.Checkbutton(
|
|
||||||
parent,
|
|
||||||
name=name,
|
|
||||||
variable=variable,
|
|
||||||
onvalue=True,
|
|
||||||
offvalue=False,
|
|
||||||
indicatoron=False,
|
|
||||||
width=4,
|
|
||||||
font=tok.font(family(parent), "label", bold=True),
|
|
||||||
relief="flat",
|
|
||||||
borderwidth=0,
|
|
||||||
highlightthickness=0,
|
|
||||||
padx=tok.SM,
|
|
||||||
pady=tok.XS,
|
|
||||||
disabledforeground=tok.MUTED_ON_DARK,
|
|
||||||
cursor="hand2",
|
|
||||||
)
|
|
||||||
switch.configure(command=lambda: paint_toggle(switch, on=variable.get()))
|
|
||||||
paint_toggle(switch, on=variable.get())
|
|
||||||
return switch
|
|
||||||
|
|||||||
@ -2,26 +2,33 @@
|
|||||||
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
|
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
|
||||||
|
|
||||||
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
|
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
|
||||||
the clicked day, the sick days still ahead underneath. The window class is
|
the clicked day, the sick days still ahead underneath (each an ISO date and
|
||||||
``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3
|
the pictograms of the lockers it keeps). The window class is ``sickdays``
|
||||||
``for_window`` rule can float it; ``Escape`` closes it.
|
(Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 ``for_window``
|
||||||
|
rule can float it; ``Escape`` closes it.
|
||||||
|
|
||||||
|
No words inside the window (kuhy's textgate rule). The title bar keeps the
|
||||||
|
app's name: an OS-level app name is a proper noun (textgate settled
|
||||||
|
decision 4), the same as ``sickdays.desktop``'s ``Name=``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from sickdays._api import is_sick_day, load
|
from sickdays._api import is_sick_day, load
|
||||||
from sickdays._day import today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._keep_api import kept_lockers
|
from sickdays._keep_api import kept_lockers
|
||||||
from sickdays._model import sick_entries
|
from sickdays._model import sick_entries
|
||||||
from sickdays._paths import resolve_paths
|
from sickdays._paths import resolve_paths
|
||||||
from sickdays.gui import _text
|
from sickdays.gui import _dates
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
from sickdays.gui._calendar import MonthCalendar
|
from sickdays.gui._calendar import MonthCalendar
|
||||||
from sickdays.gui._panel import KeepPanel
|
from sickdays.gui._controls import IconSlot
|
||||||
|
from sickdays.gui._icons import LOCKER_ICONS
|
||||||
|
from sickdays.gui._panel import STATUS_ICONS, KeepPanel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -30,19 +37,18 @@ if TYPE_CHECKING:
|
|||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
WINDOW_CLASS = "sickdays"
|
WINDOW_CLASS = "sickdays"
|
||||||
|
WINDOW_TITLE = "Sick days"
|
||||||
|
#: A kept locker's pictogram in the list of days ahead, in px.
|
||||||
|
_KEPT_PX: Final = 16
|
||||||
|
|
||||||
|
|
||||||
def saved_message(entry: SickDay | None, day: date) -> str:
|
def saved_icon(entry: SickDay | None) -> str | None:
|
||||||
"""The status line after the panel closed with ``entry``."""
|
"""The status mark after the panel closed with ``entry``.
|
||||||
label = _text.day_label(day)
|
|
||||||
if entry is None:
|
A check for any write (the grid and the list show what it was); nothing
|
||||||
return f"{label}: nothing changed"
|
when the panel was saved unchanged.
|
||||||
if not entry.is_sick:
|
"""
|
||||||
return f"{label} is a normal day again"
|
return None if entry is None else "check"
|
||||||
kept = _text.kept_label(entry.keep)
|
|
||||||
return f"{label} is a sick day — " + (
|
|
||||||
f"kept: {kept}" if kept else "every locker stands down"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SickDaysApp:
|
class SickDaysApp:
|
||||||
@ -52,7 +58,7 @@ class SickDaysApp:
|
|||||||
"""Style ``root`` and draw the current pool."""
|
"""Style ``root`` and draw the current pool."""
|
||||||
self._root = root
|
self._root = root
|
||||||
self._paths = resolve_paths(paths)
|
self._paths = resolve_paths(paths)
|
||||||
root.title("Sick days")
|
root.title(WINDOW_TITLE)
|
||||||
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
||||||
root.bind("<Escape>", self.close)
|
root.bind("<Escape>", self.close)
|
||||||
left = tk.Frame(root, bg=tok.INK)
|
left = tk.Frame(root, bg=tok.INK)
|
||||||
@ -60,13 +66,9 @@ class SickDaysApp:
|
|||||||
#: Where the clicked day's panel goes.
|
#: Where the clicked day's panel goes.
|
||||||
self.host = tk.Frame(root, name="host", bg=tok.INK)
|
self.host = tk.Frame(root, name="host", bg=tok.INK)
|
||||||
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
|
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
|
||||||
ui.label(left, "Sick days", "title").pack(anchor="w")
|
#: The last outcome's mark: a check, or the panel's refusal icons.
|
||||||
ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0))
|
self.status = IconSlot(left, name="status")
|
||||||
ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack(
|
self.status.pack(anchor="w")
|
||||||
anchor="w", pady=(tok.SM, 0)
|
|
||||||
)
|
|
||||||
self.status = ui.label(left, "", "label", name="status")
|
|
||||||
self.status.pack(anchor="w", pady=(tok.SM, 0))
|
|
||||||
log = self._paths.log
|
log = self._paths.log
|
||||||
self.calendar = MonthCalendar(
|
self.calendar = MonthCalendar(
|
||||||
left,
|
left,
|
||||||
@ -75,8 +77,8 @@ class SickDaysApp:
|
|||||||
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
|
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
|
||||||
on_day=self.select,
|
on_day=self.select,
|
||||||
)
|
)
|
||||||
self.calendar.pack(anchor="w", pady=(tok.MD, 0))
|
self.calendar.pack(anchor="w", pady=(tok.SM, 0))
|
||||||
ui.label(left, "Marked sick", "subtitle").pack(
|
IconSlot(left, name="ahead").show("sick", tok.SICK).pack(
|
||||||
anchor="w", pady=(tok.LG, tok.SM)
|
anchor="w", pady=(tok.LG, tok.SM)
|
||||||
)
|
)
|
||||||
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
||||||
@ -88,25 +90,25 @@ class SickDaysApp:
|
|||||||
self._root.destroy()
|
self._root.destroy()
|
||||||
|
|
||||||
def select(self, day: date) -> None:
|
def select(self, day: date) -> None:
|
||||||
"""Open the panel for ``day``, or refuse a free day in the status line."""
|
"""Open the panel for ``day``, or refuse a free day in the status mark."""
|
||||||
self._close_panel()
|
self._close_panel()
|
||||||
if not is_sick_day(day, log_path=self._paths.log):
|
if not is_sick_day(day, log_path=self._paths.log):
|
||||||
refusal = _text.free_day_refusal(day)
|
block = _dates.free_day_block(day)
|
||||||
if refusal is not None:
|
if block is not None:
|
||||||
self._set_status(refusal, failed=True)
|
self.status.show(*STATUS_ICONS[block])
|
||||||
return
|
return
|
||||||
self._set_status("", failed=False)
|
self.status.show(None)
|
||||||
KeepPanel(
|
KeepPanel(
|
||||||
self.host,
|
self.host,
|
||||||
day,
|
day,
|
||||||
on_saved=lambda entry: self._saved(entry, day),
|
on_saved=self._saved,
|
||||||
on_cancel=self._close_panel,
|
on_cancel=self._close_panel,
|
||||||
paths=self._paths,
|
paths=self._paths,
|
||||||
).pack(anchor="n")
|
).pack(anchor="n")
|
||||||
|
|
||||||
def _saved(self, entry: SickDay | None, day: date) -> None:
|
def _saved(self, entry: SickDay | None) -> None:
|
||||||
self._close_panel()
|
self._close_panel()
|
||||||
self._set_status(saved_message(entry, day), failed=False)
|
self.status.show(saved_icon(entry), tok.SUCCESS)
|
||||||
self.calendar.redraw()
|
self.calendar.redraw()
|
||||||
self._draw_upcoming()
|
self._draw_upcoming()
|
||||||
|
|
||||||
@ -114,29 +116,20 @@ class SickDaysApp:
|
|||||||
for child in self.host.winfo_children():
|
for child in self.host.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
|
|
||||||
def _set_status(self, text: str, *, failed: bool) -> None:
|
|
||||||
self.status.configure(text=text, fg=tok.DANGER if failed else tok.SUCCESS)
|
|
||||||
|
|
||||||
def _draw_upcoming(self) -> None:
|
def _draw_upcoming(self) -> None:
|
||||||
for child in self._list.winfo_children():
|
for child in self._list.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
reference = today()
|
reference = today()
|
||||||
upcoming = [
|
for entry in sick_entries(load(self._paths.log)):
|
||||||
entry
|
if entry.day < reference:
|
||||||
for entry in sick_entries(load(self._paths.log))
|
continue
|
||||||
if entry.day >= reference
|
row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK)
|
||||||
]
|
row.pack(anchor="w", pady=(0, tok.XS))
|
||||||
if not upcoming:
|
ui.label(row, to_iso(entry.day), "body").pack(side="left")
|
||||||
ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack(
|
for locker in _dates.kept_in_order(entry.keep):
|
||||||
anchor="w"
|
IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show(
|
||||||
)
|
LOCKER_ICONS[locker], tok.MUTED_ON_DARK
|
||||||
for entry in upcoming:
|
).pack(side="left", padx=(tok.SM, 0))
|
||||||
ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w")
|
|
||||||
kept = _text.kept_label(entry.keep)
|
|
||||||
if kept:
|
|
||||||
ui.label(
|
|
||||||
self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK
|
|
||||||
).pack(anchor="w", pady=(0, tok.XS))
|
|
||||||
|
|
||||||
|
|
||||||
def run(*, paths: Paths | None = None) -> int:
|
def run(*, paths: Paths | None = None) -> int:
|
||||||
|
|||||||
@ -61,7 +61,7 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|||||||
for module in _MODULES:
|
for module in _MODULES:
|
||||||
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
||||||
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
||||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", lambda: _never_free)
|
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", lambda: _never_free)
|
||||||
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
|
"""KeepPanel: the six switches for one day, against a real (unmapped) Tk."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sickdays._api import add_sick_day, is_sick_day, load
|
from sickdays._api import add_sick_day, is_sick_day, load
|
||||||
from sickdays._day import today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
from sickdays._keep_api import kept_lockers
|
from sickdays._keep_api import kept_lockers
|
||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
from sickdays._store import write_pool
|
from sickdays._store import write_pool
|
||||||
from sickdays.gui import KeepPanel, _text
|
from sickdays.gui import KeepPanel
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
from sickdays._model import SickDay
|
from sickdays._model import SickDay
|
||||||
|
|
||||||
@ -40,20 +41,22 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
|
|||||||
return calls
|
return calls
|
||||||
|
|
||||||
|
|
||||||
def _button(panel: KeepPanel, name: str) -> tk.Button:
|
def _button(panel: KeepPanel, name: str) -> IconButton:
|
||||||
widget = panel.nametowidget(f"actions.{name}")
|
widget = panel.nametowidget(f"actions.{name}")
|
||||||
assert isinstance(widget, tk.Button)
|
assert isinstance(widget, IconButton)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
|
def _toggle(panel: KeepPanel, locker: str) -> Toggle:
|
||||||
widget = panel.nametowidget(f"keep-{locker}")
|
widget = panel.nametowidget(f"keep-{locker}")
|
||||||
assert isinstance(widget, tk.Checkbutton)
|
assert isinstance(widget, Toggle)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _status(panel: KeepPanel) -> str:
|
def _status(panel: KeepPanel) -> str | None:
|
||||||
return str(panel.nametowidget("status").cget("text"))
|
slot = panel.nametowidget("status")
|
||||||
|
assert isinstance(slot, IconSlot)
|
||||||
|
return slot.icon
|
||||||
|
|
||||||
|
|
||||||
def _actions(panel: KeepPanel) -> set[str]:
|
def _actions(panel: KeepPanel) -> set[str]:
|
||||||
@ -72,11 +75,11 @@ def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None])
|
|||||||
assert panel.kept() == frozenset()
|
assert panel.kept() == frozenset()
|
||||||
assert _actions(panel) == {"mark"}
|
assert _actions(panel) == {"mark"}
|
||||||
toggle = _toggle(panel, "leetcode")
|
toggle = _toggle(panel, "leetcode")
|
||||||
assert toggle.cget("text") == "Off"
|
assert not toggle.is_on()
|
||||||
toggle.invoke()
|
toggle.invoke()
|
||||||
assert toggle.cget("text") == "On"
|
assert toggle.is_on()
|
||||||
assert str(toggle.cget("bg")).upper() == tok.ACCENT
|
assert _button(panel, "mark").icon == "check"
|
||||||
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
|
assert _status(panel) is None
|
||||||
_button(panel, "mark").invoke()
|
_button(panel, "mark").invoke()
|
||||||
_join(panel)
|
_join(panel)
|
||||||
assert is_sick_day(FUTURE)
|
assert is_sick_day(FUTURE)
|
||||||
@ -93,8 +96,14 @@ def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
|
|||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
|
||||||
assert panel.kept() == frozenset({"workout"})
|
assert panel.kept() == frozenset({"workout"})
|
||||||
assert _actions(panel) == {"save", "clear"}
|
assert _actions(panel) == {"save", "clear"}
|
||||||
title = panel.nametowidget("title").cget("text")
|
assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE)
|
||||||
assert title == f"Sick day: {_text.day_label(FUTURE)}"
|
state = panel.nametowidget("head.state")
|
||||||
|
assert isinstance(state, IconSlot)
|
||||||
|
assert state.icon == "sick"
|
||||||
|
assert _button(panel, "clear").icon == "trash"
|
||||||
|
icon = panel.nametowidget("icon-workout")
|
||||||
|
assert isinstance(icon, IconSlot)
|
||||||
|
assert icon.icon == "workout"
|
||||||
|
|
||||||
|
|
||||||
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
||||||
@ -139,20 +148,27 @@ def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None])
|
|||||||
|
|
||||||
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
|
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
|
||||||
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
|
||||||
assert _status(panel) == _text.PAST_NOTE
|
assert panel.reason == "past"
|
||||||
|
assert _status(panel) == "padlock"
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
|
diet = _toggle(panel, "diet")
|
||||||
|
assert str(diet.cget("state")) == "disabled"
|
||||||
|
diet.invoke()
|
||||||
|
assert not diet.is_on()
|
||||||
|
|
||||||
|
|
||||||
def test_a_free_day_is_refused_with_the_phones_message(
|
def test_a_free_day_is_refused_with_the_phones_message(
|
||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||||
expected = "is a free day — release it in Free days first."
|
assert panel.reason == "free"
|
||||||
assert _status(panel).endswith(expected)
|
assert _status(panel) == "no_entry"
|
||||||
|
state = panel.nametowidget("head.state")
|
||||||
|
assert isinstance(state, IconSlot)
|
||||||
|
assert state.icon == "normal"
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
|
|
||||||
|
|
||||||
@ -163,20 +179,24 @@ def test_an_uncheckable_free_pool_refuses(
|
|||||||
msg = "cannot check the free-day pool"
|
msg = "cannot check the free-day pool"
|
||||||
raise ConflictCheckUnavailableError(msg)
|
raise ConflictCheckUnavailableError(msg)
|
||||||
|
|
||||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||||
assert _status(panel) == "cannot check the free-day pool"
|
assert panel.reason == "unavailable"
|
||||||
|
assert _status(panel) == "warning"
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
|
|
||||||
|
|
||||||
def test_a_refused_write_is_shown_not_raised(
|
def test_a_refused_write_is_shown_not_raised(
|
||||||
tk_root: tk.Tk, synced: list[Paths | None]
|
tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
saved: list[SickDay | None] = []
|
saved: list[SickDay | None] = []
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||||
add_sick_day(FUTURE) # marked elsewhere while the panel was open
|
add_sick_day(FUTURE) # marked elsewhere while the panel was open
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
_button(panel, "mark").invoke()
|
_button(panel, "mark").invoke()
|
||||||
assert "already a sick day" in _status(panel)
|
assert panel.reason == "refused"
|
||||||
|
assert _status(panel) == "warning"
|
||||||
|
assert "already a sick day" in caplog.text
|
||||||
assert saved == []
|
assert saved == []
|
||||||
assert synced == []
|
assert synced == []
|
||||||
|
|
||||||
@ -189,7 +209,9 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
|
|||||||
on_saved=lambda _e: None,
|
on_saved=lambda _e: None,
|
||||||
on_cancel=lambda: cancelled.append(True),
|
on_cancel=lambda: cancelled.append(True),
|
||||||
)
|
)
|
||||||
_button(panel, "cancel").invoke()
|
cancel = _button(panel, "cancel")
|
||||||
|
assert cancel.icon == "cross"
|
||||||
|
cancel.invoke()
|
||||||
assert cancelled == [True]
|
assert cancelled == [True]
|
||||||
|
|
||||||
|
|
||||||
@ -242,4 +264,5 @@ def test_a_write_during_a_sync_is_refused_not_lost(
|
|||||||
for entry in saved:
|
for entry in saved:
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
|
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
|
||||||
assert _status(second) == _text.BUSY_NOTE
|
assert second.reason == "busy"
|
||||||
|
assert _status(second) == "hourglass"
|
||||||
|
|||||||
@ -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._api import add_sick_day
|
||||||
from sickdays._day import to_iso, today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._model import SickDay
|
from sickdays._model import SickDay
|
||||||
from sickdays.gui import KeepPanel, SickDaysApp, _text, _window
|
from sickdays.gui import KeepPanel, SickDaysApp, _dates, _window
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
|
from sickdays.gui._controls import IconButton, IconSlot
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pytest
|
import pytest
|
||||||
@ -19,13 +20,19 @@ if TYPE_CHECKING:
|
|||||||
FUTURE = today() + timedelta(days=3)
|
FUTURE = today() + timedelta(days=3)
|
||||||
|
|
||||||
|
|
||||||
def _texts(widget: tk.Misc) -> list[str]:
|
def _upcoming(app: SickDaysApp) -> dict[str, list[str]]:
|
||||||
found: list[str] = []
|
"""Each listed day's ISO date -> the locker icons it keeps."""
|
||||||
for child in widget.winfo_children():
|
rows = app.calendar.master.nametowidget("upcoming").winfo_children()
|
||||||
if isinstance(child, tk.Label):
|
listed: dict[str, list[str]] = {}
|
||||||
found.append(str(child.cget("text")))
|
for row in rows:
|
||||||
found.extend(_texts(child))
|
icons = [
|
||||||
return found
|
str(slot.icon)
|
||||||
|
for slot in row.winfo_children()
|
||||||
|
if isinstance(slot, IconSlot)
|
||||||
|
]
|
||||||
|
(date_label,) = [c for c in row.winfo_children() if isinstance(c, tk.Label)]
|
||||||
|
listed[str(date_label.cget("text"))] = icons
|
||||||
|
return listed
|
||||||
|
|
||||||
|
|
||||||
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
||||||
@ -40,14 +47,17 @@ def _panel(app: SickDaysApp) -> KeepPanel:
|
|||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
|
def test_an_empty_pool_lists_nothing_ahead(tk_root: tk.Tk) -> None:
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
assert tk_root.title() == "Sick days"
|
assert tk_root.title() == "Sick days"
|
||||||
assert _text.NONE_AHEAD in _texts(tk_root)
|
assert _upcoming(app) == {}
|
||||||
|
assert app.status.icon is None
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
|
|
||||||
|
|
||||||
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
||||||
|
later = FUTURE + timedelta(days=1)
|
||||||
|
add_sick_day(later)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.calendar.month = FUTURE.replace(day=1)
|
app.calendar.month = FUTURE.replace(day=1)
|
||||||
app.calendar.redraw()
|
app.calendar.redraw()
|
||||||
@ -58,10 +68,8 @@ def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
|||||||
assert panel.sync_thread is not None
|
assert panel.sync_thread is not None
|
||||||
panel.sync_thread.join(timeout=5)
|
panel.sync_thread.join(timeout=5)
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
assert app.status.cget("text") == _window.saved_message(
|
assert app.status.icon == "check"
|
||||||
SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE
|
assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []}
|
||||||
)
|
|
||||||
assert "Kept: LeetCode" in _texts(tk_root)
|
|
||||||
cell = _cell(app, FUTURE)
|
cell = _cell(app, FUTURE)
|
||||||
assert str(cell.cget("bg")).upper() == tok.SICK
|
assert str(cell.cget("bg")).upper() == tok.SICK
|
||||||
assert cell.nametowidget("dot").winfo_exists()
|
assert cell.nametowidget("dot").winfo_exists()
|
||||||
@ -78,13 +86,12 @@ def test_a_free_day_is_refused_before_the_panel_opens(
|
|||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.select(FUTURE)
|
app.select(FUTURE)
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
assert str(app.status.cget("text")).endswith("release it in Free days first.")
|
assert app.status.icon == "no_entry"
|
||||||
assert str(app.status.cget("fg")).upper() == tok.DANGER
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_sick_day_on_a_free_day_still_opens(
|
def test_a_sick_day_on_a_free_day_still_opens(
|
||||||
@ -92,7 +99,7 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
|||||||
) -> None:
|
) -> None:
|
||||||
add_sick_day(FUTURE)
|
add_sick_day(FUTURE)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.select(FUTURE)
|
app.select(FUTURE)
|
||||||
@ -102,19 +109,31 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
|||||||
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
|
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
|
||||||
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
||||||
add_sick_day(FUTURE + timedelta(days=1))
|
add_sick_day(FUTURE + timedelta(days=1))
|
||||||
texts = _texts(SickDaysApp(tk_root).calendar.master)
|
past = today() - timedelta(days=1)
|
||||||
assert _text.day_label(FUTURE) in texts
|
add_sick_day(past, now=past)
|
||||||
assert "Kept: Workout lock, Wake alarm" in texts
|
assert _upcoming(SickDaysApp(tk_root)) == {
|
||||||
assert _text.NONE_AHEAD not in texts
|
to_iso(FUTURE): ["workout", "alarm"],
|
||||||
|
to_iso(FUTURE + timedelta(days=1)): [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_saved_message_wording() -> None:
|
def test_saved_icon() -> None:
|
||||||
sick = SickDay(FUTURE, is_sick=True)
|
assert _window.saved_icon(None) is None
|
||||||
label = _text.day_label(FUTURE)
|
assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check"
|
||||||
assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed"
|
assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check"
|
||||||
assert _window.saved_message(sick, FUTURE).endswith("every locker stands down")
|
|
||||||
cleared = SickDay(FUTURE, is_sick=False)
|
|
||||||
assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again"
|
def test_the_month_reads_as_numerals_with_weekday_marks(tk_root: tk.Tk) -> None:
|
||||||
|
app = SickDaysApp(tk_root)
|
||||||
|
title = app.calendar.nametowidget("header").winfo_children()[-1]
|
||||||
|
assert isinstance(title, tk.Label)
|
||||||
|
assert title.cget("text") == _dates.month_title(today().replace(day=1))
|
||||||
|
marks = [
|
||||||
|
child.icon
|
||||||
|
for child in app.calendar.nametowidget("days").winfo_children()
|
||||||
|
if isinstance(child, IconSlot)
|
||||||
|
]
|
||||||
|
assert marks == ["dot"] * 5 + ["ring"] * 2
|
||||||
|
|
||||||
|
|
||||||
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
||||||
@ -128,7 +147,7 @@ def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
|||||||
|
|
||||||
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
first, last = _text.month_bounds(today())
|
first, last = _dates.month_bounds(today())
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
app.calendar.step(1)
|
app.calendar.step(1)
|
||||||
assert app.calendar.month == last
|
assert app.calendar.month == last
|
||||||
@ -136,6 +155,8 @@ def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
|||||||
app.calendar.step(-1)
|
app.calendar.step(-1)
|
||||||
assert app.calendar.month == first
|
assert app.calendar.month == first
|
||||||
back = app.calendar.nametowidget("header.prev")
|
back = app.calendar.nametowidget("header.prev")
|
||||||
|
assert isinstance(back, IconButton)
|
||||||
|
assert back.icon == "prev"
|
||||||
assert str(back.cget("state")) == "disabled"
|
assert str(back.cget("state")) == "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user