debugVarTool/Src/var_selector_table.py

445 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)]