diff --git a/.jules/bolt.md b/.jules/bolt.md
index 19f451e..4922779 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -38,9 +38,13 @@ if is_visible:
**Действие:** В вызывающем коде (в слоте вызова диалога настроек) всегда вызывать методы обновления и синхронизации динамических элементов UI (например, `self.update_skin_menu()`) как в ветке сохранения настроек, так и в ветке закрытия/отклонения диалога (блок `else`), а также принудительно сбрасывать активный скин на валидный, если текущий был удален.
## 2026-07-10 - [Forced Animation State Prevention from Early Idle Reset]
-**Инсайт:** Быстрый сброс в состояние `idle` из фонового таймера-watchdog перебивает анимации мгновенных побочных эффектов, таких как «eating» или «playing», запускаемых вручную через меню трея. Метод `force_state(state, duration)` в `InputManager`, искусственно сдвигающий `last_input_time` вперед во времени, позволяет временно удержать котика в нужном состоянии на заданную длительность без создания разрозненных локальных таймеров.
+**Инсайт:** Быстрый сброс в состояние `idle` из фонового таймера-watchdog перебивает анимации мгновенных побочных эффектов, таких как «eating» или «playing», запускаемых вручную через меню трея. Метод `force_state(state, duration)` in `InputManager`, искусственно сдвигающий `last_input_time` вперед во времени, позволяет временно удержать котика в нужном состоянии на заданную длительность без создания разрозненных локальных таймеров.
**Действие:** Для всех запускаемых вручную коротких состояний использовать `input_manager.force_state(state, duration=3.0)` вместо прямого вызова `play_state` в `AnimationManager`.
## 2026-07-11 - [UTC Timestamps to Local Timezone Conversion in SQLite]
**Инсайт:** Хранение меток времени в SQLite в UTC-формате (через `CURRENT_TIMESTAMP`) требует обязательной явной локализации при отображении на стороне интерфейса. Попытки ручного сдвига эпохи с использованием `time.timezone` и `time.daylight` могут приводить к ошибкам и игнорировать динамический перевод стрелок. Использование методов timezone из модуля datetime позволяет делать это нативно и безбажно.
**Действие:** Конвертировать UTC-строку из БД SQLite обратно в локальное время системы через `datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc).astimezone(None)`.
+
+## 2026-07-12 - [Optimized Real-Time Dialogue Refreshes with Widget Persistence]
+**Инсайт:** Постоянный ребилд и воссоздание сложной иерархии UI виджетов (таких как списки достижений и истории активности) по таймеру с высокой частотой приводит к утечкам памяти, скачкам CPU и прерывистой прокрутке (layout jitter). Сохранение ссылок на динамические виджеты при инициализации с последующим их селективным обновлением «in-place» гарантирует плавность интерфейса. Ограничение отрисовки вкладки истории только при её активной видимости и сравнение списков кэшированных событий экономит до 95% процессорного времени.
+**Действие:** В StatsDialog применить паттерн разделения создания UI от его обновления, сохранять ссылки на экземпляры QLabel/QProgressBar в словаре `self.achievement_widgets` для in-place обновления, а историю перерисовывать лениво и только при несовпадении нового кортежа данных с кэшированным.
diff --git a/src/ui/stats_dialog.py b/src/ui/stats_dialog.py
index d647ec4..9164580 100644
--- a/src/ui/stats_dialog.py
+++ b/src/ui/stats_dialog.py
@@ -1,6 +1,6 @@
import time
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton, QHBoxLayout, QScrollArea, QWidget, QTabWidget
-from PySide6.QtCore import Qt
+from PySide6.QtCore import Qt, QTimer
from src.utils.bonding_utils import get_level_info, ACHIEVEMENTS
from datetime import datetime, timezone
@@ -26,47 +26,54 @@ def __init__(self, db, parent=None):
self.setup_history_tab(history_tab)
self.tabs.addTab(history_tab, "История")
+ # Инициализируем значения
+ self.update_progress_ui()
+ self.update_history_ui()
+
+ # Таймер для обновления UI в реальном времени
+ self.update_timer = QTimer(self)
+ self.update_timer.timeout.connect(self.update_ui)
+ self.update_timer.start(500) # Обновление каждые 500мс
+
# Кнопка закрытия
close_btn = QPushButton("Закрыть")
close_btn.clicked.connect(self.accept)
main_layout.addWidget(close_btn)
+ def update_ui(self):
+ self.update_progress_ui()
+ if self.tabs.currentIndex() == 1:
+ self.update_history_ui()
+
def setup_progress_tab(self, widget):
layout = QVBoxLayout(widget)
- points = self.db.get_affection_points()
- level, title, points_in_level, points_for_next_level = get_level_info(points)
-
# Заголовок
- title_label = QLabel(f"Уровень {level}: {title}")
- title_label.setAlignment(Qt.AlignCenter)
- title_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 5px;")
- layout.addWidget(title_label)
+ self.title_label = QLabel()
+ self.title_label.setAlignment(Qt.AlignCenter)
+ self.title_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 5px;")
+ layout.addWidget(self.title_label)
# Общие очки и KPS в одной строке
stats_row = QHBoxLayout()
- points_label = QLabel(f"Всего: {points} ❤️")
- points_label.setStyleSheet("font-weight: bold; font-size: 12px;")
+ self.points_label = QLabel()
+ self.points_label.setStyleSheet("font-weight: bold; font-size: 12px;")
- max_kps = self.db.get_stat("max_kps")
- kps_label = QLabel(f"Рекорд: {max_kps} кл/сек ⚡")
- kps_label.setStyleSheet("color: #555; font-size: 11px;")
+ self.kps_label = QLabel()
+ self.kps_label.setStyleSheet("color: #555; font-size: 11px;")
- stats_row.addWidget(points_label)
+ stats_row.addWidget(self.points_label)
stats_row.addStretch()
- stats_row.addWidget(kps_label)
+ stats_row.addWidget(self.kps_label)
layout.addLayout(stats_row)
# Прогресс бар
- if points_for_next_level > 0:
- layout.addWidget(QLabel(f"До следующего уровня: {points_for_next_level - points_in_level}"))
- progress = QProgressBar()
- progress.setMaximum(points_for_next_level)
- progress.setValue(points_in_level)
- progress.setFormat("%v / %m")
- layout.addWidget(progress)
- else:
- layout.addWidget(QLabel("Максимальный уровень достигнут! 🎉"))
+ self.next_level_label = QLabel()
+ layout.addWidget(self.next_level_label)
+
+ self.level_progress_bar = QProgressBar()
+ self.level_progress_bar.setFormat("%v / %m")
+ layout.addWidget(self.level_progress_bar)
layout.addSpacing(10)
layout.addWidget(QLabel("Достижения:"))
@@ -78,55 +85,24 @@ def setup_progress_tab(self, widget):
scroll_layout.setSpacing(10)
scroll_layout.setContentsMargins(5, 5, 5, 5)
- unlocked = self.db.get_unlocked_achievements()
+ self.achievement_widgets = {}
for ach_id, ach_info in ACHIEVEMENTS.items():
ach_widget = QWidget()
ach_item_layout = QHBoxLayout(ach_widget)
ach_item_layout.setContentsMargins(0, 0, 0, 0)
- is_unlocked = ach_id in unlocked
- icon = ach_info['icon'] if is_unlocked else "🔒"
-
- label_text = f"{icon}"
- item_label = QLabel(label_text)
- ach_item_layout.addWidget(item_label)
+ icon_label = QLabel()
+ ach_item_layout.addWidget(icon_label)
- # Расчет прогресса
- progress_text = ""
- if not is_unlocked and 'goal' in ach_info and 'stat' in ach_info:
- if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager:
- im = self.parent().input_manager
- if ach_info['stat'] == 'bonding_points':
- current_val = im.last_affection_points + im.pending_points
- elif ach_info['stat'] == 'total_clicks':
- current_val = im.total_clicks_cache + im.pending_stats.get('total_clicks', 0)
- elif ach_info['stat'] == 'max_kps':
- current_val = im.max_kps
- else:
- current_val = self.db.get_stat(ach_info['stat']) + im.pending_stats.get(ach_info['stat'], 0)
- else:
- current_val = self.db.get_stat(ach_info['stat'])
-
- if ach_info['stat'] == 'level':
- current_val, _, _, _ = get_level_info(points)
-
- goal = ach_info['goal']
- if goal > 0:
- progress_text = f" ({current_val}/{goal})"
-
- info_label = QLabel(f"{ach_info['title']}{progress_text}
{ach_info['desc']}")
- if not is_unlocked:
- info_label.setStyleSheet("color: #888;")
-
- # Добавляем прогресс-бар для закрытых достижений
ach_text_layout = QVBoxLayout()
+ info_label = QLabel()
ach_text_layout.addWidget(info_label)
- if not is_unlocked and 'goal' in ach_info and goal > 0:
+ prog_bar = None
+ if 'goal' in ach_info and ach_info['goal'] > 0:
prog_bar = QProgressBar()
- prog_bar.setMaximum(goal)
- prog_bar.setValue(min(current_val, goal))
+ prog_bar.setMaximum(ach_info['goal'])
prog_bar.setFixedHeight(10)
prog_bar.setTextVisible(False)
prog_bar.setStyleSheet("""
@@ -147,20 +123,122 @@ def setup_progress_tab(self, widget):
scroll_layout.addWidget(ach_widget)
+ self.achievement_widgets[ach_id] = {
+ 'icon_label': icon_label,
+ 'info_label': info_label,
+ 'prog_bar': prog_bar,
+ 'widget': ach_widget
+ }
+
scroll.setWidget(scroll_content)
scroll.setFixedHeight(200)
layout.addWidget(scroll)
+ def update_progress_ui(self):
+ # Получаем данные о привязанности и KPS
+ im = None
+ if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager:
+ im = self.parent().input_manager
+
+ if im:
+ points = im.last_affection_points + im.pending_points
+ max_kps = im.max_kps
+ else:
+ points = self.db.get_affection_points()
+ max_kps = self.db.get_stat("max_kps")
+
+ level, title, points_in_level, points_for_next_level = get_level_info(points)
+
+ # Обновляем основные метки
+ self.title_label.setText(f"Уровень {level}: {title}")
+ self.points_label.setText(f"Всего: {points} ❤️")
+ self.kps_label.setText(f"Рекорд: {max_kps} кл/сек ⚡")
+
+ # Обновляем прогресс-бар уровня
+ if points_for_next_level > 0:
+ self.next_level_label.setText(f"До следующего уровня: {points_for_next_level - points_in_level}")
+ self.level_progress_bar.setVisible(True)
+ self.level_progress_bar.setMaximum(points_for_next_level)
+ self.level_progress_bar.setValue(points_in_level)
+ else:
+ self.next_level_label.setText("Максимальный уровень достигнут! 🎉")
+ self.level_progress_bar.setVisible(False)
+
+ # Получаем разблокированные достижения
+ if im:
+ unlocked = im.unlocked_achievements
+ else:
+ unlocked = self.db.get_unlocked_achievements()
+
+ # Обновляем виджеты достижений
+ for ach_id, ach_info in ACHIEVEMENTS.items():
+ ref = self.achievement_widgets[ach_id]
+ is_unlocked = ach_id in unlocked
+
+ # Иконка
+ icon = ach_info['icon'] if is_unlocked else "🔒"
+ ref['icon_label'].setText(f"{icon}")
+
+ # Текущее значение прогресса
+ current_val = 0
+ if 'stat' in ach_info:
+ stat_name = ach_info['stat']
+ if stat_name == 'level':
+ current_val = level
+ elif stat_name == 'bonding_points':
+ current_val = points
+ elif stat_name == 'total_clicks':
+ current_val = im.total_clicks_cache + im.pending_stats.get('total_clicks', 0) if im else self.db.get_stat('total_clicks')
+ elif stat_name == 'max_kps':
+ current_val = max_kps
+ else:
+ current_val = self.db.get_stat(stat_name) + (im.pending_stats.get(stat_name, 0) if im else 0)
+
+ # Текст с прогрессом
+ progress_text = ""
+ if not is_unlocked and 'goal' in ach_info and ach_info['goal'] > 0:
+ progress_text = f" ({current_val}/{ach_info['goal']})"
+
+ info_text = f"{ach_info['title']}{progress_text}
{ach_info['desc']}"
+ ref['info_label'].setText(info_text)
+
+ if is_unlocked:
+ ref['info_label'].setStyleSheet("")
+ if ref['prog_bar']:
+ ref['prog_bar'].setVisible(False)
+ else:
+ ref['info_label'].setStyleSheet("color: #888;")
+ if ref['prog_bar']:
+ ref['prog_bar'].setVisible(True)
+ ref['prog_bar'].setValue(min(current_val, ach_info['goal']))
+
def setup_history_tab(self, widget):
layout = QVBoxLayout(widget)
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- scroll_content = QWidget()
- scroll_layout = QVBoxLayout(scroll_content)
- scroll_layout.setAlignment(Qt.AlignTop)
+ self.history_scroll = QScrollArea()
+ self.history_scroll.setWidgetResizable(True)
+ self.history_content = QWidget()
+ self.history_layout = QVBoxLayout(self.history_content)
+ self.history_layout.setAlignment(Qt.AlignTop)
+ self.history_scroll.setWidget(self.history_content)
+ layout.addWidget(self.history_scroll)
+
+ self.last_events_cache = []
+
+ def update_history_ui(self):
events = self.db.get_recent_activity(30)
+ # Оптимизация: не перерисовываем историю, если события не изменились
+ if events == self.last_events_cache:
+ return
+ self.last_events_cache = events
+
+ # Очищаем старые элементы истории
+ while self.history_layout.count() > 0:
+ item = self.history_layout.takeAt(0)
+ widget = item.widget()
+ if widget:
+ widget.deleteLater()
type_icons = {
"app_start": "🚀",
@@ -171,7 +249,9 @@ def setup_history_tab(self, widget):
}
if not events:
- scroll_layout.addWidget(QLabel("История событий пуста..."))
+ no_events_label = QLabel("История событий пуста...")
+ self.history_layout.addWidget(no_events_label)
+ return
for ev in events:
# ev format: (id, timestamp, event_type, description)
@@ -182,7 +262,7 @@ def setup_history_tab(self, widget):
# Конвертация UTC времени из базы в локальное время системы автоматически
dt_local = dt_utc.astimezone(None)
time_display = dt_local.strftime("%H:%M")
- except:
+ except Exception:
time_display = ts_str
icon = type_icons.get(ev[2], "📝")
@@ -195,13 +275,10 @@ def setup_history_tab(self, widget):
label.setWordWrap(True)
item_layout.addWidget(label)
- scroll_layout.addWidget(item)
+ self.history_layout.addWidget(item)
# Разделитель
line = QWidget()
line.setFixedHeight(1)
line.setStyleSheet("background-color: #eee;")
- scroll_layout.addWidget(line)
-
- scroll.setWidget(scroll_content)
- layout.addWidget(scroll)
+ self.history_layout.addWidget(line)
diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png
index 472c96d..2171ec2 100644
Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ