mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +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",
|
||||
"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
|
||||
# only the (quoted) city interpolated — never user/attacker controlled.
|
||||
# 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 datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from screen_locker._constants import (
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
@ -23,8 +19,9 @@ from screen_locker._constants import (
|
||||
RUNNERUP_EXPORT_DIRS,
|
||||
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_tcx import RunnerUpTcxMixin
|
||||
from screen_locker._time_check import check_clock_skew
|
||||
|
||||
_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_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."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -74,61 +71,6 @@ class RunnerUpVerificationMixin(RunnerUpDbMixin):
|
||||
found.append(remote)
|
||||
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:
|
||||
"""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",
|
||||
)
|
||||
|
||||
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]:
|
||||
"""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._constants import (
|
||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS,
|
||||
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
)
|
||||
from screen_locker._sick_commitment import SickCommitmentMixin
|
||||
from screen_locker._surface_group import FrameGroup, TextGroup
|
||||
from screen_locker._ui_widgets import disable_paste as _disable_paste
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SickDialogMixin:
|
||||
class SickDialogMixin(SickCommitmentMixin):
|
||||
"""Renders the sick-day justification screen and commitment prompts."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -209,70 +207,3 @@ class SickDialogMixin:
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
import contextlib
|
||||
import tkinter as tk
|
||||
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:
|
||||
from collections.abc import Callable
|
||||
@ -15,20 +16,13 @@ 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")
|
||||
# disable_paste moved to _ui_form_fields with the fields that use it; it is
|
||||
# re-exported here because _manual_workout_widgets and _sick_dialog import it
|
||||
# from this module.
|
||||
__all__ = ["UIWidgetsMixin", "disable_paste"]
|
||||
|
||||
|
||||
class UIWidgetsMixin:
|
||||
class UIWidgetsMixin(UIFormFieldsMixin):
|
||||
"""Mixin providing low-level widget creation helpers."""
|
||||
|
||||
def clear_container(self) -> None:
|
||||
@ -183,105 +177,3 @@ class UIWidgetsMixin:
|
||||
frame = self.container.child_frame(bg=self._colors.bg)
|
||||
frame.pack(pady=self._colors.space("sm"))
|
||||
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_sport_fields",
|
||||
"screen_locker._manual_workout_widgets",
|
||||
"screen_locker._ui_form_fields",
|
||||
"screen_locker._ui_widgets",
|
||||
"screen_locker._window_setup",
|
||||
"screen_locker.status_view",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user