diff --git a/gatelock/.pre-commit-config.yaml b/gatelock/.pre-commit-config.yaml index 8957b02..d88d424 100644 --- a/gatelock/.pre-commit-config.yaml +++ b/gatelock/.pre-commit-config.yaml @@ -106,7 +106,7 @@ repos: - id: pylint args: - --rcfile=gatelock/pyproject.toml - - --fail-under=8.0 + - --fail-under=10 - --jobs=4 additional_dependencies: - pytest diff --git a/gatelock/README.md b/gatelock/README.md index a075002..b9cc954 100644 --- a/gatelock/README.md +++ b/gatelock/README.md @@ -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 diff --git a/gatelock/gatelock/__init__.py b/gatelock/gatelock/__init__.py index e495b32..d8f2d77 100644 --- a/gatelock/gatelock/__init__.py +++ b/gatelock/gatelock/__init__.py @@ -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", diff --git a/gatelock/gatelock/_arming.py b/gatelock/gatelock/_arming.py index af5cc9d..233c8f1 100644 --- a/gatelock/gatelock/_arming.py +++ b/gatelock/gatelock/_arming.py @@ -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, diff --git a/gatelock/gatelock/_config.py b/gatelock/gatelock/_config.py index 1e6a44f..82ae4c3 100644 --- a/gatelock/gatelock/_config.py +++ b/gatelock/gatelock/_config.py @@ -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" diff --git a/gatelock/gatelock/_preempt.py b/gatelock/gatelock/_preempt.py index 5f23251..54e3c7f 100644 --- a/gatelock/gatelock/_preempt.py +++ b/gatelock/gatelock/_preempt.py @@ -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 diff --git a/gatelock/gatelock/_recovery.py b/gatelock/gatelock/_recovery.py index 7cf7966..f96fb9f 100644 --- a/gatelock/gatelock/_recovery.py +++ b/gatelock/gatelock/_recovery.py @@ -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() diff --git a/gatelock/gatelock/_scrollable.py b/gatelock/gatelock/_scrollable.py index 791699e..896e894 100644 --- a/gatelock/gatelock/_scrollable.py +++ b/gatelock/gatelock/_scrollable.py @@ -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( diff --git a/gatelock/gatelock/_surfaces.py b/gatelock/gatelock/_surfaces.py index bdf297f..073b5ea 100644 --- a/gatelock/gatelock/_surfaces.py +++ b/gatelock/gatelock/_surfaces.py @@ -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) diff --git a/gatelock/gatelock/_theme.py b/gatelock/gatelock/_theme.py new file mode 100644 index 0000000..190938e --- /dev/null +++ b/gatelock/gatelock/_theme.py @@ -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 diff --git a/gatelock/gatelock/_widget_fills.py b/gatelock/gatelock/_widget_fills.py index 389583e..011324b 100644 --- a/gatelock/gatelock/_widget_fills.py +++ b/gatelock/gatelock/_widget_fills.py @@ -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), } diff --git a/gatelock/gatelock/_xlib.py b/gatelock/gatelock/_xlib.py index 08ba2e9..85ffc31 100644 --- a/gatelock/gatelock/_xlib.py +++ b/gatelock/gatelock/_xlib.py @@ -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: diff --git a/gatelock/gatelock/tests/test_surfaces.py b/gatelock/gatelock/tests/test_surfaces.py index 8ca0b52..066458a 100644 --- a/gatelock/gatelock/tests/test_surfaces.py +++ b/gatelock/gatelock/tests/test_surfaces.py @@ -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: diff --git a/gatelock/gatelock/tests/test_widgets.py b/gatelock/gatelock/tests/test_widgets.py index d113245..99f8df3 100644 --- a/gatelock/gatelock/tests/test_widgets.py +++ b/gatelock/gatelock/tests/test_widgets.py @@ -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: diff --git a/gatelock/gatelock/tests/test_window.py b/gatelock/gatelock/tests/test_window.py index e4a71a4..b845b6d 100644 --- a/gatelock/gatelock/tests/test_window.py +++ b/gatelock/gatelock/tests/test_window.py @@ -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() diff --git a/gatelock/gatelock/tests/test_window_grab.py b/gatelock/gatelock/tests/test_window_grab.py index 4d77000..7434165 100644 --- a/gatelock/gatelock/tests/test_window_grab.py +++ b/gatelock/gatelock/tests/test_window_grab.py @@ -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) diff --git a/gatelock/gatelock/tests/test_window_surfaces.py b/gatelock/gatelock/tests/test_window_surfaces.py index c0840a3..2ac157e 100644 --- a/gatelock/gatelock/tests/test_window_surfaces.py +++ b/gatelock/gatelock/tests/test_window_surfaces.py @@ -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: diff --git a/gatelock/gatelock/tests/test_window_surfaces_preempt.py b/gatelock/gatelock/tests/test_window_surfaces_preempt.py index 5f62b26..76c0fed 100644 --- a/gatelock/gatelock/tests/test_window_surfaces_preempt.py +++ b/gatelock/gatelock/tests/test_window_surfaces_preempt.py @@ -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 ( diff --git a/gatelock/gatelock/widgets.py b/gatelock/gatelock/widgets.py index d2d5d70..7badeae 100644 --- a/gatelock/gatelock/widgets.py +++ b/gatelock/gatelock/widgets.py @@ -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), diff --git a/gatelock/pyproject.toml b/gatelock/pyproject.toml index ada4e76..87acf55 100644 --- a/gatelock/pyproject.toml +++ b/gatelock/pyproject.toml @@ -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