diff --git a/README.md b/README.md index 7a553d1..eb0988d 100644 --- a/README.md +++ b/README.md @@ -28,36 +28,37 @@ 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. +- 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. +- 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 a0419ca..cb6edee 100644 --- a/README_RU.md +++ b/README_RU.md @@ -28,36 +28,37 @@ ### Π Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ΠΎΠ² -ΠŸΠΎΠ·Π²ΠΎΠ»ΡΠ΅Ρ‚ Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·ΠΎΠ²Π°Π½Π½Ρ‹ΠΌ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌ ΡΠΎΠ·Π΄Π°Π²Π°Ρ‚ΡŒ, Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΈ ΡƒΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ страницами Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°. ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° 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:** НСбольшиС Π²ΠΈΠ·ΡƒΠ°Π»ΡŒΠ½Ρ‹Π΅ ΡƒΠ»ΡƒΡ‡ΡˆΠ΅Π½ΠΈΡ ΠΈ исправлСния ошибок. +- Π”ΠΎΠ±Π°Π²Π»Π΅Π½ΠΎ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠ΅ ΠΎΠΊΠ½ΠΎ "Π§Ρ‚ΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎ", ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ΅ открываСтся послС обновлСния ΠΈ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ список ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ вСрсии. +- Π£Π»ΡƒΡ‡ΡˆΠ΅Π½ΠΈΡ интСрфСйса ΠΈ исправлСния ошибок для Π±ΠΎΠ»Π΅Π΅ ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½ΠΎΠΉ Ρ€Π°Π±ΠΎΡ‚Ρ‹. +- Π£Π»ΡƒΡ‡ΡˆΠ΅Π½Π° ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½ΠΎΡΡ‚ΡŒ сСссии послС простоя: список Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ΠΎΠ² ΠΈ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚Ρ‹ поиска большС Π½Π΅ ΠΈΡΡ‡Π΅Π·Π°ΡŽΡ‚, Ссли запрос обновлСния ΠΈΠ»ΠΈ поиска Π·Π°Π²Π΅Ρ€ΡˆΠΈΠ»ΡΡ ошибкой. +- ΠΠ°Ρ‡Π°Π»ΡŒΠ½Π°Ρ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Π³Π»Π°Π²Π½ΠΎΠ³ΠΎ ΠΎΠΊΠ½Π° пСрСнСсСна ΠΈΠ· UI-ΠΏΠΎΡ‚ΠΎΠΊΠ° Π² Ρ„ΠΎΠ½ΠΎΠ²Ρ‹ΠΉ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠΌΠ΅Π½ΡŒΡˆΠΈΡ‚ΡŒ подвисания ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π²ΠΎΠΌ ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠΈ Π΄Π°Π½Π½Ρ‹Ρ…. +- Π‘Ρ†Π΅Π½Π°Ρ€ΠΈΠΈ Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·Π°Ρ†ΠΈΠΈ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ Π·Π°Π²Π΅Ρ€ΡˆΠ°ΡŽΡ‚ΡΡ ошибкой, Ссли систСмноС Ρ…Ρ€Π°Π½ΠΈΠ»ΠΈΡ‰Π΅ ΠΊΠ»ΡŽΡ‡Π΅ΠΉ ΠΈΠ»ΠΈ сСссии нСдоступно. +- API-ΠΊΠ»ΠΈΠ΅Π½Ρ‚ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ ΡƒΡΠΏΠ΅ΡˆΠ½Ρ‹Π΅ пустыС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `204 No Content`. +- ΠŸΡ€ΠΈ Π²Ρ‹Ρ…ΠΎΠ΄Π΅ ΠΈΠ· Π°ΠΊΠΊΠ°ΡƒΠ½Ρ‚Π° Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ явно ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ΡΡ auto-login для Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ профиля. --- diff --git a/api/api_client.py b/api/api_client.py index 7872ad0..fb446ab 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,25 @@ 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. + """ + headers = {"Authorization": f"Bearer {token}"} + return self._request("PATCH", + url=self.base_url + f"/auth/user/{user_id}", + headers=headers, + json=data + ) # === Login === @@ -517,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/app.py b/app.py index c65b090..f89933d 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,8 @@ 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 +from utils.whats_new_modal import WhatsNewDialog os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") @@ -25,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.1.1" +APP_VERSION = "0.2.0" class Application: """ @@ -49,6 +51,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 +74,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 +88,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 +114,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 +133,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 +195,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() @@ -175,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: @@ -187,16 +220,29 @@ 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 - def on_login_successful(self, mode: str): + 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): """ 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 +251,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..5a19bdd 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, @@ -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() - 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") + 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() - 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") + 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: @@ -285,7 +288,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: @@ -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 e77453b..ecf4bd5 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. @@ -241,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: @@ -258,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.") @@ -358,7 +388,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/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..c00ccde 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -19,11 +19,14 @@ 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 +from utils.settings_manager import SettingsManager + logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -42,7 +45,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,16 +55,19 @@ 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 self.current_load_worker = None + self.initial_load_worker = None self.active_workers = set() self.is_updating_data = False @@ -111,7 +118,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() @@ -369,6 +380,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: @@ -725,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: @@ -741,32 +806,92 @@ 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) - # Set user data - if self.mode == "auth": - user_data = self.model.get_user_data() + # 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) + self._load_initial_data() + + 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() - if user_data: - username = user_data.get("username") - department = 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 "ΠžΡ‚Π΄Π΅Π» Π½Π΅ Π²Ρ‹Π±Ρ€Π°Π½") + 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.""" # 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) @@ -864,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() @@ -923,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(): @@ -939,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) @@ -969,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, "Ошибка Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ΠΎΠ²") @@ -1104,4 +1238,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 520a013..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: @@ -59,6 +70,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( @@ -141,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() @@ -308,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/modules/main/mvc/main_view.py b/modules/main/mvc/main_view.py index 13eedff..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 @@ -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,15 +813,14 @@ 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" # 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) @@ -923,7 +929,6 @@ def _setup_profile_menu(self) -> None: ) # Disable unimplemented actions - self.user_profile_action.setVisible(False) self.settings_action.setVisible(False) @@ -1010,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: @@ -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.""" @@ -1285,6 +1296,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. @@ -1390,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/modules/profile/profile_dialog.py b/modules/profile/profile_dialog.py new file mode 100644 index 0000000..eab9f20 --- /dev/null +++ b/modules/profile/profile_dialog.py @@ -0,0 +1,150 @@ +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 with explicit None checks + selected_dept_id = self.ui.department_comboBox.currentData() + original_dept_id = self._get_original_department_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) + + 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/screenshots/document_editor_files.png b/screenshots/document_editor_files.png new file mode 100644 index 0000000..99804ca Binary files /dev/null and b/screenshots/document_editor_files.png differ 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 + ) diff --git a/tests/test_app.py b/tests/test_app.py index de43c3f..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): @@ -75,7 +77,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() @@ -135,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_auth.py b/tests/test_auth.py index 8cacb54..c77ca76 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -59,18 +59,27 @@ 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.""" # 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() @@ -133,6 +142,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 +152,32 @@ 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_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): @@ -180,4 +215,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() diff --git a/tests/test_main.py b/tests/test_main.py index 570b7a8..76bf886 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.""" @@ -62,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() @@ -106,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.""" @@ -178,4 +243,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() 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() 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/__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/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/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/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/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/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/ui/styles/templates/dark.j2 b/ui/styles/templates/dark.j2 index b0d5907..b349a5a 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 }}; @@ -613,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 */ @@ -1058,4 +1169,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..fd6bd7a 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 }}; @@ -611,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; +} + @@ -902,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 }}; } @@ -1062,4 +1173,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..c611327 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; @@ -613,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 */ @@ -1059,4 +1169,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..a40e53b 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; @@ -611,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 */ @@ -1059,4 +1170,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/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/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/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/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 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..fdb3786 --- /dev/null +++ b/utils/settings_manager.py @@ -0,0 +1,94 @@ +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 + "last_seen_whats_new_version": "", + "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 diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py new file mode 100644 index 0000000..b926899 --- /dev/null +++ b/utils/whats_new_modal.py @@ -0,0 +1,202 @@ +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)