# variable_select_widget.py import pickle import hashlib from typing import List, Dict, Any, Optional from PySide2.QtWidgets import ( QWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QLineEdit, QHeaderView, QCompleter ) from PySide2.QtGui import QKeyEvent from PySide2.QtCore import Qt, QStringListModel from path_hints import PathHints, canonical_key, split_path_tokens # ------------------------------------------------------------------ # utils # ------------------------------------------------------------------ def compute_vars_hash(vars_list): return hashlib.sha1(pickle.dumps(vars_list)).hexdigest() def is_lazy_item(item: QTreeWidgetItem) -> bool: return item.childCount() == 1 and item.child(0).text(0) == 'lazy_marker' # ------------------------------------------------------------------ # VariableSelectWidget # ------------------------------------------------------------------ class VariableSelectWidget(QWidget): """ Виджет выбора переменных с деревом + строкой поиска + автодополнением. Подсказки полностью через PathHints. ВАЖНО: ожидается, что в данных (vars_list) каждое var['name'] — ПОЛНЫЙ ПУТЬ (например: 'project.adc.status'), даже внутри children. """ ROLE_NAME = Qt.UserRole # локальный хвост (display) ROLE_VAR_DICT = Qt.UserRole + 100 # исходный dict ROLE_FULLPATH = Qt.UserRole + 200 # полный путь def __init__(self, parent=None): super().__init__(parent) # данные self.expanded_vars: List[Dict[str, Any]] = [] self.is_autocomplete_on = True self.manual_completion_active = False self._bckspc_pressed = False self._vars_hash: Optional[str] = None # индекс: canonical_full_path -> item self._item_by_canon: Dict[str, QTreeWidgetItem] = {} # подсказки self.hints = PathHints() # --- UI --- self.search_input = QLineEdit(self) self.search_input.setPlaceholderText("Поиск...") self.tree = QTreeWidget(self) self.tree.setHeaderLabels(["Имя переменной", "Тип"]) self.tree.setSelectionMode(QTreeWidget.ExtendedSelection) self.tree.setRootIsDecorated(True) self.tree.setUniformRowHeights(True) self.tree.setStyleSheet(""" QTreeWidget::item:selected { background-color: #87CEFA; color: black; } QTreeWidget::item:hover { background-color: #D3D3D3; } """) self.tree.itemExpanded.connect(self.on_item_expanded) self.completer = QCompleter(self) self.completer.setCompletionMode(QCompleter.PopupCompletion) self.completer.setCaseSensitivity(Qt.CaseInsensitive) self.completer.setFilterMode(Qt.MatchContains) self.completer.setWidget(self.search_input) self.completer.activated[str].connect(self.insert_completion) # layout lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(self.search_input) lay.addWidget(self.tree) # signals self.search_input.textChanged.connect(self.on_search_text_changed) self.search_input.installEventFilter(self) # ------------------------------------------------------------------ # public api # ------------------------------------------------------------------ def set_autocomplete(self, enabled: bool): self.is_autocomplete_on = enabled def set_data(self, vars_list: List[Dict[str, Any]]): """ Загружаем список переменных (формат: см. класс docstring). """ # deepcopy self.expanded_vars = pickle.loads(pickle.dumps(vars_list, protocol=pickle.HIGHEST_PROTOCOL)) # rebuild hints из полного списка узлов (каждый узел уже с full_path) self._rebuild_hints_from_vars(self.expanded_vars) # rebuild tree self.populate_tree(self.expanded_vars) # ------------------------------------------------------------------ # hints builder: дети уже содержат ПОЛНЫЙ ПУТЬ # ------------------------------------------------------------------ def _rebuild_hints_from_vars(self, vars_list: List[Dict[str, Any]]): paths: List[tuple] = [] def walk(node: Dict[str, Any]): full = node.get('name', '') if full: paths.append((full, node.get('type'))) for ch in node.get('children', []) or []: walk(ch) for v in vars_list: walk(v) self.hints.set_paths(paths) # ------------------------------------------------------------------ # tree building # ------------------------------------------------------------------ def populate_tree(self, vars_list=None): if vars_list is None: vars_list = self.expanded_vars new_hash = compute_vars_hash(vars_list) if self._vars_hash == new_hash: return self._vars_hash = new_hash self.tree.setUpdatesEnabled(False) self.tree.blockSignals(True) self.tree.clear() self._item_by_canon.clear() # построим top-level из входного списка: определяем по глубине токенов # (vars_list может содержать и глубокие узлы; выберем корни = те, чей full_path не имеет родителя в списке) full_to_node = {v['name']: v for v in vars_list} # но safer: просто добавляем все как top-level, если ты уже передаёшь только корни. # Если в твоих данных vars_list == корни, просто сделаем: for v in vars_list: self._add_tree_item_lazy(None, v) self.tree.setUpdatesEnabled(True) self.tree.blockSignals(False) header = self.tree.header() header.setSectionResizeMode(QHeaderView.Interactive) header.setSectionResizeMode(1, QHeaderView.Stretch) self.tree.setColumnWidth(0, 400) def on_item_expanded(self, item: QTreeWidgetItem): if is_lazy_item(item): item.removeChild(item.child(0)) var = item.data(0, self.ROLE_VAR_DICT) if var: for ch in var.get('children', []) or []: self._add_tree_item_lazy(item, ch) # ------------------------------------------------------------------ # item creation (var['name'] — ПОЛНЫЙ ПУТЬ) # ------------------------------------------------------------------ def _add_tree_item_lazy(self, parent: Optional[QTreeWidgetItem], var: Dict[str, Any]): full_path = var.get('name', '') type_str = var.get('type', '') # здесь оставляем полный путь для отображения item = QTreeWidgetItem([full_path, type_str]) item.setData(0, self.ROLE_NAME, full_path) # теперь ROLE_NAME = полный путь item.setData(0, self.ROLE_VAR_DICT, var) item.setData(0, self.ROLE_FULLPATH, full_path) if "(bitfield:" in type_str: item.setDisabled(True) self._set_tool(item, "Битовые поля недоступны для выбора") # метаданные for i, attr in enumerate(['file', 'extern', 'static']): item.setData(0, Qt.UserRole + 1 + i, var.get(attr)) # в дерево if parent is None: self.tree.addTopLevelItem(item) else: parent.addChild(item) # lazy children if var.get('children'): dummy = QTreeWidgetItem(["lazy_marker"]) item.addChild(dummy) # индекс self._item_by_canon[canonical_key(full_path)] = item @staticmethod def _tail_token(full_path: str) -> str: toks = split_path_tokens(full_path) return toks[-1] if toks else full_path # ------------------------------------------------------------------ # filtering # ------------------------------------------------------------------ def filter_tree(self): """ Быстрый фильтр: - без разделителей → substring по ЛОКАЛЬНОМУ имени top-level - с разделителями → структурный (по токенам full_path) """ text = (self.search_input.text() or '').strip() low = text.lower() parts = split_path_tokens(low) if low else [] # простой режим (нет ., ->, [): if low and all(x not in low for x in ('.', '->', '[')): for i in range(self.tree.topLevelItemCount()): it = self.tree.topLevelItem(i) full = (it.data(0, self.ROLE_FULLPATH) or '').lower() it.setHidden(low not in full) return # структурный for i in range(self.tree.topLevelItemCount()): it = self.tree.topLevelItem(i) self._show_matching_path(it, parts, 0) def _show_matching_path(self, item: QTreeWidgetItem, path_parts: List[str], level: int = 0): """ Сравниваем введённый путь (разбитый на токены) с ПОЛНЫМ ПУТЁМ узла. Алгоритм: берём полный путь узла, разбиваем в токены, берём уровень level, и сравниваем с соответствующим токеном path_parts[level]. """ full = (item.data(0, self.ROLE_FULLPATH) or '').lower() node_parts = split_path_tokens(full) if level >= len(path_parts): item.setHidden(False) item.setExpanded(False) return True if level >= len(node_parts): item.setHidden(True) return False search_part = path_parts[level] node_part = node_parts[level] if search_part == node_part: item.setHidden(False) matched_any = False self.on_item_expanded(item) for i in range(item.childCount()): ch = item.child(i) if self._show_matching_path(ch, path_parts, level + 1): matched_any = True item.setExpanded(matched_any) return matched_any or item.childCount() == 0 elif node_part.startswith(search_part): item.setHidden(False) item.setExpanded(False) return True elif search_part in node_part and (level == len(path_parts) - 1): item.setHidden(False) item.setExpanded(False) return True else: item.setHidden(True) return False # ------------------------------------------------------------------ # completions (ONLY PathHints) # ------------------------------------------------------------------ def update_completions(self, text: Optional[str] = None) -> List[str]: if text is None: text = self.search_input.text() suggestions = self.hints.suggest(text) self.completer.setModel(QStringListModel(suggestions)) if suggestions: self.completer.complete() else: self.completer.popup().hide() return suggestions def insert_completion(self, full_path: str): text = self.hints.add_separator(full_path) if not self._bckspc_pressed: self.search_input.setText(text) self.search_input.setCursorPosition(len(text)) self.run_completions(text) # ------------------------------------------------------------------ # events # ------------------------------------------------------------------ def eventFilter(self, obj, event): if obj == self.search_input and isinstance(event, QKeyEvent): if event.key() == Qt.Key_Space and event.modifiers() & Qt.ControlModifier: self.manual_completion_active = True self.run_completions(self.search_input.text()) elif event.key() == Qt.Key_Escape: if not self.is_autocomplete_on: self.manual_completion_active = False self.completer.popup().hide() return True if event.key() == Qt.Key_Backspace: self._bckspc_pressed = True else: self._bckspc_pressed = False return super().eventFilter(obj, event) def run_completions(self, text: str): if not self.is_autocomplete_on and not self.manual_completion_active: self.completer.popup().hide() return self.update_completions(text) def on_search_text_changed(self, text: str): self.completer.setWidget(self.search_input) self.filter_tree() if text is None: text = self.search_input.text() if self.is_autocomplete_on: self.run_completions(text) else: if self.manual_completion_active: self.run_completions(text) else: self.completer.popup().hide() def focusInEvent(self, event): if self.completer.widget() != self.search_input: self.completer.setWidget(self.search_input) super().focusInEvent(event) def closeEvent(self, event): self.completer.setWidget(None) self.completer.deleteLater() super().closeEvent(event) # ------------------------------------------------------------------ # lookup by full path # ------------------------------------------------------------------ def find_item_by_fullpath(self, path: str) -> Optional[QTreeWidgetItem]: return self._item_by_canon.get(canonical_key(path)) # ------------------------------------------------------------------ # tooltips # ------------------------------------------------------------------ def _set_tool(self, item: QTreeWidgetItem, text: str): item.setToolTip(0, text) item.setToolTip(1, text) # ------------------------------------------------------------------ # selection helpers # ------------------------------------------------------------------ def get_all_items(self): """Все leaf-узлы (подгружаем lazy).""" def collect_leaf(parent): leaves = [] for i in range(parent.childCount()): ch = parent.child(i) if ch.isHidden(): continue self.on_item_expanded(ch) if ch.childCount() == 0: t = ch.text(1) if t and 'bitfield' in t.lower(): continue leaves.append(ch) else: leaves.extend(collect_leaf(ch)) return leaves out = [] for i in range(self.tree.topLevelItemCount()): top = self.tree.topLevelItem(i) self.on_item_expanded(top) if top.childCount() == 0: t = top.text(1) if t and 'bitfield' in t.lower(): continue out.append(top) else: out.extend(collect_leaf(top)) return out def _get_internal_selected_items(self): selected = self.tree.selectedItems() all_items = [] def collect(item): self.on_item_expanded(item) res = [item] for i in range(item.childCount()): res.extend(collect(item.child(i))) return res for it in selected: all_items.extend(collect(it)) return all_items def get_selected_items(self): selected = self.tree.selectedItems() leaves = [] for it in selected: self.on_item_expanded(it) if all(it.child(i).isHidden() or not it.child(i).isSelected() for i in range(it.childCount())): t = it.data(0, self.ROLE_NAME) if t and isinstance(t, str) and 'bitfield' in t.lower(): continue leaves.append(it) return leaves def get_all_var_names(self): return [it.data(0, self.ROLE_FULLPATH) for it in self.get_all_items() if it.data(0, self.ROLE_FULLPATH)] def _get_internal_selected_var_names(self): return [it.data(0, self.ROLE_FULLPATH) for it in self._get_internal_selected_items() if it.data(0, self.ROLE_FULLPATH)] def get_selected_var_names(self): return [it.data(0, self.ROLE_FULLPATH) for it in self.get_selected_items() if it.data(0, self.ROLE_FULLPATH)]