Source code for stratified_packager.gui.dlg_layers_table

"""All-layers table dialog for per-layer packaging settings (SPEC §19)."""

from __future__ import annotations

from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, override

from qgis.core import Qgis, QgsProject, QgsVectorLayer
from qgis.PyQt import uic
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtWidgets import (
    QDialog,
    QDialogButtonBox,
    QLabel,
    QPushButton,
    QStyle,
    QTableWidgetItem,
)

from stratified_packager.__about__ import __title__
from stratified_packager.processing.params import (
    LAYER_VAR_RELATION_PATH,
    LAYER_VAR_SPECS,
    VARIABLE_PREFIX,
)
from stratified_packager.toolbelt.logging import QgisLoggerWrapper, Target
from stratified_packager.toolbelt.settings import LayerVariables
from stratified_packager.toolbelt.utils import remove_diacritical_marks

from .widgets import (
    OverridePredicateCombo,
    ScopeEditor,
    apply_overrides,
    layer_fields,
    make_override_editor,
)

if TYPE_CHECKING:
    from qgis.core import QgsMapLayer
    from qgis.gui import QgisInterface
    from qgis.PyQt.QtWidgets import QTableWidget, QVBoxLayout, QWidget

    from . import LayerOptionsPageWidgetFactory

log = QgisLoggerWrapper.get_logger(__name__)

# uic.loadUiType has no type stubs; it returns dynamically-generated classes.
FORM_CLASS, _ = uic.loadUiType(Path(__file__).with_suffix(".ui"))  # type: ignore[no-untyped-call]


[docs] class _NameItem(QTableWidgetItem): """A layer-name cell sorted by name (accent-aware) or by layer-tree rank."""
[docs] @override def __init__(self, text: str, tree_rank: int) -> None: """ Build the name cell. :param text: The layer name to show. :param tree_rank: The row's position in the layer tree; sorted on to restore the default order (the third click of the header's sort cycle). """ super().__init__(text) self.tree_rank = tree_rank """The row's layer-tree position, used to restore the default order.""" self.sort_by_rank = False """When set, the next sort compares :attr:`tree_rank` instead of the name (restoring the default layer-tree order). Toggled by the host dialog before each sort."""
@override def __lt__(self, other: QTableWidgetItem) -> bool: """ Compare by layer-tree rank when restoring the default order, else by the name key. The name key is the ``(accent-free, accent-kept)`` casefolded pair (accent-insensitive primary, accents as tie-breaker). Names equal under it keep their relative order: :class:`QTableWidget` sorts with a stable sort (``QTableModel::sort`` uses ``std::stable_sort``), so identical names stay in layer-tree order in both directions. :param other: The item to compare against. :return: Whether this item sorts before *other*. """ if self.sort_by_rank and isinstance(other, _NameItem): return self.tree_rank < other.tree_rank return self._key(self.text()) < self._key(other.text())
[docs] @staticmethod def _key(text: str) -> tuple[str, str]: """ Return the ``(accent-free, accent-kept)`` casefolded sort keys: primary, then tie-breaker. :param text: The cell text. :return: The accent-stripped key followed by the accent-keeping key. """ folded = text.casefold() return remove_diacritical_marks(folded), folded
# FORM_CLASS is a dynamically-generated form class — opaque to static checkers.
[docs] class LayersTableDialog(QDialog, FORM_CLASS): # type: ignore[misc,valid-type] """Editable table of every layer's ``stratified_packager_*`` variables.""" verticalLayout: QVBoxLayout # noqa: N815 lbl_header: QLabel btn_plugin_settings: QPushButton btn_project_defaults: QPushButton table: QTableWidget button_box: QDialogButtonBox _VECTOR_ONLY: frozenset[str] = frozenset( spec.suffix for spec in LAYER_VAR_SPECS if spec.vector_only ) """Field keys (bare suffixes) that only apply to vector layers (disabled otherwise); derived from the variable table's ``vector_only`` flags.""" _VIRTUAL_ONLY: frozenset[str] = frozenset( spec.suffix for spec in LAYER_VAR_SPECS if spec.virtual_only ) """Field keys whose column is disabled for non-virtual layers; derived from the variable table's ``virtual_only`` flags."""
[docs] @override def __init__( self, iface: QgisInterface, parent: QWidget | None = None, layer_options_page_widget_factory: LayerOptionsPageWidgetFactory | None = None, ) -> None: """ Build the dialog and fill the table from the current project's layers. :param iface: The QGIS interface (for the page deep-link buttons). :param parent: The dialog parent. :param layer_options_page_widget_factory: the registered :class:`~.wdg_layer_options_page.LayerOptionsPageWidgetFactory` instance. """ super().__init__(parent) self.setupUi(self) self._iface = iface self._layer_opts_factory = layer_options_page_widget_factory self._specs = {spec.key: spec for spec in layer_fields()} # Columns follow layer_fields() order (minus relation_path, which stays on the per-layer # page), so a new §4 variable auto-appears here without touching this dialog. _relation_key = LAYER_VAR_RELATION_PATH.removeprefix(VARIABLE_PREFIX) self._column_keys: tuple[str, ...] = tuple(k for k in self._specs if k != _relation_key) self._editors: dict[tuple[str, str], ScopeEditor] = {} self._name_sort_state = 0 """The Layer column's sort cycle: 0 = layer-tree order, 1 = ascending, 2 = descending.""" self._populate() self._wire()
[docs] def _wire(self) -> None: """Connect the button box and the page deep-link buttons.""" self.button_box.accepted.connect(self._on_accept) self.button_box.rejected.connect(self.reject) if apply_button := self.button_box.button(QDialogButtonBox.StandardButton.Apply): apply_button.clicked.connect(self.apply_changes) self.btn_plugin_settings.clicked.connect(self._open_plugin_settings) self.btn_project_defaults.clicked.connect(self._open_project_defaults)
[docs] def _populate(self) -> None: """Create one row per (non-plugin) layer with an editor per §4 column.""" project = QgsProject.instance() root = project.layerTreeRoot() if project is not None else None layers = [ layer for node in (root.findLayers() if root is not None else []) if (layer := node.layer()) is not None and layer.type() != Qgis.LayerType.Plugin ] headers = [ self.tr("Layer"), *(self._specs[key].label for key in self._column_keys), self.tr("Properties"), ] self.table.setColumnCount(len(headers)) self.table.setHorizontalHeaderLabels(headers) self.table.setRowCount(len(layers)) for row, layer in enumerate(layers): self._fill_row(row, layer) self._size_columns() self._fit_dialog_width() # The Layer column is click-sortable (the editor columns hold widgets, not sortable data). # Drive it manually rather than via setSortingEnabled, so the header cycles ascending → # descending → default (layer-tree) order instead of QHeaderView's built-in two-state # toggle. The cleared indicator means the initial view is unsorted (layer-tree order). if header := self.table.horizontalHeader(): header.setSortIndicatorShown(True) header.setSortIndicator(-1, Qt.SortOrder.AscendingOrder) header.setSectionsClickable(True) header.sectionClicked.connect(self._cycle_name_sort)
[docs] def _fill_row(self, row: int, layer: QgsMapLayer) -> None: """ Fill one table row for *layer*. :param row: The table row index. :param layer: The layer to represent. """ name_item = _NameItem(layer.name(), row) name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable) # Anchor the row's identity so it maps back to its layer regardless of sort order. name_item.setData(Qt.ItemDataRole.UserRole, layer.id()) self.table.setItem(row, 0, name_item) is_virtual = layer.providerType() == "virtual" is_vector = isinstance(layer, QgsVectorLayer) field_names = layer.fields().names() if isinstance(layer, QgsVectorLayer) else [] variables = LayerVariables(layer) for column, key in enumerate(self._column_keys, start=1): if (key in self._VECTOR_ONLY and not is_vector) or ( key in self._VIRTUAL_ONLY and not is_virtual ): placeholder = QLabel("—") placeholder.setEnabled(False) self.table.setCellWidget(row, column, placeholder) continue spec = self._specs[key] editor = make_override_editor(spec, field_names=field_names, inheriting=False) raw = variables.get(spec.variable) editor.set_scope_value(None if raw is None or str(raw) == "" else str(raw)) self.table.setCellWidget(row, column, editor) self._editors[(layer.id(), key)] = editor layer_properties_button = QPushButton(self.tr("Properties…")) if self._layer_opts_factory is not None and self._layer_opts_factory.supportsLayer(layer): layer_properties_button.clicked.connect( partial(self._open_layer_properties, layer.id()) ) else: layer_properties_button.setEnabled(False) self.table.setCellWidget(row, len(self._column_keys) + 1, layer_properties_button)
[docs] def _size_columns(self) -> None: """Size columns to content, then widen editor columns to fit their editors.""" self.table.resizeColumnsToContents() widest: dict[int, int] = {} for (_layer_id, key), editor in self._editors.items(): column = self._column_keys.index(key) + 1 # column 0 is the Layer name hint = editor.sizeHint().width() if isinstance(editor, OverridePredicateCombo): hint = max(hint, editor.de9im_row_width_hint()) widest[column] = max(widest.get(column, 0), hint) for column, width in widest.items(): if self.table.columnWidth(column) < width: self.table.setColumnWidth(column, width)
[docs] def _fit_dialog_width(self) -> None: """Widen the dialog to show every column, capped at the available screen width.""" header = self.table.horizontalHeader() row_header = self.table.verticalHeader() style = self.style() if header is None or row_header is None or style is None: return side_margins = 0 if layout := self.layout(): margins = layout.contentsMargins() side_margins = margins.left() + margins.right() width = ( header.length() + row_header.width() + self.table.frameWidth() * 2 + style.pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent) + side_margins ) if screen := self.screen(): width = min(width, screen.availableGeometry().width()) self.resize(width, self.height())
[docs] def _cycle_name_sort(self, column: int) -> None: """ Cycle the Layer column: name-ascending → name-descending → default (layer-tree) order. Clicks on the editor columns are ignored — they hold widgets, not sortable values. :param column: The clicked header section. """ if column != 0: return self._name_sort_state = (self._name_sort_state + 1) % 3 restore_tree_order = self._name_sort_state == 0 for row in range(self.table.rowCount()): if isinstance(item := self.table.item(row, 0), _NameItem): item.sort_by_rank = restore_tree_order header = self.table.horizontalHeader() if restore_tree_order: self.table.sortItems(0, Qt.SortOrder.AscendingOrder) # by tree rank → default order if header is not None: header.setSortIndicator(-1, Qt.SortOrder.AscendingOrder) return order = ( Qt.SortOrder.AscendingOrder if self._name_sort_state == 1 else Qt.SortOrder.DescendingOrder ) self.table.sortItems(0, order) if header is not None: header.setSortIndicator(0, order)
[docs] def apply_changes(self) -> None: """ Write every edited cell back to its layer variable. Contained at this QGIS-invoked boundary: a failed write is reported, not raised. """ project = QgsProject.instance() if project is None: return grouped: dict[str, dict[str, str | None]] = {} for (layer_id, key), editor in self._editors.items(): grouped.setdefault(layer_id, {})[self._specs[key].variable] = editor.scope_value() try: for layer_id, values in grouped.items(): layer = project.mapLayer(layer_id) if layer is not None: apply_overrides(LayerVariables(layer), values) except RuntimeError: log.exception( self.tr("Could not save the layer settings."), targets=Target.LOG | Target.BAR )
[docs] def _on_accept(self) -> None: """Apply the edits, then close the dialog.""" self.apply_changes() self.accept()
[docs] def _open_plugin_settings(self) -> None: """Open the plugin Options page.""" self._iface.showOptionsDialog(currentPage="wdg_stratified_packager_plugin_options_page")
[docs] def _open_project_defaults(self) -> None: """Open the plugin's Project Properties defaults page.""" # QgsProjectProperties overwrites every factory page's objectName with the # factory title(), so setCurrentPage must match __title__ here — not the .ui # objectName the Options/Layer dialogs preserve and match on. self._iface.showProjectPropertiesDialog(currentPage=__title__)
[docs] def _open_layer_properties(self, layer_id: str) -> None: """ Open a layer's properties dialog. :param layer_id: The id of the layer whose properties to open. """ project = QgsProject.instance() if project is None: return if layer := project.mapLayer(layer_id): self._iface.showLayerProperties( layer, page="wdg_stratified_packager_layer_options_page" )