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

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:
Krzysztof Rudnicki 2026-09-12 19:36:20 +02:00
parent 8128851e0b
commit 68ec316b28
20 changed files with 248 additions and 145 deletions

View File

@ -106,7 +106,7 @@ repos:
- id: pylint
args:
- --rcfile=gatelock/pyproject.toml
- --fail-under=8.0
- --fail-under=10
- --jobs=4
additional_dependencies:
- pytest

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

@ -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),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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