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:
Krzysztof Rudnicki 2026-08-15 20:37:15 +02:00
parent 094a1fe5ed
commit 8bc981da4e
9 changed files with 437 additions and 318 deletions

View File

@ -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

View 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
# ------------------------------------------------------------------

View 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,
}

View File

@ -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.

View 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()

View File

@ -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()

View 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

View File

@ -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

View File

@ -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",