"""Keeping the per-object grid and the flat settings form saying the same thing.
The grid in :mod:`spacr.qt.widgets.object_settings_grid` edits VALUES; the
settings panel is a dictionary of WIDGETS, and the pipeline reads the widgets.
Mounting the grid without a binding would give a screen two answers to the
same question -- the table showing what the user typed and ``collect()``
still returning what the widget holds -- and the run would silently use the
second one. That is a settings file that means something other than what it
looks like, which is worse than the flat form the grid replaces.
So the grid never becomes the source of truth. It is a VIEW that writes
through: every edit lands in the widget behind it, and the widget stays what
the pipeline reads. Nothing downstream of ``collect()`` learns that the grid
exists, so no settings file, notebook or ``spacr-run`` invocation changes.
WHY THE PANEL IS DUCK-TYPED. This asks for two methods -- ``collect`` and
``set_value_for_key`` -- and not for a class. That keeps the binding testable
against a dictionary rather than against a built screen, and it is the whole
of what the binding needs.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, FrozenSet, Mapping, Optional
from PySide6.QtCore import QObject
from spacr.object_settings_table import _FILTER_PREFIX, _settings_key, to_table
from spacr.qt.widgets.object_settings_grid import _SWITCH_QUESTIONS
#: The change signals a settings widget might carry, most specific first.
#:
#: A `Toggle` is a QCheckBox and has NONE of the combo or text signals, so a
#: list that stopped at `textChanged` connected nothing at all to the two
#: per-object switches and they never reached the table.
_CHANGE_SIGNALS = ("currentTextChanged", "currentIndexChanged", "toggled",
"stateChanged", "textChanged", "value_changed")
LOG = logging.getLogger(__name__)
[docs]
class ObjectGridBinding(QObject):
"""Bind a per-object grid to the settings panel behind it.
:param grid: the :class:`~spacr.qt.widgets.object_settings_grid.
ObjectSettingsGrid` to drive.
:param panel: anything offering ``collect()`` and
``set_value_for_key(key, value)``.
:param parent: parent object.
"""
def __init__(self, grid, panel, parent=None):
"""Bind a per-object grid to the settings panel beside it.
The re-entrancy guard is what stops the table visibly rebuilding under a
cursor that is still in a cell: writing a value into a widget makes that
widget emit, and a screen that reseeds the grid on every widget change
would rebuild it mid-edit. No value depends on the guard -- the write
reads the grid once, before the first widget moves -- so a reseed
halfway cannot drop an edit either way.
:param grid: the object settings grid.
:param panel: the settings panel it mirrors.
:param parent: parent object, or ``None``.
"""
super().__init__(parent)
self._grid = grid
self._panel = panel
self._busy = False
#: Widgets already connected, so `follow_the_form` can be called
#: again after the table widens without doubling every connection.
self._followed: set = set()
self._grid.settings_changed.connect(self._write_back)
self._grid.settings_changed.connect(self.follow_the_form)
[docs]
def owned_keys(self) -> FrozenSet[str]:
"""The settings keys the grid answers, as the panel now stands.
Read from the panel every time rather than remembered, because a
panel hides the settings of an object whose channel names no plane
and the grid must not claim a key that is no longer there.
Columns hidden because their channel is unset are still claimed, so
their rows stay off the flat form; their channels are not claimed,
because a hidden object's channel on the form is how it is brought
back.
"""
owned = set()
claimed = getattr(self._grid, "_claimed_table", None)
table = claimed() if callable(claimed) else self._grid.table()
for question, row in table.items():
if (question.startswith(_FILTER_PREFIX)
or question in _SWITCH_QUESTIONS):
continue
for obj in row:
owned.add(_settings_key(obj, question))
if "object_filters" in getattr(self._grid, "_base", {}):
owned.add("object_filters")
return frozenset(owned)
def _switch_keys(self) -> FrozenSet[str]:
"""The channel keys that decide which columns the grid draws.
Followed like the claimed keys, so a channel set on the form shows or
hides its object's column (2026-09-29, item 592).
"""
claimed = getattr(self._grid, "_claimed_table", None)
if not callable(claimed):
return frozenset()
return frozenset(
_settings_key(obj, question)
for question, row in claimed().items()
if question in _SWITCH_QUESTIONS for obj in row)
[docs]
def seed(self) -> None:
"""Show the panel's current answers in the grid.
Idempotent, and safe to call whenever something else has written the
panel -- a settings file being loaded, a preset applied, the Live
Preview propagating what it tuned.
"""
if self._busy:
return
self._busy = True
try:
self._grid.set_settings(self._panel.collect())
finally:
self._busy = False
self.follow_the_form()
def _form_moved(self, *_args) -> None:
"""Copy what the form now holds into the cells that show it.
Reads the panel once and compares, so a widget that emitted without
changing -- which most of them do on focus -- costs a dictionary
lookup rather than a table write.
"""
if self._busy:
return
self._busy = True
try:
self._show_what_the_panel_holds()
finally:
self._busy = False
def _show_what_the_panel_holds(self) -> int:
"""Write the panel's answers into the cells, one cell at a time.
:returns: how many cells changed.
When a channel on the form switches an object on or off, the columns
are redrawn from the panel, which also shows every value.
"""
try:
current = self._panel.collect()
except Exception: # noqa: BLE001
LOG.debug("could not read the panel", exc_info=True)
return 0
same = getattr(self._grid, "_shows_the_same_objects_as", None)
if callable(same) and not same(current):
self._grid.set_settings(current)
return 1
keep = getattr(self._grid, "_keep_the_switches", None)
if callable(keep):
keep(current)
shown = self._grid.settings()
moved = 0
for key in self.owned_keys():
if key not in current:
continue
if key in shown and _same(shown[key], current[key]):
continue
if key == "object_filters":
self._grid.set_filters(current[key])
moved += 1
continue
value = current[key]
for question, row in to_table({key: value}).items():
for obj in row:
if self._grid.set_value(
question, obj,
"" if value is None else str(value)):
moved += 1
return moved
def _write_back(self) -> None:
"""Push the grid's edits into the widgets behind them."""
if self._busy:
return
self._busy = True
try:
self.write_through()
finally:
self._busy = False
[docs]
def write_through(self) -> Dict[str, Any]:
"""Write every changed cell into its widget; return what changed.
ONLY WHAT DIFFERS. Setting a widget to the value it already holds
still makes it emit, and a panel that re-validates on every emit
would do the whole form's work on each keystroke in the table.
"""
before = self._panel.collect()
after = self._grid.settings()
changed: Dict[str, Any] = {}
for key in self.owned_keys():
if key not in after:
continue
new = after[key]
if key in before and _same(before[key], new):
continue
if self._panel.set_value_for_key(key, new):
changed[key] = new
if changed:
self._reconsider_which_objects_the_run_has(changed)
return changed
def _reconsider_which_objects_the_run_has(self, changed) -> None:
"""Re-gate the form when a CHANNEL changed, and only then.
A channel is the switch that says whether the run has an object at
all, so typing one into the table has to turn that object's other
categories on the way typing it into the form does. Nothing else in
the table gates anything, and this is the whole reason for the
filter below.
ONLY ON A CHANNEL, and only once per write rather than per keystroke.
The panel's own note is explicit that re-deciding this on every
keystroke is what made the Mask module hang; `write_through` runs on
a committed cell, so this is one call after an edit is finished
rather than one per character.
"""
if not any(str(key).endswith("_channel") for key in changed):
return
refresh = getattr(self._panel, "refresh_object_visibility", None)
if callable(refresh):
try:
refresh()
except Exception: # noqa: BLE001
LOG.debug("could not re-gate the form", exc_info=True)
def _same(a: Any, b: Any) -> bool:
"""Whether two settings values mean the same thing.
``1`` and ``1.0`` are the same answer asked of a spin box, and treating
them as different would write on every pass.
"""
if a is b:
return True
if isinstance(a, (dict, str)) and isinstance(b, (dict, str)) and (
isinstance(a, dict) or isinstance(b, dict)):
return _filters_of(a) == _filters_of(b)
if isinstance(a, bool) or isinstance(b, bool):
return bool(a) == bool(b)
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return float(a) == float(b)
return a == b
def _filters_of(value: Any) -> Any:
"""``object_filters`` in one canonical shape, for comparing two spellings.
The form may hold the mapping as text and the table as a dict; both are
read into ``{object: [{property, min, max}, ...]}`` with empty lists
dropped. A value that is not an ``object_filters`` mapping comes back
unchanged.
"""
try:
from spacr.qt.mask_engine import (normalise_filters,
parse_object_filters)
parsed = parse_object_filters(value)
return {str(obj): normalise_filters(rows, strict=False)
for obj, rows in sorted(parsed.items()) if rows}
except Exception: # noqa: BLE001
return value