445 lines
18 KiB
Python
445 lines
18 KiB
Python
# 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):
|
||
"""
|
||
Пользователь выбрал подсказку (full_path).
|
||
Если у узла есть дети и пользователь не поставил разделитель —
|
||
добавим '.'. Для массивного токена ('[0]') → добавим '.' тоже.
|
||
(Позже допилим '->' при наличии метаданных.)
|
||
"""
|
||
node = self.hints.find_node(full_path)
|
||
text = full_path
|
||
|
||
if node and node.children and not (
|
||
text.endswith('.') or text.endswith('->') or text.endswith('[')
|
||
):
|
||
first_child = next(iter(node.children.values()))
|
||
if first_child.name.startswith('['):
|
||
text += '[' # пользователь сразу начнёт ввод индекса
|
||
else:
|
||
text += '.' # обычный переход
|
||
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)]
|