From 4f40524a84a6c0f4da2dc01435d7f452d69e6768 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 6 Mar 2026 23:24:29 +0300 Subject: [PATCH 01/14] feat(ui): implement personalization for authenticated users --- app.py | 45 +++++++-- modules/auth/mvc/auth_controller.py | 12 +-- modules/auth/mvc/auth_model.py | 22 ++++- modules/auth/mvc/auth_view.py | 2 +- .../mvc/document_editor_view.py | 2 +- modules/main/main_module.py | 10 +- modules/main/mvc/main_controller.py | 19 +++- modules/main/mvc/main_view.py | 13 ++- ui/custom_widgets/buttons.py | 6 +- ui/custom_widgets/checkboxes.py | 4 +- ui/custom_widgets/file_drop.py | 2 +- ui/custom_widgets/files.py | 4 +- ui/custom_widgets/labels.py | 12 +-- ui/custom_widgets/lineedits.py | 4 +- ui/custom_widgets/menu.py | 4 +- ui/custom_widgets/progress_bar.py | 2 +- ui/custom_widgets/switchers.py | 2 +- ui/custom_widgets/treeview.py | 4 +- utils/delete_info_modal.py | 4 +- utils/notifications/notification_service.py | 2 +- utils/notifications/toast_notification.py | 4 +- utils/settings_manager.py | 93 +++++++++++++++++++ utils/theme_manager.py | 15 ++- 23 files changed, 233 insertions(+), 54 deletions(-) create mode 100644 utils/settings_manager.py diff --git a/app.py b/app.py index c65b090..8426788 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,7 @@ from utils.app_paths import get_local_data_dir from core.updater import UpdateManager from utils.file_utils import load_config +from utils.settings_manager import SettingsManager os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") @@ -49,6 +50,8 @@ def __init__(self): self.main_window = None self.auth_window = None + self.settings_manager = None + self.current_user_id = None # Setup logging log_dir = get_local_data_dir() @@ -70,7 +73,7 @@ def __init__(self): self.logger = logging.getLogger("App") # Set theme - self.theme_manager = ThemeManagerInstance() + self.theme_manager = ThemeManagerInstance self.theme_manager.switch_theme(theme=1) # Check for updates @@ -84,6 +87,22 @@ def __init__(self): # NotificationService is initialized in AuthWindow self.show_auth_window() + def init_user_services(self, user_id: int): + """Initializes user-specific services like SettingsManager.""" + if not user_id or user_id == -1: + self.logger.warning("Cannot initialize user services: invalid user_id.") + # For guests or invalid IDs, we proceed without personalization. + self.theme_manager.switch_theme(theme=1) # Default to dark theme + return + + self.current_user_id = user_id + self.settings_manager = SettingsManager(user_id=self.current_user_id) + self.theme_manager.set_settings_manager(self.settings_manager) + + # Apply theme from settings + theme_id = self.settings_manager.get_setting("theme", 1) # Default to dark + self.theme_manager.switch_theme(theme=theme_id) + def check_for_updates(self): """Checks for updates using the GitHub repository specified in config.""" config = load_config() @@ -94,6 +113,12 @@ def check_for_updates(self): def attempt_auto_login(self): """Attempts to automatically log in the user using stored tokens.""" + user_id = self.auth_model.get_last_logged_user_id() + if not user_id: + self.show_auth_window() + return + + self.current_user_id = user_id self.worker = APIWorker(self.auth_model.verify_token) self.worker.finished.connect(self.on_token_verified) self.worker.error.connect(self.on_token_verification_failed) @@ -107,6 +132,8 @@ def on_token_verified(self, data): Args: data (dict): The data returned from the verification API. """ + # Initialize user-specific services (settings, etc.) + self.init_user_services(self.current_user_id) # Token is valid, show the main window self.show_main_window(mode="auth") @@ -167,7 +194,10 @@ def show_main_window(self, mode: str = "auth"): mode (str): The mode to open the window in ('auth' or 'guest'). """ try: - self.main_window = MainWindow(mode=mode) + self.main_window = MainWindow( + mode=mode, + settings_manager=self.settings_manager + ) self.main_window.logout_requested.connect(self.on_logout_requested) self.main_window.showMaximized() @@ -188,15 +218,15 @@ def show_main_window(self, mode: str = "auth"): ) - def on_login_successful(self, mode: str): + def on_login_successful(self, mode: str, user_id: int): """ Handles successful login event from AuthWindow. Args: mode (str): The mode of login ('auth' or 'guest'). + user_id (int): The ID of the logged-in user (-1 for guest). """ - # Guest mode doesn't need verification. - # Auth mode just came from a successful login, so it's already verified. + self.init_user_services(user_id=user_id) self.show_main_window(mode=mode) @@ -205,8 +235,11 @@ def on_logout_requested(self): if self.main_window: self.main_window.close() self.main_window = None - # On logout, clear the stored user data + + # On logout, clear the stored user data and settings self.auth_model.logout() + self.current_user_id = None + self.settings_manager = None self.show_auth_window() diff --git a/modules/auth/mvc/auth_controller.py b/modules/auth/mvc/auth_controller.py index cfab9df..3ae87d0 100644 --- a/modules/auth/mvc/auth_controller.py +++ b/modules/auth/mvc/auth_controller.py @@ -37,7 +37,7 @@ class AuthController(QObject): """ # Successful login signal - login_successful = pyqtSignal(str) + login_successful = pyqtSignal(str, int) def __init__( self, @@ -95,13 +95,13 @@ def login_user(self, data: dict) -> None: """ # Save user data auto_login = self.view.login_page.get_auto_login_state() - self.model.save_user(user_data=data, auto_login=auto_login) + user_id = self.model.save_user(user_data=data, auto_login=auto_login) # Clear lineedits self.view.login_page.clear_lineedits() # Switch to main window - self.login_successful.emit("auth") + self.login_successful.emit("auth", user_id) @@ -119,7 +119,7 @@ def signup_user(self, user_data: dict) -> None: """ # Save user data auto_login = self.view.signup_page.get_auto_login_state() - self.model.save_user(user_data=user_data, auto_login=auto_login) + user_id = self.model.save_user(user_data=user_data, auto_login=auto_login) # Clear lineedits self.view.signup_page.clear_lineedits() @@ -130,7 +130,7 @@ def signup_user(self, user_data: dict) -> None: message="Аккаунт создан. Добро пожаловать!" ) # Switch to main window - self.login_successful.emit("auth") + self.login_successful.emit("auth", user_id) def signup(self, data: dict, email: str, password: str) -> None: @@ -285,7 +285,7 @@ def on_login_page_guest_button_clicked(self) -> None: Emits the `login_successful` signal with 'guest' as the login type, allowing guest access to the application. """ - self.login_successful.emit("guest") + self.login_successful.emit("guest", -1) def on_login_page_create_button_clicked(self) -> None: diff --git a/modules/auth/mvc/auth_model.py b/modules/auth/mvc/auth_model.py index e77453b..5952041 100644 --- a/modules/auth/mvc/auth_model.py +++ b/modules/auth/mvc/auth_model.py @@ -125,7 +125,7 @@ def reset_password(self, password: str) -> dict: """=== User ===""" - def save_user(self, user_data: dict, auto_login: bool) -> None: + def save_user(self, user_data: dict, auto_login: bool) -> int | None: """Saves user data and tokens to local files and keyring. Args: @@ -135,7 +135,7 @@ def save_user(self, user_data: dict, auto_login: bool) -> None: # Get user data user = user_data.get("user", None) if not user: - return + return None user_id = user.get("id", None) @@ -177,6 +177,8 @@ def save_user(self, user_data: dict, auto_login: bool) -> None: with open(self.LOCAL_DIR_LAST_LOGGED, "w", encoding="utf-8") as f: json.dump(data, f, indent=4, ensure_ascii=False) + + return user_id """=== Login ===""" @@ -230,6 +232,22 @@ def get_flag(path: Path) -> bool: return get_flag(path=user_data_file_path) + def get_last_logged_user_id(self) -> int | None: + """ + Retrieves the user ID of the last successfully logged-in user. + + Returns: + The user ID as an integer, or None if not found. + """ + last_logged_data = read_json(self.LOCAL_DIR_LAST_LOGGED) + if not last_logged_data: + return None + + user_id = last_logged_data.get("user_id") + + return user_id if isinstance(user_id, int) else None + + def logout(self) -> None: """Logs out the current user. diff --git a/modules/auth/mvc/auth_view.py b/modules/auth/mvc/auth_view.py index 784052a..8b2c6e3 100644 --- a/modules/auth/mvc/auth_view.py +++ b/modules/auth/mvc/auth_view.py @@ -355,7 +355,7 @@ def __init__(self, ui: AuthWindow_UI): ui (AuthWindow_UI): The UI object generated from Qt Designer. """ self.ui = ui - self.theme_manager = ThemeManagerInstance() + self.theme_manager = ThemeManagerInstance # Setting icon for logo label self.ui.logo_label.set_icon_paths( diff --git a/modules/document_editor/mvc/document_editor_view.py b/modules/document_editor/mvc/document_editor_view.py index 7e2218a..813b8fa 100644 --- a/modules/document_editor/mvc/document_editor_view.py +++ b/modules/document_editor/mvc/document_editor_view.py @@ -473,7 +473,7 @@ def __init__(self, container): self.ui.setupUi(container) container.setObjectName("editorContainer") - self.theme_manager = ThemeManagerInstance() + self.theme_manager = ThemeManagerInstance self.ui_config = self._load_ui_config() icons_config = self.ui_config.get("icons", {}) diff --git a/modules/main/main_module.py b/modules/main/main_module.py index 2d6d366..db01c68 100644 --- a/modules/main/main_module.py +++ b/modules/main/main_module.py @@ -7,22 +7,25 @@ from PyQt5.QtWidgets import QMainWindow +from utils.settings_manager import SettingsManager + class MainWindow(QMainWindow): logout_requested = pyqtSignal() - def __init__(self, mode: str = "guest") -> None: + def __init__(self, mode: str = "guest", settings_manager: SettingsManager | None = None) -> None: super().__init__() self.setAttribute(Qt.WA_DeleteOnClose) # Application work mode ('guest' - Guest mode, 'auth' - Authorized mode) self.mode = mode + self.settings_manager = settings_manager # UI Initialization self.ui = MainWindow_UI() self.ui.setupUi(self) # Set theme - self.theme_manager = ThemeManagerInstance() + self.theme_manager = ThemeManagerInstance # MVC Initialization self.model = MainModel(mode=self.mode) @@ -31,7 +34,8 @@ def __init__(self, mode: str = "guest") -> None: model=self.model, view=self.view, window=self, - mode=self.mode + mode=self.mode, + settings_manager=self.settings_manager ) # Logout signal self.controller.logout_requested.connect(self.logout_requested.emit) \ No newline at end of file diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 2b9ee9f..836dcc7 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -24,6 +24,8 @@ +from utils.settings_manager import SettingsManager + logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -42,7 +44,8 @@ def __init__( model: MainModel, view: MainView, window: QMainWindow, - mode: str = "guest" + mode: str = "guest", + settings_manager: SettingsManager | None = None ) -> None: """Initializes the MainController. @@ -51,12 +54,14 @@ def __init__( view: The main UI view. window: The main QMainWindow instance. mode: The application mode ('guest' or 'auth'). + settings_manager: The manager for user-specific settings. """ super().__init__() self.model = model self.view = view self.window = window self.mode = mode + self.settings_manager = settings_manager self.editor_window = None self.current_documents = [] self.current_search_worker = None @@ -111,7 +116,11 @@ def _on_search_lineedit_text_changed(self) -> None: def _on_search_filters_changed(self, checked: bool) -> None: - """Handles search filter changes.""" + """Handles search filter changes and saves them.""" + filters = self.view.get_search_filters() + if self.settings_manager: + self.settings_manager.set_setting("search_filters", filters) + if self.view.get_search_text(): self._on_search_lineedit_text_changed() @@ -744,6 +753,12 @@ def _init_ui(self) -> None: self._load_sidebar_data() self.view.set_profile_mode(self.mode) + # Load and apply settings + if self.settings_manager: + search_filters = self.settings_manager.get_setting("search_filters") + if search_filters: + self.view.set_search_filters(search_filters) + # Set user data if self.mode == "auth": user_data = self.model.get_user_data() diff --git a/modules/main/mvc/main_view.py b/modules/main/mvc/main_view.py index 13eedff..34b6713 100644 --- a/modules/main/mvc/main_view.py +++ b/modules/main/mvc/main_view.py @@ -349,6 +349,13 @@ def get_search_filters(self) -> dict: "exact_match": self.action_exact_match.isChecked() } + def set_search_filters(self, filters: dict) -> None: + """Sets the state of search filters from a dictionary.""" + self.action_search_pages.setChecked(filters.get("include_pages", True)) + self.action_search_name.setChecked(filters.get("search_by_name", True)) + self.action_search_code.setChecked(filters.get("search_by_code", True)) + self.action_exact_match.setChecked(filters.get("exact_match", False)) + def _setup_filter_menu(self) -> None: """Configures the search filter menu.""" @@ -806,7 +813,7 @@ def __init__(self, ui: MainWindow_UI) -> None: """ super().__init__() self.ui = ui - self.theme_manager = ThemeManagerInstance() + self.theme_manager = ThemeManagerInstance self.ui_config = self._load_ui_config() self.current_mode = "guest" @@ -1083,6 +1090,10 @@ def get_search_filters(self) -> dict: """Returns the current search filters.""" return self.navbar.get_search_filters() + def set_search_filters(self, filters: dict) -> None: + """Sets the search filters in the navbar.""" + self.navbar.set_search_filters(filters) + def get_search_text(self) -> str: """Returns the search line edit text.""" diff --git a/ui/custom_widgets/buttons.py b/ui/custom_widgets/buttons.py index 854f036..fb81c94 100644 --- a/ui/custom_widgets/buttons.py +++ b/ui/custom_widgets/buttons.py @@ -22,7 +22,7 @@ def __init__(self, parent: QWidget | None = None) -> None: """Initializes the button with icon support.""" super().__init__(parent=parent) self.setAttribute(Qt.WA_StyledBackground, True) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self.icons = { "light": {"default": None, "hover": None, @@ -77,7 +77,7 @@ def set_icon_paths(self, def _update_icon(self) -> None: """Updates the icon based on the current state and theme.""" - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" state = "default" @@ -157,7 +157,7 @@ def paintEvent(self, event: QPaintEvent) -> None: # Determine mode/state for icon mode = QIcon.Normal if not self.isEnabled(): - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" disabled_icon = self.icons[theme].get("disabled") diff --git a/ui/custom_widgets/checkboxes.py b/ui/custom_widgets/checkboxes.py index b512640..acb6bc6 100644 --- a/ui/custom_widgets/checkboxes.py +++ b/ui/custom_widgets/checkboxes.py @@ -22,7 +22,7 @@ def __init__(self, parent: QWidget | None = None) -> None: "QCheckBox::indicator { width: 0px; height: 0px; border: none; background: transparent; }" ) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self.stateChanged.connect(self._on_state_changed) # Structure: theme -> status (checked/unchecked) -> mode (default/hover/pressed) @@ -111,7 +111,7 @@ def set_icon_paths( def _update_icon(self) -> None: """Updates the icon based on the current state and theme.""" - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" state = "checked" if self.isChecked() else "unchecked" diff --git a/ui/custom_widgets/file_drop.py b/ui/custom_widgets/file_drop.py index c317a5b..f6ac9b5 100644 --- a/ui/custom_widgets/file_drop.py +++ b/ui/custom_widgets/file_drop.py @@ -19,7 +19,7 @@ def __init__(self, parent=None): self.setAttribute(Qt.WA_StyledBackground, True) self.setAttribute(Qt.WA_Hover, True) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) # Layout layout = QVBoxLayout(self) diff --git a/ui/custom_widgets/files.py b/ui/custom_widgets/files.py index 324e58b..fff52e4 100644 --- a/ui/custom_widgets/files.py +++ b/ui/custom_widgets/files.py @@ -190,7 +190,7 @@ def __init__(self, file_data: object, width: int = 300, parent=None): actions_layout.addWidget(self.open_btn) actions_layout.addWidget(self.download_btn) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self._update_icon() def _setup_menu(self): @@ -307,7 +307,7 @@ def _get_icon_name(self) -> str: return "attachment_gray" def _update_icon(self): - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" icon_name = self._get_icon_name() diff --git a/ui/custom_widgets/labels.py b/ui/custom_widgets/labels.py index e89bf9c..ce24ba6 100644 --- a/ui/custom_widgets/labels.py +++ b/ui/custom_widgets/labels.py @@ -21,7 +21,7 @@ class LogoLabel(QLabel): def __init__(self, parent: QWidget | None = None) -> None: """Initializes the logo label.""" super().__init__(parent=parent) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self.icons = {"light": None, "dark": None} @@ -42,7 +42,7 @@ def set_icon_paths(self, light: str | None = None, dark: str | None = None) -> N def paintEvent(self, event: QPaintEvent) -> None: """Paints the logo icon.""" super().paintEvent(event) - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" icon = self.icons.get(theme) @@ -187,7 +187,7 @@ class ProfileIconLabel(IconLabel): def __init__(self, parent: QWidget | None = None) -> None: """Initializes the profile icon label.""" super().__init__(parent) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self.icons = { "guest": {"light": None, "dark": None}, @@ -223,7 +223,7 @@ def set_custom_avatar(self, icon: QIcon | None) -> None: self._update_icon() def _update_icon(self) -> None: - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" if self._mode == "auth" and self._custom_avatar and not self._custom_avatar.isNull(): @@ -243,7 +243,7 @@ def __init__(self, parent: QWidget | None = None) -> None: """Initializes the slide label.""" super().__init__(parent=parent) self._renderer = QSvgRenderer() - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self.svg_paths = {"light": None, "dark": None} def set_svg_paths(self, light: str | None = None, dark: str | None = None) -> None: @@ -253,7 +253,7 @@ def set_svg_paths(self, light: str | None = None, dark: str | None = None) -> No self._update_image() def _update_image(self) -> None: - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" path = self.svg_paths.get(theme) diff --git a/ui/custom_widgets/lineedits.py b/ui/custom_widgets/lineedits.py index 502fc2b..b6300f5 100644 --- a/ui/custom_widgets/lineedits.py +++ b/ui/custom_widgets/lineedits.py @@ -20,7 +20,7 @@ def __init__(self, parent: QWidget | None = None) -> None: """Initializes the icon line edit.""" super().__init__(parent) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) # icon settings self.icon_size = 20 @@ -146,7 +146,7 @@ def setDisabled(self, disabled: bool) -> None: # State priority + theme-based icon selection def _update_icon(self) -> None: """Updates the icon based on the current state and theme.""" - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" if self._disabled: diff --git a/ui/custom_widgets/menu.py b/ui/custom_widgets/menu.py index a5dd6ae..c978966 100644 --- a/ui/custom_widgets/menu.py +++ b/ui/custom_widgets/menu.py @@ -25,7 +25,7 @@ def __init__( parent: QWidget | None = None ) -> None: super().__init__(parent) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self.action = action self.checkable = checkable self.setMouseTracking(True) @@ -102,7 +102,7 @@ def _update_icon(self) -> None: if self.checkable: return - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" state = "default" diff --git a/ui/custom_widgets/progress_bar.py b/ui/custom_widgets/progress_bar.py index d524d38..e74c3a3 100644 --- a/ui/custom_widgets/progress_bar.py +++ b/ui/custom_widgets/progress_bar.py @@ -12,7 +12,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.setTextVisible(False) self.setFixedHeight(12) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) def setProgressValue(self, value: int) -> None: """ diff --git a/ui/custom_widgets/switchers.py b/ui/custom_widgets/switchers.py index 5ac53e6..be69db2 100644 --- a/ui/custom_widgets/switchers.py +++ b/ui/custom_widgets/switchers.py @@ -19,7 +19,7 @@ class ThemeSwitch(QWidget): def __init__(self, parent=None): super().__init__(parent) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) self.setCursor(Qt.PointingHandCursor) self.setObjectName("themeSwitch") diff --git a/ui/custom_widgets/treeview.py b/ui/custom_widgets/treeview.py index 7bd1a37..07a5bb9 100644 --- a/ui/custom_widgets/treeview.py +++ b/ui/custom_widgets/treeview.py @@ -357,7 +357,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._edit_pressed_idx = QPersistentModelIndex() self.clicked.connect(self._on_clicked) - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) # Initialization of private attributes for properties self._badge_background_color = QColor() self._badge_text_color = QColor() @@ -538,7 +538,7 @@ def set_edit_icon_paths( def get_edit_icon(self, index: QModelIndex) -> QIcon: """Returns the edit icon for the given index based on state.""" - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id theme = "light" if theme_id == "0" else "dark" state = "default" diff --git a/utils/delete_info_modal.py b/utils/delete_info_modal.py index e8911d0..142a721 100644 --- a/utils/delete_info_modal.py +++ b/utils/delete_info_modal.py @@ -77,10 +77,10 @@ def __init__(self, parent=None, info_type: str = None): # === Icon setup === self._update_icon() - ThemeManagerInstance().themeChanged.connect(self._on_theme_changed) + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) def _update_icon(self): - theme_id = ThemeManagerInstance().current_theme_id + theme_id = ThemeManagerInstance.current_theme_id if theme_id == "0": icon_path = ":/icons/light/light/notification_warning_red.svg" else: diff --git a/utils/notifications/notification_service.py b/utils/notifications/notification_service.py index ff6257d..7c1a66b 100644 --- a/utils/notifications/notification_service.py +++ b/utils/notifications/notification_service.py @@ -42,7 +42,7 @@ def __init__(self): Loads configuration from the ThemeManager and prepares the service. """ - config = ThemeManagerInstance().notification_config + config = ThemeManagerInstance.notification_config self.SPACING = config.get("spacing", 10) self.TOAST_DURATION = config.get("toast_duration", 5000) diff --git a/utils/notifications/toast_notification.py b/utils/notifications/toast_notification.py index 43cee5a..ccdea3b 100644 --- a/utils/notifications/toast_notification.py +++ b/utils/notifications/toast_notification.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, QRect, Qt, QSize, QEvent from ui.ui_converted.toast_notification import Ui_ToastNotification -from utils.theme_manager import theme_manager_singleton +from utils.theme_manager import ThemeManagerInstance class ToastNotification(QFrame, Ui_ToastNotification): @@ -49,7 +49,7 @@ def __init__( self.description.setText(message) # Programmatically set the icon to avoid SVG scaling issues with stylesheets - theme_name = theme_manager_singleton.themes.get(theme_manager_singleton.current_theme_id, 'light') + theme_name = ThemeManagerInstance.themes.get(ThemeManagerInstance.current_theme_id, 'light') icon_path = f":/icons/{theme_name}/{theme_name}/notification_{notification_type}.svg" icon = QIcon(icon_path) diff --git a/utils/settings_manager.py b/utils/settings_manager.py new file mode 100644 index 0000000..0fd5665 --- /dev/null +++ b/utils/settings_manager.py @@ -0,0 +1,93 @@ +import json +import logging +from pathlib import Path +from typing import Any, Dict + +from utils.app_paths import get_app_data_dir + +class SettingsManager: + """ + Manages user-specific application settings. + + This class handles loading, saving, and managing settings for a specific user. + Settings are stored in a JSON file in the user's profile directory. + """ + def __init__(self, user_id: int): + if not isinstance(user_id, int): + raise TypeError("user_id must be an integer.") + + self.user_id = user_id + self.settings_dir = get_app_data_dir() / "Profiles" + self.settings_dir.mkdir(parents=True, exist_ok=True) + self.settings_file = self.settings_dir / f"user_settings_{self.user_id}.json" + + self.settings = self.load_settings() + self.logger = logging.getLogger(f"SettingsManager(user_{self.user_id})") + + def get_default_settings(self) -> Dict[str, Any]: + """ + Returns the default settings for a user. + """ + return { + "theme": 1, # 0 for light, 1 for dark + "search_filters": { + "search_in_pages": True, + "search_field": "name", + "match_mode": "contains" + } + } + + def load_settings(self) -> Dict[str, Any]: + """ + Loads settings from the user's settings file. + + If the file doesn't exist or is invalid, it returns the default settings. + """ + default_settings = self.get_default_settings() + if not self.settings_file.exists(): + return default_settings + + try: + with open(self.settings_file, "r", encoding="utf-8") as f: + loaded_settings = json.load(f) + + # Merge loaded settings with defaults to ensure all keys are present + settings = default_settings.copy() + settings.update(loaded_settings) + return settings + + except (json.JSONDecodeError, IOError) as e: + logging.error(f"Failed to load settings file {self.settings_file}: {e}. Using default settings.") + return default_settings + + def save_settings(self) -> None: + """Saves the current settings to the user's settings file.""" + try: + with open(self.settings_file, "w", encoding="utf-8") as f: + json.dump(self.settings, f, indent=4, ensure_ascii=False) + except IOError as e: + self.logger.error(f"Failed to save settings to {self.settings_file}: {e}") + + def get_setting(self, key: str, default: Any = None) -> Any: + """ + Retrieves a specific setting by key. + + Args: + key: The key of the setting to retrieve. + default: The value to return if the key is not found. + + Returns: + The value of the setting. + """ + return self.settings.get(key, default) + + def set_setting(self, key: str, value: Any) -> None: + """ + Sets a specific setting and saves it to the file. + + Args: + key: The key of the setting to set. + value: The new value for the setting. + """ + self.settings[key] = value + self.save_settings() diff --git a/utils/theme_manager.py b/utils/theme_manager.py index 190a462..7ef135f 100644 --- a/utils/theme_manager.py +++ b/utils/theme_manager.py @@ -37,6 +37,7 @@ def __init__(self) -> None: """ super().__init__() + self.settings_manager = None self.ui_config = self._load_ui_config() # If the config is empty, we don't do anything. @@ -53,6 +54,10 @@ def __init__(self) -> None: self.current_theme_id = "0" # The topic ID as a string + def set_settings_manager(self, manager) -> None: + """Sets the settings manager instance for saving theme settings.""" + self.settings_manager = manager + @property def notification_config(self) -> dict: """Returns the notification-specific configuration.""" @@ -84,6 +89,10 @@ def switch_theme(self, theme: int | str | None = None) -> None: return self._apply_theme() + + # Save the theme setting if a settings manager is available + if self.settings_manager: + self.settings_manager.set_setting("theme", int(self.current_theme_id)) self.themeChanged.emit(self.current_theme_id) @@ -260,8 +269,4 @@ def _compile_all_themes(self) -> bool: return False -theme_manager_singleton = ThemeManager() - - -def ThemeManagerInstance(): - return theme_manager_singleton \ No newline at end of file +ThemeManagerInstance = ThemeManager() \ No newline at end of file From e9e925120729ec1aed2daee6e3091ac121790f24 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 6 Mar 2026 23:32:43 +0300 Subject: [PATCH 02/14] fix(test): resolve errors in API and UI test suites --- tests/test_app.py | 5 ++++- tests/test_auth.py | 3 ++- tests/test_notifications.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index de43c3f..9f8cc6f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -75,7 +75,10 @@ def test_token_verified(self, mock_dependencies): app.on_token_verified({}) # Should create and show main window - mock_dependencies["MainWindow"].assert_called_with(mode="auth") + mock_dependencies["MainWindow"].assert_called_with( + mode="auth", + settings_manager=app.settings_manager + ) mock_dependencies["MainWindow"].return_value.showMaximized.assert_called_once() # Should close auth window auth_window_mock.close.assert_called_once() diff --git a/tests/test_auth.py b/tests/test_auth.py index 8cacb54..d2d3a94 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -133,6 +133,7 @@ def test_login_success_callback(self, controller): """Test login success callback saves user and emits signal.""" data = {"user": {"id": 1}} controller.view.login_page.get_auto_login_state.return_value = True + controller.model.save_user.return_value = 1 # Mock signal mock_signal = Mock() @@ -142,7 +143,7 @@ def test_login_success_callback(self, controller): controller.model.save_user.assert_called_once_with(user_data=data, auto_login=True) controller.view.login_page.clear_lineedits.assert_called_once() - mock_signal.assert_called_once_with("auth") + mock_signal.assert_called_once_with("auth", 1) def test_signup_flow(self, controller): diff --git a/tests/test_notifications.py b/tests/test_notifications.py index c8808ee..2128bf3 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -15,7 +15,7 @@ def reset_singleton(self): def test_initialization(self, mock_tm): """Test service initialization and config loading.""" # Setup config mock - mock_tm.return_value.notification_config = {"spacing": 15, "toast_duration": 3000} + mock_tm.notification_config = {"spacing": 15, "toast_duration": 3000} service = NotificationService() From cca0692d3789a8f847e1a377aff133b334245576 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 13 Mar 2026 13:37:10 +0300 Subject: [PATCH 03/14] feat(ui): add profile window with user information editing capabilities --- api/api_client.py | 23 +- modules/main/mvc/main_controller.py | 55 ++++ modules/main/mvc/main_model.py | 20 ++ modules/main/mvc/main_view.py | 14 +- modules/profile/profile_dialog.py | 142 ++++++++ ui/custom_widgets/__init__.py | 3 +- ui/custom_widgets/combobox.py | 495 ++++++++++++++++++++++++++++ ui/styles/templates/dark.j2 | 76 ++++- ui/styles/templates/light.j2 | 76 ++++- ui/styles/themes/dark.qss | 76 ++++- ui/styles/themes/light.qss | 76 ++++- ui/ui/profile_dialog.ui | 269 +++++++++++++++ ui/ui_converted/profile_dialog.py | 121 +++++++ 13 files changed, 1437 insertions(+), 9 deletions(-) create mode 100644 modules/profile/profile_dialog.py create mode 100644 ui/custom_widgets/combobox.py create mode 100644 ui/ui/profile_dialog.ui create mode 100644 ui/ui_converted/profile_dialog.py diff --git a/api/api_client.py b/api/api_client.py index 7872ad0..1329ec7 100644 --- a/api/api_client.py +++ b/api/api_client.py @@ -28,10 +28,11 @@ def verify(self, token: str) -> dict: dict: The JSON response from the API containing user data. """ headers = {"Authorization": f"Bearer {token}"} - return self._request("GET", + res = self._request("GET", url=self.base_url + "/auth/user", headers=headers ) + return res def get_user_data(self, token: str) -> dict: @@ -44,6 +45,26 @@ def get_user_data(self, token: str) -> dict: dict: The JSON response containing user data. """ return self.verify(token) + + + def update_user_data(self, token: str, user_id: int, data: dict) -> dict: + """Updates user data. + + Args: + token (str): The access token. + user_id (int): The ID of the user to update. + data (dict): The user data to update. + + Returns: + dict: The JSON response from the API containing user data. + """ + print(data) + headers = {"Authorization": f"Bearer {token}"} + return self._request("PATCH", + url=self.base_url + f"/auth/user/{user_id}", + headers=headers, + json=data + ) # === Login === diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 836dcc7..b10d005 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -19,6 +19,7 @@ from modules.categories_editings import ( CreateCategory, EditCategory ) +from modules.profile.profile_dialog import ProfileDialog from utils import NotificationService from utils.error_messages import get_friendly_error_message @@ -378,6 +379,59 @@ def _on_logout_clicked(self) -> None: self.logout_requested.emit() + def _show_profile_dialog(self) -> None: + """Handles showing the user profile dialog.""" + if self.mode != "auth": + return + + try: + user_data = self.model.get_full_user_data() + if not user_data: + logger.warning("Could not retrieve user data for profile.") + return + + departments = self.model.departments + + updated_data = ProfileDialog.show_dialog( + parent=self.window, + user_data=user_data, + departments=departments + ) + + if updated_data: + worker = APIWorker( + self.model.update_user_profile, + user_id=user_data['id'], + data=updated_data + ) + self.active_workers.add(worker) + worker.finished.connect(self._on_profile_update_finished) + worker.error.connect(lambda e: self._handle_error(e, "Ошибка обновления профиля")) + worker.finished.connect(self._cleanup_worker) + worker.error.connect(self._cleanup_worker) + worker.start() + + except Exception as e: + self._handle_error(e, "Ошибка профиля") + + + def _on_profile_update_finished(self, new_data: dict): + """Handles successful profile update.""" + logger.info(f"User profile updated successfully for user ID: {new_data.get('id')}") + + # Refresh all data to ensure consistency + self.model.refresh_data() + + # Update the UI with new user data + self._init_ui() + + NotificationService().show_toast( + notification_type="success", + title="Профиль обновлен", + message="Ваши данные были успешно сохранены." + ) + + def _on_department_selected(self, selected, deselected) -> None: """Handles department selection change.""" if self.is_updating_data: @@ -782,6 +836,7 @@ def _setup_connections(self) -> None: """Sets up signal-slot connections.""" # Sidebar self.view.connect_logout(self._on_logout_clicked) + self.view.connect_profile_action(self._show_profile_dialog) self.view.connect_departments_selection(self._on_department_selected) self.view.connect_categories_selection(self._on_category_selected) self.view.connect_department_edit(self._on_edit_department_clicked) diff --git a/modules/main/mvc/main_model.py b/modules/main/mvc/main_model.py index 520a013..d9087c5 100644 --- a/modules/main/mvc/main_model.py +++ b/modules/main/mvc/main_model.py @@ -59,6 +59,26 @@ def get_document_pages( ) -> list[dict]: pages = self.api.get_document_pages(document_id) return pages["pages"] + + + def get_full_user_data(self) -> dict | None: + """Retrieves full user data for the profile dialog.""" + token = self._get_user_token() + if not token: + return None + + # This might be the same as get_user_data, but let's assume it could be different + return self.api.get_user_data(token) + + + def update_user_profile(self, user_id: int, data: dict) -> dict: + """Updates the user's profile data.""" + # The data dict should contain 'username', 'department' (ID) + return self._make_authorized_request( + self.api.update_user_data, + user_id=user_id, + data=data + ) def create_department( diff --git a/modules/main/mvc/main_view.py b/modules/main/mvc/main_view.py index 34b6713..10cbec0 100644 --- a/modules/main/mvc/main_view.py +++ b/modules/main/mvc/main_view.py @@ -820,8 +820,7 @@ def __init__(self, ui: MainWindow_UI) -> None: # Disabled until the next update for ui_element in[ - self.ui.change_view_pushButton, - self.ui.profile_info_label + self.ui.change_view_pushButton ]: ui_element.setVisible(False) @@ -930,7 +929,6 @@ def _setup_profile_menu(self) -> None: ) # Disable unimplemented actions - self.user_profile_action.setVisible(False) self.settings_action.setVisible(False) @@ -1296,6 +1294,16 @@ def connect_logout(self, handler) -> None: self.logout_action.triggered.connect(handler) + def connect_profile_action(self, handler) -> None: + """Connects the user profile action to a handler. + + Args: + handler: The callback function. + """ + if self.user_profile_action: + self.user_profile_action.triggered.connect(handler) + + def connect_departments_selection(self, handler) -> None: """Connects the departments selection signal to a handler. diff --git a/modules/profile/profile_dialog.py b/modules/profile/profile_dialog.py new file mode 100644 index 0000000..a20179e --- /dev/null +++ b/modules/profile/profile_dialog.py @@ -0,0 +1,142 @@ +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QGraphicsDropShadowEffect +from PyQt5.QtGui import QColor, QRegExpValidator +from PyQt5.QtCore import QRegExp + +from ui.ui_converted.profile_dialog import Ui_ProfileDialog +from ui.custom_widgets.modal_window import ShadowContainer, BaseModalDialog + +class ProfileDialog(BaseModalDialog): + def __init__(self, parent, user_data: dict, departments: list[dict]): + super().__init__(parent) + self.user_data = user_data + self.departments = departments + + # === Setup UI === + self.ui = Ui_ProfileDialog() + self.ui.setupUi(self) + + # Take layout from UI + original_layout = self.layout() + + # Create a container widget that will hold the UI and have the shadow + container = ShadowContainer(self) + container.setLayout(original_layout) + container.setObjectName("profileDialogContainer") + + # Reparent UI frames into container + self.ui.texts_frame.setParent(container) + self.ui.form_frame.setParent(container) + self.ui.buttons_frame.setParent(container) + + # === Add single word validators === + single_word_validator = QRegExpValidator(QRegExp(r'^\S+$')) + self.ui.firstname_lineEdit.setValidator(single_word_validator) + self.ui.lastname_lineEdit.setValidator(single_word_validator) + + # === Shadow === + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 0, 0, int(255 * 0.10))) + shadow.setOffset(0, 5) + container.setGraphicsEffect(shadow) + + # === Main layout (holds container with shadow margins) === + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) # For shadow space + main_layout.addWidget(container) + self.setLayout(main_layout) + + # === Populate data === + self._populate_initial_data() + + # === Connect handlers === + self.ui.accept_pushButton.clicked.connect(self.accept) + self.ui.cancel_pushButton.clicked.connect(self.reject) + self.ui.firstname_lineEdit.textChanged.connect(self._validate_changes) + self.ui.lastname_lineEdit.textChanged.connect(self._validate_changes) + self.ui.department_comboBox.currentIndexChanged.connect(self._validate_changes) + + def _get_original_department_id(self) -> int | None: + """Robustly determines the original department ID from user_data.""" + dept_id = self.user_data.get("department_id") + if dept_id is not None: + return dept_id + + dept_name = self.user_data.get("department") + if dept_name is not None: + for dept in self.departments: + if dept.get("name") == dept_name: + return dept.get("id") + return None + + def _populate_initial_data(self): + """Fills the widgets with the current user data.""" + username = self.user_data.get("username", "") + parts = username.split() + first_name = parts[0] if parts else "" + last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + + self.ui.firstname_lineEdit.setText(first_name) + self.ui.lastname_lineEdit.setText(last_name) + + # Populate departments and select the current one + self.ui.department_comboBox.clear() + target_dept_id = self._get_original_department_id() + + # Add a placeholder for no department + self.ui.department_comboBox.addItem("Отдел не выбран", user_data=None) + + selected_index = 0 # Default to placeholder + for i, dept in enumerate(self.departments): + dept_id = dept.get("id") + self.ui.department_comboBox.addItem(dept.get("name"), user_data=dept_id) + + if target_dept_id is not None and str(dept_id) == str(target_dept_id): + # The index in the combobox is i + 1 because of the placeholder + selected_index = i + 1 + + self.ui.department_comboBox.setCurrentIndex(selected_index) + + def _validate_changes(self): + """Enables the save button only if there are actual changes.""" + is_changed = False + + username = self.user_data.get("username", "") + parts = username.split() + original_first_name = parts[0] if parts else "" + original_last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + + # Check names + if self.ui.firstname_lineEdit.text() != original_first_name: + is_changed = True + if self.ui.lastname_lineEdit.text() != original_last_name: + is_changed = True + + # Check department (type-insensitive comparison) + selected_dept_id = self.ui.department_comboBox.currentData() + original_dept_id = self._get_original_department_id() + + # Compare strings to handle None and type differences gracefully + if str(selected_dept_id) != str(original_dept_id): + is_changed = True + + self.ui.accept_pushButton.setEnabled(is_changed) + + def get_updated_data(self) -> dict: + """Returns the updated user data from the form.""" + return { + "username": " ".join([ + self.ui.firstname_lineEdit.text(), + self.ui.lastname_lineEdit.text() + ]), + "department_id": self.ui.department_comboBox.currentData() + } + + @staticmethod + def show_dialog(parent, user_data: dict, departments: list[dict]): + """Creates, shows the dialog, and returns the updated data if accepted.""" + dialog = ProfileDialog(parent, user_data, departments) + if dialog.exec_() == QDialog.Accepted: + return dialog.get_updated_data() + return None + diff --git a/ui/custom_widgets/__init__.py b/ui/custom_widgets/__init__.py index 9b9cabe..f2c9f12 100644 --- a/ui/custom_widgets/__init__.py +++ b/ui/custom_widgets/__init__.py @@ -16,4 +16,5 @@ from .hints import PasswordHint from .progress_bar import ProgressBar from .file_drop import FileDropWidget -from .files import FileWidget, FileListWidget \ No newline at end of file +from .files import FileWidget, FileListWidget +from .combobox import ComboBox \ No newline at end of file diff --git a/ui/custom_widgets/combobox.py b/ui/custom_widgets/combobox.py new file mode 100644 index 0000000..f4016ec --- /dev/null +++ b/ui/custom_widgets/combobox.py @@ -0,0 +1,495 @@ +from PyQt5.QtCore import ( + Qt, QEvent, QPoint, QPropertyAnimation, + QEasingCurve, QRect, QSize, pyqtSignal, pyqtProperty +) +from PyQt5.QtGui import ( + QPainter, QPainterPath, QTransform, + QPixmap, QIcon, QFontMetrics +) +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, + QLabel, QFrame, QScrollArea, + QSizePolicy, QApplication +) + +from utils import ThemeManagerInstance + + +# --------------------------------------------------------------------------- +# Arrow label with rotation animation +# --------------------------------------------------------------------------- + +class _ArrowLabel(QLabel): + """Renders a pixmap rotated by an animated angle.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFixedSize(16, 16) + self._angle: float = 0.0 + self._source: QPixmap | None = None + + def set_source(self, pixmap: QPixmap) -> None: + """Sets the source pixmap to rotate.""" + self._source = pixmap + self._repaint() + + @pyqtProperty(float) + def angle(self) -> float: + return self._angle + + @angle.setter + def angle(self, value: float) -> None: + self._angle = value + self._repaint() + + def _repaint(self) -> None: + if self._source is None: + return + rotated = self._source.transformed( + QTransform().rotate(self._angle), + Qt.SmoothTransformation + ) + result = QPixmap(self.size()) + result.fill(Qt.transparent) + p = QPainter(result) + x = (self.width() - rotated.width()) // 2 + y = (self.height() - rotated.height()) // 2 + p.drawPixmap(x, y, rotated) + p.end() + self.setPixmap(result) + + +# --------------------------------------------------------------------------- +# Single item in the dropdown list +# --------------------------------------------------------------------------- + +class _ComboBoxItem(QWidget): + """A single selectable row in the dropdown popup.""" + + clicked = pyqtSignal(str, object) + + def __init__( + self, + text: str, + user_data: object = None, + parent: QWidget | None = None + ) -> None: + super().__init__(parent) + self._text = text + self._user_data = user_data + self._hovered = False + + self.setFixedHeight(40) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setCursor(Qt.PointingHandCursor) + self.setAttribute(Qt.WA_StyledBackground, True) + + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 0, 16, 0) + + self._label = QLabel(text, self) + self._label.setObjectName("comboItemLabel") + layout.addWidget(self._label) + + def set_hovered(self, hovered: bool) -> None: + """Updates the hovered state and refreshes styling.""" + self._hovered = hovered + self.setProperty("hovered", "true" if hovered else "false") + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self.clicked.emit(self._text, self._user_data) + + def enterEvent(self, event) -> None: + self.set_hovered(True) + + def leaveEvent(self, event) -> None: + self.set_hovered(False) + + +# --------------------------------------------------------------------------- +# Floating dropdown popup +# --------------------------------------------------------------------------- + +class _ComboBoxPopup(QFrame): + """Frameless popup window containing the list of combo box items.""" + + item_selected = pyqtSignal(str, object) + + _MAX_VISIBLE_ITEMS = 6 + _ITEM_HEIGHT = 42 # item height + spacing + _CONTAINER_PADDING = 8 # top + bottom padding inside container + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__( + parent, + Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint + ) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setObjectName("ComboBoxPopup") + + self._items: list[_ComboBoxItem] = [] + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + self._container = QFrame(self) + self._container.setObjectName("comboPopupContainer") + self._container.setAttribute(Qt.WA_StyledBackground, True) + + container_layout = QVBoxLayout(self._container) + container_layout.setContentsMargins(4, 4, 4, 4) + container_layout.setSpacing(0) + + self._scroll = QScrollArea(self._container) + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self._scroll.setObjectName("comboPopupScroll") + + self._list_widget = QWidget() + self._list_widget.setObjectName("comboPopupList") + self._list_widget.setAttribute(Qt.WA_StyledBackground, True) + + self._list_layout = QVBoxLayout(self._list_widget) + self._list_layout.setContentsMargins(0, 0, 0, 0) + self._list_layout.setSpacing(2) + + self._scroll.setWidget(self._list_widget) + container_layout.addWidget(self._scroll) + outer.addWidget(self._container) + + def add_item(self, text: str, user_data: object = None) -> None: + """Appends an item to the dropdown list.""" + item = _ComboBoxItem(text, user_data, self._list_widget) + item.clicked.connect(self._on_item_clicked) + self._list_layout.addWidget(item) + self._items.append(item) + + def clear_items(self) -> None: + """Removes all items from the dropdown list.""" + for item in self._items: + item.deleteLater() + self._items.clear() + + def show_below(self, widget: QWidget, min_width: int) -> None: + """Positions and shows the popup directly below the given widget.""" + pos = widget.mapToGlobal(QPoint(0, widget.height() + 4)) + + visible = min(len(self._items), self._MAX_VISIBLE_ITEMS) + popup_height = visible * self._ITEM_HEIGHT + self._CONTAINER_PADDING + + self.setFixedWidth(max(min_width, 120)) + self.setFixedHeight(popup_height) + self._scroll.setFixedHeight(popup_height - self._CONTAINER_PADDING) + + self.move(pos) + self.show() + self.raise_() + + def _on_item_clicked(self, text: str, user_data: object) -> None: + self.item_selected.emit(text, user_data) + self.hide() + + +# --------------------------------------------------------------------------- +# Public ComboBox widget +# --------------------------------------------------------------------------- + +class ComboBox(QWidget): + """ + Custom combo box widget with animated arrow rotation and themed dropdown. + + Provides a drop-in replacement for QComboBox with full QSS theme support + and smooth open/close arrow animation. + + Signals: + currentTextChanged(str): Emitted when the selected text changes. + currentIndexChanged(int): Emitted when the selected index changes. + + Usage: + combo = ComboBox() + combo.add_item("Option 1") + combo.add_item("Option 2", user_data={"id": 2}) + combo.current_text_changed.connect(lambda t: print(t)) + """ + + currentTextChanged = pyqtSignal(str) + currentIndexChanged = pyqtSignal(int) + + _ARROW_ANIMATION_DURATION = 180 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setObjectName("ComboBox") + + self._items: list[tuple[str, object]] = [] + self._current_index: int = -1 + self._placeholder: str = "Select..." + self._is_open: bool = False + self._hovered: bool = False + + self.setFixedHeight(40) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setCursor(Qt.PointingHandCursor) + + # Layout + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 0, 8, 0) + layout.setSpacing(4) + + self._text_label = QLabel(self._placeholder, self) + self._text_label.setObjectName("comboTextLabel") + self._text_label.setAttribute(Qt.WA_StyledBackground, True) + layout.addWidget(self._text_label, 1) + + self._arrow = _ArrowLabel(self) + self._arrow.setObjectName("comboArrow") + self._arrow.setAttribute(Qt.WA_StyledBackground, True) + layout.addWidget(self._arrow, 0, Qt.AlignVCenter) + + # Arrow animation + self._anim = QPropertyAnimation(self._arrow, b"angle") + self._anim.setDuration(self._ARROW_ANIMATION_DURATION) + self._anim.setEasingCurve(QEasingCurve.InOutQuad) + + # Popup + self._popup = _ComboBoxPopup() + self._popup.item_selected.connect(self._on_item_selected) + self._popup.installEventFilter(self) + + # Theme support + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) + self._load_arrow_icon() + self._refresh_states() + + # ------------------------------------------------------------------ + # Theme and icon management + # ------------------------------------------------------------------ + + def _load_arrow_icon(self) -> None: + """Loads the arrow icon for the current theme.""" + theme_id = ThemeManagerInstance.current_theme_id + theme = "light" if theme_id == "0" else "dark" + + pixmap = QPixmap(f":/icons/{theme}/{theme}/arrow_default.svg") + if pixmap.isNull(): + pixmap = self._draw_fallback_arrow() + + scaled = pixmap.scaled(12, 12, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self._arrow.set_source(scaled) + + def _draw_fallback_arrow(self, size: int = 12) -> QPixmap: + """Draws a simple triangle arrow as a fallback when the icon is missing.""" + px = QPixmap(size, size) + px.fill(Qt.transparent) + p = QPainter(px) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(Qt.white) + path = QPainterPath() + m = size * 0.15 + path.moveTo(m, size * 0.35) + path.lineTo(size - m, size * 0.35) + path.lineTo(size / 2, size * 0.72) + path.closeSubpath() + p.drawPath(path) + p.end() + return px + + def _on_theme_changed(self, theme_id: str) -> None: + """Reloads the arrow icon when the application theme changes.""" + self._load_arrow_icon() + + # ------------------------------------------------------------------ + # State management and QSS property refresh + # ------------------------------------------------------------------ + + def _refresh_states(self) -> None: + """Updates QSS dynamic properties to reflect the current widget state.""" + self.setProperty("open", "true" if self._is_open else "false") + self.setProperty("hovered", "true" if self._hovered else "false") + self.setProperty( + "empty", "true" if self._current_index == -1 else "false" + ) + self.style().unpolish(self) + self.style().polish(self) + + self._text_label.setProperty("open", "true" if self._is_open else "false") + self._text_label.setProperty("hovered", "true" if self._hovered else "false") + self._text_label.setProperty( + "empty", "true" if self._current_index == -1 else "false" + ) + self.style().unpolish(self._text_label) + self.style().polish(self._text_label) + self.update() + + # ------------------------------------------------------------------ + # Popup open / close + # ------------------------------------------------------------------ + + def _open_popup(self) -> None: + if not self._items or not self.isEnabled(): + return + self._is_open = True + self._refresh_states() + self._animate_arrow(open_=True) + + self._popup.clear_items() + for text, data in self._items: + self._popup.add_item(text, data) + self._popup.show_below(self, self.width()) + + def _close_popup(self) -> None: + self._is_open = False + self._refresh_states() + self._animate_arrow(open_=False) + self._popup.hide() + + def _toggle_popup(self) -> None: + if self._is_open: + self._close_popup() + else: + self._open_popup() + + def _animate_arrow(self, open_: bool) -> None: + """Animates the arrow rotation between 0° (closed) and 180° (open).""" + self._anim.stop() + self._anim.setStartValue(self._arrow.angle) + self._anim.setEndValue(180.0 if open_ else 0.0) + self._anim.start() + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + + def _on_item_selected(self, text: str, user_data: object) -> None: + old_index = self._current_index + for i, (t, _) in enumerate(self._items): + if t == text: + self._current_index = i + break + + self._text_label.setText(text) + self._is_open = False + self._animate_arrow(open_=False) + self._refresh_states() + + if self._current_index != old_index: + self.currentIndexChanged.emit(self._current_index) + self.currentTextChanged.emit(text) + + # ------------------------------------------------------------------ + # Qt event overrides + # ------------------------------------------------------------------ + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self._toggle_popup() + + def enterEvent(self, event) -> None: + self._hovered = True + self._refresh_states() + + def leaveEvent(self, event) -> None: + self._hovered = False + self._refresh_states() + + def changeEvent(self, event) -> None: + if event.type() == QEvent.EnabledChange: + self._refresh_states() + self.setCursor( + Qt.ArrowCursor if not self.isEnabled() else Qt.PointingHandCursor + ) + super().changeEvent(event) + + def hideEvent(self, event) -> None: + self._close_popup() + super().hideEvent(event) + + def eventFilter(self, obj, event) -> bool: + if obj is self._popup and event.type() == QEvent.Hide: + if self._is_open: + self._close_popup() + return super().eventFilter(obj, event) + + # ------------------------------------------------------------------ + # Public API (compatible with QComboBox naming conventions) + # ------------------------------------------------------------------ + + def addItem(self, text: str, user_data: object = None) -> None: + """Appends an item with optional associated data.""" + self._items.append((text, user_data)) + + def addItems(self, texts: list[str]) -> None: + """Appends multiple items by text.""" + for t in texts: + self.addItem(t) + + def setPlaceholderText(self, text: str) -> None: + """Sets the placeholder text shown when no item is selected.""" + self._placeholder = text + if self._current_index == -1: + self._text_label.setText(text) + + def setCurrentText(self, text: str) -> None: + """Selects the item matching the given text.""" + for i, (t, _) in enumerate(self._items): + if t == text: + self._current_index = i + self._text_label.setText(text) + self._refresh_states() + return + + def setCurrentIndex(self, index: int) -> None: + """Selects the item at the given index.""" + if 0 <= index < len(self._items): + self._current_index = index + self._text_label.setText(self._items[index][0]) + self._refresh_states() + + def currentText(self) -> str: + """Returns the currently selected text, or an empty string if none.""" + if self._current_index >= 0: + return self._items[self._current_index][0] + return "" + + def currentData(self) -> object: + """Returns the user data associated with the currently selected item.""" + if self._current_index >= 0: + return self._items[self._current_index][1] + return None + + def currentIndex(self) -> int: + """Returns the index of the currently selected item, or -1 if none.""" + return self._current_index + + def count(self) -> int: + """Returns the total number of items.""" + return len(self._items) + + def itemText(self, index: int) -> str: + """Returns the text of the item at the given index.""" + if 0 <= index < len(self._items): + return self._items[index][0] + return "" + + def itemData(self, index: int) -> object: + """Returns the user data of the item at the given index.""" + if 0 <= index < len(self._items): + return self._items[index][1] + return None + + def clear(self) -> None: + """Removes all items and resets the selection.""" + self._items.clear() + self._current_index = -1 + self._text_label.setText(self._placeholder) + self._refresh_states() diff --git a/ui/styles/templates/dark.j2 b/ui/styles/templates/dark.j2 index b0d5907..68e44fb 100644 --- a/ui/styles/templates/dark.j2 +++ b/ui/styles/templates/dark.j2 @@ -539,6 +539,13 @@ QFrame#editCategoryContainer { border: 1px solid {{ neutral.neutral_250 }}; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: {{ neutral.neutral_100 }}; + border: 1px solid {{ neutral.neutral_250 }}; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid {{ neutral.neutral_250 }}; @@ -1058,4 +1065,71 @@ FileWidget QLabel#fileInfoLabel { border-radius: 8px; border: 1px solid {{ neutral.neutral_250 }}; background-color: {{ neutral.neutral_100 }}; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid {{ neutral.neutral_250 }}; + background-color: {{ neutral.neutral_100 }}; +} +ComboBox[hovered="true"] { + border: 1px solid {{ neutral.neutral_300 }}; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid {{ accent.accent_500 }}; +} +ComboBox[disabled="true"] { + border: 1px solid {{ neutral.neutral_200 }}; + background-color: {{ neutral.neutral_100 }}; +} + +QLabel#comboTextLabel { + color: {{ neutral.neutral_400 }}; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_700 }}; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: {{ neutral.neutral_900 }}; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_200 }}; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid {{ neutral.neutral_250 }}; + border-radius: 8px; + background-color: {{ neutral.neutral_100 }}; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: {{ neutral.neutral_200 }}; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: {{ neutral.neutral_800 }}; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: {{ neutral.neutral_900 }}; +} + diff --git a/ui/styles/templates/light.j2 b/ui/styles/templates/light.j2 index 5ea3d57..1cf5c48 100644 --- a/ui/styles/templates/light.j2 +++ b/ui/styles/templates/light.j2 @@ -537,6 +537,13 @@ QFrame#editCategoryContainer { border: 1px solid {{ neutral.neutral_100 }}; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: {{ neutral.neutral_0 }}; + border: 1px solid {{ neutral.neutral_100 }}; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid {{ neutral.neutral_100 }}; @@ -1062,4 +1069,71 @@ FileWidget QPushButton#fileDeleteButton:hover { border-radius: 8px; border: 1px solid {{ neutral.neutral_200 }}; background-color: {{ neutral.neutral_0 }}; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid {{ neutral.neutral_100 }}; + background-color: {{ neutral.neutral_0 }}; +} +ComboBox[hovered="true"] { + border: 1px solid {{ neutral.neutral_150 }}; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid {{ accent.accent_500 }}; +} +ComboBox[disabled="true"] { + border: 1px solid {{ neutral.neutral_50 }}; + background-color: {{ neutral.neutral_0 }}; +} + +QLabel#comboTextLabel { + color: {{ neutral.neutral_500 }}; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_700 }}; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: {{ neutral.neutral_900 }}; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_100 }}; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid {{ neutral.neutral_100 }}; + border-radius: 8px; + background-color: {{ neutral.neutral_0 }}; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: {{ neutral.neutral_50 }}; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: {{ neutral.neutral_800 }}; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: {{ neutral.neutral_900 }}; +} + diff --git a/ui/styles/themes/dark.qss b/ui/styles/themes/dark.qss index 26d9d44..c3c206b 100644 --- a/ui/styles/themes/dark.qss +++ b/ui/styles/themes/dark.qss @@ -539,6 +539,13 @@ QFrame#editCategoryContainer { border: 1px solid #404040; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: #262626; + border: 1px solid #404040; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid #404040; @@ -1059,4 +1066,71 @@ FileWidget QLabel#fileInfoLabel { border-radius: 8px; border: 1px solid #404040; background-color: #262626; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid #404040; + background-color: #262626; +} +ComboBox[hovered="true"] { + border: 1px solid #4D4D4D; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid #C43A3A; +} +ComboBox[disabled="true"] { + border: 1px solid #333333; + background-color: #262626; +} + +QLabel#comboTextLabel { + color: #666666; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: #B3B3B3; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: #E6E6E6; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: #333333; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid #404040; + border-radius: 8px; + background-color: #262626; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: #333333; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: #CCCCCC; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: #E6E6E6; +} + diff --git a/ui/styles/themes/light.qss b/ui/styles/themes/light.qss index 294e327..fa0042d 100644 --- a/ui/styles/themes/light.qss +++ b/ui/styles/themes/light.qss @@ -537,6 +537,13 @@ QFrame#editCategoryContainer { border: 1px solid #E6E6E6; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: #FFFFFF; + border: 1px solid #E6E6E6; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid #E6E6E6; @@ -1059,4 +1066,71 @@ FileWidget QPushButton#fileDeleteButton:hover { border-radius: 8px; border: 1px solid #CCCCCC; background-color: #FFFFFF; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid #E6E6E6; + background-color: #FFFFFF; +} +ComboBox[hovered="true"] { + border: 1px solid #D9D9D9; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid #CC3333; +} +ComboBox[disabled="true"] { + border: 1px solid #F2F2F2; + background-color: #FFFFFF; +} + +QLabel#comboTextLabel { + color: #808080; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: #4D4D4D; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: #1A1A1A; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: #E6E6E6; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid #E6E6E6; + border-radius: 8px; + background-color: #FFFFFF; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: #F2F2F2; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: #333333; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: #1A1A1A; +} + diff --git a/ui/ui/profile_dialog.ui b/ui/ui/profile_dialog.ui new file mode 100644 index 0000000..2e4fab8 --- /dev/null +++ b/ui/ui/profile_dialog.ui @@ -0,0 +1,269 @@ + + + ProfileDialog + + + Qt::ApplicationModal + + + + 0 + 0 + 450 + 480 + + + + Dialog + + + true + + + + 24 + + + 16 + + + 16 + + + 16 + + + 16 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 14 + + + + Редактирование профиля + + + + + + + + + + + 16 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 16 + + + 16 + + + + + + 12 + + + + Имя + + + + + + + + 0 + 42 + + + + + 12 + + + + + + + + + 12 + + + + Фамилия + + + + + + + + 0 + 42 + + + + + 12 + + + + + + + + + 12 + + + + Отдел + + + + + + + + 0 + 42 + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 16 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 120 + 42 + + + + + 12 + + + + Отменить + + + + + + + false + + + + 200 + 42 + + + + + 12 + + + + Сохранить изменения + + + + + + + + + + + PrimaryButton + QPushButton +
ui.custom_widgets.buttons
+
+ + TertiaryButton + QPushButton +
ui.custom_widgets.buttons
+
+ + ComboBox + QWidget +
ui.custom_widgets.combobox
+
+
+ + +
diff --git a/ui/ui_converted/profile_dialog.py b/ui/ui_converted/profile_dialog.py new file mode 100644 index 0000000..b286ac3 --- /dev/null +++ b/ui/ui_converted/profile_dialog.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file '.\ui\ui\profile_dialog.ui' +# +# Created by: PyQt5 UI code generator 5.15.11 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_ProfileDialog(object): + def setupUi(self, ProfileDialog): + ProfileDialog.setObjectName("ProfileDialog") + ProfileDialog.setWindowModality(QtCore.Qt.ApplicationModal) + ProfileDialog.resize(450, 480) + ProfileDialog.setModal(True) + self.verticalLayout_3 = QtWidgets.QVBoxLayout(ProfileDialog) + self.verticalLayout_3.setContentsMargins(16, 16, 16, 16) + self.verticalLayout_3.setSpacing(24) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.texts_frame = QtWidgets.QFrame(ProfileDialog) + self.texts_frame.setObjectName("texts_frame") + self.verticalLayout = QtWidgets.QVBoxLayout(self.texts_frame) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setObjectName("verticalLayout") + self.label_label = QtWidgets.QLabel(self.texts_frame) + font = QtGui.QFont() + font.setPointSize(14) + self.label_label.setFont(font) + self.label_label.setObjectName("label_label") + self.verticalLayout.addWidget(self.label_label) + self.verticalLayout_3.addWidget(self.texts_frame) + self.form_frame = QtWidgets.QFrame(ProfileDialog) + self.form_frame.setObjectName("form_frame") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.form_frame) + self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_2.setSpacing(16) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.formLayout = QtWidgets.QFormLayout() + self.formLayout.setSpacing(16) + self.formLayout.setObjectName("formLayout") + self.label_firstname = QtWidgets.QLabel(self.form_frame) + font = QtGui.QFont() + font.setPointSize(12) + self.label_firstname.setFont(font) + self.label_firstname.setObjectName("label_firstname") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_firstname) + self.firstname_lineEdit = QtWidgets.QLineEdit(self.form_frame) + self.firstname_lineEdit.setMinimumSize(QtCore.QSize(0, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.firstname_lineEdit.setFont(font) + self.firstname_lineEdit.setObjectName("firstname_lineEdit") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.firstname_lineEdit) + self.label_lastname = QtWidgets.QLabel(self.form_frame) + font = QtGui.QFont() + font.setPointSize(12) + self.label_lastname.setFont(font) + self.label_lastname.setObjectName("label_lastname") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_lastname) + self.lastname_lineEdit = QtWidgets.QLineEdit(self.form_frame) + self.lastname_lineEdit.setMinimumSize(QtCore.QSize(0, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.lastname_lineEdit.setFont(font) + self.lastname_lineEdit.setObjectName("lastname_lineEdit") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.lastname_lineEdit) + self.label_department = QtWidgets.QLabel(self.form_frame) + font = QtGui.QFont() + font.setPointSize(12) + self.label_department.setFont(font) + self.label_department.setObjectName("label_department") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.label_department) + self.department_comboBox = ComboBox(self.form_frame) + self.department_comboBox.setMinimumSize(QtCore.QSize(0, 42)) + self.department_comboBox.setObjectName("department_comboBox") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.department_comboBox) + self.verticalLayout_2.addLayout(self.formLayout) + self.verticalLayout_3.addWidget(self.form_frame) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_3.addItem(spacerItem) + self.buttons_frame = QtWidgets.QFrame(ProfileDialog) + self.buttons_frame.setObjectName("buttons_frame") + self.horizontalLayout = QtWidgets.QHBoxLayout(self.buttons_frame) + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout.setSpacing(16) + self.horizontalLayout.setObjectName("horizontalLayout") + self.cancel_pushButton = TertiaryButton(self.buttons_frame) + self.cancel_pushButton.setMinimumSize(QtCore.QSize(120, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.cancel_pushButton.setFont(font) + self.cancel_pushButton.setObjectName("cancel_pushButton") + self.horizontalLayout.addWidget(self.cancel_pushButton) + self.accept_pushButton = PrimaryButton(self.buttons_frame) + self.accept_pushButton.setEnabled(False) + self.accept_pushButton.setMinimumSize(QtCore.QSize(200, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.accept_pushButton.setFont(font) + self.accept_pushButton.setObjectName("accept_pushButton") + self.horizontalLayout.addWidget(self.accept_pushButton) + self.verticalLayout_3.addWidget(self.buttons_frame) + + self.retranslateUi(ProfileDialog) + QtCore.QMetaObject.connectSlotsByName(ProfileDialog) + + def retranslateUi(self, ProfileDialog): + _translate = QtCore.QCoreApplication.translate + ProfileDialog.setWindowTitle(_translate("ProfileDialog", "Dialog")) + self.label_label.setText(_translate("ProfileDialog", "Редактирование профиля")) + self.label_firstname.setText(_translate("ProfileDialog", "Имя")) + self.label_lastname.setText(_translate("ProfileDialog", "Фамилия")) + self.label_department.setText(_translate("ProfileDialog", "Отдел")) + self.cancel_pushButton.setText(_translate("ProfileDialog", "Отменить")) + self.accept_pushButton.setText(_translate("ProfileDialog", "Сохранить изменения")) +from ui.custom_widgets.buttons import PrimaryButton, TertiaryButton +from ui.custom_widgets.combobox import ComboBox From f5db9df076ca6260d96d9a4cb4fe679274b09221 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 13 Mar 2026 13:41:37 +0300 Subject: [PATCH 04/14] feat(ui): implement automatic user department selection --- modules/main/mvc/main_controller.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index b10d005..7ba871e 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -819,9 +819,24 @@ def _init_ui(self) -> None: if user_data: username = user_data.get("username") - department = user_data.get("department") + department_name = user_data.get("department") self.view.set_username(name=username if username else user_data.get("email")) - self.view.set_user_department(dept=department if department else "Отдел не выбран") + self.view.set_user_department(dept=department_name if department_name else "Отдел не выбран") + + # Find the user's department ID from the name + user_dept_id = None + if department_name: + for dept in self.model.departments: + if dept.get("name") == department_name: + user_dept_id = dept.get("id") + break + + # If a department is found, set it as current by triggering selection + if user_dept_id: + # Use a timer to ensure the UI is ready for selection. + # This will trigger the _on_department_selected slot, + # which will then update the model and load the data. + QTimer.singleShot(50, lambda: self.view.select_department(user_dept_id)) else: self.view.set_username("Гость") From 8541f557c0dcdc168f19a200fe1e77cac826ebba Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 13 Mar 2026 13:56:54 +0300 Subject: [PATCH 05/14] fix(api): resolve issue with 'No department selected' state not saving --- api/api_client.py | 1 + modules/main/mvc/main_controller.py | 5 +++++ modules/profile/profile_dialog.py | 14 +++++++++++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/api/api_client.py b/api/api_client.py index 1329ec7..f133ebc 100644 --- a/api/api_client.py +++ b/api/api_client.py @@ -32,6 +32,7 @@ def verify(self, token: str) -> dict: url=self.base_url + "/auth/user", headers=headers ) + print(res) return res diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 7ba871e..40bd78b 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -837,6 +837,11 @@ def _init_ui(self) -> None: # This will trigger the _on_department_selected slot, # which will then update the model and load the data. QTimer.singleShot(50, lambda: self.view.select_department(user_dept_id)) + # If user has no department, select the first one in the list + elif self.model.departments: + first_dept_id = self.model.departments[0].get("id") + if first_dept_id: + QTimer.singleShot(50, lambda: self.view.select_department(first_dept_id)) else: self.view.set_username("Гость") diff --git a/modules/profile/profile_dialog.py b/modules/profile/profile_dialog.py index a20179e..eab9f20 100644 --- a/modules/profile/profile_dialog.py +++ b/modules/profile/profile_dialog.py @@ -112,12 +112,20 @@ def _validate_changes(self): if self.ui.lastname_lineEdit.text() != original_last_name: is_changed = True - # Check department (type-insensitive comparison) + # Check department with explicit None checks selected_dept_id = self.ui.department_comboBox.currentData() original_dept_id = self._get_original_department_id() - # Compare strings to handle None and type differences gracefully - if str(selected_dept_id) != str(original_dept_id): + department_changed = False + if selected_dept_id is None and original_dept_id is not None: + department_changed = True + elif selected_dept_id is not None and original_dept_id is None: + department_changed = True + elif selected_dept_id is not None and original_dept_id is not None: + if str(selected_dept_id) != str(original_dept_id): + department_changed = True + + if department_changed: is_changed = True self.ui.accept_pushButton.setEnabled(is_changed) From 6f639cfbae3b12efeb666e40afbf9f331edcfe9c Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 13 Mar 2026 16:34:58 +0300 Subject: [PATCH 06/14] docs: update README files to reflect changes in new version --- README.md | 29 +++++++++++--------------- README_RU.md | 29 +++++++++++--------------- api/api_client.py | 2 -- app.py | 2 +- screenshots/document_editor_files.png | Bin 0 -> 80476 bytes 5 files changed, 25 insertions(+), 37 deletions(-) create mode 100644 screenshots/document_editor_files.png diff --git a/README.md b/README.md index 7a553d1..19f0c92 100644 --- a/README.md +++ b/README.md @@ -28,36 +28,31 @@ Displays the hierarchy of Departments and Categories. Features a document table ### Document Editor: -Allows authorized users to create, edit, and manage document pages. Supports drag-and-drop reordering. +Allows authorized users to create, edit, and manage document pages. Support for adding and working with electronic document files. Supports drag-and-drop reordering. ![editor_window](screenshots/document_editor.png) +![editor_window_files](screenshots/document_editor_files.png) --- -## 💡 What's New (v1.1.0) - Tags & Filters +## 💡 What's New (v0.2.0) - Profile & Settings -This update brings powerful new ways to organize and find your documents. +This update focuses on personalization and user experience. -### 🏷️ Tags System +### 👤 Profile Editing -- **Document Tags:** You can now assign tags to documents for better categorization. -- **Auto-generation:** Automatically generate relevant tags based on document content. -- **Search by Tags:** Quickly find documents by clicking on tags or typing `@tagname` in the search bar. +- **Personal Info:** You can now edit your user information (First Name, Last Name, Department) directly within the application. +- **Access:** The profile dialog is accessible from the main application menu. -### 🔍 Search Filters +### ⚙️ Settings Persistence -A new search filter menu has been added to refine your search results: - -- **Search Targets:** Option to include or exclude document pages from search results. -- **Search Fields:** Filter by Name or Code. -- **Match Mode:** Toggle exact match for precise queries. +- **Personalization:** The application now remembers your preferences on a per-user basis. +- **Theme:** Your selected theme (Light/Dark) is saved between sessions. +- **Filters:** Your most recently used search filters are also saved, speeding up repeated searches. ### ⚡ Also in this update -- **"All Documents" Category:** Option to create a virtual category that aggregates all documents within a department. -- **Guest Visibility:** Granular control over the visibility of departments and categories for guest users. -- **Password Hints:** New interactive password strength hint widget during registration and password change. -- **UI Improvements:** Minor visual enhancements and bug fixes. +- Minor UI improvements and bug fixes for a more stable experience. --- diff --git a/README_RU.md b/README_RU.md index a0419ca..b5fd14a 100644 --- a/README_RU.md +++ b/README_RU.md @@ -28,36 +28,31 @@ ### Редактор документов -Позволяет авторизованным пользователям создавать, редактировать и управлять страницами документа. Поддержка Drag-and-Drop. +Позволяет авторизованным пользователям создавать, редактировать и управлять страницами документа. Поддержка добавления и работы с электронными файлами документа. Поддержка Drag-and-Drop. ![editor_window](screenshots/document_editor.png) +![editor_window_files](screenshots/document_editor_files.png) --- -## 💡 Что нового (v1.1.0) - Теги и Фильтры +## 💡 Что нового (v0.2.0) - Профиль и Настройки -Это обновление добавляет мощные инструменты для организации и поиска документов. +Это обновление добавляет персонализацию и удобство использования. -### 🏷️ Система тегов +### 👤 Редактирование профиля -- **Теги документов:** Теперь можно присваивать теги документам для лучшей категоризации. -- **Автогенерация:** Возможность автоматической генерации тегов на основе содержимого документа. -- **Поиск по тегам:** Быстрый поиск документов по клику на тег или при вводе `@тег` в строку поиска. +- **Персональные данные:** Теперь вы можете изменять свои данные (Имя, Фамилия, Отдел) прямо в приложении. +- **Доступ:** Диалог профиля доступен из главного меню приложения. -### 🔍 Фильтры поиска +### ⚙️ Сохранение настроек -Добавлено меню фильтров для уточнения результатов поиска: - -- **Объекты поиска:** Возможность включить или исключить страницы документов из поиска. -- **Поля поиска:** Фильтрация по Наименованию или Коду. -- **Режим совпадения:** Опция точного совпадения для конкретных запросов. +- **Персонализация:** Приложение теперь запоминает ваши предпочтения для каждого пользователя. +- **Тема:** Выбранная тема (светлая/тёмная) сохраняется между сессиями. +- **Фильтры:** Последние использованные фильтры поиска также сохраняются, ускоряя повторный поиск. ### ⚡ Также в этом обновлении -- **Категория "Все документы":** Возможность создания виртуальной категории, объединяющей все документы отдела. -- **Видимость для гостей:** Настройка видимости отделов и категорий для пользователей в гостевом режиме. -- **Подсказки пароля:** Новый интерактивный виджет с требованиями к паролю при регистрации и смене пароля. -- **Улучшения UI:** Небольшие визуальные улучшения и исправления ошибок. +- Улучшения интерфейса и исправления ошибок для более стабильной работы. --- diff --git a/api/api_client.py b/api/api_client.py index f133ebc..84e6ce8 100644 --- a/api/api_client.py +++ b/api/api_client.py @@ -32,7 +32,6 @@ def verify(self, token: str) -> dict: url=self.base_url + "/auth/user", headers=headers ) - print(res) return res @@ -59,7 +58,6 @@ def update_user_data(self, token: str, user_id: int, data: dict) -> dict: Returns: dict: The JSON response from the API containing user data. """ - print(data) headers = {"Authorization": f"Bearer {token}"} return self._request("PATCH", url=self.base_url + f"/auth/user/{user_id}", diff --git a/app.py b/app.py index 8426788..8f66dc8 100644 --- a/app.py +++ b/app.py @@ -26,7 +26,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.1.1" +APP_VERSION = "0.2.0" class Application: """ diff --git a/screenshots/document_editor_files.png b/screenshots/document_editor_files.png new file mode 100644 index 0000000000000000000000000000000000000000..99804caab20e1599a7d7b2f46b9e20d779a004dc GIT binary patch literal 80476 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͛lv2 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 Uw-B}YF!Z8lP8lF`wQX+x7q+w4+5i9m literal 0 HcmV?d00001 From fc450f51bb78db0f0af07e8022ae23b6a349045e Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 17:02:22 +0300 Subject: [PATCH 07/14] fix API client handling of empty responses --- api/api_client.py | 4 +++- tests/test_apiclient.py | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/api/api_client.py b/api/api_client.py index 84e6ce8..fb446ab 100644 --- a/api/api_client.py +++ b/api/api_client.py @@ -537,4 +537,6 @@ def _request(self, method: str, url: str, timeout: int = 10, **kwargs) -> dict: """Generic method for making API requests.""" request = self.session.request(method, url, timeout=timeout, **kwargs) request.raise_for_status() - return request.json() \ No newline at end of file + if request.status_code == 204 or not request.content: + return {} + return request.json() diff --git a/tests/test_apiclient.py b/tests/test_apiclient.py index 01edb08..5c078a7 100644 --- a/tests/test_apiclient.py +++ b/tests/test_apiclient.py @@ -146,6 +146,30 @@ def test_delete_document(self, client): headers={"Authorization": f"Bearer {token}"} ) + def test_request_returns_empty_dict_for_204(self, client): + with patch.object(client.session, "request") as mock_request: + mock_response = Mock() + mock_response.status_code = 204 + mock_response.content = b"" + mock_response.raise_for_status = Mock() + mock_request.return_value = mock_response + + result = client.delete_document(123, "auth_token") + + assert result == {} + + def test_request_returns_empty_dict_for_empty_body(self, client): + with patch.object(client.session, "request") as mock_request: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.raise_for_status = Mock() + mock_request.return_value = mock_response + + result = client._request("DELETE", f"{self.BASE_URL}/app/files/1") + + assert result == {} + def test_search_data(self, client): token = "auth_token" query = "test query" @@ -434,4 +458,4 @@ def test_update_document(self, client): assert result == expected mock_request.assert_called_once_with( "PATCH", f"{self.BASE_URL}/app/documents/{doc_id}", timeout=10, headers={"Authorization": f"Bearer {token}"}, json=data - ) \ No newline at end of file + ) From f3a3f702c6b13ce9fb57f815ea3e6b12a174053a Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 17:04:39 +0300 Subject: [PATCH 08/14] move main window startup loading off UI thread --- modules/main/mvc/main_controller.py | 98 +++++++++++++++++++---------- modules/main/mvc/main_model.py | 22 +++++-- tests/test_main.py | 38 ++++++++--- 3 files changed, 114 insertions(+), 44 deletions(-) diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 40bd78b..23d1461 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -67,6 +67,7 @@ def __init__( self.current_documents = [] self.current_search_worker = None self.current_load_worker = None + self.initial_load_worker = None self.active_workers = set() self.is_updating_data = False @@ -804,7 +805,6 @@ def _cleanup_worker(self) -> None: def _init_ui(self) -> None: """Initializes the UI with default data.""" - self._load_sidebar_data() self.view.set_profile_mode(self.mode) # Load and apply settings @@ -812,45 +812,79 @@ def _init_ui(self) -> None: search_filters = self.settings_manager.get_setting("search_filters") if search_filters: self.view.set_search_filters(search_filters) + self._load_initial_data() - # Set user data - if self.mode == "auth": - user_data = self.model.get_user_data() - - if user_data: - username = user_data.get("username") - department_name = user_data.get("department") - self.view.set_username(name=username if username else user_data.get("email")) - self.view.set_user_department(dept=department_name if department_name else "Отдел не выбран") - - # Find the user's department ID from the name - user_dept_id = None - if department_name: - for dept in self.model.departments: - if dept.get("name") == department_name: - user_dept_id = dept.get("id") - break - - # If a department is found, set it as current by triggering selection - if user_dept_id: - # Use a timer to ensure the UI is ready for selection. - # This will trigger the _on_department_selected slot, - # which will then update the model and load the data. - QTimer.singleShot(50, lambda: self.view.select_department(user_dept_id)) - # If user has no department, select the first one in the list - elif self.model.departments: - first_dept_id = self.model.departments[0].get("id") - if first_dept_id: - QTimer.singleShot(50, lambda: self.view.select_department(first_dept_id)) + def _load_initial_data(self) -> None: + """Loads the initial sidebar and profile data without blocking the UI thread.""" + worker = APIWorker(self.model.load_initial_data) + self.initial_load_worker = worker + self.active_workers.add(worker) + worker.finished.connect(self._on_initial_data_loaded) + worker.error.connect(self._on_initial_data_error) + worker.finished.connect(self._cleanup_worker) + worker.error.connect(self._cleanup_worker) + worker.start() + def _on_initial_data_loaded(self, data: dict) -> None: + if self.sender() != self.initial_load_worker: + return + + self.initial_load_worker = None + self.model.departments = data.get("departments", []) + self.model.categories = data.get("categories", []) + self.model.current_department_id = self.model.departments[0]["id"] if self.model.departments else None + self._load_sidebar_data() + + if self.mode == "auth": + self._apply_user_data(data.get("user_data")) else: self.view.set_username("Гость") self.view.set_user_department("Войдите в аккаунт") + if self.model.departments: + first_dept_id = self.model.departments[0].get("id") + if first_dept_id: + QTimer.singleShot(50, lambda: self.view.select_department(first_dept_id)) - # Set documents data self._update_create_category_state() self._update_create_document_state() + def _on_initial_data_error(self, error: Exception) -> None: + if self.sender() != self.initial_load_worker: + return + + self.initial_load_worker = None + if self.mode == "auth": + self.view.set_username("Пользователь") + self.view.set_user_department("Не удалось загрузить данные") + else: + self.view.set_username("Гость") + self.view.set_user_department("Войдите в аккаунт") + self._handle_error(error, "Ошибка загрузки данных") + + def _apply_user_data(self, user_data: dict | None) -> None: + """Applies the loaded user data to the UI and restores sidebar selection.""" + if not user_data: + return + + username = user_data.get("username") + department_name = user_data.get("department") + self.view.set_username(name=username if username else user_data.get("email")) + self.view.set_user_department(dept=department_name if department_name else "Отдел не выбран") + + user_dept_id = None + if department_name: + for dept in self.model.departments: + if dept.get("name") == department_name: + user_dept_id = dept.get("id") + break + + if user_dept_id: + QTimer.singleShot(50, lambda: self.view.select_department(user_dept_id)) + elif self.model.departments: + first_dept_id = self.model.departments[0].get("id") + if first_dept_id: + QTimer.singleShot(50, lambda: self.view.select_department(first_dept_id)) + def _setup_connections(self) -> None: """Sets up signal-slot connections.""" @@ -1194,4 +1228,4 @@ def _handle_error(self, e: Exception, title: str = "Ошибка") -> None: notification_type="error", title=title, message=msg - ) \ No newline at end of file + ) diff --git a/modules/main/mvc/main_model.py b/modules/main/mvc/main_model.py index d9087c5..0729f99 100644 --- a/modules/main/mvc/main_model.py +++ b/modules/main/mvc/main_model.py @@ -24,10 +24,10 @@ def __init__(self, mode: str = "guest") -> None: self.LOCAL_DIR_LAST_LOGGED = self.LOCAL_DIR / "last_logged.json" # Sidebar data - self.departments = self._get_departments() - self.current_department_id = self.departments[0]["id"] if self.departments else None - self.categories = self._get_categories() - + self.departments = [] + self.current_department_id = None + self.categories = [] + self.current_category_id = None # Table data @@ -46,6 +46,17 @@ def get_user_data(self) -> dict | None: data = self.api.get_user_data(token) return data + + def load_initial_data(self) -> dict: + """Loads the sidebar data and current user profile for the startup flow.""" + departments = self._get_departments() + categories = self._get_categories() + user_data = self.get_user_data() if self.mode == "auth" else None + return { + "departments": departments, + "categories": categories, + "user_data": user_data, + } def get_document(self, document_id: int) -> dict: @@ -161,6 +172,7 @@ def delete_category(self, category_id: int): def refresh_data(self) -> None: """Refreshes the data from the API.""" self.departments = self._get_departments() + self.current_department_id = self.departments[0]["id"] if self.departments else None self.categories = self._get_categories() @@ -328,4 +340,4 @@ def _get_departments(self) -> list[dict]: def _get_categories(self) -> list[dict]: categories = self._make_authorized_request(self.api.get_categories) return categories["categories"] - \ No newline at end of file + diff --git a/tests/test_main.py b/tests/test_main.py index 570b7a8..aea2090 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -20,21 +20,45 @@ def controller(self, qapp): # Mock NotificationService and QTimer to avoid side effects with patch("modules.main.mvc.main_controller.NotificationService"), \ - patch("modules.main.mvc.main_controller.QTimer"): + patch("modules.main.mvc.main_controller.QTimer"), \ + patch("modules.main.mvc.main_controller.APIWorker"): ctrl = MainController(model, view, window, mode="auth") yield ctrl def test_init_ui(self, controller): - """Test UI initialization loads sidebar and user data.""" - controller.model.get_user_data.return_value = {"username": "User", "department": "IT"} - - controller._init_ui() - + """Test UI initialization starts background loading instead of blocking.""" + with patch("modules.main.mvc.main_controller.APIWorker") as MockWorker: + controller._init_ui() + + MockWorker.assert_called_once_with(controller.model.load_initial_data) + MockWorker.return_value.start.assert_called_once() + + def test_initial_data_loaded(self, controller): + """Test applying startup data after the background worker finishes.""" + controller.initial_load_worker = Mock() + with patch.object(controller, "sender", return_value=controller.initial_load_worker): + controller._on_initial_data_loaded({ + "departments": [{"id": 1, "name": "Dept 1", "documents_count": 5}], + "categories": [{"id": 10, "name": "Cat 1", "group_id": 1, "documents_count": 2}], + "user_data": {"username": "User", "department": "Dept 1"}, + }) + controller.view.update_departments.assert_called() controller.view.update_categories.assert_called() controller.view.set_username.assert_called_with(name="User") + def test_initial_data_error(self, controller): + """Test startup error handling leaves the window in a safe state.""" + controller.initial_load_worker = Mock() + with patch.object(controller, "sender", return_value=controller.initial_load_worker), \ + patch.object(controller, "_handle_error") as mock_handle_error: + controller._on_initial_data_error(Exception("boom")) + + controller.view.set_username.assert_called_with("Пользователь") + controller.view.set_user_department.assert_called_with("Не удалось загрузить данные") + mock_handle_error.assert_called_once() + def test_search_trigger(self, controller): """Test that typing in search triggers the search worker.""" @@ -178,4 +202,4 @@ def test_logout(self, controller): mock_slot = Mock() controller.logout_requested.connect(mock_slot) controller._on_logout_clicked() - mock_slot.assert_called_once() \ No newline at end of file + mock_slot.assert_called_once() From 60d1d9c82adc89b06b04151ea58d53ed42758906 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 17:06:07 +0300 Subject: [PATCH 09/14] fail auth flows when session storage is unavailable --- modules/auth/mvc/auth_controller.py | 61 +++++++++++++++-------------- modules/auth/mvc/auth_model.py | 6 ++- tests/test_auth.py | 32 ++++++++++++++- 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/modules/auth/mvc/auth_controller.py b/modules/auth/mvc/auth_controller.py index 3ae87d0..5a19bdd 100644 --- a/modules/auth/mvc/auth_controller.py +++ b/modules/auth/mvc/auth_controller.py @@ -93,15 +93,15 @@ def login_user(self, data: dict) -> None: Args: data (dict): The user data received from the API upon login. """ - # Save user data - auto_login = self.view.login_page.get_auto_login_state() - user_id = self.model.save_user(user_data=data, auto_login=auto_login) + try: + auto_login = self.view.login_page.get_auto_login_state() + user_id = self.model.save_user(user_data=data, auto_login=auto_login) - # Clear lineedits - self.view.login_page.clear_lineedits() - - # Switch to main window - self.login_successful.emit("auth", user_id) + self.view.login_page.clear_lineedits() + self.login_successful.emit("auth", user_id) + except Exception as e: + msg = get_friendly_error_message(e) + NotificationService().show_toast("error", "Ошибка входа", msg) @@ -117,20 +117,21 @@ def signup_user(self, user_data: dict) -> None: Args: user_data (dict): The user data received from the API upon signup. """ - # Save user data - auto_login = self.view.signup_page.get_auto_login_state() - user_id = self.model.save_user(user_data=user_data, auto_login=auto_login) - - # Clear lineedits - self.view.signup_page.clear_lineedits() - - NotificationService().show_toast( - notification_type="success", - title="Регистрация успешна", - message="Аккаунт создан. Добро пожаловать!" - ) - # Switch to main window - self.login_successful.emit("auth", user_id) + try: + auto_login = self.view.signup_page.get_auto_login_state() + user_id = self.model.save_user(user_data=user_data, auto_login=auto_login) + + self.view.signup_page.clear_lineedits() + + NotificationService().show_toast( + notification_type="success", + title="Регистрация успешна", + message="Аккаунт создан. Добро пожаловать!" + ) + self.login_successful.emit("auth", user_id) + except Exception as e: + msg = get_friendly_error_message(e) + NotificationService().show_toast("error", "Ошибка регистрации", msg) def signup(self, data: dict, email: str, password: str) -> None: @@ -203,13 +204,15 @@ def switch_to_reset_password_page(self, data: dict) -> None: Args: data (dict): Data containing the reset token from the API. """ - # Save token - self.model.save_token(token_name="reset_token", token="reset_token", data=data) + try: + self.model.save_token(token_name="reset_token", token="reset_token", data=data) - # Switch to change password page - page = self.view.pages.get("change_password_change_page", None) - if page: - self.view.switch_page(page=page) + page = self.view.pages.get("change_password_change_page", None) + if page: + self.view.switch_page(page=page) + except Exception as e: + msg = get_friendly_error_message(e) + NotificationService().show_toast("error", "Ошибка восстановления", msg) def open_email_confirm_modal_window(self, data, email: str) -> None: @@ -653,4 +656,4 @@ def _worker_error(self, exception: Exception) -> None: notification_type="error", title="Ошибка", message=message - ) \ No newline at end of file + ) diff --git a/modules/auth/mvc/auth_model.py b/modules/auth/mvc/auth_model.py index 5952041..068417b 100644 --- a/modules/auth/mvc/auth_model.py +++ b/modules/auth/mvc/auth_model.py @@ -376,7 +376,9 @@ def save_token(self, token_name: str, token: str, data: dict) -> None: password=data.get(token, None) ) - except keyring_errors.PasswordSetError: + except keyring_errors.PasswordSetError as e: logging.error(msg="PasswordSetError", exc_info=True) + raise RuntimeError("Не удалось сохранить данные сессии в системное хранилище.") from e except Exception as e: - logging.error(msg=e, exc_info=True) \ No newline at end of file + logging.error(msg=e, exc_info=True) + raise RuntimeError("Не удалось сохранить данные сессии в системное хранилище.") from e diff --git a/tests/test_auth.py b/tests/test_auth.py index d2d3a94..f264bca 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -59,6 +59,11 @@ def test_save_user(self, model): # Check file writing (user data and last logged) assert mock_open.call_count >= 2 + def test_save_token_raises_when_keyring_write_fails(self, model): + with patch("modules.auth.mvc.auth_model.keyring.set_password", side_effect=Exception("keyring fail")): + with pytest.raises(RuntimeError): + model.save_token("access_token_1", "access_token", {"access_token": "acc"}) + def test_logout(self, model): """Test logout clears keyring and deletes last logged file.""" @@ -145,6 +150,31 @@ def test_login_success_callback(self, controller): controller.view.login_page.clear_lineedits.assert_called_once() mock_signal.assert_called_once_with("auth", 1) + def test_login_success_callback_handles_save_error(self, controller): + data = {"user": {"id": 1}} + controller.view.login_page.get_auto_login_state.return_value = True + controller.model.save_user.side_effect = RuntimeError("keyring fail") + + mock_signal = Mock() + controller.login_successful.connect(mock_signal) + + with patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify: + controller.login_user(data) + + mock_signal.assert_not_called() + controller.view.login_page.clear_lineedits.assert_not_called() + MockNotify.return_value.show_toast.assert_called_once() + + def test_reset_password_page_switch_handles_save_error(self, controller): + controller.view.pages = {"change_password_change_page": Mock()} + controller.model.save_token.side_effect = RuntimeError("keyring fail") + + with patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify: + controller.switch_to_reset_password_page({"reset_token": "token"}) + + controller.view.switch_page.assert_not_called() + MockNotify.return_value.show_toast.assert_called_once() + def test_signup_flow(self, controller): """Test signup button click initiates code sending.""" @@ -181,4 +211,4 @@ def test_validation_login_invalid(self, controller): controller.view.login_page.get_email.return_value = "invalid" controller.on_login_page_lineedits_changed() - controller.view.login_page.update_submit_button_state.assert_not_called() \ No newline at end of file + controller.view.login_page.update_submit_button_state.assert_not_called() From b351bf649efa7710f0e871f467d4c05d1afa5691 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 17:06:43 +0300 Subject: [PATCH 10/14] disable auto login state on logout --- modules/auth/mvc/auth_model.py | 12 ++++++++++++ tests/test_auth.py | 8 ++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/modules/auth/mvc/auth_model.py b/modules/auth/mvc/auth_model.py index 068417b..ecf4bd5 100644 --- a/modules/auth/mvc/auth_model.py +++ b/modules/auth/mvc/auth_model.py @@ -259,6 +259,16 @@ def delete_file(file_path: Path) -> None: if file_path.exists(): file_path.unlink() + def disable_auto_login(user_id: int) -> None: + profile_path = self.APP_DIR / "Profiles" / f"user_data_{user_id}.json" + profile_data = read_json(profile_path) + if not isinstance(profile_data, dict): + return + + profile_data["auto_login"] = False + with open(profile_path, "w", encoding="utf-8") as f: + json.dump(profile_data, f, indent=4, ensure_ascii=False) + # Get last user id last_logged_data = read_json(self.LOCAL_DIR_LAST_LOGGED) if not last_logged_data: @@ -276,6 +286,8 @@ def delete_file(file_path: Path) -> None: except keyring_errors.PasswordDeleteError: logging.info(f"Tokens for user_id {user_id} not found in keyring, skipping deletion.") + disable_auto_login(user_id) + # Delete the last logged user file to disable auto-login on next start delete_file(self.LOCAL_DIR_LAST_LOGGED) logging.info("Last logged user file deleted to disable auto-login. User is fully logged out.") diff --git a/tests/test_auth.py b/tests/test_auth.py index f264bca..c77ca76 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -70,12 +70,16 @@ def test_logout(self, model): # Configure the mock path object to simulate file existence model.LOCAL_DIR_LAST_LOGGED.exists.return_value = True - with patch("modules.auth.mvc.auth_model.read_json", return_value={"user_id": 1}), \ + with patch("modules.auth.mvc.auth_model.read_json", side_effect=[{"user_id": 1}, {"auto_login": True}]), \ patch("modules.auth.mvc.auth_model.keyring.delete_password") as mock_keyring_del: - model.logout() + with patch("builtins.open", new_callable=MagicMock) as mock_open, \ + patch("json.dump") as mock_json_dump: + model.logout() assert mock_keyring_del.call_count == 2 + assert mock_open.call_count >= 1 + mock_json_dump.assert_called() model.LOCAL_DIR_LAST_LOGGED.unlink.assert_called_once() From 02245618f47c67a3b2873023eaebf6fa73d15afc Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 17:17:18 +0300 Subject: [PATCH 11/14] preserve visible documents when reload or search fails --- modules/main/mvc/main_controller.py | 30 +++++++++++++------- tests/test_main.py | 43 ++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 23d1461..c00ccde 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -789,6 +789,7 @@ def _on_search_error(self, error: Exception) -> None: """Handles search errors.""" if self.sender() != self.current_search_worker: return + self.current_search_worker = None self._handle_error(error, "Ошибка поиска") def _cleanup_worker(self) -> None: @@ -988,20 +989,26 @@ def _update_categories_list(self) -> None: def _update_documents_list(self) -> None: """Updates the documents list based on the current category.""" self.model.current_document_id = None - - # Reset pagination + + if self.model.current_category_id is None: + self.offset = 0 + self.has_more = False + self.current_documents = [] + self.search_results_all = [] + self.pending_documents_to_add = [] + self.view.clear_documents_table() + self.view.set_finded_counter(0) + self.view.set_active_search_tags([]) + self.view.set_export_print_enabled(False) + return + self.offset = 0 self.has_more = True - self.current_documents = [] - self.view.clear_documents_table() - self.view.set_finded_counter(0) - self.view.set_active_search_tags([]) - self.view.set_export_print_enabled(False) - - # Reset loading state to ensure we can load new data + self.is_loading = False self.current_load_worker = None - + self.pending_documents_to_add = [] + self._load_more_documents() @@ -1047,6 +1054,7 @@ def _on_load_more_finished(self, docs: list) -> None: self.is_loading = False self.current_load_worker = None + self.search_results_all = [] # Prevent updating UI if search is active (stale request) if self.view.get_search_text(): @@ -1063,6 +1071,7 @@ def _on_load_more_finished(self, docs: list) -> None: # Instead of one large, blocking update, we add the data in chunks # to keep the UI responsive. self.view.clear_documents_table() + self.view.set_active_search_tags([]) self.pending_documents_to_add = list(self.current_documents) # Start the chunked update. Use a single shot timer to yield to the event loop. QTimer.singleShot(0, self._add_document_chunk) @@ -1093,6 +1102,7 @@ def _on_load_more_error(self, error: Exception) -> None: return self.is_loading = False + self.current_load_worker = None self._handle_error(error, "Ошибка загрузки документов") diff --git a/tests/test_main.py b/tests/test_main.py index aea2090..76bf886 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -86,7 +86,6 @@ def test_department_selection(self, controller): assert controller.model.current_department_id == 2 assert controller.model.current_category_id is None - controller.view.clear_documents_table.assert_called() controller.view.update_categories.assert_called() @@ -130,6 +129,48 @@ def test_load_more_documents(self, controller): assert kwargs['offset'] == 0 assert kwargs['limit'] == controller.limit + def test_update_documents_list_keeps_previous_table_until_success(self, controller): + """Reloading the table should not clear the old data before the new response arrives.""" + controller.model.current_category_id = 10 + controller.current_documents = [{"id": 1, "name": "Doc"}] + controller.view.get_search_text.return_value = "" + controller.view.clear_documents_table.reset_mock() + + with patch.object(controller, "_load_more_documents") as mock_load_more: + controller._update_documents_list() + + assert controller.current_documents == [{"id": 1, "name": "Doc"}] + controller.view.clear_documents_table.assert_not_called() + mock_load_more.assert_called_once() + + def test_load_more_error_keeps_existing_documents_visible(self, controller): + """A failed reload should not wipe the currently visible documents.""" + controller.current_documents = [{"id": 1, "name": "Existing"}] + controller.current_load_worker = Mock() + controller.view.clear_documents_table.reset_mock() + + with patch.object(controller, "sender", return_value=controller.current_load_worker), \ + patch.object(controller, "_handle_error") as mock_handle_error: + controller._on_load_more_error(Exception("boom")) + + assert controller.current_documents == [{"id": 1, "name": "Existing"}] + controller.view.clear_documents_table.assert_not_called() + mock_handle_error.assert_called_once() + + def test_search_error_keeps_existing_documents_visible(self, controller): + """A failed search should leave the previously rendered table intact.""" + controller.current_documents = [{"id": 1, "name": "Existing"}] + controller.current_search_worker = Mock() + controller.view.clear_documents_table.reset_mock() + + with patch.object(controller, "sender", return_value=controller.current_search_worker), \ + patch.object(controller, "_handle_error") as mock_handle_error: + controller._on_search_error(Exception("boom")) + + assert controller.current_documents == [{"id": 1, "name": "Existing"}] + controller.view.clear_documents_table.assert_not_called() + mock_handle_error.assert_called_once() + def test_create_department_flow(self, controller): """Test create department dialog and model update.""" From 8932a3c4fac781f9fe323ec107185e8b9bc4b6ef Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 17:23:20 +0300 Subject: [PATCH 12/14] update documentation for recent stability fixes --- README.md | 5 +++++ README_RU.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 19f0c92..391f368 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,11 @@ This update focuses on personalization and user experience. ### ⚡ Also in this update - 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. +- Authentication flows now fail safely if the system keyring or session storage is unavailable. +- API client now correctly handles empty successful responses such as `204 No Content`. +- Logging out now explicitly disables auto-login for the current profile. --- diff --git a/README_RU.md b/README_RU.md index b5fd14a..c2f1f19 100644 --- a/README_RU.md +++ b/README_RU.md @@ -53,6 +53,11 @@ ### ⚡ Также в этом обновлении - Улучшения интерфейса и исправления ошибок для более стабильной работы. +- Улучшена стабильность сессии после простоя: список документов и результаты поиска больше не исчезают, если запрос обновления или поиска завершился ошибкой. +- Начальная загрузка главного окна перенесена из UI-потока в фоновый, чтобы уменьшить подвисания при первом получении данных. +- Сценарии авторизации теперь корректно завершаются ошибкой, если системное хранилище ключей или сессии недоступно. +- API-клиент теперь корректно обрабатывает успешные пустые ответы, например `204 No Content`. +- При выходе из аккаунта теперь явно отключается auto-login для текущего профиля. --- From 663b44bfad4dd1507b9db23b530c0e7c13e6fa44 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Sat, 14 Mar 2026 20:47:20 +0300 Subject: [PATCH 13/14] 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 14/14] 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-потока, поэтому приложение меньше подвисает при открытии.",