mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 13:47:39 +01:00
Split _runnerup_verification, _ui_widgets and _sick_dialog
_runnerup_verification.py (312) -> _runnerup_tcx.py pull + parse TCX
_runnerup_backfill.py fill the log for a
date or a week
_ui_widgets.py (287) -> _ui_form_fields.py labelled fields,
focus, disable_paste
_sick_dialog.py (278) -> _sick_commitment.py next-day commitment
_ui_form_fields also takes disable_paste, since the fields are what use it;
_ui_widgets re-exports it for _manual_workout_widgets and _sick_dialog.
Two things had to move with the code rather than being left behind:
- conftest's _TK_MODULES gains _ui_form_fields, or its tkinter calls reach a
real display instead of the mock.
- pyproject's S314 waiver follows the ET.parse call it covers into
_runnerup_tcx.py. Same approved suppression, new home -- not a new one.
Test count 977 -> 978: _ui_form_fields is a new lock-surface module, so
test_no_popup_widgets safety-checks it too. 100% branch coverage held; pylint
back at 8.70 after fixing a genuinely undefined WidgetGroup annotation the
split exposed. `screen_lock --status` unchanged. Gate: 41 -> 28 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
parent
094a1fe5ed
commit
8bc981da4e
@ -55,7 +55,10 @@ unfixable = []
|
|||||||
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
|
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
|
||||||
"PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001",
|
"PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001",
|
||||||
]
|
]
|
||||||
"screen_locker/_runnerup_verification.py" = ["S314"]
|
# The ET.parse call this covers moved to _runnerup_tcx.py when
|
||||||
|
# _runnerup_verification.py was split for the 250-line cap. Same waiver, new
|
||||||
|
# home -- the TCX files are pulled off the user's own phone by adb.
|
||||||
|
"screen_locker/_runnerup_tcx.py" = ["S314"]
|
||||||
# URL is always built from the hardcoded https:// _WTTR_URL constant with
|
# URL is always built from the hardcoded https:// _WTTR_URL constant with
|
||||||
# only the (quoted) city interpolated — never user/attacker controlled.
|
# only the (quoted) city interpolated — never user/attacker controlled.
|
||||||
# ruff's S310 can't verify that via static analysis of a variable URL, so
|
# ruff's S310 can't verify that via static analysis of a variable URL, so
|
||||||
|
|||||||
90
screen_locker/_runnerup_backfill.py
Normal file
90
screen_locker/_runnerup_backfill.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""Backfilling the workout log from RunnerUp exports already on the phone.
|
||||||
|
|
||||||
|
Split out of :mod:`screen_locker._runnerup_verification` to keep every file
|
||||||
|
under the 250-line cap. Composed back into ``RunnerUpVerificationMixin``
|
||||||
|
there, so callers see no change.
|
||||||
|
|
||||||
|
Used by the weekly scan: a run exported days ago still counts, so the log is
|
||||||
|
filled in from whatever RunnerUp kept rather than only from today's check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._log_mixin import write_signed_entry
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerUpBackfillMixin:
|
||||||
|
"""Fills the log from RunnerUp exports for a date or a whole week."""
|
||||||
|
|
||||||
|
def _try_fill_runnerup_for_date(self, date_str: str, log_file: Path) -> bool:
|
||||||
|
"""Append a verified RunnerUp entry for ``date_str`` if not already logged.
|
||||||
|
|
||||||
|
Appends via the write chokepoint, which dedups by ``workout_id``
|
||||||
|
(``runnerup_verified:{date}``): re-scanning a day whose run is already
|
||||||
|
recorded is a no-op, but a day that only holds, say, a manual workout
|
||||||
|
still gets the run appended alongside it (multiple workouts per day).
|
||||||
|
|
||||||
|
Returns True if a new verified entry was appended for ``date_str``.
|
||||||
|
"""
|
||||||
|
for remote in self._find_runnerup_exports_for_date(date_str):
|
||||||
|
data = self._pull_and_parse_tcx(remote)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
status, msg = self._validate_runnerup_data(data)
|
||||||
|
if status != "verified":
|
||||||
|
_logger.warning(
|
||||||
|
"RunnerUp export %s for %s did not qualify (%s): %s",
|
||||||
|
remote,
|
||||||
|
date_str,
|
||||||
|
status,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
workout_data = {
|
||||||
|
"type": "runnerup_verified",
|
||||||
|
"source": f"Auto-scanned: {msg}",
|
||||||
|
"distance_km": round(data["distance_m"] / 1000, 2),
|
||||||
|
"duration_minutes": round(data["duration_seconds"] / 60, 1),
|
||||||
|
}
|
||||||
|
if write_signed_entry(log_file, date_str, workout_data).appended:
|
||||||
|
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _scan_and_fill_week_runnerup(self, log_file: Path) -> int:
|
||||||
|
"""Scan the current ISO week for RunnerUp runs and append any not logged.
|
||||||
|
|
||||||
|
Returns the count of newly appended entries (0 if phone not connected).
|
||||||
|
"""
|
||||||
|
if not self._has_adb_device():
|
||||||
|
_logger.info(
|
||||||
|
"Phone not connected; skipping auto-scan for past RunnerUp exports."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc).astimezone()
|
||||||
|
today = now.date()
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
|
||||||
|
filled = 0
|
||||||
|
current = week_start
|
||||||
|
while current <= today:
|
||||||
|
if self._try_fill_runnerup_for_date(current.strftime("%Y-%m-%d"), log_file):
|
||||||
|
filled += 1
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return filled
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Shared validation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
96
screen_locker/_runnerup_tcx.py
Normal file
96
screen_locker/_runnerup_tcx.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Pulling a RunnerUp TCX export off the phone and parsing it.
|
||||||
|
|
||||||
|
Split out of :mod:`screen_locker._runnerup_verification` to keep every file
|
||||||
|
under the 250-line cap. Composed back into ``RunnerUpVerificationMixin``
|
||||||
|
there, so callers see no change.
|
||||||
|
|
||||||
|
TCX is the no-root path: RunnerUp writes these exports to shared storage, so
|
||||||
|
they can be read without touching the app's private database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
# TCX XML namespace used by Garmin/RunnerUp.
|
||||||
|
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||||
|
|
||||||
|
_SPORT_NAMES: dict[int, str] = {
|
||||||
|
0: "Running",
|
||||||
|
1: "Cycling",
|
||||||
|
2: "Other",
|
||||||
|
3: "Orienteering",
|
||||||
|
4: "Walking",
|
||||||
|
5: "Skiing",
|
||||||
|
6: "Swimming",
|
||||||
|
7: "Stationary Bike",
|
||||||
|
}
|
||||||
|
|
||||||
|
# TCX uses sport name strings; map back to integer codes for unified validation.
|
||||||
|
_TCX_SPORT_TO_INT: dict[str, int] = {v: k for k, v in _SPORT_NAMES.items()}
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerUpTcxMixin:
|
||||||
|
"""Pulls and parses RunnerUp's TCX exports over adb."""
|
||||||
|
|
||||||
|
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
|
||||||
|
"""Pull a remote TCX file and parse it. Returns activity dict or None."""
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
|
||||||
|
local_path = str(Path(tmp_dir) / "activity.tcx")
|
||||||
|
try:
|
||||||
|
ok, _ = self._run_adb(["pull", remote_path, local_path])
|
||||||
|
if not ok or not Path(local_path).exists():
|
||||||
|
_logger.info("Failed to pull TCX file: %s", remote_path)
|
||||||
|
return None
|
||||||
|
return self._parse_tcx(local_path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
||||||
|
"""Parse a local TCX file and return activity summary dict.
|
||||||
|
|
||||||
|
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
||||||
|
multi-segment runs (pause/resume) are counted in full.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ET.parse(tcx_path)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
||||||
|
"be verified and will not count",
|
||||||
|
tcx_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
root = tree.getroot()
|
||||||
|
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
||||||
|
if activity is None:
|
||||||
|
_logger.info("No Activity element in TCX file")
|
||||||
|
return None
|
||||||
|
|
||||||
|
sport_str = activity.get("Sport", "")
|
||||||
|
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
||||||
|
|
||||||
|
total_seconds = 0.0
|
||||||
|
total_distance = 0.0
|
||||||
|
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
||||||
|
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
||||||
|
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
||||||
|
if t_elem is not None and t_elem.text:
|
||||||
|
total_seconds += float(t_elem.text)
|
||||||
|
if d_elem is not None and d_elem.text:
|
||||||
|
total_distance += float(d_elem.text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sport": sport_int,
|
||||||
|
"duration_seconds": int(total_seconds),
|
||||||
|
"distance_m": total_distance,
|
||||||
|
}
|
||||||
@ -7,13 +7,9 @@ Root DB fallback lives in ``_runnerup_db.py``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
MIN_RUN_DISTANCE_KM,
|
MIN_RUN_DISTANCE_KM,
|
||||||
@ -23,8 +19,9 @@ from screen_locker._constants import (
|
|||||||
RUNNERUP_EXPORT_DIRS,
|
RUNNERUP_EXPORT_DIRS,
|
||||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||||
)
|
)
|
||||||
from screen_locker._log_mixin import write_signed_entry
|
from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
|
||||||
from screen_locker._runnerup_db import RunnerUpDbMixin
|
from screen_locker._runnerup_db import RunnerUpDbMixin
|
||||||
|
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
|
||||||
from screen_locker._time_check import check_clock_skew
|
from screen_locker._time_check import check_clock_skew
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@ -41,13 +38,13 @@ _SPORT_NAMES: dict[int, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# TCX uses sport name strings; map back to integer codes for unified validation.
|
# TCX uses sport name strings; map back to integer codes for unified validation.
|
||||||
_TCX_SPORT_TO_INT: dict[str, int] = {v: k for k, v in _SPORT_NAMES.items()}
|
|
||||||
|
|
||||||
# TCX XML namespace used by Garmin/RunnerUp.
|
|
||||||
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
|
||||||
|
|
||||||
|
|
||||||
class RunnerUpVerificationMixin(RunnerUpDbMixin):
|
class RunnerUpVerificationMixin(
|
||||||
|
RunnerUpDbMixin,
|
||||||
|
RunnerUpTcxMixin,
|
||||||
|
RunnerUpBackfillMixin,
|
||||||
|
):
|
||||||
"""Mixin providing RunnerUp-based workout verification via ADB."""
|
"""Mixin providing RunnerUp-based workout verification via ADB."""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -74,61 +71,6 @@ class RunnerUpVerificationMixin(RunnerUpDbMixin):
|
|||||||
found.append(remote)
|
found.append(remote)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
|
|
||||||
"""Pull a remote TCX file and parse it. Returns activity dict or None."""
|
|
||||||
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
|
|
||||||
local_path = str(Path(tmp_dir) / "activity.tcx")
|
|
||||||
try:
|
|
||||||
ok, _ = self._run_adb(["pull", remote_path, local_path])
|
|
||||||
if not ok or not Path(local_path).exists():
|
|
||||||
_logger.info("Failed to pull TCX file: %s", remote_path)
|
|
||||||
return None
|
|
||||||
return self._parse_tcx(local_path)
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
|
||||||
"""Parse a local TCX file and return activity summary dict.
|
|
||||||
|
|
||||||
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
|
||||||
multi-segment runs (pause/resume) are counted in full.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = ET.parse(tcx_path)
|
|
||||||
except ET.ParseError as exc:
|
|
||||||
_logger.warning(
|
|
||||||
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
|
||||||
"be verified and will not count",
|
|
||||||
tcx_path,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
root = tree.getroot()
|
|
||||||
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
|
||||||
if activity is None:
|
|
||||||
_logger.info("No Activity element in TCX file")
|
|
||||||
return None
|
|
||||||
|
|
||||||
sport_str = activity.get("Sport", "")
|
|
||||||
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
|
||||||
|
|
||||||
total_seconds = 0.0
|
|
||||||
total_distance = 0.0
|
|
||||||
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
|
||||||
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
|
||||||
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
|
||||||
if t_elem is not None and t_elem.text:
|
|
||||||
total_seconds += float(t_elem.text)
|
|
||||||
if d_elem is not None and d_elem.text:
|
|
||||||
total_distance += float(d_elem.text)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"sport": sport_int,
|
|
||||||
"duration_seconds": int(total_seconds),
|
|
||||||
"distance_m": total_distance,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _verify_runnerup_via_files(self) -> tuple[str, str] | None:
|
def _verify_runnerup_via_files(self) -> tuple[str, str] | None:
|
||||||
"""Try to verify today's run via TCX export files.
|
"""Try to verify today's run via TCX export files.
|
||||||
|
|
||||||
@ -167,70 +109,6 @@ class RunnerUpVerificationMixin(RunnerUpDbMixin):
|
|||||||
"RunnerUp TCX export found but could not be read",
|
"RunnerUp TCX export found but could not be read",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _try_fill_runnerup_for_date(self, date_str: str, log_file: Path) -> bool:
|
|
||||||
"""Append a verified RunnerUp entry for ``date_str`` if not already logged.
|
|
||||||
|
|
||||||
Appends via the write chokepoint, which dedups by ``workout_id``
|
|
||||||
(``runnerup_verified:{date}``): re-scanning a day whose run is already
|
|
||||||
recorded is a no-op, but a day that only holds, say, a manual workout
|
|
||||||
still gets the run appended alongside it (multiple workouts per day).
|
|
||||||
|
|
||||||
Returns True if a new verified entry was appended for ``date_str``.
|
|
||||||
"""
|
|
||||||
for remote in self._find_runnerup_exports_for_date(date_str):
|
|
||||||
data = self._pull_and_parse_tcx(remote)
|
|
||||||
if data is None:
|
|
||||||
continue
|
|
||||||
status, msg = self._validate_runnerup_data(data)
|
|
||||||
if status != "verified":
|
|
||||||
_logger.warning(
|
|
||||||
"RunnerUp export %s for %s did not qualify (%s): %s",
|
|
||||||
remote,
|
|
||||||
date_str,
|
|
||||||
status,
|
|
||||||
msg,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
workout_data = {
|
|
||||||
"type": "runnerup_verified",
|
|
||||||
"source": f"Auto-scanned: {msg}",
|
|
||||||
"distance_km": round(data["distance_m"] / 1000, 2),
|
|
||||||
"duration_minutes": round(data["duration_seconds"] / 60, 1),
|
|
||||||
}
|
|
||||||
if write_signed_entry(log_file, date_str, workout_data).appended:
|
|
||||||
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _scan_and_fill_week_runnerup(self, log_file: Path) -> int:
|
|
||||||
"""Scan the current ISO week for RunnerUp runs and append any not logged.
|
|
||||||
|
|
||||||
Returns the count of newly appended entries (0 if phone not connected).
|
|
||||||
"""
|
|
||||||
if not self._has_adb_device():
|
|
||||||
_logger.info(
|
|
||||||
"Phone not connected; skipping auto-scan for past RunnerUp exports."
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
now = datetime.now(tz=timezone.utc).astimezone()
|
|
||||||
today = now.date()
|
|
||||||
week_start = today - timedelta(days=today.weekday())
|
|
||||||
|
|
||||||
filled = 0
|
|
||||||
current = week_start
|
|
||||||
while current <= today:
|
|
||||||
if self._try_fill_runnerup_for_date(current.strftime("%Y-%m-%d"), log_file):
|
|
||||||
filled += 1
|
|
||||||
current += timedelta(days=1)
|
|
||||||
|
|
||||||
return filled
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Shared validation
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
|
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
|
||||||
"""Validate a RunnerUp activity against configured thresholds.
|
"""Validate a RunnerUp activity against configured thresholds.
|
||||||
|
|
||||||
|
|||||||
90
screen_locker/_sick_commitment.py
Normal file
90
screen_locker/_sick_commitment.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""The next-day commitment prompt shown after a sick day.
|
||||||
|
|
||||||
|
Split out of :mod:`screen_locker._sick_dialog` to keep every file under the
|
||||||
|
250-line cap. Composed back into ``SickDialogMixin`` there, so callers see no
|
||||||
|
change.
|
||||||
|
|
||||||
|
Taking a sick day asks for a commitment to train tomorrow; breaking it is
|
||||||
|
recorded and costs budget, which is what stops sick days being free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker import _sick_tracker
|
||||||
|
from screen_locker._constants import COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class SickCommitmentMixin:
|
||||||
|
"""Asks for, times out, and records the next-day training commitment."""
|
||||||
|
|
||||||
|
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
|
||||||
|
"""Ask the user to commit to working out tomorrow.
|
||||||
|
|
||||||
|
Calls ``on_done()`` once the user answers or the timeout elapses.
|
||||||
|
"""
|
||||||
|
self.clear_container()
|
||||||
|
self._label(
|
||||||
|
"Commit to working out tomorrow?",
|
||||||
|
role="display",
|
||||||
|
color=self._colors.warning,
|
||||||
|
pad="md",
|
||||||
|
)
|
||||||
|
# ~80 chars at font_size 16 would render as one unbroken line past the
|
||||||
|
# ~70-char readable range (rule 21) -- wrap it explicitly.
|
||||||
|
prompt = self._text(
|
||||||
|
"If you say YES and skip via 'I'm sick' tomorrow, "
|
||||||
|
"the sick day costs 2x normal.",
|
||||||
|
role="body",
|
||||||
|
)
|
||||||
|
prompt.config(wraplength=560, justify="center")
|
||||||
|
self._commitment_done_fn = on_done
|
||||||
|
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
||||||
|
self._commitment_timer_label = self._text(
|
||||||
|
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
|
||||||
|
color=self._colors.muted,
|
||||||
|
)
|
||||||
|
row = self._button_row()
|
||||||
|
self._button(
|
||||||
|
row,
|
||||||
|
"YES",
|
||||||
|
bg=self._colors.success,
|
||||||
|
command=lambda: self._answer_commitment(commit=True),
|
||||||
|
width=12,
|
||||||
|
).pack(side="left", padx=self._colors.space("sm"))
|
||||||
|
self._button(
|
||||||
|
row,
|
||||||
|
"NO",
|
||||||
|
bg=self._colors.field_bg,
|
||||||
|
command=lambda: self._answer_commitment(commit=False),
|
||||||
|
width=12,
|
||||||
|
).pack(side="left", padx=self._colors.space("sm"))
|
||||||
|
self._tick_commitment_timeout()
|
||||||
|
|
||||||
|
def _tick_commitment_timeout(self) -> None:
|
||||||
|
"""Advance commitment auto-skip timer; default to NO when it expires."""
|
||||||
|
if self._commitment_remaining <= 0:
|
||||||
|
self._answer_commitment(commit=False)
|
||||||
|
return
|
||||||
|
self._commitment_timer_label.config(
|
||||||
|
text=f"Auto-skipping in {self._commitment_remaining}s",
|
||||||
|
)
|
||||||
|
self._commitment_remaining -= 1
|
||||||
|
self.root.after(1000, self._tick_commitment_timeout)
|
||||||
|
|
||||||
|
def _answer_commitment(self, *, commit: bool) -> None:
|
||||||
|
"""Persist the commitment answer and call the completion callback."""
|
||||||
|
# Disable timer re-entry by zeroing remaining.
|
||||||
|
self._commitment_remaining = -1
|
||||||
|
if commit:
|
||||||
|
history = _sick_tracker.load_history()
|
||||||
|
_sick_tracker.record_commitment_for_tomorrow(history)
|
||||||
|
_sick_tracker.save_history(history)
|
||||||
|
done = getattr(self, "_commitment_done_fn", None)
|
||||||
|
if done is not None:
|
||||||
|
self._commitment_done_fn = None
|
||||||
|
done()
|
||||||
@ -10,22 +10,20 @@ from gatelock import escape_text_tab_trap
|
|||||||
|
|
||||||
from screen_locker import _sick_tracker
|
from screen_locker import _sick_tracker
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS,
|
|
||||||
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
||||||
SICK_JUSTIFICATION_MIN_CHARS,
|
SICK_JUSTIFICATION_MIN_CHARS,
|
||||||
)
|
)
|
||||||
|
from screen_locker._sick_commitment import SickCommitmentMixin
|
||||||
from screen_locker._surface_group import FrameGroup, TextGroup
|
from screen_locker._surface_group import FrameGroup, TextGroup
|
||||||
from screen_locker._ui_widgets import disable_paste as _disable_paste
|
from screen_locker._ui_widgets import disable_paste as _disable_paste
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from screen_locker._sick_tracker import SickHistory
|
from screen_locker._sick_tracker import SickHistory
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SickDialogMixin:
|
class SickDialogMixin(SickCommitmentMixin):
|
||||||
"""Renders the sick-day justification screen and commitment prompts."""
|
"""Renders the sick-day justification screen and commitment prompts."""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -209,70 +207,3 @@ class SickDialogMixin:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Commitment prompt (after a verified workout)
|
# Commitment prompt (after a verified workout)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
|
|
||||||
"""Ask the user to commit to working out tomorrow.
|
|
||||||
|
|
||||||
Calls ``on_done()`` once the user answers or the timeout elapses.
|
|
||||||
"""
|
|
||||||
self.clear_container()
|
|
||||||
self._label(
|
|
||||||
"Commit to working out tomorrow?",
|
|
||||||
role="display",
|
|
||||||
color=self._colors.warning,
|
|
||||||
pad="md",
|
|
||||||
)
|
|
||||||
# ~80 chars at font_size 16 would render as one unbroken line past the
|
|
||||||
# ~70-char readable range (rule 21) -- wrap it explicitly.
|
|
||||||
prompt = self._text(
|
|
||||||
"If you say YES and skip via 'I'm sick' tomorrow, "
|
|
||||||
"the sick day costs 2x normal.",
|
|
||||||
role="body",
|
|
||||||
)
|
|
||||||
prompt.config(wraplength=560, justify="center")
|
|
||||||
self._commitment_done_fn = on_done
|
|
||||||
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
|
||||||
self._commitment_timer_label = self._text(
|
|
||||||
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
|
|
||||||
color=self._colors.muted,
|
|
||||||
)
|
|
||||||
row = self._button_row()
|
|
||||||
self._button(
|
|
||||||
row,
|
|
||||||
"YES",
|
|
||||||
bg=self._colors.success,
|
|
||||||
command=lambda: self._answer_commitment(commit=True),
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=self._colors.space("sm"))
|
|
||||||
self._button(
|
|
||||||
row,
|
|
||||||
"NO",
|
|
||||||
bg=self._colors.field_bg,
|
|
||||||
command=lambda: self._answer_commitment(commit=False),
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=self._colors.space("sm"))
|
|
||||||
self._tick_commitment_timeout()
|
|
||||||
|
|
||||||
def _tick_commitment_timeout(self) -> None:
|
|
||||||
"""Advance commitment auto-skip timer; default to NO when it expires."""
|
|
||||||
if self._commitment_remaining <= 0:
|
|
||||||
self._answer_commitment(commit=False)
|
|
||||||
return
|
|
||||||
self._commitment_timer_label.config(
|
|
||||||
text=f"Auto-skipping in {self._commitment_remaining}s",
|
|
||||||
)
|
|
||||||
self._commitment_remaining -= 1
|
|
||||||
self.root.after(1000, self._tick_commitment_timeout)
|
|
||||||
|
|
||||||
def _answer_commitment(self, *, commit: bool) -> None:
|
|
||||||
"""Persist the commitment answer and call the completion callback."""
|
|
||||||
# Disable timer re-entry by zeroing remaining.
|
|
||||||
self._commitment_remaining = -1
|
|
||||||
if commit:
|
|
||||||
history = _sick_tracker.load_history()
|
|
||||||
_sick_tracker.record_commitment_for_tomorrow(history)
|
|
||||||
_sick_tracker.save_history(history)
|
|
||||||
done = getattr(self, "_commitment_done_fn", None)
|
|
||||||
if done is not None:
|
|
||||||
self._commitment_done_fn = None
|
|
||||||
done()
|
|
||||||
|
|||||||
138
screen_locker/_ui_form_fields.py
Normal file
138
screen_locker/_ui_form_fields.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""Labelled form fields and initial focus, shared by every lock surface.
|
||||||
|
|
||||||
|
Split out of :mod:`screen_locker._ui_widgets` to keep every file under the
|
||||||
|
250-line cap. Composed back into ``UIWidgetsMixin`` there, so every host
|
||||||
|
class keeps the same surface.
|
||||||
|
|
||||||
|
Each helper builds its widget on *every* monitor (the lock spans them all),
|
||||||
|
which is why they return groups rather than single widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import tkinter as tk
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from gatelock import escape_text_tab_trap
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from screen_locker._surface_group import FrameGroup, WidgetGroup
|
||||||
|
|
||||||
|
|
||||||
|
def disable_paste(widget: tk.Widget) -> None:
|
||||||
|
"""Disable paste in a Tk Entry/Text widget.
|
||||||
|
|
||||||
|
Friction-only: a determined user can still bypass via xdotool, but the
|
||||||
|
point is removing the trivial Ctrl+V shortcut so the user must
|
||||||
|
actually type their input.
|
||||||
|
"""
|
||||||
|
for sequence in ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
|
||||||
|
with contextlib.suppress(tk.TclError, AttributeError):
|
||||||
|
widget.bind(sequence, lambda _e: "break")
|
||||||
|
|
||||||
|
|
||||||
|
class UIFormFieldsMixin:
|
||||||
|
"""Labelled entry/text fields and initial focus for the lock surfaces."""
|
||||||
|
|
||||||
|
def _add_label_entry(
|
||||||
|
self,
|
||||||
|
parent: FrameGroup,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
variable: tk.StringVar,
|
||||||
|
focus: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Add a label + single-line entry pair on every monitor.
|
||||||
|
|
||||||
|
All copies share one ``variable``, so typing on any screen is the
|
||||||
|
same text everywhere and reading it back needs no idea which monitor
|
||||||
|
the user actually used.
|
||||||
|
|
||||||
|
``focus`` gives the primary surface's copy the keyboard focus. Exactly
|
||||||
|
one field per screen should ask: the lock takes a global grab, so with
|
||||||
|
nothing focused the user's typing goes nowhere and there is no other
|
||||||
|
window on screen to make that obvious.
|
||||||
|
"""
|
||||||
|
row = parent.child_frame(bg=self._colors.bg)
|
||||||
|
row.pack(pady=self._colors.space("sm"), fill="x")
|
||||||
|
row.child_widgets(
|
||||||
|
tk.Label,
|
||||||
|
text=label,
|
||||||
|
font=self._colors.font("label"),
|
||||||
|
fg=self._colors.fg,
|
||||||
|
bg=self._colors.bg,
|
||||||
|
anchor="w",
|
||||||
|
).pack(side="top", anchor="w")
|
||||||
|
entries = row.child_widgets(
|
||||||
|
tk.Entry,
|
||||||
|
textvariable=variable,
|
||||||
|
width=50,
|
||||||
|
font=self._colors.font("label"),
|
||||||
|
bg=self._colors.field_bg,
|
||||||
|
fg=self._colors.fg,
|
||||||
|
insertbackground=self._colors.fg,
|
||||||
|
**self._colors.focus_kwargs(),
|
||||||
|
)
|
||||||
|
entries.pack(side="top", anchor="w", pady=self._colors.space("xs"))
|
||||||
|
for entry in entries:
|
||||||
|
disable_paste(entry)
|
||||||
|
if focus:
|
||||||
|
entries.first.focus_set()
|
||||||
|
|
||||||
|
def _add_label_text(
|
||||||
|
self,
|
||||||
|
parent: FrameGroup,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
height: int = 4,
|
||||||
|
) -> WidgetGroup:
|
||||||
|
"""Add a label + multi-line ``Text`` pair on every monitor.
|
||||||
|
|
||||||
|
Use this instead of building a bare ``tk.Text``: it un-traps Tab.
|
||||||
|
Tk makes ``<Tab>`` insert a literal tab and refocus the widget, and
|
||||||
|
binds ``<Shift-Tab>`` to nothing, so an untreated multi-line field is a
|
||||||
|
keyboard dead end whose only exits (``Ctrl+Tab``/``Ctrl+Shift+Tab``) are
|
||||||
|
not advertised anywhere. On a lock surface that means a user who tabs
|
||||||
|
into it can never reach the submit button.
|
||||||
|
"""
|
||||||
|
row = parent.child_frame(bg=self._colors.bg)
|
||||||
|
row.pack(pady=self._colors.space("sm"), fill="x")
|
||||||
|
row.child_widgets(
|
||||||
|
tk.Label,
|
||||||
|
text=label,
|
||||||
|
font=self._colors.font("label"),
|
||||||
|
fg=self._colors.fg,
|
||||||
|
bg=self._colors.bg,
|
||||||
|
anchor="w",
|
||||||
|
).pack(side="top", anchor="w")
|
||||||
|
boxes = row.child_widgets(
|
||||||
|
tk.Text,
|
||||||
|
width=60,
|
||||||
|
height=height,
|
||||||
|
wrap="word",
|
||||||
|
font=self._colors.font("label"),
|
||||||
|
bg=self._colors.field_bg,
|
||||||
|
fg=self._colors.fg,
|
||||||
|
insertbackground=self._colors.fg,
|
||||||
|
**self._colors.focus_kwargs(),
|
||||||
|
)
|
||||||
|
boxes.pack(side="top", anchor="w", pady=self._colors.space("xs"))
|
||||||
|
for box in boxes:
|
||||||
|
disable_paste(box)
|
||||||
|
escape_text_tab_trap(box)
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
def _focus_first_button(self, row: FrameGroup) -> None:
|
||||||
|
"""Give the primary surface's first button keyboard focus.
|
||||||
|
|
||||||
|
The lock takes a global grab and there is no other window on screen, so
|
||||||
|
a surface that starts with *nothing* focused leaves the user pressing
|
||||||
|
keys into the void with no visible ring to hint where focus went. The
|
||||||
|
retry screen (TRY AGAIN / I'm sick / Log Manual Workout) had exactly
|
||||||
|
that problem, because ``on_focus_ready`` is a documented no-op.
|
||||||
|
"""
|
||||||
|
for child in row.first.winfo_children():
|
||||||
|
if child.winfo_class() == "Button":
|
||||||
|
child.focus_set()
|
||||||
|
return
|
||||||
@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from gatelock import bind_activate, escape_text_tab_trap
|
from gatelock import bind_activate
|
||||||
|
|
||||||
|
from screen_locker._ui_form_fields import UIFormFieldsMixin, disable_paste
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -15,20 +16,13 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from screen_locker._surface_group import FrameGroup, WidgetGroup
|
from screen_locker._surface_group import FrameGroup, WidgetGroup
|
||||||
|
|
||||||
|
# disable_paste moved to _ui_form_fields with the fields that use it; it is
|
||||||
def disable_paste(widget: tk.Widget) -> None:
|
# re-exported here because _manual_workout_widgets and _sick_dialog import it
|
||||||
"""Disable paste in a Tk Entry/Text widget.
|
# from this module.
|
||||||
|
__all__ = ["UIWidgetsMixin", "disable_paste"]
|
||||||
Friction-only: a determined user can still bypass via xdotool, but the
|
|
||||||
point is removing the trivial Ctrl+V shortcut so the user must
|
|
||||||
actually type their input.
|
|
||||||
"""
|
|
||||||
for sequence in ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
|
|
||||||
with contextlib.suppress(tk.TclError, AttributeError):
|
|
||||||
widget.bind(sequence, lambda _e: "break")
|
|
||||||
|
|
||||||
|
|
||||||
class UIWidgetsMixin:
|
class UIWidgetsMixin(UIFormFieldsMixin):
|
||||||
"""Mixin providing low-level widget creation helpers."""
|
"""Mixin providing low-level widget creation helpers."""
|
||||||
|
|
||||||
def clear_container(self) -> None:
|
def clear_container(self) -> None:
|
||||||
@ -183,105 +177,3 @@ class UIWidgetsMixin:
|
|||||||
frame = self.container.child_frame(bg=self._colors.bg)
|
frame = self.container.child_frame(bg=self._colors.bg)
|
||||||
frame.pack(pady=self._colors.space("sm"))
|
frame.pack(pady=self._colors.space("sm"))
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _add_label_entry(
|
|
||||||
self,
|
|
||||||
parent: FrameGroup,
|
|
||||||
*,
|
|
||||||
label: str,
|
|
||||||
variable: tk.StringVar,
|
|
||||||
focus: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Add a label + single-line entry pair on every monitor.
|
|
||||||
|
|
||||||
All copies share one ``variable``, so typing on any screen is the
|
|
||||||
same text everywhere and reading it back needs no idea which monitor
|
|
||||||
the user actually used.
|
|
||||||
|
|
||||||
``focus`` gives the primary surface's copy the keyboard focus. Exactly
|
|
||||||
one field per screen should ask: the lock takes a global grab, so with
|
|
||||||
nothing focused the user's typing goes nowhere and there is no other
|
|
||||||
window on screen to make that obvious.
|
|
||||||
"""
|
|
||||||
row = parent.child_frame(bg=self._colors.bg)
|
|
||||||
row.pack(pady=self._colors.space("sm"), fill="x")
|
|
||||||
row.child_widgets(
|
|
||||||
tk.Label,
|
|
||||||
text=label,
|
|
||||||
font=self._colors.font("label"),
|
|
||||||
fg=self._colors.fg,
|
|
||||||
bg=self._colors.bg,
|
|
||||||
anchor="w",
|
|
||||||
).pack(side="top", anchor="w")
|
|
||||||
entries = row.child_widgets(
|
|
||||||
tk.Entry,
|
|
||||||
textvariable=variable,
|
|
||||||
width=50,
|
|
||||||
font=self._colors.font("label"),
|
|
||||||
bg=self._colors.field_bg,
|
|
||||||
fg=self._colors.fg,
|
|
||||||
insertbackground=self._colors.fg,
|
|
||||||
**self._colors.focus_kwargs(),
|
|
||||||
)
|
|
||||||
entries.pack(side="top", anchor="w", pady=self._colors.space("xs"))
|
|
||||||
for entry in entries:
|
|
||||||
disable_paste(entry)
|
|
||||||
if focus:
|
|
||||||
entries.first.focus_set()
|
|
||||||
|
|
||||||
def _add_label_text(
|
|
||||||
self,
|
|
||||||
parent: FrameGroup,
|
|
||||||
*,
|
|
||||||
label: str,
|
|
||||||
height: int = 4,
|
|
||||||
) -> WidgetGroup:
|
|
||||||
"""Add a label + multi-line ``Text`` pair on every monitor.
|
|
||||||
|
|
||||||
Use this instead of building a bare ``tk.Text``: it un-traps Tab.
|
|
||||||
Tk makes ``<Tab>`` insert a literal tab and refocus the widget, and
|
|
||||||
binds ``<Shift-Tab>`` to nothing, so an untreated multi-line field is a
|
|
||||||
keyboard dead end whose only exits (``Ctrl+Tab``/``Ctrl+Shift+Tab``) are
|
|
||||||
not advertised anywhere. On a lock surface that means a user who tabs
|
|
||||||
into it can never reach the submit button.
|
|
||||||
"""
|
|
||||||
row = parent.child_frame(bg=self._colors.bg)
|
|
||||||
row.pack(pady=self._colors.space("sm"), fill="x")
|
|
||||||
row.child_widgets(
|
|
||||||
tk.Label,
|
|
||||||
text=label,
|
|
||||||
font=self._colors.font("label"),
|
|
||||||
fg=self._colors.fg,
|
|
||||||
bg=self._colors.bg,
|
|
||||||
anchor="w",
|
|
||||||
).pack(side="top", anchor="w")
|
|
||||||
boxes = row.child_widgets(
|
|
||||||
tk.Text,
|
|
||||||
width=60,
|
|
||||||
height=height,
|
|
||||||
wrap="word",
|
|
||||||
font=self._colors.font("label"),
|
|
||||||
bg=self._colors.field_bg,
|
|
||||||
fg=self._colors.fg,
|
|
||||||
insertbackground=self._colors.fg,
|
|
||||||
**self._colors.focus_kwargs(),
|
|
||||||
)
|
|
||||||
boxes.pack(side="top", anchor="w", pady=self._colors.space("xs"))
|
|
||||||
for box in boxes:
|
|
||||||
disable_paste(box)
|
|
||||||
escape_text_tab_trap(box)
|
|
||||||
return boxes
|
|
||||||
|
|
||||||
def _focus_first_button(self, row: FrameGroup) -> None:
|
|
||||||
"""Give the primary surface's first button keyboard focus.
|
|
||||||
|
|
||||||
The lock takes a global grab and there is no other window on screen, so
|
|
||||||
a surface that starts with *nothing* focused leaves the user pressing
|
|
||||||
keys into the void with no visible ring to hint where focus went. The
|
|
||||||
retry screen (TRY AGAIN / I'm sick / Log Manual Workout) had exactly
|
|
||||||
that problem, because ``on_focus_ready`` is a documented no-op.
|
|
||||||
"""
|
|
||||||
for child in row.first.winfo_children():
|
|
||||||
if child.winfo_class() == "Button":
|
|
||||||
child.focus_set()
|
|
||||||
return
|
|
||||||
|
|||||||
@ -46,6 +46,7 @@ _TK_MODULES = (
|
|||||||
"screen_locker._manual_workout_dialog",
|
"screen_locker._manual_workout_dialog",
|
||||||
"screen_locker._manual_workout_sport_fields",
|
"screen_locker._manual_workout_sport_fields",
|
||||||
"screen_locker._manual_workout_widgets",
|
"screen_locker._manual_workout_widgets",
|
||||||
|
"screen_locker._ui_form_fields",
|
||||||
"screen_locker._ui_widgets",
|
"screen_locker._ui_widgets",
|
||||||
"screen_locker._window_setup",
|
"screen_locker._window_setup",
|
||||||
"screen_locker.status_view",
|
"screen_locker.status_view",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user