Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ This update focuses on personalization and user experience.

### ⚡ Also in this update

- Added a dedicated "What's New" changelog window that opens after updates and shows version changes.
- Minor UI improvements and bug fixes for a more stable experience.
- Improved session stability after idle time: document lists and search results no longer disappear if a reload or search request fails.
- Main window startup loading was moved off the UI thread to reduce freezes during the initial data fetch.
Expand Down
1 change: 1 addition & 0 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

### ⚡ Также в этом обновлении

- Добавлено отдельное окно "Что нового", которое открывается после обновления и показывает список изменений версии.
- Улучшения интерфейса и исправления ошибок для более стабильной работы.
- Улучшена стабильность сессии после простоя: список документов и результаты поиска больше не исчезают, если запрос обновления или поиска завершился ошибкой.
- Начальная загрузка главного окна перенесена из UI-потока в фоновый, чтобы уменьшить подвисания при первом получении данных.
Expand Down
16 changes: 16 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from core.updater import UpdateManager
from utils.file_utils import load_config
from utils.settings_manager import SettingsManager
from utils.whats_new_modal import WhatsNewDialog


os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
Expand Down Expand Up @@ -205,6 +206,8 @@ def show_main_window(self, mode: str = "auth"):
self.auth_window.close()
self.auth_window = None

self.maybe_show_whats_new(mode=mode)

except Exception as e:
self.logger.critical(f"Error initializing MainWindow: {e}", exc_info=True)
if not self.auth_window:
Expand All @@ -217,6 +220,19 @@ def show_main_window(self, mode: str = "auth"):
message=msg
)

def maybe_show_whats_new(self, mode: str) -> None:
"""Shows the release notes once per app version for authorized users."""
if mode != "auth" or not self.settings_manager or not self.main_window:
return

last_seen_version = self.settings_manager.get_setting("last_seen_whats_new_version", "")
if last_seen_version == APP_VERSION:
return

dialog = WhatsNewDialog(parent=self.main_window, version=APP_VERSION)
dialog.exec_()
self.settings_manager.set_setting("last_seen_whats_new_version", APP_VERSION)


def on_login_successful(self, mode: str, user_id: int):
"""
Expand Down
44 changes: 23 additions & 21 deletions modules/main/mvc/main_view.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -1015,29 +1015,31 @@ def _replace_theme_button(self) -> None:
This method removes the placeholder button from the layout and inserts
the custom ThemeSwitch widget in its place, preserving the layout index.
"""
# Getting the parent container and its layout
parent = self.ui.navbar_actions_frame
layout = parent.layout()

# We find the old button and its position
if not layout:
return

old_button = self.ui.theme_pushButton
if old_button:
index = layout.indexOf(old_button)
layout.removeWidget(old_button)
old_button.deleteLater()

# Creating and inserting a new switcher in the same place
self.ui.theme_pushButton = ThemeSwitch(parent)
index = layout.indexOf(old_button) if old_button else -1
if index < 0:
index = 1 # Between search block and create block in navbar_actions_frame.

# Copying properties from the old button if needed, or setting defaults
self.ui.theme_pushButton.setMinimumSize(125, 42)
self.ui.theme_pushButton.setObjectName("themeSwitch")

# Setting the initial state
is_dark = self.theme_manager.current_theme_id != "0"
self.ui.theme_pushButton.setChecked(is_dark)

layout.insertWidget(index, self.ui.theme_pushButton)
# Remove any leftover placeholder buttons from .ui to avoid "theme" rectangle artifacts.
for placeholder in parent.findChildren(QPushButton, "theme_pushButton"):
layout.removeWidget(placeholder)
placeholder.hide()
placeholder.setParent(None)
placeholder.deleteLater()

self.ui.theme_pushButton = ThemeSwitch(parent)
self.ui.theme_pushButton.setMinimumSize(125, 42)
self.ui.theme_pushButton.setObjectName("themeSwitch")

is_dark = self.theme_manager.current_theme_id != "0"
self.ui.theme_pushButton.setChecked(is_dark)

layout.insertWidget(index, self.ui.theme_pushButton)


def _replace_profile_icon_label(self) -> None:
Expand Down Expand Up @@ -1409,4 +1411,4 @@ def connect_documents_scroll(self, handler) -> None:
Args:
handler: The callback function.
"""
self.documents_list.connect_scroll_changed(handler)
self.documents_list.connect_scroll_changed(handler)
38 changes: 36 additions & 2 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand All @@ -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):
Expand Down Expand Up @@ -138,4 +140,36 @@ def test_logout_requested(self, mock_dependencies):
main_window_mock.close.assert_called_once()
assert app.main_window is None
app.auth_model.logout.assert_called_once()
mock_dependencies["AuthWindow"].return_value.show.assert_called()
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()
13 changes: 13 additions & 0 deletions tests/test_settings_manager.py
Original file line number Diff line number Diff line change
@@ -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"] == ""
19 changes: 18 additions & 1 deletion tests/test_theme_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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")
35 changes: 32 additions & 3 deletions ui/custom_widgets/modal_window.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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():
Expand All @@ -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)
self.move(x, y)
Loading
Loading