From 8bc981da4e4e49c6746e1cdd850be1d5f4885467 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 20:37:15 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- pyproject.toml | 5 +- screen_locker/_runnerup_backfill.py | 90 ++++++++++++++++ screen_locker/_runnerup_tcx.py | 96 +++++++++++++++++ screen_locker/_runnerup_verification.py | 138 ++---------------------- screen_locker/_sick_commitment.py | 90 ++++++++++++++++ screen_locker/_sick_dialog.py | 73 +------------ screen_locker/_ui_form_fields.py | 138 ++++++++++++++++++++++++ screen_locker/_ui_widgets.py | 124 ++------------------- screen_locker/tests/conftest.py | 1 + 9 files changed, 437 insertions(+), 318 deletions(-) create mode 100644 screen_locker/_runnerup_backfill.py create mode 100644 screen_locker/_runnerup_tcx.py create mode 100644 screen_locker/_sick_commitment.py create mode 100644 screen_locker/_ui_form_fields.py diff --git a/pyproject.toml b/pyproject.toml index 749b21c..232628e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/screen_locker/_runnerup_backfill.py b/screen_locker/_runnerup_backfill.py new file mode 100644 index 0000000..9fa64c6 --- /dev/null +++ b/screen_locker/_runnerup_backfill.py @@ -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 + # ------------------------------------------------------------------ diff --git a/screen_locker/_runnerup_tcx.py b/screen_locker/_runnerup_tcx.py new file mode 100644 index 0000000..3ebe851 --- /dev/null +++ b/screen_locker/_runnerup_tcx.py @@ -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, + } diff --git a/screen_locker/_runnerup_verification.py b/screen_locker/_runnerup_verification.py index d38ba1a..5418ac1 100644 --- a/screen_locker/_runnerup_verification.py +++ b/screen_locker/_runnerup_verification.py @@ -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. diff --git a/screen_locker/_sick_commitment.py b/screen_locker/_sick_commitment.py new file mode 100644 index 0000000..3a2ac9e --- /dev/null +++ b/screen_locker/_sick_commitment.py @@ -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() diff --git a/screen_locker/_sick_dialog.py b/screen_locker/_sick_dialog.py index 3dcd186..66aae7f 100644 --- a/screen_locker/_sick_dialog.py +++ b/screen_locker/_sick_dialog.py @@ -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() diff --git a/screen_locker/_ui_form_fields.py b/screen_locker/_ui_form_fields.py new file mode 100644 index 0000000..b62fd59 --- /dev/null +++ b/screen_locker/_ui_form_fields.py @@ -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 ("<>", "", "", ""): + 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 ```` insert a literal tab and refocus the widget, and + binds ```` 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 diff --git a/screen_locker/_ui_widgets.py b/screen_locker/_ui_widgets.py index 155432e..a3c3022 100644 --- a/screen_locker/_ui_widgets.py +++ b/screen_locker/_ui_widgets.py @@ -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 ("<>", "", "", ""): - 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 ```` insert a literal tab and refocus the widget, and - binds ```` 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 diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 5607aa8..d7806fd 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -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",