""" LowLevelSelectorWidget (PySide2) -------------------------------- Виджет для: * Выбора XML файла с описанием переменных (как в примере пользователя) * Парсинга всех и их вложенных * Построения плоского списка путей (имя/подпуть) с расчётом абсолютного адреса (base_address + offset) * Определения структур с полями даты (year, month, day, hour, minute) * Выбора переменной и (опционально) переменной даты / ручного ввода даты * Выбора типов: ptr_type , iq_type, return_type * Форматирования адреса в виде 0x000000 (6 HEX) * Генерации словаря/кадра для последующей LowLevel-команды (не отправляет сам) Интеграция: * Подключите сигнал variablePrepared(dict) к функции, формирующей и отправляющей пакет. * Содержимое dict: { 'address': int, 'address_hex': str, # '0x....' 'ptr_type': int, # значение enum * '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 from generate_debug_vars import choose_type_map, type_map # ------------------------------------------------------------ Enumerations -- # Сопоставление строк из XML типу ptr_type (адаптируйте под реальный проект) PTR_TYPE_MAP = type_map PT_ENUM_ORDER = [ 'unknown','int8','int16','int32','int64', 'uint8','uint16','uint32','uint64','float', 'struct','union' ] IQ_ENUM_ORDER = [ 'iq_none','iq','iq1','iq2','iq3','iq4','iq5','iq6', 'iq7','iq8','iq9','iq10','iq11','iq12','iq13','iq14', 'iq15','iq16','iq17','iq18','iq19','iq20','iq21','iq22', 'iq23','iq24','iq25','iq26','iq27','iq28','iq29','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] = [] choose_type_map(0) 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_bytes/count if size_bytes and count and count > 0: if size_bytes % count == 0: stride = size_bytes // count if stride <= 0: stride = 1 return stride else: # size не кратен count → скорее всего size = размер одного элемента return max(size_bytes, 1) # 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._backspace_pressed = False 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('') 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('Compile 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('Type:', self.cmb_ptr_type) form.addRow('IQ Type:', self.cmb_iq_type) form.addRow('Return IQ Type:', self.cmb_return_type) lay.addLayout(form) self.btn_prepare = QtWidgets.QPushButton('Prepare Variable') 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) QtWidgets.QApplication.instance().focusChanged.connect(self._on_focus_changed) def focusOutEvent(self, event): super().focusOutEvent(event) self._hide_popup() def _on_focus_changed(self, old, now): # Если фокус ушёл из нашего виджета — скрываем подсказки if now is None or not self.isAncestorOf(now): self._hide_popup() # ---------------- 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(str(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): # Показываем подсказки при вводе текста, если не было Backspace (чтобы не добавлять разделитель) t = text.strip() if self._backspace_pressed: # При стирании не показываем автодополнение с разделителем self._backspace_pressed = False self._hide_popup() return # адрес? 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 not path: return # Реализуем автодополнение по цепочке, пока подсказка имеет детей current_path = path children = self._hints.get_children(str(current_path)) if children: current_path = self._hints.add_separator(current_path) self._set_current_variable(str(current_path)) # <-- здесь if not children: 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: key = ev.key() if key in (QtCore.Qt.Key_Down, QtCore.Qt.Key_Up): if not self._popup.isVisible(): self._show_popup() else: step = 1 if 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 key == QtCore.Qt.Key_Escape: self._hide_popup() return True elif key == QtCore.Qt.Key_Backspace: # Помечаем, что была нажата Backspace self._backspace_pressed = True return False # дальше обрабатываем обычным образом elif key == QtCore.Qt.Key_Space and (ev.modifiers() & QtCore.Qt.ControlModifier): # Ctrl+Space — показать подсказки text = self.edit_var_search.text() suggestions = self._hints.suggest(text) self._update_popup_model(suggestions) self._show_popup() return True else: # Любая другая клавиша — сбрасываем Backspace-флаг self._backspace_pressed = False return super().eventFilter(obj, ev) def _set_current_variable(self, path: str, from_address=False): self.edit_var_search.setText(path) self._update_popup_model(self._hints.suggest(path)) self._show_popup() if path not in self._path_info: return addr, type_str = self._path_info[path] # Разделитель добавляем только если не стираем (Backspace), и если уже не добавлен # В этой функции не будем добавлять разделитель, добавляем его только при автодополнении выше 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 'unknown' low = type_str.lower() token = low.replace('*',' ').replace('[',' ') return PTR_TYPE_MAP.get(token, 'unknown') def _select_combo_text(self, combo: QtWidgets.QComboBox, text: str): ix = combo.findText(text.replace('pt_', '')) 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_())