606 lines
24 KiB
Python
606 lines
24 KiB
Python
import re
|
||
from PySide2.QtWidgets import (
|
||
QWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QLineEdit,
|
||
QHeaderView, QCompleter
|
||
)
|
||
from PySide2.QtGui import QKeyEvent
|
||
from PySide2.QtCore import Qt, QStringListModel
|
||
import pickle
|
||
import time
|
||
import hashlib
|
||
|
||
def compute_vars_hash(vars_list):
|
||
return hashlib.sha1(pickle.dumps(vars_list)).hexdigest()
|
||
|
||
# Вспомогательные функции, которые теперь будут использоваться виджетом
|
||
def split_path(path):
|
||
"""
|
||
Разбивает путь на компоненты:
|
||
- 'foo[2].bar[1]->baz' → ['foo', '[2]', 'bar', '[1]', 'baz']
|
||
Если видит '-' в конце строки (без '>' после) — обрезает этот '-'
|
||
"""
|
||
tokens = []
|
||
token = ''
|
||
i = 0
|
||
length = len(path)
|
||
while i < length:
|
||
c = path[i]
|
||
# Разделители: '->' и '.'
|
||
if c == '-' and i + 1 < length and path[i:i+2] == '->':
|
||
if token:
|
||
tokens.append(token)
|
||
token = ''
|
||
i += 2
|
||
continue
|
||
elif c == '-' and i == length - 1:
|
||
# '-' на конце строки без '>' после — просто пропускаем его
|
||
i += 1
|
||
continue
|
||
elif c == '.':
|
||
if token:
|
||
tokens.append(token)
|
||
token = ''
|
||
i += 1
|
||
continue
|
||
elif c == '[':
|
||
if token:
|
||
tokens.append(token)
|
||
token = ''
|
||
idx = ''
|
||
while i < length and path[i] != ']':
|
||
idx += path[i]
|
||
i += 1
|
||
if i < length and path[i] == ']':
|
||
idx += ']'
|
||
i += 1
|
||
tokens.append(idx)
|
||
continue
|
||
else:
|
||
token += c
|
||
i += 1
|
||
if token:
|
||
tokens.append(token)
|
||
return tokens
|
||
|
||
|
||
def is_lazy_item(item):
|
||
return item.childCount() == 1 and item.child(0).text(0) == 'lazy_marker'
|
||
|
||
|
||
class VariableSelectWidget(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.expanded_vars = []
|
||
self.node_index = {}
|
||
self.is_autocomplete_on = True # <--- ДОБАВИТЬ ЭТУ СТРОКУ
|
||
self._bckspc_pressed = False
|
||
self.manual_completion_active = False
|
||
self._vars_hash = None
|
||
|
||
# --- 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)
|
||
|
||
# --- Layout ---
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.addWidget(self.search_input)
|
||
layout.addWidget(self.tree)
|
||
|
||
# --- Соединения ---
|
||
#self.search_input.textChanged.connect(self.on_search_text_changed)
|
||
self.search_input.textChanged.connect(lambda text: self.on_search_text_changed(text))
|
||
self.search_input.installEventFilter(self)
|
||
self.completer.activated[str].connect(lambda text: self.insert_completion(text))
|
||
|
||
# --- Публичные методы для управления виджетом снаружи ---
|
||
|
||
def set_autocomplete(self, enabled: bool):
|
||
"""Включает или выключает режим автодополнения."""
|
||
self.is_autocomplete_on = enabled
|
||
|
||
def set_data(self, vars_list):
|
||
"""Основной метод для загрузки данных в виджет."""
|
||
self.expanded_vars = pickle.loads(pickle.dumps(vars_list, protocol=pickle.HIGHEST_PROTOCOL))
|
||
# self.build_completion_list() # Если нужна полная перестройка списка
|
||
self.populate_tree()
|
||
|
||
|
||
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.node_index.clear()
|
||
|
||
for var in vars_list:
|
||
self.add_tree_item_lazy(None, var)
|
||
|
||
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):
|
||
if is_lazy_item(item):
|
||
item.removeChild(item.child(0))
|
||
var = item.data(0, Qt.UserRole + 100)
|
||
if var:
|
||
for child_var in var.get('children', []):
|
||
self.add_tree_item_lazy(item, child_var)
|
||
|
||
|
||
def get_full_item_name(self, item):
|
||
fullname = item.text(0)
|
||
# Заменяем '->' на '.'
|
||
fullname = fullname.replace('->', '.')
|
||
fullname = fullname.replace('[', '.[')
|
||
return fullname
|
||
|
||
def add_tree_item_lazy(self, parent, var):
|
||
name = var['name']
|
||
type_str = var.get('type', '')
|
||
item = QTreeWidgetItem([name, type_str])
|
||
item.setData(0, Qt.UserRole, name)
|
||
full_name = self.get_full_item_name(item)
|
||
self.node_index[full_name.lower()] = item
|
||
|
||
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)
|
||
|
||
# Если есть дети — добавляем заглушку (чтобы можно было раскрыть)
|
||
if var.get('children'):
|
||
dummy = QTreeWidgetItem(["lazy_marker"])
|
||
item.addChild(dummy)
|
||
|
||
# Кэшируем детей для подгрузки по событию
|
||
item.setData(0, Qt.UserRole + 100, var) # Сохраняем var целиком
|
||
|
||
|
||
def show_matching_path(self, item, path_parts, level=0):
|
||
node_name = item.text(0).lower()
|
||
node_parts = split_path(node_name)
|
||
|
||
if 'project' in node_name:
|
||
a = 1
|
||
|
||
if level >= len(path_parts):
|
||
# Путь полностью пройден — показываем только этот узел (без раскрытия всех детей)
|
||
item.setHidden(False)
|
||
item.setExpanded(False)
|
||
return True
|
||
|
||
if level >= len(node_parts):
|
||
# Уровень поиска больше длины пути узла — скрываем
|
||
item.setHidden(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()):
|
||
child = item.child(i)
|
||
if self.show_matching_path(child, 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
|
||
|
||
|
||
def filter_tree(self):
|
||
text = self.search_input.text().strip().lower()
|
||
path_parts = split_path(text) if text else []
|
||
|
||
if '.' not in text and '->' not in text and '[' not in text and text != '':
|
||
for i in range(self.tree.topLevelItemCount()):
|
||
item = self.tree.topLevelItem(i)
|
||
name = item.text(0).lower()
|
||
if text in name:
|
||
item.setHidden(False)
|
||
# Не сбрасываем expanded, чтобы можно было раскрывать вручную
|
||
else:
|
||
item.setHidden(True)
|
||
else:
|
||
for i in range(self.tree.topLevelItemCount()):
|
||
item = self.tree.topLevelItem(i)
|
||
self.show_matching_path(item, path_parts, 0)
|
||
|
||
|
||
|
||
def find_node_by_path(self, root_vars, path_list):
|
||
current_level = root_vars
|
||
node = None
|
||
for part in path_list:
|
||
node = None
|
||
for var in current_level:
|
||
if var['name'] == part:
|
||
node = var
|
||
break
|
||
if node is None:
|
||
return None
|
||
current_level = node.get('children', [])
|
||
return node
|
||
|
||
|
||
def update_completions(self, text=None):
|
||
if text is None:
|
||
text = self.search_input.text().strip()
|
||
else:
|
||
text = text.strip()
|
||
|
||
normalized_text = text.replace('->', '.')
|
||
parts = split_path(text)
|
||
path_parts = parts[:-1] if parts else []
|
||
prefix = parts[-1].lower() if parts else ''
|
||
ends_with_sep = text.endswith('.') or text.endswith('->') or text.endswith('[')
|
||
is_index_suggestion = text.endswith('[')
|
||
|
||
completions = []
|
||
|
||
def find_exact_node(parts):
|
||
if not parts:
|
||
return None
|
||
fullname = parts[0]
|
||
for p in parts[1:]:
|
||
fullname += '.' + p
|
||
return self.node_index.get(fullname.lower())
|
||
|
||
if is_index_suggestion:
|
||
base_text = text[:-1] # убираем '['
|
||
parent_node = self.find_node_by_fullname(base_text)
|
||
if not parent_node:
|
||
base_text_clean = re.sub(r'\[\d+\]$', '', base_text)
|
||
parent_node = self.find_node_by_fullname(base_text_clean)
|
||
if parent_node:
|
||
seen = set()
|
||
for i in range(parent_node.childCount()):
|
||
child = parent_node.child(i)
|
||
if child.isHidden():
|
||
continue
|
||
cname = child.text(0)
|
||
m = re.match(rf'^{re.escape(base_text)}\[(\d+)\]$', cname)
|
||
if m and cname not in seen:
|
||
completions.append(cname)
|
||
seen.add(cname)
|
||
self.completer.setModel(QStringListModel(completions))
|
||
return completions
|
||
|
||
if ends_with_sep:
|
||
node = self.find_node_by_fullname(text[:-1])
|
||
if node:
|
||
for i in range(node.childCount()):
|
||
child = node.child(i)
|
||
if child.isHidden():
|
||
continue
|
||
completions.append(child.text(0))
|
||
elif not path_parts:
|
||
# Первый уровень — только если имя начинается с prefix
|
||
for i in range(self.tree.topLevelItemCount()):
|
||
item = self.tree.topLevelItem(i)
|
||
if item.isHidden():
|
||
continue
|
||
name = item.text(0)
|
||
if name.lower().startswith(prefix):
|
||
completions.append(name)
|
||
else:
|
||
node = find_exact_node(path_parts)
|
||
if node:
|
||
for i in range(node.childCount()):
|
||
child = node.child(i)
|
||
if child.isHidden():
|
||
continue
|
||
name = child.text(0)
|
||
name_parts = child.data(0, Qt.UserRole + 10)
|
||
if name_parts is None:
|
||
name_parts = split_path(name)
|
||
child.setData(0, Qt.UserRole + 10, name_parts)
|
||
if not name_parts:
|
||
continue
|
||
last_part = name_parts[-1].lower()
|
||
if prefix == '' or prefix in last_part: # ← строго startswith
|
||
completions.append(name)
|
||
|
||
self.completer.setModel(QStringListModel(completions))
|
||
self.completer.complete()
|
||
return completions
|
||
|
||
|
||
# Функция для поиска узла с полным именем
|
||
def find_node_by_fullname(self, name):
|
||
if name is None:
|
||
return None
|
||
normalized_name = name.replace('->', '.').lower()
|
||
normalized_name = normalized_name.replace('[', '.[').lower()
|
||
return self.node_index.get(normalized_name)
|
||
|
||
def insert_completion(self, text):
|
||
node = self.find_node_by_fullname(text)
|
||
if node and node.childCount() > 0 and not (text.endswith('.') or text.endswith('->') or text.endswith('[')):
|
||
# Определяем разделитель по имени первого ребёнка
|
||
child_name = node.child(0).text(0)
|
||
if child_name.startswith(text + '->'):
|
||
text += '->'
|
||
elif child_name.startswith(text + '.'):
|
||
text += '.'
|
||
elif '[' in child_name:
|
||
text += '[' # для массивов
|
||
else:
|
||
text += '.' # fallback
|
||
|
||
if not self._bckspc_pressed:
|
||
self.search_input.setText(text)
|
||
self.search_input.setCursorPosition(len(text))
|
||
|
||
self.run_completions(text)
|
||
else:
|
||
self.search_input.setText(text)
|
||
self.search_input.setCursorPosition(len(text))
|
||
|
||
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
|
||
text = self.search_input.text().strip()
|
||
self.run_completions(text)
|
||
elif event.key() == Qt.Key_Escape:
|
||
# Esc — выключаем ручной режим и скрываем подсказки, если autocomplete выключен
|
||
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):
|
||
completions = self.update_completions(text)
|
||
|
||
if not self.is_autocomplete_on and self._bckspc_pressed:
|
||
text = text[:-1]
|
||
|
||
if len(completions) == 1 and completions[0].lower() == text.lower():
|
||
# Найдем узел с таким именем
|
||
def find_exact_item(name):
|
||
stack = [self.tree.topLevelItem(i) for i in range(self.tree.topLevelItemCount())]
|
||
while stack:
|
||
node = stack.pop()
|
||
if node.text(0).lower() == name.lower():
|
||
return node
|
||
for i in range(node.childCount()):
|
||
stack.append(node.child(i))
|
||
return None
|
||
|
||
node = find_exact_item(completions[0])
|
||
if node and node.childCount() > 0:
|
||
# Используем первую подсказку, чтобы определить нужный разделитель
|
||
completions = self.update_completions(text + '.')
|
||
if not completions:
|
||
return
|
||
suggestion = completions[0]
|
||
|
||
# Ищем, какой символ идёт после текущего текста
|
||
separator = '.'
|
||
if suggestion.startswith(text):
|
||
rest = suggestion[len(text):]
|
||
if rest.startswith(text + '->'):
|
||
separator += '->'
|
||
elif rest.startswith(text + '.'):
|
||
separator += '.'
|
||
elif '[' in rest:
|
||
separator += '[' # для массивов
|
||
else:
|
||
separator += '.' # fallback
|
||
|
||
if not self._bckspc_pressed:
|
||
self.search_input.setText(text + separator)
|
||
completions = self.update_completions(text)
|
||
self.completer.setModel(QStringListModel(completions))
|
||
self.completer.complete()
|
||
return True
|
||
|
||
# Иначе просто показываем подсказки
|
||
self.completer.setModel(QStringListModel(completions))
|
||
if completions:
|
||
self.completer.complete()
|
||
return True
|
||
|
||
def on_search_text_changed(self, text):
|
||
sender_widget = self.sender()
|
||
sender_name = sender_widget.objectName() if sender_widget else "Unknown Sender"
|
||
|
||
self.completer.setWidget(self.search_input)
|
||
self.filter_tree()
|
||
if text == None:
|
||
text = self.search_input.text().strip()
|
||
if self.is_autocomplete_on:
|
||
self.run_completions(text)
|
||
else:
|
||
# Если выключено, показываем подсказки только если флаг ручного вызова True
|
||
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 _custom_focus_in_event(self, event):
|
||
# Принудительно установить виджет для completer при получении фокуса
|
||
if self.completer.widget() != self.search_input:
|
||
self.completer.setWidget(self.search_input)
|
||
super(QLineEdit, self.search_input).focusInEvent(event) # Вызвать оригинальный обработчик
|
||
|
||
|
||
def build_completion_list(self):
|
||
completions = []
|
||
|
||
def recurse(var, prefix=''):
|
||
fullname = f"{prefix}.{var['name']}" if prefix else var['name']
|
||
completions.append(fullname)
|
||
for child in var.get('children', []):
|
||
recurse(child, fullname)
|
||
|
||
for v in self.expanded_vars:
|
||
recurse(v)
|
||
self.all_completions = completions
|
||
|
||
def set_tool(self, item, text):
|
||
item.setToolTip(0, text)
|
||
item.setToolTip(1, text)
|
||
|
||
def get_all_items(self):
|
||
"""Возвращает все конечные (leaf) элементы, исключая битовые поля и элементы с детьми (реальными)."""
|
||
def collect_leaf_items(parent):
|
||
leaf_items = []
|
||
for i in range(parent.childCount()):
|
||
child = parent.child(i)
|
||
if child.isHidden():
|
||
continue
|
||
|
||
# Если есть заглушка — раскрываем
|
||
self.on_item_expanded(child)
|
||
|
||
if child.childCount() == 0:
|
||
item_type = child.text(1)
|
||
if item_type and 'bitfield' in str(item_type).lower():
|
||
continue
|
||
leaf_items.append(child)
|
||
else:
|
||
leaf_items.extend(collect_leaf_items(child))
|
||
return leaf_items
|
||
|
||
all_leaf_items = []
|
||
for i in range(self.tree.topLevelItemCount()):
|
||
top = self.tree.topLevelItem(i)
|
||
|
||
# Раскрываем lazy, если надо
|
||
self.on_item_expanded(top)
|
||
|
||
if top.childCount() == 0:
|
||
item_type = top.text(1)
|
||
if item_type and 'bitfield' in str(item_type).lower():
|
||
continue
|
||
all_leaf_items.append(top)
|
||
else:
|
||
all_leaf_items.extend(collect_leaf_items(top))
|
||
return all_leaf_items
|
||
|
||
|
||
|
||
def _get_internal_selected_items(self):
|
||
"""Возвращает выделенные элементы и всех их потомков, включая lazy."""
|
||
selected = self.tree.selectedItems()
|
||
all_items = []
|
||
|
||
def collect_children(item):
|
||
# Раскрываем при необходимости
|
||
# Раскрываем lazy, если надо
|
||
self.on_item_expanded(item)
|
||
|
||
items = [item]
|
||
for i in range(item.childCount()):
|
||
child = item.child(i)
|
||
items.extend(collect_children(child))
|
||
return items
|
||
|
||
for item in selected:
|
||
all_items.extend(collect_children(item))
|
||
|
||
return all_items
|
||
|
||
def get_selected_items(self):
|
||
"""Возвращает только конечные (leaf) выделенные элементы, исключая bitfield."""
|
||
selected = self.tree.selectedItems()
|
||
leaf_items = []
|
||
for item in selected:
|
||
# Раскрываем lazy, если надо
|
||
self.on_item_expanded(item)
|
||
|
||
# Если у узла нет видимых/выделенных детей — он лист
|
||
if all(item.child(i).isHidden() or not item.child(i).isSelected() for i in range(item.childCount())):
|
||
item_type = item.data(0, Qt.UserRole)
|
||
if item_type and 'bitfield' in str(item_type).lower():
|
||
continue
|
||
leaf_items.append(item)
|
||
return leaf_items
|
||
|
||
|
||
def get_all_var_names(self):
|
||
"""Возвращает имена всех конечных (leaf) переменных, исключая битовые поля и группы."""
|
||
return [item.text(0) for item in self.get_all_items() if item.text(0)]
|
||
|
||
|
||
def _get_internal_selected_var_names(self):
|
||
"""Возвращает имена выделенных переменных."""
|
||
return [item.text(0) for item in self._get_internal_selected_items() if item.text(0)]
|
||
|
||
|
||
def get_selected_var_names(self):
|
||
"""Возвращает имена только конечных (leaf) переменных из выделенных."""
|
||
return [item.text(0) for item in self.get_selected_items() if item.text(0)]
|
||
|
||
def closeEvent(self, event):
|
||
self.completer.setWidget(None)
|
||
self.completer.deleteLater()
|
||
super().closeEvent(event) |