mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
The grab axes are grab=GrabPolicy(kind=..., disable_vt=..., preempt_weaker_holder=...) and every colour read goes through config.palette; nothing about the lock's behaviour changed. Also carries the pylint hook on 4.0.8 with the source-only scope config; the gate stays at 8.0 until the remaining findings are cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
180 lines
6.9 KiB
Python
180 lines
6.9 KiB
Python
"""UI widget helper methods mixin for the screen locker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import bind_activate
|
|
|
|
from screen_locker._ui_form_fields import UIFormFieldsMixin, disable_paste
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from gatelock import SpaceStep, TypeRole
|
|
|
|
from screen_locker._surface_group import FrameGroup, WidgetGroup
|
|
|
|
# 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(UIFormFieldsMixin):
|
|
"""Mixin providing low-level widget creation helpers."""
|
|
|
|
def clear_container(self) -> None:
|
|
"""Remove all widgets from the main container, on every monitor.
|
|
|
|
Also schedules the surface viewports to re-settle once the new screen
|
|
has been painted. Every repaint in the app starts by calling this, so
|
|
hooking it here is what makes focus-following and scroll-reset
|
|
unforgettable -- there are ~19 repaint sites, and "remember to call
|
|
settle after painting" is a rule that would eventually be missed, on a
|
|
lock where a missed re-arm means unreachable content.
|
|
|
|
``after_idle`` is what defers the work until *after* the caller has
|
|
finished building widgets; running it inline here would settle an empty
|
|
container. Repeated calls within one repaint coalesce harmlessly.
|
|
"""
|
|
self.container.clear()
|
|
settle = getattr(self, "settle_surfaces", None)
|
|
if settle is not None:
|
|
self.root.after_idle(settle)
|
|
|
|
def _label(
|
|
self,
|
|
text: str,
|
|
*,
|
|
role: TypeRole = "display",
|
|
scale: float = 1.0,
|
|
color: str | None = None,
|
|
pad: SpaceStep = "md",
|
|
) -> WidgetGroup:
|
|
"""Create and pack a bold label on every monitor.
|
|
|
|
``role``/``pad`` name a design-system token instead of a pixel count.
|
|
That is what makes a screen shrink on a short display: the tokens are
|
|
compacted per screen height (``gatelock._density``), and a raw literal
|
|
is not. It also fixes the unit bug the literals carried -- Tk reads a
|
|
positive size as *points*, so ``36`` rendered ~37% larger than the
|
|
36px it was meant to be, on every screen.
|
|
|
|
``scale`` is for deliberate emphasis (a hero countdown), kept explicit
|
|
so an outlier is visible rather than hidden behind a fresh literal.
|
|
"""
|
|
label = self.container.child_widgets(
|
|
tk.Label,
|
|
text=text,
|
|
font=self._colors.font(role, bold=True, scale=scale),
|
|
fg=color or self._colors.palette.fg,
|
|
bg=self._colors.palette.bg,
|
|
)
|
|
label.pack(pady=self._colors.space(pad))
|
|
return label
|
|
|
|
def _text(
|
|
self,
|
|
text: str,
|
|
*,
|
|
role: TypeRole = "body",
|
|
scale: float = 1.0,
|
|
color: str | None = None,
|
|
pad: SpaceStep = "sm",
|
|
) -> WidgetGroup:
|
|
"""Create and pack a non-bold text label on every monitor.
|
|
|
|
See :meth:`_label` for why these are token names, not pixel counts.
|
|
"""
|
|
label = self.container.child_widgets(
|
|
tk.Label,
|
|
text=text,
|
|
font=self._colors.font(role, scale=scale),
|
|
fg=color or self._colors.palette.fg,
|
|
bg=self._colors.palette.bg,
|
|
)
|
|
label.pack(pady=self._colors.space(pad))
|
|
return label
|
|
|
|
def _fg_for(self, bg: str) -> str:
|
|
"""Return the correct label color for a widget with background `bg`.
|
|
|
|
The four filled semantic surfaces (accent/success/warning/danger)
|
|
all sit in the same mid-light lightness band, so `fg` (near-white,
|
|
meant for the neutral `bg`/`field_bg` surfaces) under-contrasts on
|
|
every one of them -- `on_fill` (dark) is required instead. Deriving
|
|
this from `bg` means a new filled-button call site can't reintroduce
|
|
the bug by omission.
|
|
"""
|
|
fills = {
|
|
self._colors.palette.accent,
|
|
self._colors.palette.success,
|
|
self._colors.palette.warning,
|
|
self._colors.palette.danger,
|
|
}
|
|
return self._colors.palette.on_fill if bg in fills else self._colors.palette.fg
|
|
|
|
def _button(
|
|
self,
|
|
parent: FrameGroup,
|
|
text: str,
|
|
*,
|
|
bg: str,
|
|
command: Callable[[], None],
|
|
width: int = 10,
|
|
) -> WidgetGroup:
|
|
"""Create a styled button on every monitor (caller must pack).
|
|
|
|
Every copy gets the *same* ``command``, so pressing the button on
|
|
whichever screen the user is actually looking at does the one thing
|
|
once -- the same trick the shared entry variable plays for typing.
|
|
|
|
Padding follows the shared 2x horizontal:vertical ratio (rule 22);
|
|
`relief="flat"` is the one depth technique every button in the app
|
|
uses now (rule 27), matching what `_heat_skip.py` already did alone.
|
|
|
|
Two keyboard properties are wired here rather than per call site, so a
|
|
new button cannot omit them:
|
|
|
|
* A **visible focus ring** (`focus_kwargs`). Tk's default is a 1px
|
|
*black* ring, which on ``bg`` (#211D1B) is invisible -- and
|
|
`relief="flat"` removes the depth cue too, so an unringed button
|
|
gives a keyboard user no indication of where they are.
|
|
* **Enter activates.** Tk's X11 class bindings give ``Button`` only
|
|
``<space>``; there is no ``<Return>`` binding at all. Under a global
|
|
grab with no pointer, "the obvious key does nothing" is the
|
|
difference between satisfying the lock and being stuck at it.
|
|
"""
|
|
buttons = parent.child_widgets(
|
|
tk.Button,
|
|
text=text,
|
|
font=self._colors.font("title", bold=True),
|
|
bg=bg,
|
|
fg=self._fg_for(bg),
|
|
width=width,
|
|
command=command,
|
|
cursor="hand2" if self.demo_mode else "",
|
|
relief="flat",
|
|
padx=self._colors.space("lg"),
|
|
pady=self._colors.space("sm"),
|
|
**self._colors.focus_kwargs(),
|
|
)
|
|
# Every per-monitor copy, not just `.first` -- one flow paints N screens
|
|
# and the user may be looking at any of them.
|
|
for button in buttons:
|
|
bind_activate(button)
|
|
return buttons
|
|
|
|
def _button_row(self) -> FrameGroup:
|
|
"""Create and pack a horizontal button container on every monitor.
|
|
|
|
The gap is "sm" rather than "md" because this row is the last thing
|
|
on every screen: the pixels it spends are the ones the manual-workout
|
|
form has left over on a 1024x600 panel.
|
|
"""
|
|
frame = self.container.child_frame(bg=self._colors.palette.bg)
|
|
frame.pack(pady=self._colors.space("sm"))
|
|
return frame
|