mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
gatelock 0.8.0: LockConfig as behaviour + GrabPolicy + theme records; pylint at 10
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
LockConfig was one 35-field record: the lock's behaviour, the grab policy, nine colours, a type scale and a spacing scale, flat. It is now the seven behavioural knobs plus four nested records -- GrabPolicy(kind, disable_vt, retry_ms, log_every, preempt_weaker_holder), LockPalette, LockTypography and LockSpacing -- each overridden as a unit. Breaking for every constructor that passed grab=/disable_vt=/ grab_retry_ms=/preempt_weaker_holder= or a colour, and for every read of config.<colour>; hence the minor bump. screen-locker, leetcode-guard and diet-guard follow in their own commits. The two fail-open guards in the recovery loop carry per-line suppressions: catching everything is the property they exist for, and the check stays live for the next careless except. That was the last finding, so the gate now says 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
This commit is contained in:
parent
8128851e0b
commit
68ec316b28
@ -106,7 +106,7 @@ repos:
|
||||
- id: pylint
|
||||
args:
|
||||
- --rcfile=gatelock/pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --fail-under=10
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
|
||||
@ -55,9 +55,11 @@ class MyGate:
|
||||
|
||||
`LockConfig`'s `mode` preset bundles the common combination ("soft" = topmost
|
||||
only, typeable, WM-escapable; "hard" = overrideredirect + global grab +
|
||||
VT-disable). Each axis (`overrideredirect`, `grab`, `disable_vt`,
|
||||
`grab_retry_ms`) can be set explicitly to reproduce a consumer's exact prior
|
||||
behavior where it diverges from the preset.
|
||||
VT-disable). `overrideredirect` and the `grab=GrabPolicy(kind=..., disable_vt=...,
|
||||
retry_ms=...)` axes can be set explicitly to reproduce a consumer's exact prior
|
||||
behavior where it diverges from the preset. Colours, type scale and spacing are
|
||||
the `palette`, `typography` and `spacing` records (`gatelock.LockPalette` etc.),
|
||||
overridden as a unit.
|
||||
|
||||
`gatelock.log_integrity` ports the HMAC-signed state module used by all three
|
||||
projects; `DEFAULT_HMAC_KEY_FILE` (`/etc/workout-locker/hmac.key`) is
|
||||
|
||||
@ -15,6 +15,7 @@ from gatelock._arbiter import (
|
||||
Arbiter,
|
||||
)
|
||||
from gatelock._claims import ArbiterVerdict, Claim, default_runtime_dir, grab_strength
|
||||
from gatelock._config import GrabPolicy
|
||||
from gatelock._detect import OutputChangeDetector
|
||||
from gatelock._escape import (
|
||||
EscapeDraft,
|
||||
@ -53,6 +54,7 @@ from gatelock._surfaces import (
|
||||
mirror_text_widgets,
|
||||
needs_backdrop_root,
|
||||
)
|
||||
from gatelock._theme import LockPalette, LockSpacing, LockTypography
|
||||
from gatelock._vt import disable_vt_switching, restore_vt_switching
|
||||
from gatelock._window import (
|
||||
LockConfig,
|
||||
@ -92,7 +94,11 @@ __all__ = [
|
||||
"EscapeTracker",
|
||||
"FitResult",
|
||||
"GateRoot",
|
||||
"GrabPolicy",
|
||||
"LockConfig",
|
||||
"LockPalette",
|
||||
"LockSpacing",
|
||||
"LockTypography",
|
||||
"LockWindow",
|
||||
"LockWindowHooks",
|
||||
"Output",
|
||||
|
||||
@ -157,7 +157,7 @@ def acquire_global_grab(
|
||||
collab: The window's arming collaborators.
|
||||
attempt: 1-based attempt counter, used only to throttle the log.
|
||||
"""
|
||||
retry_ms = collab.config.grab_retry_ms
|
||||
retry_ms = collab.config.grab.retry_ms
|
||||
try:
|
||||
root.grab_set_global()
|
||||
except tk.TclError:
|
||||
@ -167,7 +167,7 @@ def acquire_global_grab(
|
||||
root.grab_set()
|
||||
return
|
||||
effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS
|
||||
if not attempt % collab.config.grab_log_every:
|
||||
if not attempt % collab.config.grab.log_every:
|
||||
collab.log_grab_blocked(attempt)
|
||||
root.after(
|
||||
effective_retry_ms,
|
||||
|
||||
@ -13,38 +13,37 @@ import LockConfig`` keeps working for the modules and tests that already do.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from gatelock import _density
|
||||
from gatelock._arbiter import RANK_SCREEN_LOCKER
|
||||
from gatelock._theme import (
|
||||
LockPalette,
|
||||
LockSpacing,
|
||||
LockTypography,
|
||||
SpaceStep,
|
||||
TypeRole,
|
||||
)
|
||||
|
||||
GrabKind = Literal["none", "local", "global"]
|
||||
LockMode = Literal["soft", "hard"]
|
||||
TypeRole = Literal["display", "title", "subtitle", "body", "label", "caption"]
|
||||
SpaceStep = Literal["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LockConfig:
|
||||
"""Declarative knobs for one :class:`LockWindow` instance.
|
||||
class GrabPolicy:
|
||||
"""How the lock takes and keeps the input grab.
|
||||
|
||||
Each field left as ``None`` is derived from ``mode``; an explicit value
|
||||
always overrides the preset for that one axis.
|
||||
Each field left as ``None`` is derived from :attr:`LockConfig.mode`; an
|
||||
explicit value always overrides the preset for that one axis.
|
||||
|
||||
Attributes:
|
||||
mode: Preset bundling the common combination. "soft" = topmost only,
|
||||
typeable, WM-escapable. "hard" = overrideredirect + global grab +
|
||||
VT-disable (the production lock for all three apps).
|
||||
overrideredirect: Force a WM-unmanaged window. None = derive from mode.
|
||||
Note that per-output placement *requires* this: a window manager
|
||||
rewrites a managed window's geometry wholesale.
|
||||
grab: Input grab strategy. None = derive from mode.
|
||||
kind: Input grab strategy. None = derive from mode.
|
||||
disable_vt: Disable Ctrl+Alt+Fn VT switching. None = derive from mode.
|
||||
grab_retry_ms: Retry interval in ms for a "global" grab that initially
|
||||
retry_ms: Retry interval in ms for a "global" grab that initially
|
||||
fails. 0 means "try once, then fall back to a local grab". Left
|
||||
unset (None), a "global" grab retries forever every 200ms.
|
||||
grab_log_every: Log a warning every N failed retry-forever attempts.
|
||||
log_every: Log a warning every N failed retry-forever attempts.
|
||||
preempt_weaker_holder: SIGTERM a lower-ranked incumbent that is
|
||||
blocking our grab, instead of retrying against it forever. Only
|
||||
the *direction* the arbiter's own ranking already sanctions: we
|
||||
@ -56,82 +55,55 @@ class LockConfig:
|
||||
with the obligation unmet. Only an app whose own dismissal is
|
||||
harmless -- or which genuinely outranks every enforcer -- should
|
||||
turn this on, and only after considering who it can now evict.
|
||||
"""
|
||||
|
||||
kind: GrabKind | None = None
|
||||
disable_vt: bool | None = None
|
||||
retry_ms: int | None = None
|
||||
log_every: int = 25
|
||||
preempt_weaker_holder: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LockConfig:
|
||||
"""Declarative knobs for one :class:`LockWindow` instance.
|
||||
|
||||
``overrideredirect`` and the grab policy's ``None`` fields are derived
|
||||
from ``mode``; an explicit value always overrides the preset for that one
|
||||
axis. The visual vocabulary lives in the three :mod:`gatelock._theme`
|
||||
records, overridden as a unit.
|
||||
|
||||
Attributes:
|
||||
mode: Preset bundling the common combination. "soft" = topmost only,
|
||||
typeable, WM-escapable. "hard" = overrideredirect + global grab +
|
||||
VT-disable (the production lock for all three apps).
|
||||
overrideredirect: Force a WM-unmanaged window. None = derive from mode.
|
||||
Note that per-output placement *requires* this: a window manager
|
||||
rewrites a managed window's geometry wholesale.
|
||||
grab: How the input grab is taken and kept; see :class:`GrabPolicy`.
|
||||
app_name: This app's name, used in arbitration logs so a blocked app
|
||||
can say who is actually holding the screen.
|
||||
rank: Arbitration priority; higher wins. See the ``RANK_*`` constants
|
||||
in :mod:`gatelock._arbiter`.
|
||||
recovery_tick_ms: Interval of the full re-assertion pass.
|
||||
detect_drain_ms: Interval of the cheap "did anything change?" drain.
|
||||
bg: Background color for the lock surfaces.
|
||||
fg: Primary (near-white) text color.
|
||||
muted: Secondary/caption text color.
|
||||
field_bg: Background for "raised" surfaces (entry/spinbox fields,
|
||||
input wells) -- one step lighter than ``bg``.
|
||||
accent: The shared brand accent (buttons, primary actions).
|
||||
success: Positive/on-track status color.
|
||||
warning: Caution/pending status color.
|
||||
danger: Negative/error status color.
|
||||
on_fill: Text/icon color for anything drawn on top of a filled
|
||||
accent/success/warning/danger surface (e.g. a button's label) --
|
||||
NOT ``fg``. All four fills sit in the same mid-light band, so
|
||||
near-white text under-contrasts on every one of them; callers
|
||||
must pick ``fg`` vs. ``on_fill`` based on the widget's own
|
||||
background, never hardcode one for all buttons.
|
||||
font_family: Default font family for lock-window widgets.
|
||||
focus_ring: Color of the *focused* widget's highlight ring. Defaults to
|
||||
``accent``, because Tk's own default is black -- invisible against
|
||||
``bg``. Pass to ``highlightcolor``; note ``highlightbackground`` is
|
||||
the *unfocused* ring, so setting that one inverts the affordance.
|
||||
focus_thickness: Ring width in px. Never set 0 on a focusable widget.
|
||||
type_display, type_title, type_subtitle, type_body, type_label,
|
||||
type_caption: The type scale, in **pixels**. Do not pass these to
|
||||
Tk directly -- use :meth:`font`, which applies the sign convention.
|
||||
space_xs, space_sm, space_md, space_lg, space_xl, space_xxl: The 4px
|
||||
spacing scale, in pixels. Use for ``padx``/``pady``/``ipadx``.
|
||||
|
||||
All color/font defaults come from the ``unified-design-system`` docs
|
||||
(``~/src/utils/unified-design-system/tokens.md``) -- the same palette used by
|
||||
every one of kuhy's apps, Flutter and web included.
|
||||
palette: Colours; see :class:`gatelock._theme.LockPalette`.
|
||||
typography: Font family and type scale;
|
||||
see :class:`gatelock._theme.LockTypography`.
|
||||
spacing: Spacing scale and focus-ring width;
|
||||
see :class:`gatelock._theme.LockSpacing`.
|
||||
"""
|
||||
|
||||
mode: LockMode = "hard"
|
||||
overrideredirect: bool | None = None
|
||||
grab: GrabKind | None = None
|
||||
disable_vt: bool | None = None
|
||||
grab_retry_ms: int | None = None
|
||||
grab_log_every: int = 25
|
||||
preempt_weaker_holder: bool = False
|
||||
grab: GrabPolicy = field(default_factory=GrabPolicy)
|
||||
app_name: str = "gatelock"
|
||||
rank: int = RANK_SCREEN_LOCKER
|
||||
recovery_tick_ms: int = 1000
|
||||
detect_drain_ms: int = 100
|
||||
bg: str = "#211D1B"
|
||||
fg: str = "#ECEAE9"
|
||||
muted: str = "#AAA09A"
|
||||
field_bg: str = "#2B2624"
|
||||
accent: str = "#B8862E"
|
||||
success: str = "#8A9A3C"
|
||||
warning: str = "#E0A63C"
|
||||
danger: str = "#E2585F"
|
||||
on_fill: str = "#211D1B"
|
||||
font_family: str = "Arial"
|
||||
focus_ring: str = "#B8862E"
|
||||
focus_thickness: int = 2
|
||||
# Type scale in PIXELS (unified-design-system tokens.md). Convert via
|
||||
# font(); a raw positive value handed to Tk means *points*, ~37% bigger.
|
||||
type_display: int = 32
|
||||
type_title: int = 24
|
||||
type_subtitle: int = 20
|
||||
type_body: int = 16
|
||||
type_label: int = 14
|
||||
type_caption: int = 12
|
||||
# 4px spacing scale, in pixels.
|
||||
space_xs: int = 4
|
||||
space_sm: int = 8
|
||||
space_md: int = 16
|
||||
space_lg: int = 24
|
||||
space_xl: int = 32
|
||||
space_xxl: int = 48
|
||||
palette: LockPalette = field(default_factory=LockPalette)
|
||||
typography: LockTypography = field(default_factory=LockTypography)
|
||||
spacing: LockSpacing = field(default_factory=LockSpacing)
|
||||
|
||||
def type_px(self, role: TypeRole = "body") -> int:
|
||||
"""Return the type-scale size for ``role``, in pixels.
|
||||
@ -141,14 +113,14 @@ class LockConfig:
|
||||
surface has to fit one screen and cannot scroll its way out of being
|
||||
too tall.
|
||||
"""
|
||||
return _density.scale_type(int(getattr(self, f"type_{role}")))
|
||||
return _density.scale_type(int(getattr(self.typography, role)))
|
||||
|
||||
def space(self, step: SpaceStep = "md") -> int:
|
||||
"""Return the spacing-scale value for ``step``, in pixels.
|
||||
|
||||
Compacted on short displays, exactly like :meth:`type_px`.
|
||||
"""
|
||||
return _density.scale_space(int(getattr(self, f"space_{step}")))
|
||||
return _density.scale_space(int(getattr(self.spacing, step)))
|
||||
|
||||
def font(
|
||||
self,
|
||||
@ -171,7 +143,7 @@ class LockConfig:
|
||||
Args:
|
||||
role: Type-scale role.
|
||||
bold: Append Tk's ``"bold"`` weight.
|
||||
family: Override the font family. Defaults to ``font_family``.
|
||||
family: Override the font family. Defaults to the typography's.
|
||||
scale: Multiplier for display-only emphasis (e.g. an oversized
|
||||
countdown). Kept explicit so outliers are visible rather than
|
||||
hidden behind a fresh literal.
|
||||
@ -180,7 +152,7 @@ class LockConfig:
|
||||
A Tk font tuple with a negative (pixel) size.
|
||||
"""
|
||||
px = max(1, round(self.type_px(role) * scale))
|
||||
name = family if family is not None else self.font_family
|
||||
name = family if family is not None else self.typography.font_family
|
||||
return (name, -px, "bold") if bold else (name, -px)
|
||||
|
||||
def focus_kwargs(self) -> dict[str, str | int]:
|
||||
@ -192,9 +164,9 @@ class LockConfig:
|
||||
black-on-``bg``, which reads as no ring at all.
|
||||
"""
|
||||
return {
|
||||
"highlightcolor": self.focus_ring,
|
||||
"highlightbackground": self.bg,
|
||||
"highlightthickness": self.focus_thickness,
|
||||
"highlightcolor": self.palette.focus_ring,
|
||||
"highlightbackground": self.palette.bg,
|
||||
"highlightthickness": self.spacing.focus_thickness,
|
||||
}
|
||||
|
||||
def resolved_overrideredirect(self) -> bool:
|
||||
@ -205,12 +177,12 @@ class LockConfig:
|
||||
|
||||
def resolved_grab(self) -> GrabKind:
|
||||
"""Return the effective grab strategy."""
|
||||
if self.grab is not None:
|
||||
return self.grab
|
||||
if self.grab.kind is not None:
|
||||
return self.grab.kind
|
||||
return "global" if self.mode == "hard" else "none"
|
||||
|
||||
def resolved_disable_vt(self) -> bool:
|
||||
"""Return whether VT switching should be disabled."""
|
||||
if self.disable_vt is not None:
|
||||
return self.disable_vt
|
||||
if self.grab.disable_vt is not None:
|
||||
return self.grab.disable_vt
|
||||
return self.mode == "hard"
|
||||
|
||||
@ -95,7 +95,7 @@ def maybe_preempt(
|
||||
this entirely and defaults to off.
|
||||
preempted_pids: Pids already signalled, so each is signalled once.
|
||||
"""
|
||||
if not config.preempt_weaker_holder or arbiter is None:
|
||||
if not config.grab.preempt_weaker_holder or arbiter is None:
|
||||
return
|
||||
if holder.rank >= arbiter.claim.rank:
|
||||
return
|
||||
|
||||
@ -107,7 +107,7 @@ class RecoveryLoop:
|
||||
_logger.debug("output-change signal received; re-asserting the lock")
|
||||
self.tick()
|
||||
# Same fail-open reasoning as _verify.
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("drain tick raised; the lock loop keeps running")
|
||||
finally:
|
||||
self._schedule_drain()
|
||||
@ -124,7 +124,7 @@ class RecoveryLoop:
|
||||
"""
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("verify tick raised; the lock loop keeps running")
|
||||
finally:
|
||||
self._schedule_verify()
|
||||
|
||||
@ -80,13 +80,13 @@ class ScrollableSurface:
|
||||
"""
|
||||
self._config = config
|
||||
self._center_when_fits = center_when_fits
|
||||
self.container = tk.Frame(parent, bg=config.bg)
|
||||
self.container = tk.Frame(parent, bg=config.palette.bg)
|
||||
self.canvas = tk.Canvas(
|
||||
self.container,
|
||||
bg=config.bg,
|
||||
highlightthickness=config.focus_thickness,
|
||||
highlightcolor=config.focus_ring,
|
||||
highlightbackground=config.bg,
|
||||
bg=config.palette.bg,
|
||||
highlightthickness=config.spacing.focus_thickness,
|
||||
highlightcolor=config.palette.focus_ring,
|
||||
highlightbackground=config.palette.bg,
|
||||
borderwidth=0,
|
||||
# Tk gives Canvas no key bindings, so it is not a focus stop by
|
||||
# default and the viewport becomes pointer-only. Opt in.
|
||||
@ -111,7 +111,7 @@ class ScrollableSurface:
|
||||
self.canvas.configure(yscrollcommand=self._scrollbar.set)
|
||||
self.canvas.pack(side="left", fill="both", expand=True)
|
||||
|
||||
self.content = tk.Frame(self.canvas, bg=config.bg)
|
||||
self.content = tk.Frame(self.canvas, bg=config.palette.bg)
|
||||
# anchor="n": top-anchored, so overflow scrolls instead of shearing
|
||||
# the header and the submit button off opposite edges.
|
||||
self._content_id = self.canvas.create_window(
|
||||
|
||||
@ -117,7 +117,7 @@ class SurfaceSet:
|
||||
width = self._root.winfo_screenwidth()
|
||||
height = self._root.winfo_screenheight()
|
||||
self._root.geometry(f"{width}x{height}+0+0")
|
||||
self._root.configure(bg=self._config.bg, cursor="arrow")
|
||||
self._root.configure(bg=self._config.palette.bg, cursor="arrow")
|
||||
|
||||
def apply(self, scan: OutputScan) -> SurfaceDelta:
|
||||
"""Bring the surface set in line with ``scan``.
|
||||
@ -201,7 +201,7 @@ class SurfaceSet:
|
||||
window.withdraw()
|
||||
if self._config.resolved_overrideredirect():
|
||||
window.overrideredirect(boolean=True)
|
||||
window.configure(bg=self._config.bg, cursor="arrow")
|
||||
window.configure(bg=self._config.palette.bg, cursor="arrow")
|
||||
set_geometry(window, info.rect)
|
||||
if not self._config.resolved_overrideredirect():
|
||||
window.attributes("-topmost", _TOPMOST_ON)
|
||||
|
||||
93
gatelock/gatelock/_theme.py
Normal file
93
gatelock/gatelock/_theme.py
Normal file
@ -0,0 +1,93 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Design tokens for lock surfaces: palette, type scale and spacing scale.
|
||||
|
||||
Split from :mod:`gatelock._config` so that :class:`LockConfig` reads as the
|
||||
handful of behavioural knobs it is, with the visual vocabulary held by three
|
||||
records an app overrides as a unit -- a palette is swapped whole, never one
|
||||
colour at a time.
|
||||
|
||||
All defaults come from the ``unified-design-system`` docs
|
||||
(``~/src/utils/unified-design-system/tokens.md``) -- the same palette used by
|
||||
every one of kuhy's apps, Flutter and web included.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
TypeRole = Literal["display", "title", "subtitle", "body", "label", "caption"]
|
||||
SpaceStep = Literal["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LockPalette:
|
||||
"""The colours a lock surface is drawn with.
|
||||
|
||||
Attributes:
|
||||
bg: Background color for the lock surfaces.
|
||||
fg: Primary (near-white) text color.
|
||||
muted: Secondary/caption text color.
|
||||
field_bg: Background for "raised" surfaces (entry/spinbox fields,
|
||||
input wells) -- one step lighter than ``bg``.
|
||||
accent: The shared brand accent (buttons, primary actions).
|
||||
success: Positive/on-track status color.
|
||||
warning: Caution/pending status color.
|
||||
danger: Negative/error status color.
|
||||
on_fill: Text/icon color for anything drawn on top of a filled
|
||||
accent/success/warning/danger surface (e.g. a button's label) --
|
||||
NOT ``fg``. All four fills sit in the same mid-light band, so
|
||||
near-white text under-contrasts on every one of them; callers
|
||||
must pick ``fg`` vs. ``on_fill`` based on the widget's own
|
||||
background, never hardcode one for all buttons.
|
||||
focus_ring: Color of the *focused* widget's highlight ring. Defaults to
|
||||
``accent``, because Tk's own default is black -- invisible against
|
||||
``bg``. Pass to ``highlightcolor``; note ``highlightbackground`` is
|
||||
the *unfocused* ring, so setting that one inverts the affordance.
|
||||
"""
|
||||
|
||||
bg: str = "#211D1B"
|
||||
fg: str = "#ECEAE9"
|
||||
muted: str = "#AAA09A"
|
||||
field_bg: str = "#2B2624"
|
||||
accent: str = "#B8862E"
|
||||
success: str = "#8A9A3C"
|
||||
warning: str = "#E0A63C"
|
||||
danger: str = "#E2585F"
|
||||
on_fill: str = "#211D1B"
|
||||
focus_ring: str = "#B8862E"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LockTypography:
|
||||
"""The font family and the type scale, in **pixels**.
|
||||
|
||||
Do not pass the sizes to Tk directly -- use :meth:`LockConfig.font`,
|
||||
which applies Tk's sign convention (a raw positive value means *points*,
|
||||
~37% bigger).
|
||||
"""
|
||||
|
||||
font_family: str = "Arial"
|
||||
display: int = 32
|
||||
title: int = 24
|
||||
subtitle: int = 20
|
||||
body: int = 16
|
||||
label: int = 14
|
||||
caption: int = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LockSpacing:
|
||||
"""The 4px spacing scale, in pixels, plus the focus-ring width.
|
||||
|
||||
Use the steps for ``padx``/``pady``/``ipadx``. ``focus_thickness`` is the
|
||||
ring width in px -- never set it to 0 on a focusable widget.
|
||||
"""
|
||||
|
||||
xs: int = 4
|
||||
sm: int = 8
|
||||
md: int = 16
|
||||
lg: int = 24
|
||||
xl: int = 32
|
||||
xxl: int = 48
|
||||
focus_thickness: int = 2
|
||||
@ -42,7 +42,7 @@ def _button_fills(config: LockConfig) -> dict[ButtonVariant, tuple[str, str]]:
|
||||
variant instead of letting each call site invent its own hex pair.
|
||||
"""
|
||||
return {
|
||||
"primary": (config.accent, config.on_fill),
|
||||
"secondary": (config.field_bg, config.fg),
|
||||
"danger": (config.danger, config.on_fill),
|
||||
"primary": (config.palette.accent, config.palette.on_fill),
|
||||
"secondary": (config.palette.field_bg, config.palette.fg),
|
||||
"danger": (config.palette.danger, config.palette.on_fill),
|
||||
}
|
||||
|
||||
@ -12,7 +12,10 @@ by the two call sites that need them.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import ModuleType
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
def load_xlib(*names: str) -> tuple[ModuleType, ...] | None:
|
||||
|
||||
@ -7,6 +7,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gatelock._config import GrabPolicy
|
||||
from gatelock._outputs import Output, OutputRect, OutputScan
|
||||
from gatelock._surfaces import (
|
||||
SurfaceSet,
|
||||
@ -41,9 +42,14 @@ class TestNeedsBackdropRoot:
|
||||
[
|
||||
(LockConfig(mode="hard"), True),
|
||||
(LockConfig(mode="soft"), False),
|
||||
(LockConfig(mode="soft", grab="global"), True),
|
||||
(LockConfig(mode="soft", grab="local"), True),
|
||||
(LockConfig(mode="hard", overrideredirect=False, grab="none"), False),
|
||||
(LockConfig(mode="soft", grab=GrabPolicy(kind="global")), True),
|
||||
(LockConfig(mode="soft", grab=GrabPolicy(kind="local")), True),
|
||||
(
|
||||
LockConfig(
|
||||
mode="hard", overrideredirect=False, grab=GrabPolicy(kind="none")
|
||||
),
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_predicate(self, config: LockConfig, *, expected: bool) -> None:
|
||||
|
||||
@ -125,7 +125,7 @@ def test_every_variant_draws_text_on_its_own_fill(
|
||||
|
||||
fill = button.cget("bg")
|
||||
text_color = button.cget("fg")
|
||||
expected = config.fg if variant == "secondary" else config.on_fill
|
||||
expected = config.palette.fg if variant == "secondary" else config.palette.on_fill
|
||||
assert text_color == expected
|
||||
assert fill != text_color
|
||||
|
||||
@ -137,8 +137,8 @@ def test_every_variant_has_a_visible_focus_ring(
|
||||
"""Tk's default ring is 1px black -- invisible on this palette."""
|
||||
button = make_button(root, config, "Go", lambda: None, ButtonStyle(variant=variant))
|
||||
|
||||
assert int(button.cget("highlightthickness")) == config.focus_thickness
|
||||
assert str(button.cget("highlightcolor")) == config.focus_ring
|
||||
assert int(button.cget("highlightthickness")) == config.spacing.focus_thickness
|
||||
assert str(button.cget("highlightcolor")) == config.palette.focus_ring
|
||||
|
||||
|
||||
def test_primary_reads_larger_than_the_other_variants(
|
||||
@ -168,7 +168,7 @@ def test_the_active_fill_is_lighter_than_the_resting_fill(
|
||||
"""Hover feedback is a lighter fill, not a glow shadow."""
|
||||
button = make_button(root, config, "Go", lambda: None)
|
||||
|
||||
assert button.cget("activebackground") == _lighten(config.accent)
|
||||
assert button.cget("activebackground") == _lighten(config.palette.accent)
|
||||
assert button.cget("activebackground") != button.cget("bg")
|
||||
|
||||
|
||||
@ -185,7 +185,7 @@ def test_heading_uses_the_accent_and_returns_the_label(
|
||||
label = heading(root, config, "Right now")
|
||||
|
||||
assert label.cget("text") == "Right now"
|
||||
assert str(label.cget("fg")) == config.accent
|
||||
assert str(label.cget("fg")) == config.palette.accent
|
||||
assert label.winfo_manager() == "pack"
|
||||
|
||||
|
||||
@ -193,15 +193,15 @@ def test_row_defaults_to_the_body_foreground(root: tk.Tk, config: LockConfig) ->
|
||||
"""An uncoloured row is ordinary text, not a status."""
|
||||
label = row(root, config, "Earned all-time: 3")
|
||||
|
||||
assert str(label.cget("fg")) == config.fg
|
||||
assert str(label.cget("fg")) == config.palette.fg
|
||||
assert int(label.cget("wraplength")) == DEFAULT_WRAP
|
||||
|
||||
|
||||
def test_row_honours_an_explicit_status_colour(root: tk.Tk, config: LockConfig) -> None:
|
||||
"""Status colours are load-bearing, so a caller can set one."""
|
||||
label = row(root, config, "LOCKED", RowStyle(color=config.danger))
|
||||
label = row(root, config, "LOCKED", RowStyle(color=config.palette.danger))
|
||||
|
||||
assert str(label.cget("fg")) == config.danger
|
||||
assert str(label.cget("fg")) == config.palette.danger
|
||||
|
||||
|
||||
def test_row_wraps_at_the_width_it_is_given(root: tk.Tk, config: LockConfig) -> None:
|
||||
|
||||
@ -6,6 +6,8 @@ from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from gatelock._config import GrabPolicy
|
||||
from gatelock._theme import LockPalette
|
||||
from gatelock._window import LockConfig
|
||||
from gatelock.tests.conftest import make_window
|
||||
|
||||
@ -29,7 +31,9 @@ class TestLockConfigResolution:
|
||||
|
||||
def test_explicit_overrides_win_over_mode(self) -> None:
|
||||
"""Explicit fields override the mode preset (screen-locker demo case)."""
|
||||
config = LockConfig(mode="soft", overrideredirect=True, grab="local")
|
||||
config = LockConfig(
|
||||
mode="soft", overrideredirect=True, grab=GrabPolicy(kind="local")
|
||||
)
|
||||
assert config.resolved_overrideredirect() is True
|
||||
assert config.resolved_grab() == "local"
|
||||
assert config.resolved_disable_vt() is False
|
||||
@ -37,7 +41,9 @@ class TestLockConfigResolution:
|
||||
def test_explicit_false_overrides_hard_mode(self) -> None:
|
||||
"""An explicit False/none is respected even under mode="hard"."""
|
||||
config = LockConfig(
|
||||
mode="hard", overrideredirect=False, grab="none", disable_vt=False
|
||||
mode="hard",
|
||||
overrideredirect=False,
|
||||
grab=GrabPolicy(kind="none", disable_vt=False),
|
||||
)
|
||||
assert config.resolved_overrideredirect() is False
|
||||
assert config.resolved_grab() == "none"
|
||||
@ -86,7 +92,7 @@ class TestSetup:
|
||||
def test_uses_configured_background(self, mock_root: MagicMock) -> None:
|
||||
"""The configured bg color is passed to root.configure."""
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="soft", bg="#000000")
|
||||
mock_root, config=LockConfig(mode="soft", palette=LockPalette(bg="#000000"))
|
||||
)
|
||||
|
||||
window.setup()
|
||||
@ -104,7 +110,7 @@ class TestGrabInput:
|
||||
def test_global_grab_dispatches_to_acquire(self, mock_root: MagicMock) -> None:
|
||||
"""grab="global" triggers the retry-aware acquisition path."""
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="soft", grab="global")
|
||||
mock_root, config=LockConfig(mode="soft", grab=GrabPolicy(kind="global"))
|
||||
)
|
||||
|
||||
with patch("gatelock._arming.acquire_global_grab") as mock_acquire:
|
||||
@ -116,7 +122,7 @@ class TestGrabInput:
|
||||
def test_local_grab_calls_grab_set(self, mock_root: MagicMock) -> None:
|
||||
"""grab="local" calls grab_set directly, no retry logic."""
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="soft", grab="local")
|
||||
mock_root, config=LockConfig(mode="soft", grab=GrabPolicy(kind="local"))
|
||||
)
|
||||
|
||||
window.grab_input()
|
||||
@ -127,7 +133,7 @@ class TestGrabInput:
|
||||
"""A TclError from grab_set (e.g. window already gone) is swallowed."""
|
||||
mock_root.grab_set.side_effect = tk.TclError("gone")
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="soft", grab="local")
|
||||
mock_root, config=LockConfig(mode="soft", grab=GrabPolicy(kind="local"))
|
||||
)
|
||||
|
||||
window.grab_input() # must not raise
|
||||
@ -135,7 +141,7 @@ class TestGrabInput:
|
||||
def test_none_grab_takes_no_grab_action(self, mock_root: MagicMock) -> None:
|
||||
"""grab="none" calls neither grab_set nor grab_set_global."""
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="soft", grab="none")
|
||||
mock_root, config=LockConfig(mode="soft", grab=GrabPolicy(kind="none"))
|
||||
)
|
||||
|
||||
window.grab_input()
|
||||
|
||||
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from gatelock._config import GrabPolicy
|
||||
from gatelock._window import LockConfig
|
||||
from gatelock.tests.conftest import make_window
|
||||
|
||||
@ -41,7 +42,7 @@ class TestAcquireGlobalGrab:
|
||||
"""grab_retry_ms=0 falls back to a local grab on the first failure."""
|
||||
mock_root.grab_set_global.side_effect = tk.TclError("held by another client")
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", grab_retry_ms=0)
|
||||
mock_root, config=LockConfig(mode="hard", grab=GrabPolicy(retry_ms=0))
|
||||
)
|
||||
|
||||
window._acquire_global_grab(attempt=1)
|
||||
@ -56,7 +57,7 @@ class TestAcquireGlobalGrab:
|
||||
mock_root.grab_set_global.side_effect = tk.TclError("held")
|
||||
mock_root.grab_set.side_effect = tk.TclError("also gone")
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", grab_retry_ms=0)
|
||||
mock_root, config=LockConfig(mode="hard", grab=GrabPolicy(retry_ms=0))
|
||||
)
|
||||
|
||||
window._acquire_global_grab(attempt=1) # must not raise
|
||||
@ -80,7 +81,7 @@ class TestAcquireGlobalGrab:
|
||||
"""A warning is logged only when attempt is a multiple of grab_log_every."""
|
||||
mock_root.grab_set_global.side_effect = tk.TclError("held")
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", grab_log_every=5)
|
||||
mock_root, config=LockConfig(mode="hard", grab=GrabPolicy(log_every=5))
|
||||
)
|
||||
|
||||
with patch("gatelock._preempt._logger") as mock_logger:
|
||||
@ -95,7 +96,7 @@ class TestAcquireGlobalGrab:
|
||||
"""An explicit positive grab_retry_ms is used as the reschedule delay."""
|
||||
mock_root.grab_set_global.side_effect = tk.TclError("held")
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", grab_retry_ms=50)
|
||||
mock_root, config=LockConfig(mode="hard", grab=GrabPolicy(retry_ms=50))
|
||||
)
|
||||
|
||||
window._acquire_global_grab(attempt=1)
|
||||
|
||||
@ -10,6 +10,7 @@ from gatelock._arbiter import (
|
||||
RANK_SCREEN_LOCKER,
|
||||
)
|
||||
from gatelock._claims import Claim
|
||||
from gatelock._config import GrabPolicy
|
||||
from gatelock._detect import _RandrEventSource
|
||||
from gatelock._outputs import Output, OutputRect
|
||||
from gatelock._window import LockConfig
|
||||
@ -84,7 +85,10 @@ class TestGrabBlockedLogging:
|
||||
instance_id="t",
|
||||
)
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=False)
|
||||
mock_root,
|
||||
config=LockConfig(
|
||||
mode="hard", grab=GrabPolicy(preempt_weaker_holder=False)
|
||||
),
|
||||
)
|
||||
window._arbiter = arbiter
|
||||
with patch("gatelock._preempt._logger") as logger:
|
||||
|
||||
@ -17,6 +17,7 @@ from gatelock._arbiter import (
|
||||
RANK_WAKE_ALARM,
|
||||
)
|
||||
from gatelock._claims import Claim
|
||||
from gatelock._config import GrabPolicy
|
||||
from gatelock._detect import _RandrEventSource
|
||||
from gatelock._outputs import Output, OutputRect
|
||||
from gatelock._window import LockConfig
|
||||
@ -64,7 +65,8 @@ class TestPreemptWeakerHolder:
|
||||
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
|
||||
)
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)
|
||||
mock_root,
|
||||
config=LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True)),
|
||||
)
|
||||
window._arbiter = arbiter
|
||||
with patch("gatelock._preempt.os.kill") as kill:
|
||||
@ -76,7 +78,8 @@ class TestPreemptWeakerHolder:
|
||||
our_rank=RANK_DIET_GUARD, holder_rank=RANK_SCREEN_LOCKER, holder_pid=4137
|
||||
)
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)
|
||||
mock_root,
|
||||
config=LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True)),
|
||||
)
|
||||
window._arbiter = arbiter
|
||||
with patch("gatelock._preempt.os.kill") as kill:
|
||||
@ -88,7 +91,8 @@ class TestPreemptWeakerHolder:
|
||||
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
|
||||
)
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)
|
||||
mock_root,
|
||||
config=LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True)),
|
||||
)
|
||||
window._arbiter = arbiter
|
||||
with patch("gatelock._preempt.os.kill") as kill:
|
||||
@ -101,7 +105,10 @@ class TestPreemptWeakerHolder:
|
||||
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
|
||||
)
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=False)
|
||||
mock_root,
|
||||
config=LockConfig(
|
||||
mode="hard", grab=GrabPolicy(preempt_weaker_holder=False)
|
||||
),
|
||||
)
|
||||
window._arbiter = arbiter
|
||||
with patch("gatelock._preempt.os.kill") as kill:
|
||||
@ -128,7 +135,8 @@ class TestPreemptWeakerHolder:
|
||||
|
||||
def test_no_arbiter_means_no_preemption(self, mock_root: MagicMock) -> None:
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)
|
||||
mock_root,
|
||||
config=LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True)),
|
||||
)
|
||||
with patch("gatelock._preempt.os.kill") as kill:
|
||||
_preempt.maybe_preempt(
|
||||
@ -154,7 +162,8 @@ class TestPreemptWeakerHolder:
|
||||
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
|
||||
)
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)
|
||||
mock_root,
|
||||
config=LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True)),
|
||||
)
|
||||
window._arbiter = arbiter
|
||||
with (
|
||||
@ -171,7 +180,8 @@ class TestPreemptWeakerHolder:
|
||||
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
|
||||
)
|
||||
window, _hooks = make_window(
|
||||
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)
|
||||
mock_root,
|
||||
config=LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True)),
|
||||
)
|
||||
window._arbiter = arbiter
|
||||
with (
|
||||
|
||||
@ -167,8 +167,8 @@ def heading(parent: tk.Misc, config: LockConfig, text: str) -> tk.Label:
|
||||
parent,
|
||||
text=text,
|
||||
font=config.font("body", bold=True),
|
||||
fg=config.accent,
|
||||
bg=config.bg,
|
||||
fg=config.palette.accent,
|
||||
bg=config.palette.bg,
|
||||
anchor="w",
|
||||
)
|
||||
label.pack(
|
||||
@ -212,8 +212,8 @@ def row(
|
||||
parent,
|
||||
text=text,
|
||||
font=config.font(role),
|
||||
fg=color if color is not None else config.fg,
|
||||
bg=config.bg,
|
||||
fg=color if color is not None else config.palette.fg,
|
||||
bg=config.palette.bg,
|
||||
anchor="w",
|
||||
justify="left",
|
||||
wraplength=max(_MIN_WRAP, wrap),
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gatelock"
|
||||
version = "0.7.3"
|
||||
version = "0.8.0"
|
||||
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [] # pure stdlib — tkinter is bundled with Python
|
||||
|
||||
Loading…
Reference in New Issue
Block a user