← Back to DataSelectionPanel documentation
DataSelectionPanel - Source Code¶
File: Distributed_Design_Optimizer/postprocess/widgets/DataSelectionPanel.py
# Copyright (C) The DistributedDesignOptimizer Contributors
# Licensed under the GNU General Public License v3.0. See LICENSE file for details.
#
# Additional Request:
# We kindly request that any modifications or changes to the source code be
# shared with us by submitting them as pull requests to our GitHub repository.
# This request is not legally binding but is made in the spirit of
# collaboration and open-source development.
"""Data selection panel for file loading, tree browsing, and item selection."""
import re
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QTreeView, QListWidget, QLabel, QLineEdit, QFileDialog,
QAbstractItemView, QListWidgetItem, QMenu, QApplication,
QSplitter, QSplitterHandle, QFrame, QSizePolicy,
)
from PySide6.QtGui import QStandardItemModel, QStandardItem, QAction, QPainter, QColor
from PySide6.QtCore import Signal, Qt, QModelIndex, QPoint, QSortFilterProxyModel
from ..styles.Theme import get_accent_color, PRIMARY, PRIMARY_LIGHT, SECONDARY, PRIMARY_DARK
from .ToggleSwitch import ToggleSwitch
_UNDO_STACK_LIMIT = 20
def _natural_sort_key(key: str) -> tuple:
"""Return a sort key that orders numeric strings numerically.
Splits the key into numeric and non-numeric chunks so that, e.g.,
"2" sorts before "11" instead of lexicographically after it. Each chunk
is paired with a type-rank so numbers and text never compare against each
other (which would raise a TypeError).
"""
parts = re.split(r'(\d+)', str(key))
return tuple(
(0, int(part)) if part.isdigit() else (1, part.lower())
for part in parts if part != ''
)
class _GripSplitterHandle(QSplitterHandle):
"""Splitter handle that paints small grip dots and a directional arrow."""
def paintEvent(self, event) -> None:
"""Paint grip dots and directional arrow on the handle.
Args:
event: The paint event triggered by Qt.
"""
super().paintEvent(event)
p = QPainter()
if not p.begin(self):
return
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
is_vertical = self.orientation() == Qt.Orientation.Vertical
# Grip dots
dot_c = QColor(0, 0, 0, 80)
hover = self.underMouse()
if hover:
dot_c = QColor(3, 89, 112, 140)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(dot_c)
r = 1.5
if is_vertical:
# Horizontal row of dots across center
cy = h / 2
n = 5
spacing = 6
x0 = w / 2 - (n - 1) * spacing / 2
for i in range(n):
p.drawEllipse(int(x0 + i * spacing - r), int(cy - r), int(2 * r), int(2 * r))
else:
# Vertical column of dots
cx = w / 2
n = 5
spacing = 6
y0 = h / 2 - (n - 1) * spacing / 2
for i in range(n):
p.drawEllipse(int(cx - r), int(y0 + i * spacing - r), int(2 * r), int(2 * r))
p.end()
class _GripSplitter(QSplitter):
"""QSplitter that uses grip-dot handles."""
def createHandle(self) -> QSplitterHandle:
"""Create a custom grip-dot splitter handle.
Returns:
A new grip-dot styled splitter handle.
"""
return _GripSplitterHandle(self.orientation(), self)
class DataSelectionPanel(QWidget):
"""Left panel: file loading, tree browsing, item selection."""
files_load_requested = Signal(list) # [file_path, ...]
selection_changed = Signal(list) # [full_item_path, ...] currently in ListWidget
def __init__(self, parent: QWidget | None = None) -> None:
"""Initialize the data selection panel.
Args:
parent: Optional parent widget.
"""
super().__init__(parent)
self.setMinimumWidth(250)
self._undo_stack: list[list[str]] = []
self._data_handler = None # Set via set_data_handler()
self._pending_trees: list[tuple[str, dict]] = [] # stored for re-filtering
self._setup_ui()
def set_data_handler(self, handler) -> None:
"""Set the DataHandler reference for plottability checks.
Args:
handler: The DataHandler instance used for plottability filtering.
"""
self._data_handler = handler
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
# Load button row (primary action + Clear All Data)
load_row = QHBoxLayout()
load_row.setContentsMargins(0, 0, 0, 0)
load_row.setSpacing(4)
self._load_btn = QPushButton("Add More .dill History Files")
self._load_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self._load_btn.setStyleSheet(
f"QPushButton {{ background-color: {PRIMARY}; color: white; "
f"font-weight: bold; padding: 5px 12px; border-radius: 3px; }}"
f"QPushButton:hover {{ background-color: {PRIMARY_DARK}; }}"
)
self._load_btn.clicked.connect(self.load_files)
load_row.addWidget(self._load_btn)
self._clear_all_btn = QPushButton("Clear All Data")
self._clear_all_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self._clear_all_btn.setStyleSheet(
f"QPushButton {{ background-color: #cc4444; color: white; "
f"font-weight: bold; padding: 5px 12px; border-radius: 3px; }}"
f"QPushButton:hover {{ background-color: #aa3333; }}"
f"QPushButton:disabled {{ background-color: #cccccc; color: #888888; }}"
)
self._clear_all_btn.setToolTip("Remove all loaded data and clear plots")
self._clear_all_btn.setEnabled(False)
load_row.addWidget(self._clear_all_btn)
load_row.addStretch()
layout.addLayout(load_row)
# Second row: Auto-Update toggle + Refresh button
action_row = QHBoxLayout()
action_row.setContentsMargins(0, 0, 0, 0)
action_row.setSpacing(4)
self._auto_update_btn = ToggleSwitch("Auto-Update")
self._auto_update_btn.setToolTip("Enable automatic file-change detection")
self._auto_update_btn.setEnabled(False)
action_row.addWidget(self._auto_update_btn)
self._refresh_btn = QPushButton("Update Now")
self._refresh_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self._refresh_btn.setStyleSheet(
f"QPushButton {{ background-color: {PRIMARY}; color: white; "
f"font-weight: bold; padding: 5px 12px; border-radius: 3px; }}"
f"QPushButton:hover {{ background-color: {PRIMARY_DARK}; }}"
f"QPushButton:disabled {{ background-color: #cccccc; color: #888888; }}"
)
self._refresh_btn.setToolTip("Check for file changes and replot")
self._refresh_btn.setEnabled(False)
action_row.addWidget(self._refresh_btn)
# Disable "Update Now" when auto-update is active
self._auto_update_btn.toggled.connect(
lambda on: self._refresh_btn.setEnabled(not on and self._refresh_btn_base_enabled)
)
self._refresh_btn_base_enabled = False
action_row.addStretch()
layout.addLayout(action_row)
# Separator
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setFixedHeight(1)
sep.setStyleSheet("background-color: rgba(128, 128, 128, 0.3);")
layout.addWidget(sep)
# Filter field with clear button
self._filter_input = QLineEdit()
self._filter_input.setPlaceholderText("\U0001f50d Filter tree items...")
self._filter_input.setClearButtonEnabled(True)
self._filter_input.textChanged.connect(self._apply_filter)
layout.addWidget(self._filter_input)
# Hide non-plottable toggle
self._hide_nonplottable_cb = ToggleSwitch("Hide non-plottable quantities")
self._hide_nonplottable_cb.setChecked(True)
self._hide_nonplottable_cb.setEnabled(False)
self._hide_nonplottable_cb.setToolTip(
"Exclude attributes that have no numeric data across all iterations"
)
self._hide_nonplottable_cb.toggled.connect(self._rebuild_tree)
layout.addWidget(self._hide_nonplottable_cb)
# Tree view
tree_container = QWidget()
tree_layout = QVBoxLayout(tree_container)
tree_layout.setContentsMargins(0, 0, 0, 0)
tree_layout.setSpacing(2)
tree_label = QLabel("Data Structure")
tree_label.setStyleSheet("font-weight: bold; font-size: 9pt;")
tree_layout.addWidget(tree_label)
self._tree_view = QTreeView()
self._tree_model = QStandardItemModel()
self._proxy_model = QSortFilterProxyModel()
self._proxy_model.setSourceModel(self._tree_model)
self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self._proxy_model.setRecursiveFilteringEnabled(True)
self._tree_view.setModel(self._proxy_model)
self._tree_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self._tree_view.setHeaderHidden(True)
self._tree_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self._tree_view.setTextElideMode(Qt.TextElideMode.ElideNone)
self._tree_view.expanded.connect(self._fit_tree_column)
self._tree_view.collapsed.connect(self._fit_tree_column)
self._tree_view.doubleClicked.connect(self._on_tree_double_click)
tree_layout.addWidget(self._tree_view)
# P6/P7: Add/Remove buttons between tree and list (vertical arrows)
btn_row = QHBoxLayout()
btn_row.setContentsMargins(0, 2, 0, 2)
self._add_btn = QPushButton("\u2193 Add")
self._add_btn.setToolTip("Add selected tree items to the plot list")
self._add_btn.setEnabled(False)
self._add_btn.setStyleSheet(
f"QPushButton {{ background-color: {PRIMARY}; color: white; "
f"font-weight: bold; padding: 4px 10px; border-radius: 3px; }}"
f"QPushButton:hover {{ background-color: {PRIMARY_DARK}; }}"
f"QPushButton:disabled {{ background-color: #cccccc; color: #888888; }}"
)
self._add_btn.clicked.connect(self.move_selected_keys)
self._remove_btn = QPushButton("\u2191 Remove")
self._remove_btn.setToolTip("Remove selected items from the plot list")
self._remove_btn.setEnabled(False)
self._remove_btn.setStyleSheet(
f"QPushButton {{ background-color: #cc4444; color: white; "
f"font-weight: bold; padding: 4px 10px; border-radius: 3px; }}"
f"QPushButton:hover {{ background-color: #aa3333; }}"
f"QPushButton:disabled {{ background-color: #cccccc; color: #888888; }}"
)
self._remove_btn.clicked.connect(self.remove_selected_keys)
btn_row.addWidget(self._add_btn)
btn_row.addWidget(self._remove_btn)
# Selected data label + list widget (B1: buttons placed here, between tree and list)
list_container = QWidget()
list_layout = QVBoxLayout(list_container)
list_layout.setContentsMargins(0, 0, 0, 0)
# B1: Add/Remove buttons at top of list container
list_layout.addLayout(btn_row)
# Header row with count (B2: bold header for visual weight)
list_header = QHBoxLayout()
selected_label = QLabel("Selected Data")
selected_label.setStyleSheet("font-weight: bold; font-size: 9pt;")
list_header.addWidget(selected_label)
self._item_count_label = QLabel("0 items")
self._item_count_label.setStyleSheet("color: gray; font-size: 8pt;")
list_header.addStretch()
list_header.addWidget(self._item_count_label)
list_layout.addLayout(list_header)
self._list_widget = QListWidget()
self._list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
# B5: Enable drag-drop reorder for controlling plot/legend order
self._list_widget.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self._list_widget.setDefaultDropAction(Qt.DropAction.MoveAction)
list_layout.addWidget(self._list_widget)
# QSplitter: tree → list (2 children only)
self._splitter = _GripSplitter(Qt.Orientation.Vertical)
self._splitter.addWidget(tree_container)
self._splitter.addWidget(list_container)
self._splitter.setStretchFactor(0, 3)
self._splitter.setStretchFactor(1, 1)
layout.addWidget(self._splitter, stretch=1)
# Context menus
self._tree_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self._tree_view.customContextMenuRequested.connect(self._show_tree_context_menu)
self._list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self._list_widget.customContextMenuRequested.connect(self._show_list_context_menu)
# C3: Enable/disable Add/Remove based on selection state
self._tree_view.selectionModel().selectionChanged.connect(self._update_add_btn_state)
self._list_widget.itemSelectionChanged.connect(self._update_remove_btn_state)
# B5: Re-emit selection when items are reordered via drag-drop
self._list_widget.model().rowsMoved.connect(lambda *_: self._emit_selection())
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def showEvent(self, event) -> None: # noqa: N802
"""Handle the widget show event.
Args:
event: The show event triggered by Qt.
"""
super().showEvent(event)
def load_files(self) -> None:
"""Open a file dialog and emit paths."""
paths, _ = QFileDialog.getOpenFileNames(
self, "Select .dill Files", "", "Dill Files (*.dill)"
)
if paths:
self.files_load_requested.emit(paths)
def populate_tree(self, entry_id: str, tree_structure: dict) -> None:
"""Add or update an entry's tree structure in the tree view.
Args:
entry_id: Unique identifier for the data entry.
tree_structure: Nested dict representing the hierarchical data structure.
"""
# Replace existing entry in pending list, or append new
self._pending_trees = [
(eid, ts) if eid != entry_id else (entry_id, tree_structure)
for eid, ts in self._pending_trees
]
if not any(eid == entry_id for eid, _ in self._pending_trees):
self._pending_trees.append((entry_id, tree_structure))
# Remove existing root item from the tree model if present,
# preserving expanded state so the tree doesn't collapse on refresh.
expanded_paths = self._collect_expanded_paths()
root = self._tree_model.invisibleRootItem()
for row in range(root.rowCount()):
item = root.child(row)
if item is not None and item.text() == entry_id:
self._tree_model.removeRow(row)
break
self._add_entry_to_tree(entry_id, tree_structure, expand_default=False)
self._restore_expanded_paths(expanded_paths)
def _add_entry_to_tree(self, entry_id: str, tree_structure: dict,
expand_default: bool = True) -> None:
"""Build tree items for one entry, respecting the plottability filter."""
root_item = QStandardItem(entry_id)
root_item.setEditable(False)
filter_active = self._hide_nonplottable_cb.isChecked()
self._add_children(root_item, tree_structure, entry_id, "", filter_active)
# Only add if the root has children (or filter is off)
if root_item.rowCount() > 0 or not filter_active:
self._tree_model.appendRow(root_item)
if expand_default:
self._tree_view.expandToDepth(0)
self._fit_tree_column()
def _rebuild_tree(self, *_args) -> None:
"""Rebuild the entire tree when the plottability filter is toggled."""
# Save expanded paths before clearing
expanded_paths = self._collect_expanded_paths()
self._tree_model.clear()
# Reconnect selection model (clearing the model resets it)
self._tree_view.selectionModel().selectionChanged.connect(self._update_add_btn_state)
for entry_id, tree_structure in self._pending_trees:
self._add_entry_to_tree(entry_id, tree_structure, expand_default=False)
# Restore expanded state
self._restore_expanded_paths(expanded_paths)
self._fit_tree_column()
def _collect_expanded_paths(self) -> set[str]:
"""Collect UserRole paths of all currently expanded items."""
expanded = set()
self._walk_expanded(self._tree_model.invisibleRootItem(), expanded)
return expanded
def _walk_expanded(self, parent_item: QStandardItem, expanded: set[str]) -> None:
"""Recursively walk the tree model and record expanded item paths."""
for row in range(parent_item.rowCount()):
child = parent_item.child(row)
if child is None:
continue
proxy_idx = self._proxy_model.mapFromSource(child.index())
if proxy_idx.isValid() and self._tree_view.isExpanded(proxy_idx):
path = child.data(Qt.ItemDataRole.UserRole) or f"__root__{child.text()}"
expanded.add(path)
self._walk_expanded(child, expanded)
def _restore_expanded_paths(self, expanded_paths: set[str]) -> None:
"""Re-expand items whose UserRole path is in the given set."""
if not expanded_paths:
return
self._walk_and_expand(self._tree_model.invisibleRootItem(), expanded_paths)
def _walk_and_expand(self, parent_item: QStandardItem, expanded_paths: set[str]) -> None:
"""Recursively walk and expand matching items."""
for row in range(parent_item.rowCount()):
child = parent_item.child(row)
if child is None:
continue
path = child.data(Qt.ItemDataRole.UserRole) or f"__root__{child.text()}"
if path in expanded_paths:
proxy_idx = self._proxy_model.mapFromSource(child.index())
if proxy_idx.isValid():
self._tree_view.setExpanded(proxy_idx, True)
self._walk_and_expand(child, expanded_paths)
def clear_tree(self) -> None:
"""Remove all entries from the tree and selection list."""
self._tree_model.clear()
self._tree_view.selectionModel().selectionChanged.connect(self._update_add_btn_state)
self._list_widget.clear()
self._undo_stack.clear()
self._pending_trees.clear()
def move_selected_keys(self) -> None:
"""Move selected tree items to the list widget."""
indexes = self._tree_view.selectedIndexes()
if not indexes:
return
self._save_undo_state()
for index in indexes:
source_index = self._proxy_model.mapToSource(index)
item = self._tree_model.itemFromIndex(source_index)
if item is None or item.hasChildren():
continue # skip branch nodes
full_path = self._build_item_path(item)
# Avoid duplicates
existing = [self._list_widget.item(i).text() for i in range(self._list_widget.count())]
if full_path not in existing:
item = QListWidgetItem(full_path)
item.setToolTip(full_path)
self._list_widget.addItem(item)
self._emit_selection()
def remove_selected_keys(self) -> None:
"""Remove selected items from the list widget."""
if not self._list_widget.selectedItems():
return
self._save_undo_state()
for item in reversed(self._list_widget.selectedItems()):
row = self._list_widget.row(item)
self._list_widget.takeItem(row)
self._emit_selection()
def get_selected_item_paths(self) -> list[str]:
"""Return currently highlighted items in the list widget.
Returns:
List of full paths for items currently selected in the list.
"""
return [item.text() for item in self._list_widget.selectedItems()]
def get_all_item_paths(self) -> list[str]:
"""Return all items in the list widget.
Returns:
List of full paths for all items in the list.
"""
return [self._list_widget.item(i).text() for i in range(self._list_widget.count())]
def undo(self) -> None:
"""Restore the list widget to its previous state."""
if not self._undo_stack:
return
state = self._undo_stack.pop()
self._list_widget.clear()
for path in state:
item = QListWidgetItem(path)
item.setToolTip(path)
self._list_widget.addItem(item)
self._emit_selection()
# ------------------------------------------------------------------
# Private
# ------------------------------------------------------------------
def _add_children(self, parent_item: QStandardItem, structure: dict,
entry_id: str, prefix: str, filter_active: bool = False) -> None:
"""Recursively populate tree model from nested dict.
When *filter_active* is True, leaf nodes that are non-plottable
(all-NaN values) are excluded, and branches with no remaining
children are also excluded.
"""
if not isinstance(structure, dict):
return
for key, subtree in sorted(structure.items(), key=lambda kv: _natural_sort_key(kv[0])):
current_path = f"{prefix}.{key}" if prefix else key
full_path = f"{entry_id}/{current_path}"
if isinstance(subtree, dict) and subtree:
# Branch node — recurse first, then decide whether to keep
child = QStandardItem(key)
child.setEditable(False)
child.setData(full_path, Qt.ItemDataRole.UserRole)
self._add_children(child, subtree, entry_id, current_path, filter_active)
if filter_active and child.rowCount() == 0:
continue # skip empty branches
parent_item.appendRow(child)
else:
# Leaf node — check plottability if filter is active
if filter_active and self._data_handler is not None:
if not self._data_handler.is_path_plottable(entry_id, current_path):
continue
child = QStandardItem(key)
child.setEditable(False)
child.setData(full_path, Qt.ItemDataRole.UserRole)
parent_item.appendRow(child)
def _build_item_path(self, item: QStandardItem) -> str:
"""Build the full path from a tree item's stored data."""
data = item.data(Qt.ItemDataRole.UserRole)
if data:
return data
# Fallback: walk parents
parts = []
current = item
while current:
parts.insert(0, current.text())
current = current.parent()
return "/".join(parts[:1]) + "/" + ".".join(parts[1:]) if len(parts) > 1 else parts[0]
def _on_tree_double_click(self, index: QModelIndex) -> None:
"""Double-click: leaf adds itself, branch adds all leaves (C5a)."""
source_index = self._proxy_model.mapToSource(index)
item = self._tree_model.itemFromIndex(source_index)
if item is None:
return
existing = {self._list_widget.item(i).text() for i in range(self._list_widget.count())}
if item.rowCount() == 0:
full_path = self._build_item_path(item)
if full_path not in existing:
list_item = QListWidgetItem(full_path)
list_item.setToolTip(full_path)
self._list_widget.addItem(list_item)
else:
self._save_undo_state()
self._collect_leaves(item, existing)
self._emit_selection()
def _show_tree_context_menu(self, pos: QPoint) -> None:
"""Right-click context menu for the tree view."""
menu = QMenu(self)
add_action = QAction("Add to Selection", self)
add_action.triggered.connect(self.move_selected_keys)
menu.addAction(add_action)
add_children_action = QAction("Add All Children", self)
add_children_action.triggered.connect(self._add_all_children)
menu.addAction(add_children_action)
expand_action = QAction("Expand All", self)
expand_action.triggered.connect(self._tree_view.expandAll)
menu.addAction(expand_action)
collapse_action = QAction("Collapse All", self)
collapse_action.triggered.connect(self._tree_view.collapseAll)
menu.addAction(collapse_action)
menu.exec(self._tree_view.viewport().mapToGlobal(pos))
def _fit_tree_column(self, *_args) -> None:
"""Ensure tree column is at least as wide as the viewport."""
self._tree_view.resizeColumnToContents(0)
vw = self._tree_view.viewport().width()
if self._tree_view.header().sectionSize(0) < vw:
self._tree_view.header().resizeSection(0, vw)
def _show_list_context_menu(self, pos: QPoint) -> None:
"""Right-click context menu for the list widget."""
menu = QMenu(self)
remove_action = QAction("Remove Selected", self)
remove_action.triggered.connect(self.remove_selected_keys)
menu.addAction(remove_action)
remove_all_action = QAction("Remove All", self)
remove_all_action.triggered.connect(self._remove_all)
menu.addAction(remove_all_action)
if self._list_widget.currentItem():
copy_action = QAction("Copy Path", self)
copy_action.triggered.connect(self._copy_selected_path)
menu.addAction(copy_action)
menu.exec(self._list_widget.viewport().mapToGlobal(pos))
def _remove_all(self) -> None:
"""Remove all items from the list widget."""
self._save_undo_state()
self._list_widget.clear()
self._emit_selection()
def _add_all_children(self) -> None:
"""Recursively add all leaf children of selected tree items to the list."""
self._save_undo_state() # B5: allow undo
indexes = self._tree_view.selectedIndexes()
existing = {self._list_widget.item(i).text() for i in range(self._list_widget.count())}
for index in indexes:
source_index = self._proxy_model.mapToSource(index)
item = self._tree_model.itemFromIndex(source_index)
if item:
self._collect_leaves(item, existing)
self._emit_selection()
def _collect_leaves(self, item: QStandardItem, existing: set[str]) -> None:
"""Recursively collect leaf nodes from a tree item."""
if item.rowCount() == 0:
full_path = self._build_item_path(item)
if full_path not in existing:
item = QListWidgetItem(full_path)
item.setToolTip(full_path)
self._list_widget.addItem(item)
existing.add(full_path)
else:
for row in range(item.rowCount()):
self._collect_leaves(item.child(row), existing)
def _copy_selected_path(self) -> None:
"""Copy the current item's path to clipboard."""
item = self._list_widget.currentItem()
if item:
QApplication.clipboard().setText(item.text())
def _apply_filter(self, text: str) -> None:
"""Filter tree items via proxy model."""
self._proxy_model.setFilterFixedString(text)
if text:
self._tree_view.expandAll()
else:
self._tree_view.collapseAll()
self._tree_view.expandToDepth(0)
def _emit_selection(self) -> None:
"""Emit the current full list of items and update count label."""
paths = self.get_all_item_paths()
count = len(paths)
self._item_count_label.setText(f"{count} item{'s' if count != 1 else ''}")
self.selection_changed.emit(paths)
def _update_add_btn_state(self, *_args) -> None:
"""Enable Add only when at least one selected item is a leaf node."""
for index in self._tree_view.selectedIndexes():
source_index = self._proxy_model.mapToSource(index)
item = self._tree_model.itemFromIndex(source_index)
if item is not None and not item.hasChildren():
self._add_btn.setEnabled(True)
return
self._add_btn.setEnabled(False)
def _update_remove_btn_state(self) -> None:
self._remove_btn.setEnabled(bool(self._list_widget.selectedItems()))
def _save_undo_state(self) -> None:
"""Snapshot current list state onto the undo stack."""
state = [self._list_widget.item(i).text() for i in range(self._list_widget.count())]
self._undo_stack.append(state)
if len(self._undo_stack) > _UNDO_STACK_LIMIT:
self._undo_stack.pop(0)