From 663b44bfad4dd1507b9db23b530c0e7c13e6fa44 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 20:47:20 +0300 Subject: [PATCH 1/2] Add changelog window for displaying version updates --- app.py | 16 +++ modules/main/mvc/main_view.py | 44 +++---- tests/test_app.py | 38 +++++- tests/test_settings_manager.py | 13 ++ tests/test_theme_manager.py | 19 ++- ui/custom_widgets/modal_window.py | 35 +++++- ui/styles/templates/dark.j2 | 104 ++++++++++++++++ ui/styles/templates/light.j2 | 106 +++++++++++++++- ui/styles/themes/dark.qss | 103 +++++++++++++++ ui/styles/themes/light.qss | 104 ++++++++++++++++ ui/ui/main.ui | 2 +- ui/ui_converted/main.py | 2 +- utils/settings_manager.py | 1 + utils/whats_new_modal.py | 201 ++++++++++++++++++++++++++++++ 14 files changed, 758 insertions(+), 30 deletions(-) create mode 100644 tests/test_settings_manager.py create mode 100644 utils/whats_new_modal.py diff --git a/app.py b/app.py index 8f66dc8..f89933d 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,7 @@ from core.updater import UpdateManager from utils.file_utils import load_config from utils.settings_manager import SettingsManager +from utils.whats_new_modal import WhatsNewDialog os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") @@ -205,6 +206,8 @@ def show_main_window(self, mode: str = "auth"): self.auth_window.close() self.auth_window = None + self.maybe_show_whats_new(mode=mode) + except Exception as e: self.logger.critical(f"Error initializing MainWindow: {e}", exc_info=True) if not self.auth_window: @@ -217,6 +220,19 @@ def show_main_window(self, mode: str = "auth"): message=msg ) + def maybe_show_whats_new(self, mode: str) -> None: + """Shows the release notes once per app version for authorized users.""" + if mode != "auth" or not self.settings_manager or not self.main_window: + return + + last_seen_version = self.settings_manager.get_setting("last_seen_whats_new_version", "") + if last_seen_version == APP_VERSION: + return + + dialog = WhatsNewDialog(parent=self.main_window, version=APP_VERSION) + dialog.exec_() + self.settings_manager.set_setting("last_seen_whats_new_version", APP_VERSION) + def on_login_successful(self, mode: str, user_id: int): """ diff --git a/modules/main/mvc/main_view.py b/modules/main/mvc/main_view.py index 10cbec0..cf228a5 100644 --- a/modules/main/mvc/main_view.py +++ b/modules/main/mvc/main_view.py @@ -1,7 +1,7 @@ import json from pathlib import Path -from PyQt5.QtWidgets import QWidget, QLabel, QLineEdit, QFrame, QHBoxLayout, QAction +from PyQt5.QtWidgets import QWidget, QLabel, QLineEdit, QFrame, QHBoxLayout, QAction, QPushButton from PyQt5.QtGui import QIcon from PyQt5.QtCore import QEvent, Qt, QObject, QPoint, QItemSelectionModel @@ -1015,29 +1015,31 @@ def _replace_theme_button(self) -> None: This method removes the placeholder button from the layout and inserts the custom ThemeSwitch widget in its place, preserving the layout index. """ - # Getting the parent container and its layout parent = self.ui.navbar_actions_frame layout = parent.layout() - - # We find the old button and its position + if not layout: + return + old_button = self.ui.theme_pushButton - if old_button: - index = layout.indexOf(old_button) - layout.removeWidget(old_button) - old_button.deleteLater() - - # Creating and inserting a new switcher in the same place - self.ui.theme_pushButton = ThemeSwitch(parent) + index = layout.indexOf(old_button) if old_button else -1 + if index < 0: + index = 1 # Between search block and create block in navbar_actions_frame. - # Copying properties from the old button if needed, or setting defaults - self.ui.theme_pushButton.setMinimumSize(125, 42) - self.ui.theme_pushButton.setObjectName("themeSwitch") - - # Setting the initial state - is_dark = self.theme_manager.current_theme_id != "0" - self.ui.theme_pushButton.setChecked(is_dark) - - layout.insertWidget(index, self.ui.theme_pushButton) + # Remove any leftover placeholder buttons from .ui to avoid "theme" rectangle artifacts. + for placeholder in parent.findChildren(QPushButton, "theme_pushButton"): + layout.removeWidget(placeholder) + placeholder.hide() + placeholder.setParent(None) + placeholder.deleteLater() + + self.ui.theme_pushButton = ThemeSwitch(parent) + self.ui.theme_pushButton.setMinimumSize(125, 42) + self.ui.theme_pushButton.setObjectName("themeSwitch") + + is_dark = self.theme_manager.current_theme_id != "0" + self.ui.theme_pushButton.setChecked(is_dark) + + layout.insertWidget(index, self.ui.theme_pushButton) def _replace_profile_icon_label(self) -> None: @@ -1409,4 +1411,4 @@ def connect_documents_scroll(self, handler) -> None: Args: handler: The callback function. """ - self.documents_list.connect_scroll_changed(handler) \ No newline at end of file + self.documents_list.connect_scroll_changed(handler) diff --git a/tests/test_app.py b/tests/test_app.py index 9f8cc6f..4ae1b10 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -11,6 +11,7 @@ def mock_dependencies(self): patch("app.AuthWindow") as MockAuthWindow, \ patch("app.MainWindow") as MockMainWindow, \ patch("app.AuthModel") as MockAuthModel, \ + patch("app.WhatsNewDialog") as MockWhatsNewDialog, \ patch("app.ThemeManagerInstance"), \ patch("app.NotificationService"), \ patch("app.UpdateManager") as MockUpdateManager, \ @@ -35,7 +36,8 @@ def mock_dependencies(self): "MainWindow": MockMainWindow, "AuthModel": auth_model, "APIWorker": MockAPIWorker, - "UpdateManager": MockUpdateManager + "UpdateManager": MockUpdateManager, + "WhatsNewDialog": MockWhatsNewDialog, } def test_init_no_auto_login(self, mock_dependencies): @@ -138,4 +140,36 @@ def test_logout_requested(self, mock_dependencies): main_window_mock.close.assert_called_once() assert app.main_window is None app.auth_model.logout.assert_called_once() - mock_dependencies["AuthWindow"].return_value.show.assert_called() \ No newline at end of file + mock_dependencies["AuthWindow"].return_value.show.assert_called() + + def test_show_main_window_displays_whats_new_once_for_authorized_user(self, mock_dependencies): + app = Application() + app.settings_manager = Mock() + app.settings_manager.get_setting.return_value = "" + + app.show_main_window(mode="auth") + + mock_dependencies["WhatsNewDialog"].assert_called_once_with( + parent=app.main_window, + version=APP_VERSION + ) + mock_dependencies["WhatsNewDialog"].return_value.exec_.assert_called_once() + app.settings_manager.set_setting.assert_called_once_with("last_seen_whats_new_version", APP_VERSION) + + def test_show_main_window_skips_whats_new_if_already_seen(self, mock_dependencies): + app = Application() + app.settings_manager = Mock() + app.settings_manager.get_setting.return_value = APP_VERSION + + app.show_main_window(mode="auth") + + mock_dependencies["WhatsNewDialog"].assert_not_called() + app.settings_manager.set_setting.assert_not_called() + + def test_show_main_window_skips_whats_new_for_guest(self, mock_dependencies): + app = Application() + app.settings_manager = Mock() + + app.show_main_window(mode="guest") + + mock_dependencies["WhatsNewDialog"].assert_not_called() diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py new file mode 100644 index 0000000..9eeedab --- /dev/null +++ b/tests/test_settings_manager.py @@ -0,0 +1,13 @@ +from unittest.mock import MagicMock, patch + +from utils.settings_manager import SettingsManager + + +class TestSettingsManager: + def test_default_settings_include_whats_new_version(self): + with patch("utils.settings_manager.get_app_data_dir") as mock_app_dir: + mock_app_dir.return_value = MagicMock() + + manager = SettingsManager(user_id=1) + + assert manager.get_default_settings()["last_seen_whats_new_version"] == "" diff --git a/tests/test_theme_manager.py b/tests/test_theme_manager.py index df2e109..824688a 100644 --- a/tests/test_theme_manager.py +++ b/tests/test_theme_manager.py @@ -152,4 +152,21 @@ def test_auto_compile_on_missing_qss(self, mock_configs): light_qss = themes_dir / "light.qss" assert light_qss.exists() content = light_qss.read_text(encoding="utf-8") - assert "background: #FFFFFF;" in content \ No newline at end of file + assert "background: #FFFFFF;" in content + + def test_compile_whats_new_styles(self, mock_configs): + templates_dir = mock_configs / "ui/styles/templates" + templates_dir.mkdir(parents=True) + (templates_dir / "light.j2").write_text("#whatsNewContainer { background: {{ color }}; }", encoding="utf-8") + + themes_dir = mock_configs / "ui/styles/themes" + themes_dir.mkdir(parents=True, exist_ok=True) + + with patch("utils.theme_manager.get_app_root", return_value=mock_configs): + tm = ThemeManager() + success = tm._compile_all_themes() + + assert success is True + light_qss = themes_dir / "light.qss" + assert light_qss.exists() + assert "#whatsNewContainer" in light_qss.read_text(encoding="utf-8") diff --git a/ui/custom_widgets/modal_window.py b/ui/custom_widgets/modal_window.py index 126015a..7d4a1da 100644 --- a/ui/custom_widgets/modal_window.py +++ b/ui/custom_widgets/modal_window.py @@ -1,5 +1,5 @@ from PyQt5.QtWidgets import QFrame, QWidget, QDialog, QApplication -from PyQt5.QtCore import Qt, QTimer, QPoint +from PyQt5.QtCore import Qt, QTimer, QPoint, QEvent from PyQt5.QtGui import QColor, QPainter, QShowEvent, QCloseEvent @@ -17,6 +17,7 @@ class ModalOverlay(QWidget): def __init__(self, parent): super().__init__(parent) self.setAttribute(Qt.WA_TransparentForMouseEvents, False) + self.setAttribute(Qt.WA_AlwaysStackOnTop, True) self.setWindowFlags(Qt.FramelessWindowHint) self.setAttribute(Qt.WA_NoSystemBackground, True) self.setAttribute(Qt.WA_TranslucentBackground, True) @@ -32,6 +33,7 @@ class BaseModalDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.overlay = None + self._overlay_parent = None self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) self.setWindowModality(Qt.ApplicationModal) self.setAttribute(Qt.WA_TranslucentBackground) @@ -44,26 +46,53 @@ def showEvent(self, event: QShowEvent): def closeEvent(self, event: QCloseEvent): if self.overlay: self.overlay.close() + if self._overlay_parent: + self._overlay_parent.removeEventFilter(self) + self._overlay_parent = None super().closeEvent(event) def accept(self): if self.overlay: self.overlay.close() + if self._overlay_parent: + self._overlay_parent.removeEventFilter(self) + self._overlay_parent = None super().accept() def reject(self): if self.overlay: self.overlay.close() + if self._overlay_parent: + self._overlay_parent.removeEventFilter(self) + self._overlay_parent = None super().reject() def create_overlay(self): if self.parent(): parent_window = self.parent().window() + self._overlay_parent = parent_window + parent_window.installEventFilter(self) self.overlay = ModalOverlay(parent_window) - self.overlay.resize(parent_window.size()) + self._sync_overlay_geometry() self.overlay.show() self.overlay.raise_() + def _sync_overlay_geometry(self): + if self.overlay and self._overlay_parent: + self.overlay.setGeometry(self._overlay_parent.rect()) + self.overlay.raise_() + + def eventFilter(self, watched, event): + if watched == self._overlay_parent and event.type() in ( + QEvent.Resize, + QEvent.Move, + QEvent.Show, + QEvent.ZOrderChange, + QEvent.ChildAdded, + ): + self._sync_overlay_geometry() + return super().eventFilter(watched, event) + def center_on_screen(self): self.adjustSize() if self.parent(): @@ -75,4 +104,4 @@ def center_on_screen(self): screen = QApplication.primaryScreen().availableGeometry() x = screen.center().x() - self.width() // 2 y = screen.center().y() - self.height() // 2 - self.move(x, y) \ No newline at end of file + self.move(x, y) diff --git a/ui/styles/templates/dark.j2 b/ui/styles/templates/dark.j2 index 68e44fb..b349a5a 100644 --- a/ui/styles/templates/dark.j2 +++ b/ui/styles/templates/dark.j2 @@ -620,6 +620,110 @@ QFrame#deleteInfoContainer { border: 1px solid {{ neutral.neutral_250 }}; } +/* What's New Window */ +#whatsNewContainer { + background-color: {{ neutral.neutral_100 }}; + border-radius: 12px; + border: 1px solid {{ neutral.neutral_250 }}; +} + +#whatsNewHeader { + background-color: {{ accent.accent_700 }}; + border-radius: 10px; +} + +#whatsNewBadge { + background-color: {{ neutral.neutral_100 }}; + color: {{ accent.accent_100 }}; + border: 1px solid {{ accent.accent_500 }}; + border-radius: 12px; + padding: 4px 10px; + font-size: 10pt; + font-weight: bold; +} + +#whatsNewTitle { + background-color: transparent; + color: {{ accent.accent_100 }}; + font-size: 18pt; + font-weight: bold; +} + +#whatsNewSubtitle { + background-color: transparent; + color: {{ accent.accent_300 }}; + font-size: 11pt; +} + +/* Внешний фрейм — он даёт скругления и клиппинг */ +QFrame#whatsNewContentFrame { + background-color: {{ neutral.neutral_0 }}; + border-radius: 10px; +} + +/* QScrollArea прозрачная, без рамок, без нативного скроллбара */ +QScrollArea#whatsNewContentScroll { + background-color: transparent; + border: none; +} + +QWidget#whatsNewContentViewport { + background-color: {{ neutral.neutral_0 }}; +} + +QWidget#whatsNewContentWidget { + background-color: transparent; +} + +/* Overlay-скроллбар поверх контента */ +QScrollBar#whatsNewScrollBar { + background: transparent; + width: 12px; + border-radius: 6px; +} + +QScrollBar#whatsNewScrollBar::handle { + background-color: {{ accent.accent_500 }}; + min-height: 36px; + border-radius: 3px; +} + +QScrollBar#whatsNewScrollBar::add-line, +QScrollBar#whatsNewScrollBar::sub-line { + border: none; + background: none; + height: 0px; +} + +QScrollBar#whatsNewScrollBar::add-page, +QScrollBar#whatsNewScrollBar::sub-page { + background: transparent; +} + +#whatsNewItem, +#whatsNewItemText, +#whatsNewBullet { + background-color: transparent; +} + +#whatsNewSectionTitle { + background-color: transparent; + color: {{ neutral.neutral_900 }}; + font-size: 13pt; + font-weight: bold; +} + +#whatsNewBullet { + color: {{ accent.accent_400 }}; + font-size: 16pt; + font-weight: bold; +} + +#whatsNewItemText { + color: {{ neutral.neutral_800 }}; + font-size: 11pt; +} + /* MAIN APPLICATION */ diff --git a/ui/styles/templates/light.j2 b/ui/styles/templates/light.j2 index 1cf5c48..fd6bd7a 100644 --- a/ui/styles/templates/light.j2 +++ b/ui/styles/templates/light.j2 @@ -618,6 +618,110 @@ QFrame#deleteInfoContainer { border: 1px solid {{ neutral.neutral_100 }}; } +/* What's New Window */ +#whatsNewContainer { + background-color: {{ neutral.neutral_0 }}; + border-radius: 12px; + border: 1px solid {{ neutral.neutral_100 }}; +} + +#whatsNewHeader { + background-color: {{ accent.accent_100 }}; + border-radius: 10px; +} + +#whatsNewBadge { + background-color: {{ neutral.neutral_0 }}; + color: {{ accent.accent_500 }}; + border: 1px solid {{ accent.accent_300 }}; + border-radius: 12px; + padding: 4px 10px; + font-size: 10pt; + font-weight: bold; +} + +#whatsNewTitle { + background-color: transparent; + color: {{ accent.accent_500 }}; + font-size: 18pt; + font-weight: bold; +} + +#whatsNewSubtitle { + background-color: transparent; + color: {{ neutral.neutral_700 }}; + font-size: 11pt; +} + +/* Внешний фрейм — он даёт скругления и клиппинг */ +QFrame#whatsNewContentFrame { + background-color: {{ neutral.neutral_50 }}; + border-radius: 10px; +} + +/* QScrollArea прозрачная, без рамок, без нативного скроллбара */ +QScrollArea#whatsNewContentScroll { + background-color: transparent; + border: none; +} + +QWidget#whatsNewContentViewport { + background-color: {{ neutral.neutral_50 }}; +} + +QWidget#whatsNewContentWidget { + background-color: transparent; +} + +/* Overlay-скроллбар поверх контента */ +QScrollBar#whatsNewScrollBar { + background: transparent; + width: 12px; + border-radius: 6px; +} + +QScrollBar#whatsNewScrollBar::handle { + background-color: {{ accent.accent_500 }}; + min-height: 36px; + border-radius: 3px; +} + +QScrollBar#whatsNewScrollBar::add-line, +QScrollBar#whatsNewScrollBar::sub-line { + border: none; + background: none; + height: 0px; +} + +QScrollBar#whatsNewScrollBar::add-page, +QScrollBar#whatsNewScrollBar::sub-page { + background: transparent; +} + +#whatsNewItem, +#whatsNewItemText, +#whatsNewBullet { + background-color: transparent; +} + +#whatsNewSectionTitle { + background-color: transparent; + color: {{ neutral.neutral_900 }}; + font-size: 13pt; + font-weight: bold; +} + +#whatsNewBullet { + color: {{ accent.accent_500 }}; + font-size: 16pt; + font-weight: bold; +} + +#whatsNewItemText { + color: {{ neutral.neutral_800 }}; + font-size: 11pt; +} + @@ -909,7 +1013,7 @@ QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { /* Password Hint */ QWidget#passwordHintContainer { - border-radius: 8px; + border-radius: 8px; border: 1px solid {{ neutral.neutral_100 }}; background-color: {{ neutral.neutral_0 }}; } diff --git a/ui/styles/themes/dark.qss b/ui/styles/themes/dark.qss index c3c206b..c611327 100644 --- a/ui/styles/themes/dark.qss +++ b/ui/styles/themes/dark.qss @@ -620,6 +620,109 @@ QFrame#deleteInfoContainer { border: 1px solid #404040; } +/* What's New Window */ +#whatsNewContainer { + background-color: #262626; + border-radius: 12px; + border: 1px solid #404040; +} + +#whatsNewHeader { + background-color: #5D1717; + border-radius: 10px; +} + +#whatsNewBadge { + background-color: #262626; + color: #F3C7C7; + border: 1px solid #C43A3A; + border-radius: 12px; + padding: 4px 10px; + font-size: 10pt; + font-weight: bold; +} + +#whatsNewTitle { + background-color: transparent; + color: #F3C7C7; + font-size: 18pt; + font-weight: bold; +} + +#whatsNewSubtitle { + background-color: transparent; + color: #E38282; + font-size: 11pt; +} + +/* The outer frame provides rounding and clipping */ +QFrame#whatsNewContentFrame { + background-color: #1A1A1A; + border-radius: 10px; +} + +/* QScrollArea is transparent, without borders, and without a native scrollbar */ +QScrollArea#whatsNewContentScroll { + background-color: transparent; + border: none; +} + +QWidget#whatsNewContentViewport { + background-color: #1A1A1A; +} + +QWidget#whatsNewContentWidget { + background-color: transparent; +} + +/* Overlay-scrollbar on top of the content */ +QScrollBar#whatsNewScrollBar { + background: transparent; + width: 12px; + border-radius: 6px; +} + +QScrollBar#whatsNewScrollBar::handle { + background-color: #C43A3A; + min-height: 36px; + border-radius: 3px; +} + +QScrollBar#whatsNewScrollBar::add-line, +QScrollBar#whatsNewScrollBar::sub-line { + border: none; + background: none; + height: 0px; +} + +QScrollBar#whatsNewScrollBar::add-page, +QScrollBar#whatsNewScrollBar::sub-page { + background: transparent; +} + +#whatsNewItem, +#whatsNewItemText, +#whatsNewBullet { + background-color: transparent; +} + +#whatsNewSectionTitle { + background-color: transparent; + color: #E6E6E6; + font-size: 13pt; + font-weight: bold; +} + +#whatsNewBullet { + color: #D65A5A; + font-size: 16pt; + font-weight: bold; +} + +#whatsNewItemText { + color: #CCCCCC; + font-size: 11pt; +} /* MAIN APPLICATION */ diff --git a/ui/styles/themes/light.qss b/ui/styles/themes/light.qss index fa0042d..a40e53b 100644 --- a/ui/styles/themes/light.qss +++ b/ui/styles/themes/light.qss @@ -618,6 +618,110 @@ QFrame#deleteInfoContainer { border: 1px solid #E6E6E6; } +/* What's New Window */ +#whatsNewContainer { + background-color: #FFFFFF; + border-radius: 12px; + border: 1px solid #E6E6E6; +} + +#whatsNewHeader { + background-color: #F5D6D6; + border-radius: 10px; +} + +#whatsNewBadge { + background-color: #FFFFFF; + color: #CC3333; + border: 1px solid #E08585; + border-radius: 12px; + padding: 4px 10px; + font-size: 10pt; + font-weight: bold; +} + +#whatsNewTitle { + background-color: transparent; + color: #CC3333; + font-size: 18pt; + font-weight: bold; +} + +#whatsNewSubtitle { + background-color: transparent; + color: #4D4D4D; + font-size: 11pt; +} + +/* The outer frame provides rounding and clipping */ +QFrame#whatsNewContentFrame { + background-color: #F2F2F2; + border-radius: 10px; +} + +/* QScrollArea is transparent, without borders, and without a native scrollbar */ +QScrollArea#whatsNewContentScroll { + background-color: transparent; + border: none; +} + +QWidget#whatsNewContentViewport { + background-color: #F2F2F2; +} + +QWidget#whatsNewContentWidget { + background-color: transparent; +} + +/* Overlay-scrollbar on top of the content */ +QScrollBar#whatsNewScrollBar { + background: transparent; + width: 12px; + border-radius: 6px; +} + +QScrollBar#whatsNewScrollBar::handle { + background-color: #CC3333; + min-height: 36px; + border-radius: 3px; +} + +QScrollBar#whatsNewScrollBar::add-line, +QScrollBar#whatsNewScrollBar::sub-line { + border: none; + background: none; + height: 0px; +} + +QScrollBar#whatsNewScrollBar::add-page, +QScrollBar#whatsNewScrollBar::sub-page { + background: transparent; +} + +#whatsNewItem, +#whatsNewItemText, +#whatsNewBullet { + background-color: transparent; +} + +#whatsNewSectionTitle { + background-color: transparent; + color: #1A1A1A; + font-size: 13pt; + font-weight: bold; +} + +#whatsNewBullet { + color: #CC3333; + font-size: 16pt; + font-weight: bold; +} + +#whatsNewItemText { + color: #333333; + font-size: 11pt; +} + /* MAIN APPLICATION */ diff --git a/ui/ui/main.ui b/ui/ui/main.ui index 7eb077d..66cf493 100644 --- a/ui/ui/main.ui +++ b/ui/ui/main.ui @@ -493,7 +493,7 @@ - theme + diff --git a/ui/ui_converted/main.py b/ui/ui_converted/main.py index a4d4fe1..abdadfc 100644 --- a/ui/ui_converted/main.py +++ b/ui/ui_converted/main.py @@ -333,7 +333,7 @@ def retranslateUi(self, MainWindow): self.profile_name_label.setText(_translate("MainWindow", "Гость")) self.profile_info_label.setText(_translate("MainWindow", "Войдите в аккаунт")) self.search_lineEdit.setPlaceholderText(_translate("MainWindow", "Поиск...")) - self.theme_pushButton.setText(_translate("MainWindow", "theme")) + self.theme_pushButton.setText(_translate("MainWindow", "")) self.create_pushButton.setText(_translate("MainWindow", "Создать")) self.finded_label.setText(_translate("MainWindow", "Найдено:")) self.tags_label.setText(_translate("MainWindow", "Популярные теги:")) diff --git a/utils/settings_manager.py b/utils/settings_manager.py index 0fd5665..fdb3786 100644 --- a/utils/settings_manager.py +++ b/utils/settings_manager.py @@ -30,6 +30,7 @@ def get_default_settings(self) -> Dict[str, Any]: """ return { "theme": 1, # 0 for light, 1 for dark + "last_seen_whats_new_version": "", "search_filters": { "search_in_pages": True, "search_field": "name", diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py new file mode 100644 index 0000000..f98361f --- /dev/null +++ b/utils/whats_new_modal.py @@ -0,0 +1,201 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor, QPainterPath, QRegion +from PyQt5.QtWidgets import ( + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QScrollArea, + QScrollBar, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ui.custom_widgets import PrimaryButton +from ui.custom_widgets.modal_window import BaseModalDialog, ShadowContainer + + +RELEASE_NOTES = { + "0.2.0": [ + { + "title": "Редактирование профиля", + "items": [ + "Теперь можно редактировать данные профиля пользователя прямо в приложении: имя, фамилию и отдел.", + "Диалог профиля доступен из главного меню приложения.", + ], + }, + { + "title": "Сохранение настроек", + "items": [ + "Приложение теперь запоминает персональные настройки отдельно для каждого пользователя.", + "Выбранная тема интерфейса сохраняется между сессиями.", + "Последние использованные фильтры поиска сохраняются и ускоряют повторную работу.", + ], + }, + { + "title": "Также в этом обновлении", + "items": [ + "Небольшие улучшения интерфейса и общие исправления ошибок для более стабильной работы.", + "Улучшена стабильность после простоя: список документов и результаты поиска больше не исчезают при ошибках повторной загрузки.", + "Стартовая загрузка главного окна вынесена из UI-потока, поэтому приложение меньше подвисает при открытии.", + "Сценарии авторизации теперь корректно завершаются ошибкой, если системное хранилище сессии недоступно.", + "API-клиент теперь корректно обрабатывает пустые успешные ответы, например 204 No Content.", + "При выходе из аккаунта теперь явно отключается auto-login для текущего профиля.", + ], + }, + ] +} + + +class RoundedScrollArea(QFrame): + """QScrollArea с overlay-скроллбаром и скруглёнными углами.""" + + RADIUS = 10 + BAR_WIDTH = 10 + BAR_MARGIN_X = 4 # отступ от правого края + BAR_MARGIN_Y = 8 # отступ сверху/снизу + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("whatsNewContentFrame") + + # Внутренняя QScrollArea без нативного вертикального скроллбара + self._scroll = QScrollArea(self) + self._scroll.setObjectName("whatsNewContentScroll") + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.viewport().setObjectName("whatsNewContentViewport") + + # Overlay-скроллбар поверх контента + self._bar = QScrollBar(Qt.Vertical, self) + self._bar.setObjectName("whatsNewScrollBar") + + native = self._scroll.verticalScrollBar() + native.valueChanged.connect(self._bar.setValue) + native.rangeChanged.connect(self._sync_range) + self._bar.valueChanged.connect(native.setValue) + + self._update_mask() + + def setWidget(self, widget): + self._scroll.setWidget(widget) + + def _sync_range(self, min_val, max_val): + self._bar.setRange(min_val, max_val) + self._bar.setPageStep(self._scroll.verticalScrollBar().pageStep()) + self._bar.setVisible(max_val > min_val) + + def _update_mask(self): + """Пиксельная маска для реального клиппинга дочерних виджетов.""" + path = QPainterPath() + path.addRoundedRect(0.0, 0.0, float(self.width()), float(self.height()), + self.RADIUS, self.RADIUS) + self.setMask(QRegion(path.toFillPolygon().toPolygon())) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._scroll.setGeometry(self.rect()) + self._bar.setGeometry( + self.width() - self.BAR_WIDTH - self.BAR_MARGIN_X, + self.BAR_MARGIN_Y, + self.BAR_WIDTH, + self.height() - self.BAR_MARGIN_Y * 2, + ) + self._update_mask() + + +class WhatsNewDialog(BaseModalDialog): + def __init__(self, parent=None, version: str = "", notes: list[str] | None = None): + super().__init__(parent) + self.notes = notes or RELEASE_NOTES.get(version, []) + + self.container = ShadowContainer(self) + self.container.setObjectName("whatsNewContainer") + self.container.setMinimumWidth(640) + self.container.setMaximumWidth(720) + + container_layout = QVBoxLayout(self.container) + container_layout.setContentsMargins(28, 28, 28, 28) + container_layout.setSpacing(18) + + header_frame = QFrame() + header_frame.setObjectName("whatsNewHeader") + header_layout = QVBoxLayout(header_frame) + header_layout.setContentsMargins(20, 20, 20, 20) + header_layout.setSpacing(8) + + badge_label = QLabel(f"Версия {version}") + badge_label.setObjectName("whatsNewBadge") + badge_label.setAlignment(Qt.AlignCenter) + header_layout.addWidget(badge_label, 0, Qt.AlignLeft) + + title_label = QLabel("Что нового") + title_label.setObjectName("whatsNewTitle") + header_layout.addWidget(title_label) + + container_layout.addWidget(header_frame) + + content_frame = RoundedScrollArea() + + content_widget = QWidget() + content_widget.setObjectName("whatsNewContentWidget") + content_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + content_layout = QVBoxLayout(content_widget) + content_layout.setContentsMargins(20, 20, 28, 20) + content_layout.setSpacing(18) + + for section in self.notes: + section_title = QLabel(section["title"]) + section_title.setObjectName("whatsNewSectionTitle") + content_layout.addWidget(section_title) + + for note in section["items"]: + item_row = QWidget() + item_row.setObjectName("whatsNewItem") + item_layout = QHBoxLayout(item_row) + item_layout.setContentsMargins(0, 0, 0, 0) + item_layout.setSpacing(12) + + bullet = QLabel("•") + bullet.setObjectName("whatsNewBullet") + bullet.setAlignment(Qt.AlignTop) + + note_label = QLabel(note) + note_label.setObjectName("whatsNewItemText") + note_label.setWordWrap(True) + note_label.setTextFormat(Qt.PlainText) + + item_layout.addWidget(bullet, 0, Qt.AlignTop) + item_layout.addWidget(note_label, 1) + content_layout.addWidget(item_row) + + content_frame.setWidget(content_widget) + content_frame.setMinimumHeight(320) + content_frame.setMaximumHeight(420) + container_layout.addWidget(content_frame) + + action_row = QHBoxLayout() + action_row.setContentsMargins(0, 0, 0, 0) + action_row.addStretch(1) + + continue_button = PrimaryButton() + continue_button.setText("Продолжить") + continue_button.setMinimumHeight(42) + continue_button.clicked.connect(self.accept) + action_row.addWidget(continue_button) + + container_layout.addLayout(action_row) + + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 0, 0, int(255 * 0.10))) + shadow.setOffset(0, 5) + self.container.setGraphicsEffect(shadow) + + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.addWidget(self.container) + self.setLayout(main_layout) From d3de673f04900ee7fb4fdfc147c2c8d057483462 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 20:54:33 +0300 Subject: [PATCH 2/2] update changelog notes for what's new window --- README.md | 1 + README_RU.md | 1 + utils/whats_new_modal.py | 1 + 3 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 391f368..eb0988d 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ This update focuses on personalization and user experience. ### ⚡ Also in this update +- Added a dedicated "What's New" changelog window that opens after updates and shows version changes. - Minor UI improvements and bug fixes for a more stable experience. - Improved session stability after idle time: document lists and search results no longer disappear if a reload or search request fails. - Main window startup loading was moved off the UI thread to reduce freezes during the initial data fetch. diff --git a/README_RU.md b/README_RU.md index c2f1f19..cb6edee 100644 --- a/README_RU.md +++ b/README_RU.md @@ -52,6 +52,7 @@ ### ⚡ Также в этом обновлении +- Добавлено отдельное окно "Что нового", которое открывается после обновления и показывает список изменений версии. - Улучшения интерфейса и исправления ошибок для более стабильной работы. - Улучшена стабильность сессии после простоя: список документов и результаты поиска больше не исчезают, если запрос обновления или поиска завершился ошибкой. - Начальная загрузка главного окна перенесена из UI-потока в фоновый, чтобы уменьшить подвисания при первом получении данных. diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index f98361f..b926899 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -36,6 +36,7 @@ { "title": "Также в этом обновлении", "items": [ + "Добавлено отдельное окно «Что нового», которое показывается после обновления и отображает список изменений версии.", "Небольшие улучшения интерфейса и общие исправления ошибок для более стабильной работы.", "Улучшена стабильность после простоя: список документов и результаты поиска больше не исчезают при ошибках повторной загрузки.", "Стартовая загрузка главного окна вынесена из UI-потока, поэтому приложение меньше подвисает при открытии.",