Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
288 lines
33 KiB
Python
288 lines
33 KiB
Python
"""Reusable CAN monitor controls for host applications."""
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QTimer, Signal # Таймер обновляет буфер, а общий сигнал сообщает владельцу об изменении любых настроек расшифровки.
|
|
from PySide6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QPushButton,
|
|
QCheckBox, QComboBox, QLabel, QLineEdit, QTableWidget, QTableWidgetItem,
|
|
QMessageBox, QApplication, QAbstractItemView, QFileDialog)
|
|
from ..can_transport import build_frame
|
|
from ..can_monitor import CanSession, PROTOCOLS, UMP_MAPS, parse_filter, parse_gas_filter, matches_filters, format_data, decode_protocol # Общие декодер, карты и независимые фильтры обеспечивают одинаковое поведение всех CAN-мониторов.
|
|
|
|
|
|
class MonitorControls(QWidget):
|
|
decoding_changed = Signal() # Владельцы обновляют онлайн и трассировку при смене формата, карты адресов или номера УМП.
|
|
|
|
def __init__(self, owner, directory, default="auto"):
|
|
super().__init__(owner)
|
|
self.owner = owner
|
|
self.directory = directory
|
|
self.session = CanSession()
|
|
self._dirty = False
|
|
self._pending_frames = {} # Упорядоченные ключи объединяют повторы между тактами таймера и сохраняют порядок первого поступления новых кадров.
|
|
self._history_rows = {} # Ключ уникального кадра указывает на строку и последний показанный счётчик, исключая поиск по всей таблице.
|
|
self._history_total = 0 # Сумма показанных повторов обновляется по разнице счётчиков, без перебора накопленной истории.
|
|
self._history_needs_reset = False # Частичная ошибка записи требует однократной сверки таблицы с фактически сохранённой сессией.
|
|
controls_layout = QVBoxLayout(self) # Отдельная строка параметров УМП сохраняет компактную ширину основной панели монитора.
|
|
controls_layout.setContentsMargins(0, 0, 0, 0) # Панель встраивается в существующую вкладку без дополнительных внешних отступов.
|
|
layout = QHBoxLayout() # Основные кнопки остаются в прежнем горизонтальном порядке над параметрами УМП.
|
|
controls_layout.addLayout(layout) # Общие элементы занимают первую строку вертикального контейнера.
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
self.protocol = QComboBox()
|
|
for key, label in PROTOCOLS:
|
|
self.protocol.addItem(label, key)
|
|
self.protocol.setMinimumContentsLength(28)
|
|
self.protocol.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
|
|
self.protocol.setToolTip("Выберите формат CAN. Для УМП / ProtoCAN GAS карта адресов и устройство выбираются отдельно.") # Подсказка объясняет разделение протокола и прикладного профиля.
|
|
legacy_ump = default in ("ump1_gas167", "ump2_gas167", "ump_gas") # Старые значения исходного профиля поддерживаются без возврата дублирующихся пунктов в список.
|
|
self.protocol.setCurrentIndex(self.protocol.findData("ump_protocan" if legacy_ump else default)) # Прежние профили УМП открывают единый формат с отдельно восстановленными параметрами.
|
|
layout.addWidget(QLabel("Протокол"))
|
|
layout.addWidget(self.protocol)
|
|
self.raw = QCheckBox("Без парса")
|
|
layout.addWidget(self.raw)
|
|
self.reset_button = QPushButton("Сброс")
|
|
self.reset_button.clicked.connect(owner.clear)
|
|
self.reset_button.setToolTip("Очистить таблицы, счётчики и буфер; уже записанный CSV сохраняется")
|
|
layout.addWidget(self.reset_button)
|
|
self.pause = QPushButton("Пауза")
|
|
self.pause.clicked.connect(owner._toggle_pause)
|
|
self.pause.setToolTip("Пауза экрана; буфер сессии и запись продолжаются")
|
|
layout.addWidget(self.pause)
|
|
self.record = QPushButton("● Запись")
|
|
self.record.clicked.connect(self.toggle_record)
|
|
self.record.setToolTip("Записать буфер сессии и последующие кадры в CSV")
|
|
layout.addWidget(self.record)
|
|
self.save_button = QPushButton("Сохранить…")
|
|
self.save_button.setToolTip("Сохранить весь буфер сессии в CSV (данные HEX), включая кадры вне таблицы")
|
|
self.save_button.clicked.connect(self.save_session)
|
|
layout.addWidget(self.save_button)
|
|
self.data_format = QPushButton("Data: HEX")
|
|
self.data_format.setCheckable(True)
|
|
self.data_format.setToolTip("Переключить отображение байтов Data: HEX / DEC")
|
|
self.data_format.toggled.connect(self._change_data_format)
|
|
layout.addWidget(self.data_format)
|
|
self.status = QLabel("Буфер: 0")
|
|
layout.addWidget(self.status)
|
|
layout.addStretch(1)
|
|
self.ump_options = QWidget() # Контейнер дополнительных параметров показывается только для формата УМП / ProtoCAN GAS.
|
|
ump_layout = QHBoxLayout(self.ump_options) # Карта и номер УМП расположены рядом на второй строке панели.
|
|
ump_layout.setContentsMargins(0, 0, 0, 0) # Вложенная строка выравнивается с основными элементами монитора.
|
|
ump_layout.addWidget(QLabel("Карта адресов")) # Подпись отличает нумерацию регистров от формата CAN-кадра.
|
|
self.ump_map = QComboBox() # Выбор карты определяет словарь параметров при неизменном формате ProtoCAN.
|
|
for key, label in UMP_MAPS: # Оба монитора используют единый каталог действующей и планируемой карт.
|
|
self.ump_map.addItem(label, key) # Пользователь видит статус карты, а декодер получает устойчивый ключ профиля.
|
|
self.ump_map.setToolTip("167/v1: слова 0…127 и 0x5000…0x507F. Новые десятичные ID 5000…5127 и 6000…6127 назначены, но ещё не перенесены в прошивку; устройство определяется по ID.") # Подсказка указывает диапазоны обеих карт и состояние планируемой адресации.
|
|
self.ump_map.setCurrentIndex(self.ump_map.findData("assigned" if default == "ump_gas" else "gas167")) # Прежний профиль новых ID восстанавливает сквозную карту, остальные начинают с текущей.
|
|
ump_layout.addWidget(self.ump_map) # Карта выбирается независимо от списка форматов CAN.
|
|
self.ump_node_label = QLabel("Устройство") # Номер УМП относится к устройству, а не к отдельному протоколу.
|
|
ump_layout.addWidget(self.ump_node_label) # Подпись находится перед селектором номера УМП.
|
|
self.ump_node = QComboBox() # Текущая локальная карта требует ручного назначения УМП 1 или УМП 2.
|
|
self.ump_node.addItem("УМП 1", 1) # Число 1 обозначает первый УМП и передаётся декодеру как номер устройства.
|
|
self.ump_node.addItem("УМП 2", 2) # Число 2 обозначает второй УМП; карта регистров остаётся общей для обоих приборов.
|
|
self.ump_node.setCurrentIndex(self.ump_node.findData(2 if default == "ump2_gas167" else 1)) # Старый профиль второго УМП сохраняет выбранный прибор, остальные используют первый.
|
|
self.ump_node.setToolTip("Номер УМП для карты 167/v1 задаётся вручную. В новой карте устройство определяется по GAS ID.") # Подсказка объясняет, почему ручной выбор скрывается для сквозных ID.
|
|
ump_layout.addWidget(self.ump_node) # Устройство занимает собственное поле после карты адресов.
|
|
ump_layout.addStretch(1) # Свободное место справа удерживает дополнительные поля у левого края панели.
|
|
controls_layout.addWidget(self.ump_options) # Параметры УМП добавляются под основной строкой без расширения списка протоколов.
|
|
self._update_ump_options() # Исходный формат определяет видимость дополнительной строки до первого показа вкладки.
|
|
self.timer = QTimer(self)
|
|
self.timer.setInterval(250)
|
|
self.timer.timeout.connect(self.refresh)
|
|
self.timer.start()
|
|
QApplication.instance().aboutToQuit.connect(self.session.close)
|
|
self.destroyed.connect(lambda: self.session.close())
|
|
self.protocol.currentIndexChanged.connect(self._change_protocol) # Смена формата обновляет параметры УМП и все представления принятых кадров.
|
|
self.ump_map.currentIndexChanged.connect(self._change_protocol) # Смена карты перерасшифровывает буфер и обновляет необходимость ручного номера устройства.
|
|
self.ump_node.currentIndexChanged.connect(self._change_protocol) # Смена устройства применяется к уже принятым кадрам текущей локальной карты.
|
|
self.raw.toggled.connect(lambda *_: self.refilter())
|
|
|
|
def _update_ump_options(self): # Видимость дополнительных полей зависит от формата и способности карты определить устройство по адресу.
|
|
self.ump_options.setVisible(self.protocol.currentData() == "ump_protocan") # Остальные форматы обходятся без настроек карты УМП.
|
|
manual_node = self.ump_map.currentData() == "gas167" # Только действующая локальная карта требует вручную выбранного номера УМП.
|
|
self.ump_node_label.setVisible(manual_node) # Подпись устройства скрывается вместе с ненужным ручным селектором новой карты.
|
|
self.ump_node.setVisible(manual_node) # Сквозные ID сами задают УМП, поэтому ручной номер не должен противоречить адресу кадра.
|
|
|
|
def _change_protocol(self, *_args): # Сигналы трёх селекторов используют общий путь обновления, не зависящий от индекса изменённого поля.
|
|
self._update_ump_options() # Сначала интерфейс показывает только применимые к выбранному формату параметры.
|
|
self.refilter() # Таблица уникальных кадров сразу получает расшифровку с новой картой и устройством.
|
|
self.decoding_changed.emit() # Владелец обновляет онлайн, трассировку и сведения о ранее принятых кадрах.
|
|
|
|
def decode(self, frame): # Все таблицы монитора разбирают кадры с одним набором выбранных параметров.
|
|
return decode_protocol(frame, self.protocol.currentData(), ump_map=self.ump_map.currentData(), ump_node=self.ump_node.currentData()) # Формат, карта и номер УМП передаются независимо, исключая расхождение онлайн и буфера.
|
|
|
|
def format_data(self, data):
|
|
return format_data(data, self.data_format.isChecked())
|
|
|
|
def _change_data_format(self, decimal):
|
|
self.data_format.setText("Data: DEC" if decimal else "Data: HEX")
|
|
self.owner._refresh_data_format()
|
|
self.refilter()
|
|
|
|
def filter_panel(self):
|
|
panel = QWidget()
|
|
layout = QVBoxLayout(panel)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
row = QHBoxLayout()
|
|
self.filter_enabled = QCheckBox("Фильтр CAN ID (hex)") # Первый флажок управляет существующим отбором по идентификатору кадра.
|
|
self.filter_enabled.setChecked(True) # Сохраняем прежнее поведение: ввод CAN ID сразу фильтрует нижнюю таблицу.
|
|
row.addWidget(self.filter_enabled) # Флажок занимает место прежней подписи фильтра.
|
|
self.filter = QLineEdit()
|
|
self.filter.setPlaceholderText("701 или 0x701, 201")
|
|
self.filter.setToolTip("CAN ID в HEX. Оба включённых фильтра работают по И; фильтруется только нижняя таблица, сверху остаются все кадры.") # Поясняем совместный отбор и сохраняем независимость основных таблиц.
|
|
self.filter.setClearButtonEnabled(True)
|
|
self.filter.textChanged.connect(self.refilter)
|
|
row.addWidget(self.filter)
|
|
self.gas_filter_enabled = QCheckBox("Фильтр GAS (dec / 0xHEX)") # Второй флажок позволяет включить адреса GAS отдельно или совместно с CAN ID.
|
|
row.addWidget(self.gas_filter_enabled) # Размещаем новый критерий в той же строке рядом с существующим фильтром.
|
|
self.gas_filter = QLineEdit() # Поле содержит список адресов слов GAS, независимо от списка идентификаторов кадров.
|
|
self.gas_filter.setPlaceholderText("5000, 5127 или 0x5000") # Примеры различают десятичные назначенные ID и шестнадцатеричное локальное окно.
|
|
self.gas_filter.setToolTip("GAS ID: DEC либо HEX с 0x, через запятую. Учитываются все слова кадра. При включённых обоих фильтрах должны совпасть и CAN ID, и GAS ID.") # Объясняем формат и поиск адреса внутри многословной посылки.
|
|
self.gas_filter.setClearButtonEnabled(True) # Очистка временно убирает GAS-условие без изменения флажка CAN ID.
|
|
self.gas_filter.setEnabled(False) # Новый фильтр включается пользователем явно и не меняет прежний начальный экран.
|
|
self.gas_filter.textChanged.connect(self.refilter) # Изменение списка сразу пересчитывает строки и счётчики сохранённого буфера.
|
|
self.filter_enabled.toggled.connect(self.filter.setEnabled) # Выключенный CAN-критерий сохраняет введённый текст для повторного включения.
|
|
self.filter_enabled.toggled.connect(self.refilter) # Снятие флажка сразу убирает ограничение CAN ID.
|
|
self.gas_filter_enabled.toggled.connect(self.gas_filter.setEnabled) # Состояние поля показывает, применяется ли GAS-условие.
|
|
self.gas_filter_enabled.toggled.connect(self.refilter) # Каждый флажок независимо пересчитывает пересечение двух критериев.
|
|
row.addWidget(self.gas_filter) # Поле GAS расположено перед общим счётчиком совпавших кадров.
|
|
self.count = QLabel("Кадров: 0")
|
|
row.addWidget(self.count)
|
|
layout.addLayout(row)
|
|
headers = ("Последний приём",) + CanSession.HEADER[1:6] + ("Счётчик", "Данные", "Расшифровка")
|
|
self.history = QTableWidget(0, len(headers))
|
|
self.history.setHorizontalHeaderLabels(headers)
|
|
self.history.setToolTip("Уникальные кадры и число их повторений с момента сброса")
|
|
self.history.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
self.history.horizontalHeader().setStretchLastSection(True)
|
|
self.history.setColumnWidth(0, 190)
|
|
self.history.setColumnWidth(7, 220)
|
|
self.history.setColumnWidth(8, 500)
|
|
self.history.verticalHeader().setVisible(False)
|
|
self.history.setMinimumHeight(180)
|
|
self.history.setVisible(False)
|
|
layout.addWidget(self.history)
|
|
return panel
|
|
|
|
def ingest(self, identifier, data, extended, rtr=False, source="CAN", timestamp=None):
|
|
try:
|
|
key = self.session.append(identifier, data, extended, rtr, source, timestamp) # Сессия возвращает точную идентичность кадра с учётом канала, STD/EXT, RTR и данных.
|
|
self._pending_frames[key] = None # Один ключ объединяет любое число повторов до следующего обновления экрана.
|
|
except OSError as exc:
|
|
self._history_needs_reset = True # Ошибка могла возникнуть после изменения буфера, поэтому следующий такт заново сверит все строки.
|
|
self.status.setText("Ошибка записи: " + str(exc))
|
|
self.session.stop()
|
|
self.record.setText("● Запись")
|
|
self._dirty = True
|
|
|
|
def reset(self):
|
|
self.session.reset()
|
|
self._dirty = True
|
|
self.refilter()
|
|
self.status.setText("Буфер: 0")
|
|
|
|
def save_session(self):
|
|
if not self.session.counts:
|
|
QMessageBox.information(self, "Сохранение CAN", "Буфер сессии пуст.")
|
|
return
|
|
try:
|
|
directory = Path(self.directory())
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
suggested = directory / datetime.now().strftime("session_%Y%m%d_%H%M%S.csv")
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, "Сохранить буфер CAN", str(suggested), "CSV (*.csv)")
|
|
if not path:
|
|
return
|
|
saved = self.session.save(path)
|
|
except (OSError, ValueError) as exc:
|
|
QMessageBox.critical(self, "Сохранение CAN", str(exc))
|
|
return
|
|
self.save_button.setToolTip("Сохранено: " + str(saved))
|
|
QMessageBox.information(self, "Сохранение CAN", "Сохранено: " + str(saved))
|
|
|
|
def toggle_record(self):
|
|
try:
|
|
if self.session.output is None:
|
|
path = self.session.start(self.directory())
|
|
self.record.setText("■ Стоп записи")
|
|
self.record.setToolTip(str(path))
|
|
else:
|
|
self.session.stop()
|
|
self.record.setText("● Запись")
|
|
except OSError as exc:
|
|
QMessageBox.critical(self, "Запись CAN", str(exc))
|
|
|
|
def refresh(self):
|
|
try:
|
|
self.session.flush()
|
|
except OSError as exc:
|
|
self.status.setText("Ошибка записи: " + str(exc))
|
|
return
|
|
if not self.owner._paused and self._dirty:
|
|
self._dirty = False
|
|
self.status.setText("Буфер: %d" % sum(self.session.counts.values()))
|
|
self._update_history(self._history_needs_reset) # Обычный такт обрабатывает лишь новые данные; полная сверка нужна только после ошибки записи.
|
|
|
|
def refilter(self): # Изменение фильтра, протокола или формата требует однократного пересчёта существующих строк.
|
|
self._update_history(True) # Полная перестройка применяется только по явному изменению настроек либо сбросу буфера.
|
|
|
|
def _update_history(self, rebuild): # Таймер и настройки используют один путь отбора, но разный объём входных записей.
|
|
if not hasattr(self, "filter"): # При сборке панели сигналы могут прийти до создания нижней таблицы.
|
|
return # Отложим отображение до завершения построения элементов интерфейса.
|
|
error = None # Ошибка активного фильтра заменяет счётчики понятным сообщением и очищает выборку.
|
|
try: # Разбор коротких условий выполняется одинаково для полного и добавочного обновления.
|
|
ids = parse_filter(self.filter.text()) if self.filter_enabled.isChecked() else None # Текст выключенного CAN-фильтра не влияет на выборку и не вызывает ошибок ввода.
|
|
gas_ids = parse_gas_filter(self.gas_filter.text()) if self.gas_filter_enabled.isChecked() else None # Независимо разбираем только включённое условие адресов GAS.
|
|
except ValueError as exc: # Неверный ввод не должен оставлять на экране строки от прежнего корректного фильтра.
|
|
error = str(exc) # Сохраняем объяснение для итоговой подписи после очистки строк.
|
|
ids, gas_ids = set(), None # Пустое множество CAN ID даёт пустую видимую выборку.
|
|
filtered = ids is not None or gas_ids is not None # Нижняя таблица нужна лишь при наличии хотя бы одного непустого условия.
|
|
self.history.horizontalHeaderItem(7).setText("Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)") # Заголовок соответствует выбранному представлению байтов.
|
|
self.history.setColumnHidden(8, self.raw.isChecked()) # Сырой режим скрывает расшифровку независимо от способа обновления.
|
|
self.history.setVisible(filtered) # Без ограничений сохраняем компактный вид основной таблицы.
|
|
updates = self.history.updatesEnabled() # Запоминаем исходный режим отрисовки, чтобы не включать её вопреки владельцу.
|
|
self.history.setUpdatesEnabled(False) # Пачка изменений не должна вызывать промежуточную перерисовку и перерасчёт геометрии.
|
|
blocked = self.history.blockSignals(True) # Программное заполнение не должно вызывать обработчики отдельных изменённых ячеек.
|
|
try: # Восстановление состояния Qt гарантируется также при ошибке декодирования.
|
|
if rebuild or not filtered or error is not None: # Смена условий и недопустимый ввод отменяют прежнее соответствие ключей строкам.
|
|
self.history.setRowCount(0) # Полная очистка выполняется по настройке, а не каждые 250 мс при приёме.
|
|
self._history_rows.clear() # Индексы строк больше не относятся к новой выборке.
|
|
self._history_total = 0 # Сумма повторов пересчитывается для нового набора подходящих кадров.
|
|
if filtered and error is None: # Скрытая таблица и некорректные условия не требуют фильтрации или декодирования истории.
|
|
keys = self.session.unique_frames if rebuild else self._pending_frames # Только явная перестройка перебирает всю сессию; таймер берёт изменённые ключи.
|
|
for key in keys: # Словари сохраняют порядок первого появления уникальных кадров, даже если между ними пришли повторы.
|
|
values, count = self.session.unique_frames[key] # Получаем последние время и счётчик с учётом всех повторов в текущей пачке.
|
|
previous = self._history_rows.get(key) # Уже показанный кадр прошёл неизменные фильтры и не нуждается в повторной расшифровке.
|
|
if previous is not None: # У повторов меняются только время последнего приёма и число физических кадров.
|
|
row, old_count = previous # Сохраняем прежнюю сумму, чтобы добавить только новые приёмы.
|
|
display = ((0, values[0]), (6, count)) # Колонки 0 и 6 содержат время и счётчик, остальные данные идентичны по ключу.
|
|
else: # Новый ключ необходимо проверить по обоим активным условиям.
|
|
data = bytes.fromhex(values[6]) # Данные нужны для GAS-отбора и однократной расшифровки новой строки.
|
|
if not matches_filters(int(values[3], 16), data, values[2] == "EXT", bool(values[4]), can_ids=ids, gas_ids=gas_ids): # Проверяем пересечение CAN/GAS с учётом формата и RTR.
|
|
continue # Неподходящий кадр остаётся в полной сессии для сохранения и последующей смены фильтров.
|
|
row, old_count = self.history.rowCount(), 0 # Новая уникальная посылка добавляется в конец с её полным счётчиком с начала сессии.
|
|
self.history.insertRow(row) # Существующие строки и выделение не пересоздаются при поступлении новой посылки.
|
|
summary = "" # В режиме без парса не вызываем декодер даже при первом отображении кадра.
|
|
if not self.raw.isChecked(): # Расшифровка вычисляется только для новой строки либо при явной перестройке настроек.
|
|
frame = build_frame(int(values[3], 16), data, ide=values[2] == "EXT", rtr=bool(values[4]), to_can=values[1] == "TX") # Восстанавливаем исходную метаинформацию сохранённой посылки.
|
|
summary = self.decode(frame).summary # Используем выбранные протокол, карту адресов и номер устройства.
|
|
display = enumerate(values[:6] + (count, self.format_data(data), summary)) # Новая строка заполняется целиком в порядке заголовков таблицы.
|
|
for column, value in display: # У повторов этот цикл касается двух ячеек вместо всей накопленной таблицы.
|
|
item = self.history.item(row, column) # Повторно используем существующий объект ячейки для сохранения выделения.
|
|
if item is None: # Ячейки создаются только при добавлении новой уникальной посылки.
|
|
item = QTableWidgetItem() # Пустой объект получает текст общим путём ниже.
|
|
self.history.setItem(row, column, item) # Таблица принимает владение созданной ячейкой.
|
|
if item.text() != str(value): # Не уведомляем модель Qt о фактически неизменившемся значении.
|
|
item.setText(str(value)) # Преобразование сохраняет прежнее отображение чисел и меток времени.
|
|
if column == 8: # Последний столбец содержит длинную расшифровку, которую удобно читать в подсказке.
|
|
item.setToolTip(str(value)) # Подсказка обновляется вместе с новой расшифровкой при смене настроек.
|
|
self._history_rows[key] = (row, count) # Запоминаем актуальные индекс и показанный счётчик для следующей пачки повторов.
|
|
self._history_total += count - old_count # Добавляем только новые физические приёмы без суммирования всей истории.
|
|
self._pending_frames.clear() # Все поступившие ключи учтены либо скрыты неизменными фильтрами.
|
|
self._history_needs_reset = False # Сверка после ошибки успешно завершена и больше не требует полного обхода.
|
|
total = self._history_total if filtered else sum(self.session.counts.values()) # Без фильтра подпись относится ко всей сессии, хотя сама таблица скрыта.
|
|
unique = len(self._history_rows) if filtered else len(self.session.unique_frames) # Число уникальных кадров берём из готового индекса без повторного отбора.
|
|
self.count.setText(error if error is not None else "Кадров: %d; уникальных: %d" % (total, unique)) # При ошибке оставляем причину, иначе показываем согласованные счётчики выборки.
|
|
finally: # Любой выход должен вернуть исходное поведение сигналов и перерисовки Qt.
|
|
self.history.blockSignals(blocked) # Восстанавливаем состояние, установленное владельцем до обновления.
|
|
self.history.setUpdatesEnabled(updates) # Готовая пачка отображается за одно обновление виджета.
|