740 lines
30 KiB
Python
740 lines
30 KiB
Python
"""
|
||
LowLevelSelectorWidget (PySide2)
|
||
--------------------------------
|
||
Виджет для:
|
||
* Выбора XML файла с описанием переменных (как в примере пользователя)
|
||
* Парсинга всех <variable> и их вложенных <member>
|
||
* Построения плоского списка путей (имя/подпуть) с расчётом абсолютного адреса (base_address + offset)
|
||
* Определения структур с полями даты (year, month, day, hour, minute)
|
||
* Выбора переменной и (опционально) переменной даты / ручного ввода даты
|
||
* Выбора типов: ptr_type (pt_*), iq_type, return_type
|
||
* Форматирования адреса в виде 0x000000 (6 HEX)
|
||
* Генерации словаря/кадра для последующей LowLevel-команды (не отправляет сам)
|
||
|
||
Интеграция:
|
||
* Подключите сигнал variablePrepared(dict) к функции, формирующей и отправляющей пакет.
|
||
* Содержимое dict:
|
||
{
|
||
'address': int,
|
||
'address_hex': str, # '0x....'
|
||
'ptr_type': int, # значение enum pt_*
|
||
'iq_type': int,
|
||
'return_type': int,
|
||
'datetime': {
|
||
'year': int,
|
||
'month': int,
|
||
'day': int,
|
||
'hour': int,
|
||
'minute': int,
|
||
},
|
||
'path': str, # полный путь переменной
|
||
'type_string': str, # строка типа из XML
|
||
}
|
||
|
||
Зависимости: только PySide2 и стандартная библиотека.
|
||
"""
|
||
from __future__ import annotations
|
||
import sys
|
||
import xml.etree.ElementTree as ET
|
||
from dataclasses import dataclass, field
|
||
from typing import List, Dict, Optional, Tuple
|
||
from PySide2 import QtCore, QtGui, QtWidgets
|
||
from path_hints import PathHints
|
||
|
||
# ------------------------------------------------------------ Enumerations --
|
||
# Сопоставление строк из XML типу ptr_type (адаптируйте под реальный проект)
|
||
PTR_TYPE_MAP = {
|
||
'int8': 'pt_int8', 'signed char': 'pt_int8', 'char': 'pt_int8',
|
||
'int16': 'pt_int16', 'short': 'pt_int16', 'int': 'pt_int16',
|
||
'int32': 'pt_int32', 'long': 'pt_int32',
|
||
'int64': 'pt_int64', 'long long': 'pt_int64',
|
||
'uint8': 'pt_uint8', 'unsigned char': 'pt_uint8',
|
||
'uint16': 'pt_uint16', 'unsigned short': 'pt_uint16', 'unsigned int': 'pt_uint16',
|
||
'uint32': 'pt_uint32', 'unsigned long': 'pt_uint32',
|
||
'uint64': 'pt_uint64', 'unsigned long long': 'pt_uint64',
|
||
'float': 'pt_float', 'floatf': 'pt_float',
|
||
'struct': 'pt_struct', 'union': 'pt_union',
|
||
}
|
||
|
||
PT_ENUM_ORDER = [
|
||
'pt_unknown','pt_int8','pt_int16','pt_int32','pt_int64',
|
||
'pt_uint8','pt_uint16','pt_uint32','pt_uint64','pt_float',
|
||
'pt_struct','pt_union'
|
||
]
|
||
|
||
IQ_ENUM_ORDER = [
|
||
't_iq_none','t_iq','t_iq1','t_iq2','t_iq3','t_iq4','t_iq5','t_iq6',
|
||
't_iq7','t_iq8','t_iq9','t_iq10','t_iq11','t_iq12','t_iq13','t_iq14',
|
||
't_iq15','t_iq16','t_iq17','t_iq18','t_iq19','t_iq20','t_iq21','t_iq22',
|
||
't_iq23','t_iq24','t_iq25','t_iq26','t_iq27','t_iq28','t_iq29','t_iq30'
|
||
]
|
||
|
||
# Для примера: маппинг имени enum -> числовое значение (индекс по порядку)
|
||
PT_ENUM_VALUE = {name: idx for idx, name in enumerate(PT_ENUM_ORDER)}
|
||
IQ_ENUM_VALUE = {name: idx for idx, name in enumerate(IQ_ENUM_ORDER)}
|
||
|
||
# -------------------------------------------------------------- Data types --
|
||
DATE_FIELD_SET = {'year','month','day','hour','minute'}
|
||
|
||
@dataclass
|
||
class MemberNode:
|
||
name: str
|
||
offset: int = 0
|
||
type_str: str = ''
|
||
size: Optional[int] = None
|
||
children: List['MemberNode'] = field(default_factory=list)
|
||
# --- новые, но необязательные (совместимость) ---
|
||
kind: Optional[str] = None # 'array', 'union', ...
|
||
count: Optional[int] = None # size1 (число элементов в массиве)
|
||
|
||
def is_date_struct(self) -> bool:
|
||
if not self.children:
|
||
return False
|
||
child_names = {c.name for c in self.children}
|
||
return DATE_FIELD_SET.issubset(child_names)
|
||
|
||
|
||
@dataclass
|
||
class VariableNode:
|
||
name: str
|
||
address: int
|
||
type_str: str
|
||
size: Optional[int]
|
||
members: List[MemberNode] = field(default_factory=list)
|
||
# --- новые, но необязательные ---
|
||
kind: Optional[str] = None # 'array'
|
||
count: Optional[int] = None # size1
|
||
|
||
def base_address_hex(self) -> str:
|
||
return f"0x{self.address:06X}"
|
||
|
||
|
||
# --------------------------- XML Parser ----------------------------
|
||
|
||
class VariablesXML:
|
||
"""
|
||
Читает твой XML и выдаёт плоский список путей:
|
||
- Массивы -> name[i], многоуровневые -> name[i][j]
|
||
- Указатель на структуру -> дети через '->'
|
||
- Обычная структура -> дети через '.'
|
||
"""
|
||
# предположительные размеры примитивов (под STM/MCU: int=2)
|
||
_PRIM_SIZE = {
|
||
'char':1, 'signed char':1, 'unsigned char':1, 'uint8_t':1, 'int8_t':1,
|
||
'short':2, 'short int':2, 'signed short':2, 'unsigned short':2,
|
||
'uint16_t':2, 'int16_t':2,
|
||
'int':2, 'signed int':2, 'unsigned int':2,
|
||
'long':4, 'unsigned long':4, 'int32_t':4, 'uint32_t':4,
|
||
'float':4,
|
||
'long long':8, 'unsigned long long':8, 'int64_t':8, 'uint64_t':8, 'double':8,
|
||
}
|
||
|
||
def __init__(self, path: str):
|
||
self.path = path
|
||
self.timestamp: str = ''
|
||
self.variables: List[VariableNode] = []
|
||
self._parse()
|
||
|
||
# ------------------ low helpers ------------------
|
||
|
||
@staticmethod
|
||
def _parse_int_guess(txt: Optional[str]) -> Optional[int]:
|
||
if not txt:
|
||
return None
|
||
txt = txt.strip()
|
||
if txt.startswith(('0x','0X')):
|
||
return int(txt, 16)
|
||
# если в строке есть буквы A-F → возможно hex
|
||
if any(c in 'abcdefABCDEF' for c in txt):
|
||
try:
|
||
return int(txt, 16)
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
return int(txt, 10)
|
||
except ValueError:
|
||
return None
|
||
|
||
@staticmethod
|
||
def _is_pointer_to_struct(t: str) -> bool:
|
||
if not t:
|
||
return False
|
||
low = t.replace('\t',' ').replace('\n',' ')
|
||
return 'struct ' in low and '*' in low
|
||
|
||
@staticmethod
|
||
def _is_struct_or_union(t: str) -> bool:
|
||
if not t:
|
||
return False
|
||
low = t.strip()
|
||
return low.startswith('struct ') or low.startswith('union ')
|
||
|
||
@staticmethod
|
||
def _strip_array_suffix(t: str) -> str:
|
||
return t[:-2].strip() if t.endswith('[]') else t
|
||
|
||
def _guess_primitive_size(self, type_str: str) -> Optional[int]:
|
||
if not type_str:
|
||
return None
|
||
base = type_str
|
||
for tok in ('volatile','const'):
|
||
base = base.replace(tok, '')
|
||
base = base.replace('*',' ')
|
||
base = base.replace('[',' ').replace(']',' ')
|
||
base = ' '.join(base.split()).strip()
|
||
return self._PRIM_SIZE.get(base)
|
||
|
||
# ------------------ XML read ------------------
|
||
|
||
def _parse(self):
|
||
tree = ET.parse(self.path)
|
||
root = tree.getroot()
|
||
|
||
ts = root.find('timestamp')
|
||
self.timestamp = ts.text.strip() if ts is not None and ts.text else ''
|
||
|
||
def parse_member(elem) -> MemberNode:
|
||
name = elem.get('name','')
|
||
offset = int(elem.get('offset','0'),16) if elem.get('offset') else 0
|
||
t = elem.get('type','') or ''
|
||
size_attr = elem.get('size')
|
||
size = int(size_attr,16) if size_attr else None
|
||
kind = elem.get('kind')
|
||
size1_attr = elem.get('size1')
|
||
count = None
|
||
if size1_attr:
|
||
count = self._parse_int_guess(size1_attr)
|
||
node = MemberNode(name=name, offset=offset, type_str=t, size=size,
|
||
kind=kind, count=count)
|
||
for ch in elem.findall('member'):
|
||
node.children.append(parse_member(ch))
|
||
return node
|
||
|
||
for var in root.findall('variable'):
|
||
addr = int(var.get('address','0'),16)
|
||
name = var.get('name','')
|
||
t = var.get('type','') or ''
|
||
size_attr = var.get('size')
|
||
size = int(size_attr,16) if size_attr else None
|
||
kind = var.get('kind')
|
||
size1_attr = var.get('size1')
|
||
count = None
|
||
if size1_attr:
|
||
count = self._parse_int_guess(size1_attr)
|
||
members = [parse_member(m) for m in var.findall('member')]
|
||
self.variables.append(
|
||
VariableNode(name=name, address=addr, type_str=t, size=size,
|
||
members=members, kind=kind, count=count)
|
||
)
|
||
|
||
# ------------------ flatten (expanded) ------------------
|
||
|
||
def flattened(self,
|
||
max_array_elems: Optional[int] = None
|
||
) -> List[Tuple[str,int,str]]:
|
||
"""
|
||
Вернёт [(path, addr, type_str), ...].
|
||
max_array_elems: ограничить разворачивание больших массивов (None = все).
|
||
"""
|
||
out: List[Tuple[str,int,str]] = []
|
||
|
||
def add(path: str, addr: int, t: str):
|
||
out.append((path, addr, t))
|
||
|
||
def compute_stride(size_bytes: Optional[int],
|
||
count: Optional[int],
|
||
base_type: Optional[str],
|
||
node_children: Optional[List[MemberNode]]) -> int:
|
||
# 1) пробуем size/count
|
||
if size_bytes and count and count > 0:
|
||
stride = size_bytes // count
|
||
if stride * count != size_bytes:
|
||
# округлённо вверх
|
||
stride = (size_bytes + count - 1) // count
|
||
if stride <= 0:
|
||
stride = 1
|
||
return stride
|
||
# 2) размер примитива по типу
|
||
if base_type:
|
||
gs = self._guess_primitive_size(base_type)
|
||
if gs:
|
||
return gs
|
||
# 3) попытка по детям (структура)
|
||
if node_children:
|
||
min_off = min(ch.offset for ch in node_children)
|
||
max_end = min_off
|
||
for ch in node_children:
|
||
sz = ch.size
|
||
if not sz:
|
||
sz = self._guess_primitive_size(ch.type_str) or 1
|
||
end = ch.offset + sz
|
||
if end > max_end:
|
||
max_end = end
|
||
stride = max_end - min_off
|
||
if stride > 0:
|
||
return stride
|
||
return 1
|
||
|
||
def expand_members(prefix_name: str,
|
||
base_addr: int,
|
||
members: List[MemberNode],
|
||
parent_is_ptr_struct: bool):
|
||
"""
|
||
Разворачиваем список members относительно базового адреса.
|
||
parent_is_ptr_struct: если True, то соединение '->' иначе '.'
|
||
"""
|
||
join = '->' if parent_is_ptr_struct else '.'
|
||
for m in members:
|
||
path_m = f"{prefix_name}{join}{m.name}" if prefix_name else m.name
|
||
addr_m = base_addr + m.offset
|
||
add(path_m, addr_m, m.type_str)
|
||
|
||
# массив?
|
||
if (m.kind == 'array') or m.type_str.endswith('[]'):
|
||
count = m.count
|
||
if count is None:
|
||
count = 0 # неизвестно → не разворачиваем
|
||
if count <= 0:
|
||
continue
|
||
base_t = self._strip_array_suffix(m.type_str)
|
||
stride = compute_stride(m.size, count, base_t, m.children if m.children else None)
|
||
limit = count if max_array_elems is None else min(count, max_array_elems)
|
||
for i in range(limit):
|
||
path_i = f"{path_m}[{i}]"
|
||
addr_i = addr_m + i*stride
|
||
add(path_i, addr_i, base_t)
|
||
# элемент массива: если структура / union → раскроем поля
|
||
if m.children and self._is_struct_or_union(base_t):
|
||
expand_members(path_i, addr_i, m.children, parent_is_ptr_struct=False)
|
||
# элемент массива: если указатель на структуру
|
||
elif self._is_pointer_to_struct(base_t):
|
||
# у таких обычно нет children в XML, но если есть — используем
|
||
expand_members(path_i, addr_i, m.children, parent_is_ptr_struct=True)
|
||
continue
|
||
|
||
# не массив
|
||
if m.children:
|
||
is_ptr_struct = self._is_pointer_to_struct(m.type_str)
|
||
expand_members(path_m, addr_m, m.children, parent_is_ptr_struct=is_ptr_struct)
|
||
|
||
# --- top-level ---
|
||
for v in self.variables:
|
||
add(v.name, v.address, v.type_str)
|
||
|
||
# top-level массив?
|
||
if (v.kind == 'array') or v.type_str.endswith('[]'):
|
||
count = v.count
|
||
if count is None:
|
||
count = 0
|
||
if count > 0:
|
||
base_t = self._strip_array_suffix(v.type_str)
|
||
stride = compute_stride(v.size, count, base_t, v.members if v.members else None)
|
||
limit = count if max_array_elems is None else min(count, max_array_elems)
|
||
for i in range(limit):
|
||
p = f"{v.name}[{i}]"
|
||
a = v.address + i*stride
|
||
add(p, a, base_t)
|
||
# массив структур?
|
||
if v.members and self._is_struct_or_union(base_t):
|
||
expand_members(p, a, v.members, parent_is_ptr_struct=False)
|
||
# массив указателей на структуры?
|
||
elif self._is_pointer_to_struct(base_t):
|
||
expand_members(p, a, v.members, parent_is_ptr_struct=True)
|
||
continue # к след. переменной
|
||
|
||
# top-level не массив
|
||
if v.members:
|
||
is_ptr_struct = self._is_pointer_to_struct(v.type_str)
|
||
expand_members(v.name, v.address, v.members, parent_is_ptr_struct=is_ptr_struct)
|
||
|
||
return out
|
||
|
||
# -------------------- date candidates (как было) --------------------
|
||
|
||
def date_struct_candidates(self) -> List[Tuple[str,int]]:
|
||
cands = []
|
||
for v in self.variables:
|
||
# верхний уровень (если есть все поля даты)
|
||
direct_names = {mm.name for mm in v.members}
|
||
if DATE_FIELD_SET.issubset(direct_names):
|
||
cands.append((v.name, v.address))
|
||
# проверка членов первого уровня
|
||
for m in v.members:
|
||
if m.is_date_struct():
|
||
cands.append((f"{v.name}.{m.name}", v.address + m.offset))
|
||
return cands
|
||
|
||
|
||
# ------------------------------------------- Address / validation helpers --
|
||
HEX_ADDR_MASK = QtCore.QRegExp(r"0x[0-9A-Fa-f]{0,6}")
|
||
class HexAddrValidator(QtGui.QRegExpValidator):
|
||
def __init__(self, parent=None):
|
||
super().__init__(HEX_ADDR_MASK, parent)
|
||
|
||
@staticmethod
|
||
def normalize(text: str) -> str:
|
||
if not text:
|
||
return '0x000000'
|
||
try:
|
||
val = int(text,16)
|
||
except ValueError:
|
||
return '0x000000'
|
||
return f"0x{val & 0xFFFFFF:06X}"
|
||
|
||
# --------------------------------------------------------- Main Widget ----
|
||
class LowLevelSelectorWidget(QtWidgets.QWidget):
|
||
variablePrepared = QtCore.Signal(dict)
|
||
xmlLoaded = QtCore.Signal(str)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle('LowLevel Variable Selector')
|
||
self._xml: Optional[VariablesXML] = None
|
||
self._paths = []
|
||
self._path_info = {}
|
||
self._addr_index = {}
|
||
self._hints = PathHints()
|
||
self._build_ui()
|
||
self._connect()
|
||
|
||
|
||
def _build_ui(self):
|
||
lay = QtWidgets.QVBoxLayout(self)
|
||
|
||
# --- File chooser ---
|
||
file_box = QtWidgets.QHBoxLayout()
|
||
self.btn_load = QtWidgets.QPushButton('Load XML...')
|
||
self.lbl_file = QtWidgets.QLabel('<no file>')
|
||
self.lbl_file.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
|
||
file_box.addWidget(self.btn_load)
|
||
file_box.addWidget(self.lbl_file, 1)
|
||
lay.addLayout(file_box)
|
||
|
||
self.lbl_timestamp = QtWidgets.QLabel('Timestamp: -')
|
||
lay.addWidget(self.lbl_timestamp)
|
||
|
||
form = QtWidgets.QFormLayout()
|
||
|
||
# --- Search field for variable ---
|
||
self.edit_var_search = QtWidgets.QLineEdit()
|
||
self.edit_var_search.setPlaceholderText("Введите имя/путь или адрес 0x......")
|
||
form.addRow('Variable:', self.edit_var_search)
|
||
|
||
# Popup list
|
||
self._popup = QtWidgets.QListView()
|
||
self._popup.setWindowFlags(QtCore.Qt.ToolTip)
|
||
self._popup.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
||
self._popup.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||
self._popup.clicked.connect(self._on_popup_clicked)
|
||
self._model_all = QtGui.QStandardItemModel(self)
|
||
self._model_filtered = QtGui.QStandardItemModel(self)
|
||
|
||
# Address
|
||
self.edit_address = QtWidgets.QLineEdit('0x000000')
|
||
self.edit_address.setValidator(HexAddrValidator(self))
|
||
self.edit_address.setMaximumWidth(120)
|
||
form.addRow('Address:', self.edit_address)
|
||
|
||
# Manual date spins
|
||
dt_row = QtWidgets.QHBoxLayout()
|
||
self.spin_year = QtWidgets.QSpinBox(); self.spin_year.setRange(2000, 2100); self.spin_year.setValue(2025)
|
||
self.spin_month = QtWidgets.QSpinBox(); self.spin_month.setRange(1,12)
|
||
self.spin_day = QtWidgets.QSpinBox(); self.spin_day.setRange(1,31)
|
||
self.spin_hour = QtWidgets.QSpinBox(); self.spin_hour.setRange(0,23)
|
||
self.spin_minute = QtWidgets.QSpinBox(); self.spin_minute.setRange(0,59)
|
||
for w,label in [(self.spin_year,'Y'),(self.spin_month,'M'),(self.spin_day,'D'),(self.spin_hour,'h'),(self.spin_minute,'m')]:
|
||
box = QtWidgets.QVBoxLayout()
|
||
box.addWidget(QtWidgets.QLabel(label, alignment=QtCore.Qt.AlignHCenter))
|
||
box.addWidget(w)
|
||
dt_row.addLayout(box)
|
||
form.addRow('Manual Date:', dt_row)
|
||
|
||
# Types
|
||
self.cmb_ptr_type = QtWidgets.QComboBox(); self.cmb_ptr_type.addItems(PT_ENUM_ORDER)
|
||
self.cmb_iq_type = QtWidgets.QComboBox(); self.cmb_iq_type.addItems(IQ_ENUM_ORDER)
|
||
self.cmb_return_type = QtWidgets.QComboBox(); self.cmb_return_type.addItems(IQ_ENUM_ORDER)
|
||
form.addRow('ptr_type:', self.cmb_ptr_type)
|
||
form.addRow('iq_type:', self.cmb_iq_type)
|
||
form.addRow('return_type:', self.cmb_return_type)
|
||
|
||
lay.addLayout(form)
|
||
|
||
self.btn_prepare = QtWidgets.QPushButton('Prepare Variable Dict')
|
||
lay.addWidget(self.btn_prepare)
|
||
lay.addStretch(1)
|
||
|
||
self.txt_info = QtWidgets.QPlainTextEdit()
|
||
self.txt_info.setReadOnly(True)
|
||
self.txt_info.setMaximumHeight(140)
|
||
lay.addWidget(QtWidgets.QLabel('Info:'))
|
||
lay.addWidget(self.txt_info)
|
||
|
||
# Event filter for keyboard on search field
|
||
self.edit_var_search.installEventFilter(self)
|
||
|
||
def _connect(self):
|
||
self.btn_load.clicked.connect(self._on_load_xml)
|
||
self.edit_address.editingFinished.connect(self._normalize_address)
|
||
self.btn_prepare.clicked.connect(self._emit_variable)
|
||
self.edit_var_search.textEdited.connect(self._on_var_search_edited)
|
||
self.edit_var_search.returnPressed.connect(self._activate_current_popup_selection)
|
||
|
||
# ---------------- XML Load ----------------
|
||
def _on_load_xml(self):
|
||
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||
self, 'Select variables XML', '', 'XML Files (*.xml);;All Files (*)')
|
||
if not path:
|
||
return
|
||
try:
|
||
self._xml = VariablesXML(path)
|
||
except Exception as e:
|
||
QtWidgets.QMessageBox.critical(self, 'Parse error', f'Ошибка парсинга:\n{e}')
|
||
return
|
||
self.lbl_file.setText(path)
|
||
self.lbl_timestamp.setText(f'Timestamp: {self._xml.timestamp or "-"}')
|
||
self._populate_variables()
|
||
self._apply_timestamp_to_date()
|
||
self.xmlLoaded.emit(path)
|
||
self._log(f'Loaded {path}, variables={len(self._xml.variables)}')
|
||
|
||
def _apply_timestamp_to_date(self):
|
||
if not self._xml.timestamp:
|
||
return
|
||
import datetime
|
||
try:
|
||
# Пример: "Sat Jul 19 15:27:59 2025"
|
||
dt = datetime.datetime.strptime(self._xml.timestamp, "%a %b %d %H:%M:%S %Y")
|
||
self.spin_year.setValue(dt.year)
|
||
self.spin_month.setValue(dt.month)
|
||
self.spin_day.setValue(dt.day)
|
||
self.spin_hour.setValue(dt.hour)
|
||
self.spin_minute.setValue(dt.minute)
|
||
except Exception as e:
|
||
print(f"Ошибка разбора timestamp '{self._xml.timestamp}': {e}")
|
||
|
||
def _populate_variables(self):
|
||
if not self._xml:
|
||
return
|
||
flat = self._xml.flattened()
|
||
# flat: [(path, addr, type_str), ...]
|
||
|
||
self._paths = []
|
||
self._path_info = {}
|
||
self._addr_index = {}
|
||
self._model_all.clear() # держим «сырой» полный список (можно не показывать)
|
||
self._model_filtered.clear() # текущие подсказки
|
||
|
||
# индексирование
|
||
for path, addr, t in flat:
|
||
self._paths.append(path)
|
||
self._path_info[path] = (addr, t)
|
||
if addr in self._addr_index:
|
||
self._addr_index[addr] = None
|
||
else:
|
||
self._addr_index[addr] = path
|
||
|
||
# наполняем «all» модель (необязательная, но пусть остаётся — не используем напрямую)
|
||
it = QtGui.QStandardItem(f"{path} [{addr:06X}]")
|
||
it.setData(path, QtCore.Qt.UserRole+1)
|
||
it.setData(addr, QtCore.Qt.UserRole+2)
|
||
it.setData(t, QtCore.Qt.UserRole+3)
|
||
self._model_all.appendRow(it)
|
||
|
||
# построить подсказки
|
||
self._hints.set_paths([(p, self._path_info[p][1]) for p in self._paths])
|
||
|
||
# начальное состояние попапа (пустой ввод → top-level)
|
||
self._update_popup_model(self._hints.suggest(''))
|
||
|
||
self._log(f"Variables loaded: {len(flat)}")
|
||
|
||
# --------------- Search mechanics ---------------
|
||
def _update_popup_model(self, paths: List[str]):
|
||
"""Обновляет модель попапа списком путей (full paths)."""
|
||
self._model_filtered.clear()
|
||
limit = 400
|
||
added = 0
|
||
for p in paths:
|
||
info = self._path_info.get(p)
|
||
if not info:
|
||
continue
|
||
addr, t = info
|
||
it = QtGui.QStandardItem(f"{p} [{addr:06X}]")
|
||
it.setData(p, QtCore.Qt.UserRole+1)
|
||
it.setData(addr, QtCore.Qt.UserRole+2)
|
||
it.setData(t, QtCore.Qt.UserRole+3)
|
||
self._model_filtered.appendRow(it)
|
||
added += 1
|
||
if added >= limit:
|
||
break
|
||
if added >= limit:
|
||
extra = QtGui.QStandardItem("... (more results truncated)")
|
||
extra.setEnabled(False)
|
||
self._model_filtered.appendRow(extra)
|
||
|
||
def _show_popup(self):
|
||
if self._model_filtered.rowCount() == 0:
|
||
self._popup.hide()
|
||
return
|
||
self._popup.setModel(self._model_filtered)
|
||
self._popup.setMinimumWidth(self.edit_var_search.width())
|
||
pos = self.edit_var_search.mapToGlobal(QtCore.QPoint(0, self.edit_var_search.height()))
|
||
self._popup.move(pos)
|
||
self._popup.show()
|
||
self._popup.raise_()
|
||
self._popup.setFocus()
|
||
self._popup.setCurrentIndex(self._model_filtered.index(0,0))
|
||
|
||
def _hide_popup(self):
|
||
self._popup.hide()
|
||
|
||
def _on_var_search_edited(self, text: str):
|
||
t = text.strip()
|
||
|
||
# адрес?
|
||
if t.startswith("0x") and len(t) >= 3:
|
||
try:
|
||
addr = int(t, 16)
|
||
path = self._addr_index.get(addr)
|
||
if path:
|
||
self._set_current_variable(path, from_address=True)
|
||
self._hide_popup()
|
||
return
|
||
except ValueError:
|
||
pass
|
||
|
||
# подсказки по имени
|
||
suggestions = self._hints.suggest(t)
|
||
self._update_popup_model(suggestions)
|
||
self._show_popup()
|
||
|
||
def _on_popup_clicked(self, idx: QtCore.QModelIndex):
|
||
if not idx.isValid():
|
||
return
|
||
path = idx.data(QtCore.Qt.UserRole+1)
|
||
if path:
|
||
self._set_current_variable(path)
|
||
self._hide_popup()
|
||
|
||
def _activate_current_popup_selection(self):
|
||
if self._popup.isVisible():
|
||
idx = self._popup.currentIndex()
|
||
if idx.isValid():
|
||
self._on_popup_clicked(idx)
|
||
return
|
||
# Попытка прямого совпадения
|
||
path = self.edit_var_search.text().strip()
|
||
if path in self._path_info:
|
||
self._set_current_variable(path)
|
||
|
||
def eventFilter(self, obj, ev):
|
||
if obj is self.edit_var_search and ev.type() == QtCore.QEvent.KeyPress:
|
||
if ev.key() in (QtCore.Qt.Key_Down, QtCore.Qt.Key_Up):
|
||
if not self._popup.isVisible():
|
||
self._show_popup()
|
||
else:
|
||
step = 1 if ev.key()==QtCore.Qt.Key_Down else -1
|
||
cur = self._popup.currentIndex()
|
||
row = cur.row() + step
|
||
if row < 0: row = 0
|
||
if row >= self._model_filtered.rowCount():
|
||
row = self._model_filtered.rowCount()-1
|
||
self._popup.setCurrentIndex(self._model_filtered.index(row,0))
|
||
return True
|
||
elif ev.key() == QtCore.Qt.Key_Escape:
|
||
self._hide_popup()
|
||
return True
|
||
return super().eventFilter(obj, ev)
|
||
|
||
def _set_current_variable(self, path: str, from_address=False):
|
||
if path not in self._path_info:
|
||
return
|
||
addr, type_str = self._path_info[path]
|
||
self.edit_var_search.setText(path)
|
||
self.edit_address.setText(f"0x{addr:06X}")
|
||
ptr_enum_name = self._map_type_to_ptr_enum(type_str)
|
||
self._select_combo_text(self.cmb_ptr_type, ptr_enum_name)
|
||
source = "ADDR" if from_address else "SEARCH"
|
||
self._log(f"[{source}] Selected {path} @0x{addr:06X} type={type_str} -> ptr={ptr_enum_name}")
|
||
|
||
# --------------- Date struct / address / helpers ---------------
|
||
|
||
def _normalize_address(self):
|
||
self.edit_address.setText(HexAddrValidator.normalize(self.edit_address.text()))
|
||
|
||
def _map_type_to_ptr_enum(self, type_str: str) -> str:
|
||
if not type_str:
|
||
return 'pt_unknown'
|
||
low = type_str.lower()
|
||
token = low.replace('*',' ').replace('[',' ').split()[0]
|
||
return PTR_TYPE_MAP.get(token, 'pt_unknown')
|
||
|
||
def _select_combo_text(self, combo: QtWidgets.QComboBox, text: str):
|
||
ix = combo.findText(text)
|
||
if ix >= 0:
|
||
combo.setCurrentIndex(ix)
|
||
|
||
def _collect_datetime(self) -> Dict[str,int]:
|
||
return {
|
||
'year': self.spin_year.value(),
|
||
'month': self.spin_month.value(),
|
||
'day': self.spin_day.value(),
|
||
'hour': self.spin_hour.value(),
|
||
'minute': self.spin_minute.value(),
|
||
}
|
||
|
||
def _emit_variable(self):
|
||
if not self._path_info:
|
||
QtWidgets.QMessageBox.warning(self, 'No XML', 'Сначала загрузите XML файл.')
|
||
return
|
||
path = self.edit_var_search.text().strip()
|
||
if path not in self._path_info:
|
||
QtWidgets.QMessageBox.warning(self, 'Variable', 'Переменная не выбрана / не найдена.')
|
||
return
|
||
addr, type_str = self._path_info[path]
|
||
ptr_type_name = self.cmb_ptr_type.currentText()
|
||
iq_type_name = self.cmb_iq_type.currentText()
|
||
ret_type_name = self.cmb_return_type.currentText()
|
||
out = {
|
||
'address': addr,
|
||
'address_hex': f"0x{addr:06X}",
|
||
'ptr_type': PT_ENUM_VALUE.get(ptr_type_name, 0),
|
||
'iq_type': IQ_ENUM_VALUE.get(iq_type_name, 0),
|
||
'return_type': IQ_ENUM_VALUE.get(ret_type_name, 0),
|
||
'datetime': self._collect_datetime(),
|
||
'path': path,
|
||
'type_string': type_str,
|
||
'ptr_type_name': ptr_type_name,
|
||
'iq_type_name': iq_type_name,
|
||
'return_type_name': ret_type_name,
|
||
}
|
||
self._log(f"Prepared variable: {out}")
|
||
self.variablePrepared.emit(out)
|
||
|
||
def _log(self, msg: str):
|
||
self.txt_info.appendPlainText(msg)
|
||
|
||
# ----------------------------------------------------------- Demo window --
|
||
class _DemoWindow(QtWidgets.QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle('LowLevel Selector Demo')
|
||
self.selector = LowLevelSelectorWidget(self)
|
||
self.setCentralWidget(self.selector)
|
||
self.selector.variablePrepared.connect(self.on_var)
|
||
|
||
def on_var(self, data: dict):
|
||
print('Variable prepared ->', data)
|
||
|
||
def closeEvent(self, ev):
|
||
self.setCentralWidget(None)
|
||
super().closeEvent(ev)
|
||
|
||
# ----------------------------------------------------------------- main ---
|
||
if __name__ == '__main__':
|
||
app = QtWidgets.QApplication(sys.argv)
|
||
w = _DemoWindow()
|
||
w.resize(640, 520)
|
||
w.show()
|
||
sys.exit(app.exec_())
|