mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0) and nothing else. Removed: the 7/30/90-day budget, the escalating lockout countdown, the justification form, the commitment prompt and its double debt, the workout-debt counter, the -60 min shutdown cut and its sick_day_state.json, the post-sick-day --verify-workout mode, the sick auto-upgrade, scripts/revoke_sick_day.py and their tests. - Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens; one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort background sickdays.sync_quietly -> unlock. Refusals (free day, unreadable pool) are shown and the lock stays. - Shutdown: the reset resolves with earned_time.resolve(..., sick=True) (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace lift, so later credits absorb it instead of stacking on 20:00. A day marked after the reset is lifted by the new _sick_floor live pass (every 5-min tick, --sync-only, --apply-bonuses, and the tap itself). - Workout lock and grace floor stand down on a sick day; workouts that day credit normally through the sync paths. - wake-alarm workday_penalty.json is void when its issued_date or penalty_date is a sick day. - Status window, i3 line, CLI --status, MCP flags and web reasons read sickdays. - scripts/migrate_sick_history.py imports sick_history.json's dates via sickdays.import_legacy, verifies and removes the file; idempotent, run by install.sh. - Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending); pre-commit 4.7.0, ruff 0.17.1 (freshness gate). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
245 lines
9.9 KiB
Python
245 lines
9.9 KiB
Python
"""Low-level widget/grid builders for the manual-workout evidence form.
|
|
|
|
Split out of ``_manual_workout_dialog`` so the dialog module stays under the
|
|
repo's file-length cap. These helpers build individual form cells and place
|
|
them into a two-column grid; the orchestration (which fields, in what order,
|
|
and submission) lives in ``ManualWorkoutDialogMixin``, which inherits this.
|
|
|
|
The host is expected to have initialised ``self._mw_grid_counters`` (per-master
|
|
grid cursors) and, for ``_mw_rpe_row``, ``self._mw_rpe_var``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
|
|
from gatelock import escape_text_tab_trap
|
|
|
|
from screen_locker._constants import (
|
|
MANUAL_WORKOUT_RPE_MAX,
|
|
MANUAL_WORKOUT_RPE_MIN,
|
|
)
|
|
from screen_locker._ui_widgets import disable_paste
|
|
|
|
|
|
class ManualWorkoutFormWidgetsMixin:
|
|
"""Two-column grid placement + individual field-cell builders."""
|
|
|
|
def _mw_grid(
|
|
self, parent: tk.Widget, widget: tk.Widget, *, full: bool = False
|
|
) -> None:
|
|
"""Place ``widget`` into ``parent``'s two-column grid cursor.
|
|
|
|
A half-width item (``full=False``) fills the next cell (left, then
|
|
right); a ``full`` item starts a fresh row and spans both columns. Each
|
|
master keeps its own cursor (keyed by ``id(parent)``) so nested sport
|
|
frames grid independently of the main form.
|
|
"""
|
|
counters = self._mw_grid_counters
|
|
idx = counters.get(id(parent), 0)
|
|
if full and idx % 2 == 1:
|
|
idx += 1 # a full-width item can't share a row — bump to a new one
|
|
row, col = divmod(idx, 2)
|
|
widget.grid(
|
|
row=row,
|
|
column=col,
|
|
columnspan=2 if full else 1,
|
|
sticky="ew",
|
|
padx=self._colors.space("sm"),
|
|
# "xs", not "sm": with ten cells plus headings this gap is paid
|
|
# per row, and the form has to fit a 768px panel in full.
|
|
pady=self._colors.space("xs"),
|
|
)
|
|
counters[id(parent)] = idx + (2 if full else 1)
|
|
|
|
def _mw_next_full_row(self, parent: tk.Widget) -> int:
|
|
"""Reserve a fresh full-width row for a manually-gridded widget.
|
|
|
|
Used for the twin sport frames, which share one slot and are gridded
|
|
directly (not through ``_mw_grid``); this advances the cursor past that
|
|
row so following fields resume cleanly.
|
|
"""
|
|
counters = self._mw_grid_counters
|
|
idx = counters.get(id(parent), 0)
|
|
if idx % 2 == 1:
|
|
idx += 1
|
|
counters[id(parent)] = idx + 2
|
|
return idx // 2
|
|
|
|
def _mw_section(self, parent: tk.Widget, title: str) -> None:
|
|
"""Add a full-width section heading inside the form."""
|
|
label = tk.Label(
|
|
parent,
|
|
text=title,
|
|
font=self._colors.font("body", bold=True),
|
|
fg=self._colors.palette.accent,
|
|
bg=self._colors.palette.bg,
|
|
anchor="w",
|
|
)
|
|
self._mw_grid(parent, label, full=True)
|
|
|
|
def _mw_entry(
|
|
self, parent: tk.Widget, label: str, *, focus: bool = False
|
|
) -> tk.StringVar:
|
|
"""Add a half-width label+entry cell and return its backing StringVar.
|
|
|
|
Builds its own cell inline (rather than reusing ``_add_label_entry``,
|
|
which is pack-based) so it slots into
|
|
the two-column grid. Paste stays disabled via ``disable_paste``.
|
|
|
|
Args:
|
|
parent: The grid master to place the cell in.
|
|
label: Text shown above the entry.
|
|
focus: Give this entry the keyboard focus; see
|
|
``_ui_widgets.UIWidgetsMixin._add_label_entry`` for why exactly
|
|
one field per form should ask.
|
|
"""
|
|
var = tk.StringVar()
|
|
cell = tk.Frame(parent, bg=self._colors.palette.bg)
|
|
# Label beside the field, not above it. Stacked, each of the ten cells
|
|
# cost two lines plus the gap between them, and the form measured
|
|
# 1320px against a 768px panel; side-by-side each cell is one line
|
|
# tall. The lock cannot scroll its way out of not fitting, so rows this
|
|
# form does not need are rows it must not spend.
|
|
tk.Label(
|
|
cell,
|
|
text=label,
|
|
font=self._colors.font("label"),
|
|
fg=self._colors.palette.fg,
|
|
bg=self._colors.palette.bg,
|
|
anchor="w",
|
|
).pack(side="left", padx=(0, self._colors.space("xs")))
|
|
entry = tk.Entry(
|
|
cell,
|
|
textvariable=var,
|
|
font=self._colors.font("label"),
|
|
bg=self._colors.palette.field_bg,
|
|
fg=self._colors.palette.fg,
|
|
insertbackground=self._colors.palette.fg,
|
|
**self._colors.focus_kwargs(),
|
|
)
|
|
entry.pack(side="left", fill="x", expand=True)
|
|
disable_paste(entry)
|
|
if focus:
|
|
entry.focus_set()
|
|
self._mw_grid(parent, cell)
|
|
return var
|
|
|
|
def _mw_int_field(
|
|
self, parent: tk.Widget, label: str, *, frm: int = 0, to: int = 99
|
|
) -> tk.IntVar:
|
|
"""Add a half-width label + numeric Spinbox and return its IntVar."""
|
|
var = tk.IntVar(value=0)
|
|
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
|
tk.Label(
|
|
row,
|
|
text=label,
|
|
font=self._colors.font("label"),
|
|
fg=self._colors.palette.fg,
|
|
bg=self._colors.palette.bg,
|
|
).pack(side="left", padx=self._colors.space("xs"))
|
|
tk.Spinbox(
|
|
row,
|
|
from_=frm,
|
|
to=to,
|
|
textvariable=var,
|
|
width=4,
|
|
font=self._colors.font("label"),
|
|
**self._colors.focus_kwargs(),
|
|
).pack(side="left", padx=self._colors.space("xs"))
|
|
self._mw_grid(parent, row)
|
|
return var
|
|
|
|
def _mw_textbox(self, parent: tk.Widget, label: str) -> tk.Text:
|
|
"""Add a half-width label + multi-line Text cell and return the widget.
|
|
|
|
Half-width, not full: three full-width reflection boxes were three
|
|
whole rows of a form that has to fit a 768px panel, and two of them
|
|
sit side by side just as legibly.
|
|
"""
|
|
cell = tk.Frame(parent, bg=self._colors.palette.bg)
|
|
tk.Label(
|
|
cell,
|
|
text=label,
|
|
font=self._colors.font("label"),
|
|
fg=self._colors.palette.fg,
|
|
bg=self._colors.palette.bg,
|
|
anchor="w",
|
|
).pack(fill="x")
|
|
text_widget = tk.Text(
|
|
cell,
|
|
height=2,
|
|
font=self._colors.font("label"),
|
|
bg=self._colors.palette.field_bg,
|
|
fg=self._colors.palette.fg,
|
|
insertbackground=self._colors.palette.fg,
|
|
**self._colors.focus_kwargs(),
|
|
)
|
|
text_widget.pack(pady=self._colors.space("xs"), fill="x")
|
|
disable_paste(text_widget)
|
|
# Tk makes <Tab> insert a literal tab and refocus the widget, and binds
|
|
# <Shift-Tab> to nothing, so an untreated Text is a keyboard dead end --
|
|
# and the only exits (Ctrl+Tab / Ctrl+Shift+Tab) are advertised nowhere.
|
|
# On this form that means never reaching SUBMIT.
|
|
escape_text_tab_trap(text_widget)
|
|
self._mw_grid(parent, cell)
|
|
return text_widget
|
|
|
|
def _mw_rpe_row(self, parent: tk.Widget) -> None:
|
|
"""Add the RPE (rate of perceived exertion) spinbox row."""
|
|
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
|
tk.Label(
|
|
row,
|
|
text=(
|
|
f"RPE — perceived exertion "
|
|
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
|
|
),
|
|
font=self._colors.font("label"),
|
|
fg=self._colors.palette.fg,
|
|
bg=self._colors.palette.bg,
|
|
).pack(side="left", padx=self._colors.space("xs"))
|
|
tk.Spinbox(
|
|
row,
|
|
from_=MANUAL_WORKOUT_RPE_MIN,
|
|
to=MANUAL_WORKOUT_RPE_MAX,
|
|
textvariable=self._mw_rpe_var,
|
|
width=4,
|
|
font=self._colors.font("label"),
|
|
**self._colors.focus_kwargs(),
|
|
).pack(side="left", padx=self._colors.space("xs"))
|
|
self._mw_grid(parent, row)
|
|
|
|
def _mw_scrollable_form(self) -> tk.Frame:
|
|
"""Create the two-column form frame inside the surface's viewport.
|
|
|
|
This used to build its *own* ``Canvas`` + ``Scrollbar`` viewport, which
|
|
had three problems that are all gone by deletion now that the surface
|
|
container is itself a :class:`~gatelock.ScrollableSurface`:
|
|
|
|
1. **Scrolling was pointer-only.** ``tk.Canvas`` has no class-level key
|
|
bindings, so ``::tk::FocusOK`` rejected it as a focus stop, and the
|
|
canvas bound no ``<MouseWheel>``, ``<Prior>``/``<Next>`` or arrows.
|
|
The scrollbar thumb had to be *dragged* -- inside a lock that cannot
|
|
be dismissed without submitting this form.
|
|
2. **Focus walked off-screen.** Canvas clipping does not unmap a child,
|
|
so every below-the-fold field stayed ``winfo viewable`` and stayed in
|
|
the tab ring while Tk never scrolled to follow focus. Tab led to
|
|
fields the user could neither see nor bring into view.
|
|
3. **The height was a magic fraction.** ``height=0.7 * toplevel`` sat
|
|
above ~230-290px of fixed chrome (title, budget line, error label,
|
|
and a 24pt button row), so the constraint was ``0.3 * H >= chrome``:
|
|
satisfied at 1080p, violated at 768p, where SUBMIT/BACK were pushed
|
|
off the bottom of a centred, unscrollable container.
|
|
|
|
Still built on the primary surface only -- an independently scrolled
|
|
copy per monitor would show two different parts of one form.
|
|
"""
|
|
form = tk.Frame(self.container.first, bg=self._colors.palette.bg)
|
|
# No outer gap: the budget line above and the first section heading
|
|
# below already separate the grid, and on a 1024x600 panel this form
|
|
# fits by single-digit pixels.
|
|
form.pack(fill="both", expand=True)
|
|
form.grid_columnconfigure(0, weight=1, uniform="mw")
|
|
form.grid_columnconfigure(1, weight=1, uniform="mw")
|
|
return form
|