From eb32952c01c50cfbd77d5bf7bb2638d1dd12acdd Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 9 Aug 2026 23:41:37 +0800 Subject: [PATCH] Introduce page-based navigation Signed-off-by: Loren Eteval --- Furious/Application/DesktopApplication.py | 6 +- Furious/Core/CoreProcessWorker.py | 8 +- Furious/Core/Tun2socks.py | 2 +- Furious/Externals/GenTranslation.py | 131 +-- Furious/Frozenlib/Globals.py | 6 +- Furious/Frozenlib/__init__.py | 4 +- Furious/Qt/AppStyleSheet.py | 52 +- Furious/Widget/NavigationView.py | 270 +++++ Furious/Widget/__init__.py | 2 + Furious/Window/HomePage.py | 960 ++++++++++++++++++ Furious/Window/{LogWindow.py => LogPage.py} | 30 +- Furious/Window/MainWindow.py | 1016 ++----------------- Furious/Window/__init__.py | 6 +- 13 files changed, 1463 insertions(+), 1030 deletions(-) create mode 100644 Furious/Widget/NavigationView.py create mode 100644 Furious/Window/HomePage.py rename Furious/Window/{LogWindow.py => LogPage.py} (92%) diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 96cfab7..2a6704a 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -31,7 +31,7 @@ from Furious.Qt import gettext as _ from Furious.Repository import * from Furious.Service import ApplicationLogHandler, LogManager from Furious.Application.TrayIcon import * -from Furious.Window.LogWindow import * +from Furious.Window.LogPage import * from Furious.Window.MainWindow import * from PySide6 import QtCore @@ -182,7 +182,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): # Unified logging service and presentation self.logManager = None - self.logWindow = None + self.logPage = None # Protected storage access self._userActivatedItemIndex = None @@ -231,7 +231,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): """Configure logging.""" self.logManager = LogManager(parent=self) - self.logWindow = LogWindow( + self.logPage = LogPage( manager=self.logManager, fontFamily=self.customFontName, ) diff --git a/Furious/Core/CoreProcessWorker.py b/Furious/Core/CoreProcessWorker.py index f78fe7d..eaf68b3 100644 --- a/Furious/Core/CoreProcessWorker.py +++ b/Furious/Core/CoreProcessWorker.py @@ -184,7 +184,7 @@ class MsgQueue(multiprocessing.queues.Queue): self.callback(msg) if self.optimizer is not None and self.optimizer.isVisible(): - # Logger window is visible: maximum loading speed for user + # Log page is visible: maximum loading speed for user # 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 2, 2, ... # For timeout value 2 Furious can handle at about 500 messages per second @@ -193,7 +193,7 @@ class MsgQueue(multiprocessing.queues.Queue): ) self.startTimer() else: - # Logger window does not exist or is not visible: low speed in background + # Log page is unavailable or hidden: low speed in background # to avoid consuming too much CPU resources # 1024, 512, 256, 256, 256, ... @@ -300,8 +300,8 @@ class CoreProcessWorker(CoreProcessMonitor, ABC): def __init__(self, **kwargs): """Initialize the CoreProcessWorker.""" msgCallback = kwargs.pop('msgCallback', None) - # Drain output more frequently while the unified log window is visible. - backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLogWindow) + # Drain output more frequently while the unified log page is visible. + backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLogPage) super().__init__(**kwargs) diff --git a/Furious/Core/Tun2socks.py b/Furious/Core/Tun2socks.py index 249749e..ef2e5df 100644 --- a/Furious/Core/Tun2socks.py +++ b/Furious/Core/Tun2socks.py @@ -60,7 +60,7 @@ class Tun2socks(CoreProcessWorker): def __init__(self, **kwargs): """Initialize the Tun2socks.""" - backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLogWindow) + backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLogPage) super().__init__(**kwargs, backgroundOptimizer=backgroundOptimizer) diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 3163e1e..ef89d50 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -274,7 +274,7 @@ TRANSLATION = { }, "Save As...": { "source": [ - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Сохранить как...", @@ -292,7 +292,7 @@ TRANSLATION = { "File": { "source": [ "Furious.Backends.Xray.AssetWindow", - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Файл", @@ -325,7 +325,7 @@ TRANSLATION = { }, "Copy": { "source": [ - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Копировать", @@ -343,7 +343,7 @@ TRANSLATION = { "Select All": { "source": [ "Furious.Widget.ServerTableView", - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Выбрать все", @@ -360,7 +360,7 @@ TRANSLATION = { }, "Edit": { "source": [ - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Редактировать", @@ -369,7 +369,7 @@ TRANSLATION = { }, "Zoom In": { "source": [ - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Увеличить маcштаб", @@ -378,7 +378,7 @@ TRANSLATION = { }, "Zoom Out": { "source": [ - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Уменьшить маcштаб", @@ -387,7 +387,7 @@ TRANSLATION = { }, "View": { "source": [ - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Вид", @@ -404,7 +404,7 @@ TRANSLATION = { }, "Save File": { "source": [ - "Furious.Window.LogWindow", + "Furious.Window.LogPage", "Furious.Window.TextEditorWindow" ], "RU": "Сохранить файл", @@ -508,7 +508,7 @@ TRANSLATION = { "Subscription": { "source": [ "Furious.Widget.ServerTableView", - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Подписка", "ZH": "订阅", @@ -606,7 +606,7 @@ TRANSLATION = { "New Empty Configuration": { "source": [ "Furious.Widget.ServerTableView", - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Создать новую пустую конфигурацию", "ZH": "新建空白配置", @@ -713,8 +713,7 @@ TRANSLATION = { "Exit": { "source": [ "Furious.Actions.Exit", - "Furious.Backends.Xray.AssetWindow", - "Furious.Window.LogWindow" + "Furious.Backends.Xray.AssetWindow" ], "RU": "Выход", "ZH": "退出", @@ -722,7 +721,7 @@ TRANSLATION = { }, "Unable to save log": { "source": [ - "Furious.Window.LogWindow" + "Furious.Window.LogPage" ], "RU": "Не удалось сохранить журнал", "ZH": "无法保存日志", @@ -730,7 +729,7 @@ TRANSLATION = { }, "Text files (*.txt);;All files (*)": { "source": [ - "Furious.Window.LogWindow" + "Furious.Window.LogPage" ], "RU": "Текстовые файлы (*.txt);;Все файлы (*)", "ZH": "文本文件 (*.txt);;所有文件 (*)", @@ -738,20 +737,12 @@ TRANSLATION = { }, "Error saving log": { "source": [ - "Furious.Window.LogWindow" + "Furious.Window.LogPage" ], "RU": "Ошибка сохранения журнала", "ZH": "保存日志出错", "isReviewed": "True" }, - "Log Viewer": { - "source": [ - "Furious.Window.LogWindow" - ], - "RU": "Средство просмотра журнала", - "ZH": "日志查看器", - "isReviewed": "True" - }, "Asset file already exists. Overwrite?": { "source": [ "Furious.Backends.Xray.AssetListWidget" @@ -812,7 +803,7 @@ TRANSLATION = { "source": [ "Furious.Backends.Hysteria1.Editor", "Furious.Backends.Hysteria2.Editor", - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Сервер", "ZH": "服务器", @@ -826,17 +817,41 @@ TRANSLATION = { "ZH": "日志", "isReviewed": "True" }, - "Update Subscription (Use Current Proxy)": { + "Home": { "source": [ "Furious.Window.MainWindow" ], + "RU": "Главная", + "ZH": "主页", + "isReviewed": "True" + }, + "Expand Navigation": { + "source": [ + "Furious.Widget.NavigationView" + ], + "RU": "Развернуть навигацию", + "ZH": "展开导航", + "isReviewed": "True" + }, + "Collapse Navigation": { + "source": [ + "Furious.Widget.NavigationView" + ], + "RU": "Свернуть навигацию", + "ZH": "折叠导航", + "isReviewed": "True" + }, + "Update Subscription (Use Current Proxy)": { + "source": [ + "Furious.Window.HomePage" + ], "RU": "Обновить подписки (использовать текущий прокси-сервер)", "ZH": "更新订阅(使用当前代理)", "isReviewed": "True" }, "Update Subscription (Force Proxy)": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Обновить подписки (обязательно через прокси-сервер)", "ZH": "更新订阅(强制代理)", @@ -844,7 +859,7 @@ TRANSLATION = { }, "Update Subscription (No Proxy)": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Обновить подписки (не использовать прокси)", "ZH": "更新订阅(无代理)", @@ -852,7 +867,7 @@ TRANSLATION = { }, "Edit Subscription...": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Редактировать подписки...", "ZH": "编辑订阅...", @@ -860,7 +875,7 @@ TRANSLATION = { }, "Tools": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Инструменты", "ZH": "工具", @@ -868,7 +883,7 @@ TRANSLATION = { }, "Plugins": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Плагины", "ZH": "插件", @@ -876,7 +891,7 @@ TRANSLATION = { }, "Core": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Core", "ZH": "Core", @@ -884,7 +899,7 @@ TRANSLATION = { }, "Open Application Folder": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Открыть папку приложения", "ZH": "打开应用程序文件夹", @@ -892,7 +907,7 @@ TRANSLATION = { }, "Check For Updates": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Проверка обновлений", "ZH": "检查更新", @@ -900,7 +915,7 @@ TRANSLATION = { }, "About": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "О нас", "ZH": "关于", @@ -1197,7 +1212,7 @@ TRANSLATION = { }, "Help": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Помощь", "ZH": "帮助", @@ -1566,7 +1581,7 @@ TRANSLATION = { "Customize Tun2socks Settings...": { "source": [ "Furious.Actions.Settings", - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Настроить параметры Tun2socks...", "ZH": "自定义Tun2socks设置...", @@ -1662,7 +1677,7 @@ TRANSLATION = { }, "Restart The Application As Administrator": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Перезапустите приложение от имени администратора", "ZH": "以管理员身份重新启动应用程序", @@ -1670,7 +1685,7 @@ TRANSLATION = { }, "Restart The Application As Superuser": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Перезапустите приложение от имени суперпользователя", "ZH": "以Superuser身份重新启动应用程序", @@ -1686,7 +1701,7 @@ TRANSLATION = { }, "I am the supreme authority 👑": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Я — единственная высшая инстанция 👑", "ZH": "我就是至高无上的存在 👑", @@ -1702,7 +1717,7 @@ TRANSLATION = { }, "Customize System Proxy Bypass Address...": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Настроить адреса исключений системного прокси...", "ZH": "自定义系统代理绕过地址...", @@ -1735,7 +1750,7 @@ TRANSLATION = { }, "Customize Network Test URL...": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Настроить URL теста сети...", "ZH": "自定义网络测试URL...", @@ -1767,7 +1782,7 @@ TRANSLATION = { }, "Search servers with text or regex, e.g. trojan, hk|jp, ^vmess, (us|sg).*tls": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Поиск серверов по тексту или регулярному выражению, например: trojan, hk|jp, ^vmess, (us|sg).*tls", "ZH": "使用文本或正则表达式搜索服务器,例如:trojan、hk|jp、^vmess、(us|sg).*tls", @@ -1775,7 +1790,7 @@ TRANSLATION = { }, "Search": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Поиск", "ZH": "查找", @@ -1783,7 +1798,7 @@ TRANSLATION = { }, "Network OK": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Сеть работает", "ZH": "网络正常", @@ -1791,7 +1806,7 @@ TRANSLATION = { }, "Network error": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Ошибка сети", "ZH": "网络异常", @@ -2245,23 +2260,15 @@ TRANSLATION = { }, "Extensions": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Расширения", "ZH": "扩展功能", "isReviewed": "True" }, - "Show Logs...": { - "source": [ - "Furious.Window.MainWindow" - ], - "RU": "Показать журналы...", - "ZH": "显示日志...", - "isReviewed": "True" - }, "Log Type": { "source": [ - "Furious.Window.LogWindow" + "Furious.Window.LogPage" ], "RU": "Тип журнала", "ZH": "日志类型", @@ -2269,7 +2276,7 @@ TRANSLATION = { }, "Application": { "source": [ - "Furious.Window.LogWindow" + "Furious.Window.LogPage" ], "RU": "Приложение", "ZH": "应用程序", @@ -2277,7 +2284,7 @@ TRANSLATION = { }, "All Logs": { "source": [ - "Furious.Window.LogWindow" + "Furious.Window.LogPage" ], "RU": "Все журналы", "ZH": "全部日志", @@ -2285,7 +2292,7 @@ TRANSLATION = { }, "Upload Speed": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Скорость отдачи", "ZH": "上传速度", @@ -2293,7 +2300,7 @@ TRANSLATION = { }, "Download Speed": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Скорость загрузки", "ZH": "下载速度", @@ -2301,7 +2308,7 @@ TRANSLATION = { }, "Upload Traffic Usage": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Объём исходящего трафика", "ZH": "上传流量", @@ -2309,7 +2316,7 @@ TRANSLATION = { }, "Download Traffic Usage": { "source": [ - "Furious.Window.MainWindow" + "Furious.Window.HomePage" ], "RU": "Объём входящего трафика", "ZH": "下载流量", diff --git a/Furious/Frozenlib/Globals.py b/Furious/Frozenlib/Globals.py index a5e6563..d2c29f0 100644 --- a/Furious/Frozenlib/Globals.py +++ b/Furious/Frozenlib/Globals.py @@ -27,7 +27,7 @@ __all__ = [ 'APP', 'AppFontName', 'AppLogManager', - 'AppLogWindow', + 'AppLogPage', 'AppThreadPool', ] @@ -42,11 +42,11 @@ def getAppAttributes(name: str): ( AppFontName, AppLogManager, - AppLogWindow, + AppLogPage, AppThreadPool, ) = ( functools.partial(getAppAttributes, 'customFontName'), functools.partial(getAppAttributes, 'logManager'), - functools.partial(getAppAttributes, 'logWindow'), + functools.partial(getAppAttributes, 'logPage'), functools.partial(getAppAttributes, 'threadPool'), ) diff --git a/Furious/Frozenlib/__init__.py b/Furious/Frozenlib/__init__.py index 144303e..72928dd 100644 --- a/Furious/Frozenlib/__init__.py +++ b/Furious/Frozenlib/__init__.py @@ -67,7 +67,7 @@ from .Constants import ( XRAY_ASSET_PATH_GEOSITE, ) from .Enum import AppBuiltinCommand, AppBuiltinProxyMode, AppBuiltinRouting -from .Globals import APP, AppFontName, AppLogManager, AppLogWindow, AppThreadPool +from .Globals import APP, AppFontName, AppLogManager, AppLogPage, AppThreadPool from .Mixins import Mixins from .PySide6Legacy import PySide6Legacy from .StartupOnBoot import StartupOnBoot @@ -112,7 +112,7 @@ __all__ = [ 'AppBuiltinRouting', 'AppFontName', 'AppLogManager', - 'AppLogWindow', + 'AppLogPage', 'AppResources', 'AppSettings', 'AppThreadPool', diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index a6e3d8c..bffb9b0 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -296,6 +296,17 @@ class AppStyleSheet: border-color: {palette['border']}; }} + QFrame#NavigationPanel {{ + border: none; + border-right: 1px solid {palette['border']}; + background-color: {palette['panel_alt']}; + }} + + QStackedWidget#ApplicationPageStack {{ + border: none; + background-color: {palette['window']}; + }} + QGroupBox {{ margin-top: 16px; padding: 14px 12px 12px 12px; @@ -415,13 +426,15 @@ class AppStyleSheet: background-color: {palette['border']}; }} - QToolBar#AppMainWindow_AppQToolBar {{ + QToolBar#AppMainWindow_AppQToolBar, + QToolBar#HomePageToolBar {{ padding: 6px 8px; spacing: 2px; background-color: {palette['panel']}; }} - QToolBar#AppMainWindow_AppQToolBar::separator {{ + QToolBar#AppMainWindow_AppQToolBar::separator, + QToolBar#HomePageToolBar::separator {{ margin: 8px 4px; }} @@ -434,13 +447,46 @@ class AppStyleSheet: color: {palette['text']}; }} - QToolBar#AppMainWindow_AppQToolBar QToolButton {{ + QToolBar#AppMainWindow_AppQToolBar QToolButton, + QToolBar#HomePageToolBar QToolButton {{ min-width: 72px; min-height: 54px; padding: 5px 11px; border-radius: 8px; }} + QToolButton#NavigationToggleButton, + QToolButton#NavigationPageButton {{ + min-width: 0; + min-height: 38px; + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 7px; + background-color: transparent; + color: {palette['text']}; + text-align: left; + }} + + QToolButton#NavigationToggleButton:hover, + QToolButton#NavigationPageButton:hover {{ + border-color: transparent; + background-color: {palette['hover']}; + color: {palette['text_strong']}; + }} + + QToolButton#NavigationPageButton:checked {{ + border: none; + border-left: 3px solid {palette['accent']}; + border-radius: 7px; + background-color: {palette['accent_soft']}; + color: {palette['text_strong']}; + font-weight: 600; + }} + + QToolButton#NavigationPageButton:checked:hover {{ + background-color: {palette['accent_soft_hover']}; + }} + QToolButton:hover {{ border-color: {palette['border']}; background-color: {palette['hover']}; diff --git a/Furious/Widget/NavigationView.py b/Furious/Widget/NavigationView.py new file mode 100644 index 0000000..b38a930 --- /dev/null +++ b/Furious/Widget/NavigationView.py @@ -0,0 +1,270 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide extensible collapsible navigation for application pages.""" + +from __future__ import annotations + +from Furious.Frozenlib import APP, Mixins +from Furious.Qt import AppStyleSheet, bootstrapIcon, bootstrapIconWhite +from Furious.Qt import gettext as _ + +from PySide6 import QtCore +from PySide6.QtWidgets import * + +from dataclasses import dataclass + +__all__ = ['NavigationView'] + + +@dataclass +class _NavigationPage: + """Describe one registered page and its navigation control.""" + + id: str + title: str + iconFileName: str + widget: QWidget + button: QToolButton + translatable: bool = True + + +class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): + """Route registered pages through a collapsible left navigation rail.""" + + pageChanged = QtCore.Signal(str) + expandedChanged = QtCore.Signal(bool) + + CollapsedWidth = 56 + ExpandedWidth = 220 + IconSize = QtCore.QSize(20, 20) + + def __init__(self, parent=None): + """Initialize an empty page registry and its navigation controls.""" + super().__init__(parent) + + self._pages: dict[str, _NavigationPage] = {} + self._currentPageId = '' + self._expanded = False + + self.setObjectName('NavigationView') + + self.navigationPanel = QFrame(parent=self) + self.navigationPanel.setObjectName('NavigationPanel') + + self.toggleButton = QToolButton(parent=self.navigationPanel) + self.toggleButton.setObjectName('NavigationToggleButton') + self.toggleButton.setToolTip(_('Expand Navigation')) + self.toggleButton.setIconSize(self.IconSize) + self.toggleButton.clicked.connect(self.toggleExpanded) + + self.navigationLayout = QVBoxLayout(self.navigationPanel) + self.navigationLayout.setContentsMargins(6, 8, 6, 8) + self.navigationLayout.setSpacing(4) + self.navigationLayout.addWidget(self.toggleButton) + + self.pageButtonLayout = QVBoxLayout() + self.pageButtonLayout.setContentsMargins(0, 6, 0, 0) + self.pageButtonLayout.setSpacing(4) + self.navigationLayout.addLayout(self.pageButtonLayout) + self.navigationLayout.addStretch() + + self.pageButtonGroup = QButtonGroup(self) + self.pageButtonGroup.setExclusive(True) + + self.pageStack = QStackedWidget(parent=self) + self.pageStack.setObjectName('ApplicationPageStack') + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(0) + self._layout.addWidget(self.navigationPanel) + self._layout.addWidget(self.pageStack, 1) + + self.setExpanded(False) + self.setIconByTheme(APP().theme()) + + def addPage( + self, + pageId: str, + widget: QWidget, + title: str, + iconFileName: str, + *, + translatable: bool = True, + ): + """Register a page and create its navigation control.""" + if not isinstance(pageId, str) or not pageId: + raise ValueError('pageId must be a non-empty string') + + if pageId in self._pages: + raise ValueError(f'page is already registered: {pageId}') + + if not isinstance(widget, QWidget): + raise TypeError('widget must be a QWidget') + + button = QToolButton(parent=self.navigationPanel) + button.setObjectName('NavigationPageButton') + button.setProperty('pageId', pageId) + button.setCheckable(True) + button.setIconSize(self.IconSize) + button.setToolButtonStyle( + QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon + if self._expanded + else QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly + ) + button.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + button.clicked.connect( + lambda _checked=False, selectedPageId=pageId: self.setCurrentPage( + selectedPageId + ) + ) + + page = _NavigationPage( + pageId, + title, + iconFileName, + widget, + button, + translatable, + ) + self._pages[pageId] = page + self.pageButtonGroup.addButton(button) + self.pageButtonLayout.addWidget(button) + self.pageStack.addWidget(widget) + + self._updatePageText(page) + self._updatePageIcon(page, APP().theme()) + + if not self._currentPageId: + self.setCurrentPage(pageId) + + def page(self, pageId: str) -> QWidget | None: + """Return the registered widget for *pageId*.""" + page = self._pages.get(pageId) + + return page.widget if page is not None else None + + def currentPageId(self) -> str: + """Return the identifier of the visible page.""" + return self._currentPageId + + @QtCore.Slot(str) + def setCurrentPage(self, pageId: str): + """Show the page registered under *pageId*.""" + page = self._pages.get(pageId) + + if page is None: + return + + changed = pageId != self._currentPageId + self._currentPageId = pageId + page.button.setChecked(True) + self.pageStack.setCurrentWidget(page.widget) + + if changed: + self.pageChanged.emit(pageId) + + def isExpanded(self) -> bool: + """Return whether labels are visible beside navigation icons.""" + return self._expanded + + @QtCore.Slot() + def toggleExpanded(self): + """Toggle between the compact rail and expanded navigation menu.""" + self.setExpanded(not self._expanded) + + @QtCore.Slot(bool) + def setExpanded(self, expanded: bool): + """Set the navigation menu expansion state.""" + expanded = bool(expanded) + changed = expanded != self._expanded + self._expanded = expanded + + self.navigationPanel.setFixedWidth( + self.ExpandedWidth if expanded else self.CollapsedWidth + ) + self.toggleButton.setToolTip( + _('Collapse Navigation') if expanded else _('Expand Navigation') + ) + self.toggleButton.setProperty('expanded', expanded) + self._refreshWidgetStyle(self.toggleButton) + self.setIconByTheme(APP().theme()) + + style = ( + QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon + if expanded + else QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly + ) + + for page in self._pages.values(): + page.button.setToolButtonStyle(style) + self._updatePageText(page) + + if changed: + self.expandedChanged.emit(expanded) + + @staticmethod + def _refreshWidgetStyle(widget: QWidget): + """Re-polish a widget after a dynamic property changes.""" + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + def _updatePageText(self, page: _NavigationPage): + """Apply translated navigation text and compact-mode tooltips.""" + text = _(page.title) if page.translatable else page.title + page.button.setText(text) + page.button.setToolTip('' if self._expanded else text) + + @staticmethod + def _iconFactory(theme: str): + """Return the icon factory appropriate for *theme*.""" + return bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon + + def _updatePageIcon(self, page: _NavigationPage, theme: str): + """Apply one page icon for the active theme.""" + page.button.setIcon(self._iconFactory(theme)(page.iconFileName)) + + def setIconByTheme(self, theme: str): + """Refresh navigation icons for the active theme.""" + iconFactory = self._iconFactory(theme) + toggleIcon = ( + 'layout-sidebar-inset-reverse.svg' + if self._expanded + else 'layout-sidebar-inset.svg' + ) + self.toggleButton.setIcon(iconFactory(toggleIcon)) + + for page in self._pages.values(): + self._updatePageIcon(page, theme) + + def themeChangedCallback(self, theme: str): + """Refresh icons after an application theme change.""" + self.setIconByTheme(theme) + + def retranslate(self): + """Refresh navigation labels and expansion affordances.""" + self.toggleButton.setToolTip( + _('Collapse Navigation') if self._expanded else _('Expand Navigation') + ) + + for page in self._pages.values(): + self._updatePageText(page) diff --git a/Furious/Widget/__init__.py b/Furious/Widget/__init__.py index b03e2c9..b5a819d 100644 --- a/Furious/Widget/__init__.py +++ b/Furious/Widget/__init__.py @@ -20,12 +20,14 @@ from __future__ import annotations from .ConnectionProgressWidget import ConnectionProgressWidget +from .NavigationView import NavigationView from .ServerTableView import ServerTableView from .SubscriptionTableView import SubscriptionTableView from .WaitingSpinner import WaitingSpinner __all__ = [ 'ConnectionProgressWidget', + 'NavigationView', 'ServerTableView', 'SubscriptionTableView', 'WaitingSpinner', diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py new file mode 100644 index 0000000..bcec94b --- /dev/null +++ b/Furious/Window/HomePage.py @@ -0,0 +1,960 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide the server-management home page.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Models import * +from Furious.Repository import * +from Furious.Plugins import CapabilityKind, getPluginRegistry +from Furious.Qt import * +from Furious.Qt import gettext as _ +from Furious.Service import ( + ConnectivityManager, + TrafficStatsManager, + UpdateManager, + formatTrafficSpeed, + formatTrafficUsage, +) +from Furious.Actions.Import import ( + ImportFromFileAction, + ImportJSONFromClipboardAction, + ImportQRCodeOnTheScreenAction, + ImportURIFromClipboardAction, +) +from Furious.Widget.ServerTableView import * +from Furious.Window.NetworkTestDialog import * +from Furious.Window.ProxyBypassDialog import * +from Furious.Window.QRCodeWindow import QRCodeWindow +from Furious.Window.SubscriptionWindow import * +from Furious.Window.TextEditorWindow import TextEditorWindow +from Furious.Window.TunSettingsDialog import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +from typing import Union + +import os +import logging +import functools + +__all__ = ['HomePage'] + +logger = logging.getLogger(__name__) + + +def _isCoreActive(coreType) -> bool: + """Return whether the tray's active connection owns *coreType*.""" + try: + connectAction = APP().systemTray.ConnectAction + + return connectAction.isConnected() and any( + isinstance(process, coreType) + for process in connectAction.coreManager.processesPool + ) + except (AttributeError, RuntimeError): + # The home page is built before the system tray is attached. + return False + + +class AppConnectivityManager(ConnectivityManager): + """Coordinate app network connectivity operations.""" + + def __init__(self, parent=None): + """Initialize the application connectivity manager.""" + super().__init__(parent) + + def successCallback(self, networkReply, **kwargs): + """Handle a successful network operation.""" + parent = self.parent() + + if isinstance(parent, HomePage): + parent.setNetworkState(True) + + super().successCallback(networkReply, **kwargs) + + def failureCallback(self, networkReply, **kwargs): + """Handle a failed network operation.""" + parent = self.parent() + + if isinstance(parent, HomePage): + parent.setNetworkState(False, errorString=networkReply.errorString()) + + super().failureCallback(networkReply, **kwargs) + + def startSingleTest(self): + """Start single test.""" + if not APP().isSystemTrayConnected(): + parent = self.parent() + + if isinstance(parent, HomePage): + parent.resetNetworkState() + + self.stopTest() + + connectedHttpProxy = Storage.Extras.UserHttpProxy() + + if connectedHttpProxy is None: + parent = self.parent() + + if isinstance(parent, HomePage): + parent.resetNetworkState() + else: + self.configureHttpProxy(connectedHttpProxy) + + super().startSingleTest() + + def disconnectedCallback(self): + """Update the app network connectivity manager for a disconnected state.""" + parent = self.parent() + + if isinstance(parent, HomePage): + parent.resetNetworkState() + + super().disconnectedCallback() + + +class NetworkStateBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): + """Provide the network state badge widget.""" + + DefaultIconFileName = 'reception-4.svg' + StateIconFileName = { + 'success': 'reception-4.svg', + 'failure': 'reception-0.svg', + } + IconSize = QtCore.QSize(16, 16) + + def __init__(self, parent=None): + """Initialize the NetworkStateBadge.""" + super().__init__(parent) + + self.currentIconFileName = self.DefaultIconFileName + self.currentState = '' + self.currentRemark = '' + self.currentErrorString = '' + + self.setObjectName('NetworkStateBadge') + self.setProperty('networkState', '') + self.setVisible(False) + + self.iconLabel = QLabel(parent=self) + self.textLabel = AppQLabel(translatable=False, parent=self) + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(10, 3, 10, 3) + self._layout.setSpacing(7) + self._layout.addWidget(self.iconLabel) + self._layout.addWidget(self.textLabel) + + self.setIconByTheme(APP().theme()) + + def setIconByTheme(self, theme: str, iconFileName: Union[str, None] = None): + """Set icon by theme.""" + iconFileName = iconFileName or self.currentIconFileName + + if theme == AppStyleSheet.Dark: + icon = bootstrapIconWhite(iconFileName) + else: + icon = bootstrapIcon(iconFileName) + + self.iconLabel.setPixmap(icon.pixmap(self.IconSize)) + + def statusText(self): + """Return the status text value used by the network state badge.""" + if self.currentState == 'success': + return f'{_("Network OK")} - {self.currentRemark}' + + if self.currentState == 'failure': + return f'{_("Network error")} - {self.currentRemark}' + + return '' + + def statusToolTip(self): + """Return the status tool tip value used by the network state badge.""" + if self.currentState == 'success': + return self.currentRemark + + if self.currentState == 'failure': + return f'{self.currentRemark}\n{self.currentErrorString}'.strip() + + return '' + + def updateStatusText(self): + """Update status text.""" + text = self.statusText() + tooltip = self.statusToolTip() + + self.textLabel.setText(text) + self.setToolTip(tooltip) + self.iconLabel.setToolTip(tooltip) + self.textLabel.setToolTip(tooltip) + + def setStatus(self, state: str, remark: str, errorString: str = ''): + """Set status.""" + self.currentState = state + self.currentRemark = remark + self.currentErrorString = errorString + + self.updateStatusText() + self.setProperty('networkState', state) + self.currentIconFileName = self.StateIconFileName.get( + state, self.DefaultIconFileName + ) + self.setIconByTheme(APP().theme()) + self.refreshStyle() + + def clearStatus(self): + """Clear status.""" + self.currentState = '' + self.currentRemark = '' + self.currentErrorString = '' + + self.updateStatusText() + self.setProperty('networkState', '') + self.currentIconFileName = self.DefaultIconFileName + self.setIconByTheme(APP().theme()) + self.setVisible(False) + self.refreshStyle() + + def refreshStyle(self): + """Refresh style.""" + for widget in [self, self.iconLabel, self.textLabel]: + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + self.setVisible(bool(self.textLabel.text())) + + def themeChangedCallback(self, theme: str): + """Update the network state badge for a theme change.""" + self.setIconByTheme(theme) + + def retranslate(self): + """Refresh translated text for the network state badge.""" + self.updateStatusText() + + +class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): + """Display independently updated traffic speeds and usage.""" + + UploadIconFileName = 'cloud-upload.svg' + DownloadIconFileName = 'cloud-download.svg' + IconSize = QtCore.QSize(16, 16) + + def __init__(self, parent=None): + """Initialize dedicated traffic-direction icons and labels.""" + super().__init__(parent) + + self.setObjectName('TrafficStatsBadge') + self.setVisible(False) + + self.uploadIconLabel = QLabel(parent=self) + self.uploadTextLabel = AppQLabel(translatable=False, parent=self) + self.uploadUsageLabel = AppQLabel(translatable=False, parent=self) + self.downloadIconLabel = QLabel(parent=self) + self.downloadTextLabel = AppQLabel(translatable=False, parent=self) + self.downloadUsageLabel = AppQLabel(translatable=False, parent=self) + + self.uploadUsageLabel.setObjectName('TrafficUsageLabel') + self.downloadUsageLabel.setObjectName('TrafficUsageLabel') + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(8, 3, 8, 3) + self._layout.setSpacing(6) + self._layout.addWidget(self.uploadIconLabel) + self._layout.addWidget(self.uploadTextLabel) + self._layout.addWidget(self.uploadUsageLabel) + self._layout.addSpacing(4) + self._layout.addWidget(self.downloadIconLabel) + self._layout.addWidget(self.downloadTextLabel) + self._layout.addWidget(self.downloadUsageLabel) + + self.setIconByTheme(APP().theme()) + self.updateToolTips() + + def updateToolTips(self): + """Apply translated descriptions to each traffic statistic.""" + uploadSpeed, downloadSpeed, uploadUsage, downloadUsage = ( + _('Upload Speed'), + _('Download Speed'), + _('Upload Traffic Usage'), + _('Download Traffic Usage'), + ) + + self.uploadIconLabel.setToolTip(uploadSpeed) + self.uploadTextLabel.setToolTip(uploadSpeed) + self.uploadUsageLabel.setToolTip(uploadUsage) + self.downloadIconLabel.setToolTip(downloadSpeed) + self.downloadTextLabel.setToolTip(downloadSpeed) + self.downloadUsageLabel.setToolTip(downloadUsage) + + def setIconByTheme(self, theme: str): + """Apply theme-appropriate upload and download icons.""" + iconFactory = ( + bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon + ) + + self.uploadIconLabel.setPixmap( + iconFactory(self.UploadIconFileName).pixmap(self.IconSize) + ) + self.downloadIconLabel.setPixmap( + iconFactory(self.DownloadIconFileName).pixmap(self.IconSize) + ) + + @QtCore.Slot(float, float) + def setSpeeds(self, upload: float, download: float): + """Display formatted upload and download byte rates.""" + self.uploadTextLabel.setText(formatTrafficSpeed(upload)) + self.downloadTextLabel.setText(formatTrafficSpeed(download)) + self.setVisible(True) + + @QtCore.Slot(object, object) + def setUsage(self, upload: int, download: int): + """Display formatted cumulative upload and download usage.""" + self.uploadUsageLabel.setText(f'({formatTrafficUsage(upload)})') + self.downloadUsageLabel.setText(f'({formatTrafficUsage(download)})') + self.setVisible(True) + + @QtCore.Slot() + def clearStatistics(self): + """Hide stale speed and usage values when statistics are unavailable.""" + self.uploadTextLabel.clear() + self.uploadUsageLabel.clear() + self.downloadTextLabel.clear() + self.downloadUsageLabel.clear() + self.setVisible(False) + + def themeChangedCallback(self, theme: str): + """Refresh traffic-direction icons after a theme change.""" + self.setIconByTheme(theme) + + def retranslate(self): + """Refresh translated traffic-statistic tooltips.""" + self.updateToolTips() + + +class ConnectionStatusWidget(QWidget): + """Compose network state and traffic speed as one permanent status item.""" + + def __init__(self, parent=None): + """Initialize separate network-state and traffic-speed components.""" + super().__init__(parent) + + self.setObjectName('ConnectionStatusWidget') + self.networkState = NetworkStateBadge(parent=self) + self.trafficStats = TrafficStatsBadge(parent=self) + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(6) + self._layout.addWidget(self.trafficStats) + self._layout.addWidget(self.networkState) + + +class SearchButton(AppQPushButton): + """Represent search button.""" + + def __init__(self, *args, **kwargs): + """Initialize the SearchButton.""" + super().__init__(*args, **kwargs) + + self.setText(self.customText()) + self.setIcon(bootstrapIcon('search.svg')) + + @staticmethod + def customText(): + """Return the user-facing message text for the search button.""" + return ' ' * 2 + _('Search') + + def retranslate(self): + """Refresh translated text for the search button.""" + self.setText(self.customText()) + + +class HomePage(Mixins.QTranslatable, QMainWindow): + """Own server management, its actions, and connection presentation.""" + + @staticmethod + def serverImportActions(): + """Build import actions contributed to the server table context menu.""" + return ( + ImportFromFileAction(), + ImportURIFromClipboardAction( + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_V, + ), + ), + ImportJSONFromClipboardAction( + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier, + QtCore.Qt.Key.Key_J, + ), + ), + ImportQRCodeOnTheScreenAction(), + ) + + def __init__(self, *args, **kwargs): + """Initialize the server-management page.""" + super().__init__(*args, **kwargs) + + self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + + self.updatesManager = UpdateManager(parent=self) + self.networkConnectivityManager = AppConnectivityManager(parent=self) + + self.userServersQTableWidget = ServerTableView( + parent=self, + configurationEditorFactory=lambda: TextEditorWindow(parent=self), + qrCodeWindowFactory=QRCodeWindow, + importActionsFactory=self.serverImportActions, + ) + self.userSubsWindow = SubscriptionWindow( + parent=self, + deleteUniqueCallback=lambda unique: self.userServersQTableWidget.deleteItemByIndex( + list( + index + for index, server in enumerate(Storage.UserServers()) + if server.itemSubscription == unique + ), + showProgress=False, + ), + ) + pluginRegistry = getPluginRegistry() + self.customizeProxyBypassDialog = ProxyBypassDialog(parent=self) + self.customizeNetworkTestDialog = NetworkTestDialog(parent=self) + + serverActions = [] + + for descriptor in pluginRegistry.protocolDescriptors(): + if ( + not descriptor.addActionText + or pluginRegistry.editorForProtocol(descriptor.id) is None + ): + continue + + if descriptor.separatorBefore and serverActions: + serverActions.append(AppQSeperator()) + + actionText = ( + _(descriptor.addActionText) + if descriptor.translatable + else descriptor.addActionText + ) + serverActions.append( + AppQAction( + actionText, + callback=functools.partial( + self.userServersQTableWidget.addServerViaGui, + descriptor.id, + actionText, + ), + ) + ) + + if serverActions: + serverActions.append(AppQSeperator()) + serverActions.append( + AppQAction( + _('New Empty Configuration'), + callback=lambda: self.userServersQTableWidget.newEmptyItem(), + ) + ) + + subsActions = [ + AppQAction( + _('Update Subscription (Use Current Proxy)'), + callback=lambda: self.userServersQTableWidget.updateSubs( + Storage.Extras.UserHttpProxy(), + parent=self, + ), + ), + AppQAction( + _('Update Subscription (Force Proxy)'), + callback=lambda: self.userServersQTableWidget.updateSubs( + '127.0.0.1:10809', + parent=self, + ), + ), + AppQAction( + _('Update Subscription (No Proxy)'), + callback=lambda: self.userServersQTableWidget.updateSubs( + None, + parent=self, + ), + ), + AppQSeperator(), + AppQAction( + _('Edit Subscription...'), + icon=bootstrapIcon('star.svg'), + callback=lambda: self.userSubsWindow.show(), + ), + ] + + if SystemRuntime.flatpakID(): + customizeTUNSettingsAction = [] + else: + customizeTUNSettingsAction = [ + AppQAction( + _('Customize Tun2socks Settings...'), + icon=bootstrapIcon('diagram-3.svg'), + checkable=False, + callback=lambda: self.getGuiTUNSettings().open(), + ), + ] + + if PLATFORM == 'Windows' or PLATFORM == 'Darwin': + _TRANSLATABLE_RESTART_AS_ADMIN = [ + _('Restart The Application As Administrator'), + _('Restart The Application As Superuser'), + ] + + restartAsAdminAction = [ + AppQAction( + _(f'Restart The Application As {ADMINISTRATOR_NAME}'), + icon=bootstrapIcon('arrow-clockwise.svg'), + checkable=False, + callback=lambda: self.restartAsAdmin(), + ), + ] + else: + restartAsAdminAction = [] + + if PLATFORM == 'Darwin' or SystemRuntime.flatpakID(): + openAppFolderAction = [] + else: + openAppFolderAction = [ + AppQAction( + _('Open Application Folder'), + icon=bootstrapIcon('folder2-open.svg'), + checkable=False, + callback=lambda: self.openApplicationFolder(), + ), + ] + + toolsActions = [ + *customizeTUNSettingsAction, + AppQAction( + _('Customize System Proxy Bypass Address...'), + checkable=False, + callback=lambda: self.customizeProxyBypassDialog.open(), + ), + AppQAction( + _('Customize Network Test URL...'), + icon=bootstrapIcon('speedometer2.svg'), + checkable=False, + callback=lambda: self.customizeNetworkTestDialog.open(), + ), + ] + systemTools = [*restartAsAdminAction, *openAppFolderAction] + + if systemTools: + toolsActions.extend([AppQSeperator(), *systemTools]) + + corePluginActions = [] + + for plugin in pluginRegistry.pluginsWithCapability( + CapabilityKind.KernelFactory + ): + pluginMetadata = pluginRegistry.metadataFor(plugin) + managementActions = pluginRegistry.managementActions( + plugin, + parent=self, + isCoreActive=_isCoreActive, + ) + if managementActions: + corePluginActions.append( + AppQAction( + pluginMetadata.displayName, + menu=AppQMenu(*managementActions), + useActionGroup=False, + checkable=False, + ) + ) + else: + corePluginActions.append( + AppQAction( + pluginMetadata.displayName, + checkable=False, + ) + ) + + extensionPluginActions = [] + + corePlugins = set( + pluginRegistry.pluginsWithCapability(CapabilityKind.KernelFactory) + ) + + for plugin in pluginRegistry.pluginsWithCapability( + CapabilityKind.ActionProvider + ): + if plugin in corePlugins: + continue + + managementActions = pluginRegistry.managementActions( + plugin, + parent=self, + isCoreActive=_isCoreActive, + ) + + if managementActions: + pluginMetadata = pluginRegistry.metadataFor(plugin) + extensionPluginActions.append( + AppQAction( + pluginMetadata.displayName, + menu=AppQMenu(*managementActions), + useActionGroup=False, + checkable=False, + ) + ) + + pluginActions = [ + AppQAction( + _('Core'), + menu=AppQMenu(*corePluginActions), + useActionGroup=False, + checkable=False, + ), + ] + + if extensionPluginActions: + pluginActions.append( + AppQAction( + _('Extensions'), + menu=AppQMenu(*extensionPluginActions), + useActionGroup=False, + checkable=False, + ) + ) + + pluginsToolbarActions = [ + AppQSeperator(), + AppQAction( + _('Plugins'), + icon=bootstrapIcon('plugin.svg'), + menu=AppQMenu(*pluginActions), + useSetMenu=False, + useActionGroup=False, + checkable=False, + ), + ] + + if hasattr(AppQAction, 'setMenu'): + self.toolbar = AppQToolBar( + AppQAction( + _('Server'), + icon=bootstrapIcon('server.svg'), + menu=AppQMenu(*serverActions), + useSetMenu=False, + useActionGroup=False, + checkable=False, + ), + AppQSeperator(), + AppQAction( + _('Subscription'), + icon=bootstrapIcon('collection.svg'), + menu=AppQMenu(*subsActions), + useSetMenu=False, + useActionGroup=False, + checkable=False, + ), + *pluginsToolbarActions, + AppQSeperator(), + AppQAction( + _('Tools'), + icon=bootstrapIcon('tools.svg'), + menu=AppQMenu(*toolsActions), + useSetMenu=False, + useActionGroup=False, + checkable=False, + ), + AppQSeperator(), + AppQAction( + _('Check For Updates'), + icon=bootstrapIcon('download.svg'), + checkable=False, + callback=lambda: self.checkForUpdates(parent=self), + ), + AppQSeperator(), + AppQAction( + _('About'), + icon=bootstrapIcon('info-circle.svg'), + checkable=False, + callback=lambda: self.openAboutPage(), + ), + ) + self.toolbar.setObjectName('HomePageToolBar') + self.toolbar.setIconSize(QtCore.QSize(64, 32)) + self.toolbar.setToolButtonStyle( + QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon + ) + self.addToolBar(self.toolbar) + else: + # Menu actions + serverMenu = { + 'name': 'Server', + 'actions': [*serverActions], + } + + subsMenu = { + 'name': 'Subscription', + 'actions': [*subsActions], + } + + pluginsMenu = { + 'name': 'Plugins', + 'actions': [*pluginActions], + } + + toolsMenu = { + 'name': 'Tools', + 'actions': [*toolsActions], + } + + helpMenu = { + 'name': 'Help', + 'actions': [ + AppQAction( + _('Check For Updates'), + icon=bootstrapIcon('download.svg'), + checkable=False, + callback=lambda: self.checkForUpdates(parent=self), + ), + AppQSeperator(), + AppQAction( + _('About'), + icon=bootstrapIcon('info-circle.svg'), + checkable=False, + callback=lambda: self.openAboutPage(), + ), + ], + } + + # Corresponds to menus defined above + _TRANSLATABLE_MENU_NAME = [ + _('Server'), + _('Subscription'), + _('Plugins'), + _('Tools'), + _('Help'), + ] + + # Menus + for menuDict in ( + serverMenu, + subsMenu, + pluginsMenu, + toolsMenu, + helpMenu, + ): + menuName = menuDict['name'] + menuObjName = f'_{menuName}Menu' + menu = AppQMenu( + *menuDict['actions'], title=_(menuName), parent=self.menuBar() + ) + + # Set reference + setattr(self, menuObjName, menu) + + self.menuBar().addMenu(menu) + + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) + + # TODO: Custom status tip + # self.setStatusBar(QStatusBar(self)) + + self.connectionStatus = ConnectionStatusWidget(parent=self) + self.networkState = self.connectionStatus.networkState + self.trafficStats = self.connectionStatus.trafficStats + self.trafficStatsManager = TrafficStatsManager(parent=self) + self.trafficStatsManager.speedChanged.connect(self.trafficStats.setSpeeds) + self.trafficStatsManager.usageChanged.connect(self.trafficStats.setUsage) + self.trafficStatsManager.statisticsUnavailable.connect( + self.trafficStats.clearStatistics + ) + + self.statusBar().addPermanentWidget(self.connectionStatus) + + self._widget = QWidget() + self._layout = QVBoxLayout(self._widget) + + self.searchLayout = QHBoxLayout() + + self.searchLineEdit = AppQLineEdit() + self.searchLineEdit.setPlaceholderText( + _( + 'Search servers with text or regex, e.g. trojan, hk|jp, ^vmess, (us|sg).*tls' + ) + ) + + self.searchButton = SearchButton() + + self.searchLayout.addWidget(self.searchLineEdit) + self.searchLayout.addWidget(self.searchButton) + + self._layout.addLayout(self.searchLayout) + self._layout.addWidget(self.userServersQTableWidget) + + self.searchButton.clicked.connect( + lambda: self.userServersQTableWidget.search(self.searchLineEdit.text()) + ) + self.searchLineEdit.returnPressed.connect( + lambda: self.userServersQTableWidget.search(self.searchLineEdit.text()) + ) + self.searchLineEdit.textChanged.connect(self.handleUserServersSearchTextChanged) + + self.setCentralWidget(self._widget) + + @QtCore.Slot(str) + def handleUserServersSearchTextChanged(self, text: str): + """Handle user servers search text changed.""" + if not text: + self.userServersQTableWidget.clearSearch() + + def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs): + """Update subs by unique.""" + self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs) + + def appendNewItemByFactory(self, factory: ConfigFactory | ServerProfile): + """Append new item by factory.""" + self.userServersQTableWidget.appendNewItemByFactory(factory) + + def flushRow(self, row: int, item: ServerProfile): + """Refresh row.""" + self.userServersQTableWidget.flushRow(row, item) + + def showTabAndSpaces(self): + """Show tab and spaces.""" + self.userServersQTableWidget.showTabAndSpaces() + + def hideTabAndSpaces(self): + """Hide tab and spaces.""" + self.userServersQTableWidget.hideTabAndSpaces() + + def getGuiTUNSettings(self, **kwargs): + """Return GUI TUN settings.""" + + @functools.lru_cache(None) + def cachedGuiTUNSettings(): + """Return the TUN settings editor owned by the home page.""" + parent = kwargs.pop('parent', self) + + return TunSettingsDialog(parent=parent, **kwargs) + + guiTUNSettings = cachedGuiTUNSettings() + guiTUNSettings.factoryToInput(Storage.UserTUNSettings()) + + return guiTUNSettings + + def checkForUpdates(self, **kwargs): + """Check for updates.""" + self.updatesManager.configureHttpProxy(Storage.Extras.UserHttpProxy()) + self.updatesManager.checkForUpdates(**kwargs) + + def resetNetworkState(self): + """Reset network state.""" + self.networkState.clearStatus() + + def setNetworkState(self, success: bool, **kwargs): + """Set network state.""" + remark = Storage.Extras.UserServerRemark() + + if not remark: + self.resetNetworkState() + + return + + if success: + self.networkState.setStatus( + 'success', + remark, + ) + + return + + errorString = kwargs.pop('errorString', '') + + self.networkState.setStatus( + 'failure', + remark, + errorString, + ) + + @staticmethod + def openApplicationFolder(): + """Open application folder.""" + appFolder = os.path.dirname(APP().applicationFilePath()) + + if QDesktopServices.openUrl(QtCore.QUrl(appFolder)): + logger.info(f'open application folder \'{appFolder}\' success') + else: + logger.error(f'open application folder \'{appFolder}\' failed') + + @staticmethod + def restartAsAdmin(): + """Handle the home-page request to restart with elevated privileges.""" + if not SystemRuntime.isScriptMode(): + if not SystemRuntime.isAdmin(): + process = QtCore.QProcess() + + if PLATFORM == 'Windows': + process.startDetached( + 'powershell', + arguments=[ + '-Command', + f'Start-Process \'{APP().applicationFilePath()}\' ' + f'\'{AppBuiltinCommand.RunAs.value}\' -Verb runAs', + ], + ) + elif PLATFORM == 'Darwin': + process.startDetached( + 'osascript', + arguments=[ + '-e', + f'do shell script \"{APP().applicationFilePath()} ' + f'{AppBuiltinCommand.RunAs.value}\" with administrator privileges', + ], + ) + else: + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) + mbox.setWindowTitle(_(APPLICATION_NAME)) + mbox.setText(_('I am the supreme authority 👑')) + + # Show the MessageBox asynchronously + mbox.open() + + logger.info('ignored request to restart as admin as already is') + else: + logger.info(f'ignored request to restart as admin in script mode') + + @staticmethod + def openAboutPage(): + """Open about page.""" + if QDesktopServices.openUrl(QtCore.QUrl(APPLICATION_ABOUT_PAGE)): + logger.info('open about page success') + else: + logger.error('open about page failed') + + def retranslate(self): + """Refresh text owned directly by the home page.""" + pass diff --git a/Furious/Window/LogWindow.py b/Furious/Window/LogPage.py similarity index 92% rename from Furious/Window/LogWindow.py rename to Furious/Window/LogPage.py index 8ca0de3..d1af206 100644 --- a/Furious/Window/LogWindow.py +++ b/Furious/Window/LogPage.py @@ -15,11 +15,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Provide the application window for log viewer window.""" +"""Provide the unified logging page.""" from __future__ import annotations -from Furious.Frozenlib import AppSettings, registerAppSettings +from Furious.Frozenlib import AppSettings, Mixins, registerAppSettings from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Service.LogManager import ( @@ -33,7 +33,7 @@ from Furious.Service.LogManager import ( from PySide6 import QtCore from PySide6.QtWidgets import * -__all__ = ['LogWindow'] +__all__ = ['LogPage'] registerAppSettings('LogViewerWidgetPointSize') registerAppSettings('LogViewerSelectedCategory', default=ALL_LOGS_FILTER) @@ -110,11 +110,11 @@ def saveAsFile(content: str): mbox.open() -class LogWindow(AppQMainWindow): - """Present and filter the unified application log stream.""" +class LogPage(Mixins.QTranslatable, QMainWindow): + """Present and filter the unified application log stream as a page.""" def __init__(self, *args, **kwargs): - """Initialize the log window.""" + """Initialize the logging page.""" manager, fontFamily = ( kwargs.pop('manager', None), kwargs.pop('fontFamily', ''), @@ -132,8 +132,6 @@ class LogWindow(AppQMainWindow): self.manager = manager self._preferredFilter = str(AppSettings.get('LogViewerSelectedCategory')) - self.setWindowTitle(_('Log Viewer')) - self.filterLabel = AppQLabel(_('Log Type')) self.filterComboBox = QComboBox() self.filterComboBox.setMinimumWidth(180) @@ -163,11 +161,6 @@ class LogWindow(AppQMainWindow): _('Save As...'), callback=lambda: saveAsFile(self.textBrowser.toPlainText()), ), - AppQSeperator(), - AppQAction( - _('Exit'), - callback=lambda: self.close(), - ), title=_('File'), parent=self, ) @@ -285,7 +278,7 @@ class LogWindow(AppQMainWindow): @QtCore.Slot(object) def _categoryRegistered(self, category): - """Add a newly registered component without rebuilding the window.""" + """Add a newly registered component without rebuilding the page.""" if self.filterComboBox.findData(category.id) == -1: self.filterComboBox.addItem(self._categoryText(category), category.id) @@ -307,12 +300,6 @@ class LogWindow(AppQMainWindow): """Refresh the presentation after the underlying collection changes.""" self._refreshEntries() - def showEvent(self, event): - """Focus the window itself instead of an untouched child control.""" - super().showEvent(event) - - self.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) - def plainText(self) -> str: """Return the plain text currently shown by the selected filter.""" return self.textBrowser.toPlainText() @@ -322,8 +309,7 @@ class LogWindow(AppQMainWindow): self.manager.clear() def retranslate(self): - """Refresh translated window and filter labels.""" - super().retranslate() + """Refresh translated page controls and filter labels.""" selectedCategoryId = self.filterComboBox.currentData() diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 5839f55..209a80c 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -15,1011 +15,172 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Provide the application window for app main window.""" +"""Provide the page-based application main window.""" from __future__ import annotations from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Models import * -from Furious.Repository import * -from Furious.Plugins import CapabilityKind, getPluginRegistry +from Furious.Models import ConfigFactory, ServerProfile from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Service import ( - ConnectivityManager, - TrafficStatsManager, - UpdateManager, - formatTrafficSpeed, - formatTrafficUsage, -) -from Furious.Actions.Import import ( - ImportFromFileAction, - ImportJSONFromClipboardAction, - ImportQRCodeOnTheScreenAction, - ImportURIFromClipboardAction, -) -from Furious.Widget.ServerTableView import * -from Furious.Window.NetworkTestDialog import * -from Furious.Window.ProxyBypassDialog import * -from Furious.Window.QRCodeWindow import QRCodeWindow -from Furious.Window.SubscriptionWindow import * -from Furious.Window.TextEditorWindow import TextEditorWindow -from Furious.Window.TunSettingsDialog import * +from Furious.Widget.NavigationView import NavigationView +from Furious.Window.HomePage import HomePage +from Furious.Window.LogPage import LogPage from PySide6 import QtCore -from PySide6.QtGui import * -from PySide6.QtWidgets import * from typing import Union -import os import logging -import functools __all__ = ['MainWindow'] logger = logging.getLogger(__name__) - -def _isCoreActive(coreType) -> bool: - """Return whether the tray's active connection owns *coreType*.""" - try: - connectAction = APP().systemTray.ConnectAction - - return connectAction.isConnected() and any( - isinstance(process, coreType) - for process in connectAction.coreManager.processesPool - ) - except (AttributeError, RuntimeError): - # The main window is built before the system tray is attached. - return False - - -# Migrate legacy settings +# Migrate legacy settings. registerAppSettings('ServerWidgetWindowSize') registerAppSettings('AppMainWindowGeometry') registerAppSettings('AppMainWindowState') +registerAppSettings('AppMainWindowSelectedPage', default='home') +registerAppSettings('AppMainWindowNavigationExpanded', isBinary=True) - -class AppConnectivityManager(ConnectivityManager): - """Coordinate app network connectivity operations.""" - - def __init__(self, parent=None): - """Initialize the application connectivity manager.""" - super().__init__(parent) - - def successCallback(self, networkReply, **kwargs): - """Handle a successful network operation.""" - parent = self.parent() - - if isinstance(parent, MainWindow): - parent.setNetworkState(True) - - super().successCallback(networkReply, **kwargs) - - def failureCallback(self, networkReply, **kwargs): - """Handle a failed network operation.""" - parent = self.parent() - - if isinstance(parent, MainWindow): - parent.setNetworkState(False, errorString=networkReply.errorString()) - - super().failureCallback(networkReply, **kwargs) - - def startSingleTest(self): - """Start single test.""" - if not APP().isSystemTrayConnected(): - parent = self.parent() - - if isinstance(parent, MainWindow): - parent.resetNetworkState() - - self.stopTest() - - connectedHttpProxy = Storage.Extras.UserHttpProxy() - - if connectedHttpProxy is None: - parent = self.parent() - - if isinstance(parent, MainWindow): - parent.resetNetworkState() - else: - self.configureHttpProxy(connectedHttpProxy) - - super().startSingleTest() - - def disconnectedCallback(self): - """Update the app network connectivity manager for a disconnected state.""" - parent = self.parent() - - if isinstance(parent, MainWindow): - parent.resetNetworkState() - - super().disconnectedCallback() - - -class NetworkStateBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): - """Provide the network state badge widget.""" - - DefaultIconFileName = 'reception-4.svg' - StateIconFileName = { - 'success': 'reception-4.svg', - 'failure': 'reception-0.svg', - } - IconSize = QtCore.QSize(16, 16) - - def __init__(self, parent=None): - """Initialize the NetworkStateBadge.""" - super().__init__(parent) - - self.currentIconFileName = self.DefaultIconFileName - self.currentState = '' - self.currentRemark = '' - self.currentErrorString = '' - - self.setObjectName('NetworkStateBadge') - self.setProperty('networkState', '') - self.setVisible(False) - - self.iconLabel = QLabel(parent=self) - self.textLabel = AppQLabel(translatable=False, parent=self) - - self._layout = QHBoxLayout(self) - self._layout.setContentsMargins(10, 3, 10, 3) - self._layout.setSpacing(7) - self._layout.addWidget(self.iconLabel) - self._layout.addWidget(self.textLabel) - - self.setIconByTheme(APP().theme()) - - def setIconByTheme(self, theme: str, iconFileName: Union[str, None] = None): - """Set icon by theme.""" - iconFileName = iconFileName or self.currentIconFileName - - if theme == AppStyleSheet.Dark: - icon = bootstrapIconWhite(iconFileName) - else: - icon = bootstrapIcon(iconFileName) - - self.iconLabel.setPixmap(icon.pixmap(self.IconSize)) - - def statusText(self): - """Return the status text value used by the network state badge.""" - if self.currentState == 'success': - return f'{_("Network OK")} - {self.currentRemark}' - - if self.currentState == 'failure': - return f'{_("Network error")} - {self.currentRemark}' - - return '' - - def statusToolTip(self): - """Return the status tool tip value used by the network state badge.""" - if self.currentState == 'success': - return self.currentRemark - - if self.currentState == 'failure': - return f'{self.currentRemark}\n{self.currentErrorString}'.strip() - - return '' - - def updateStatusText(self): - """Update status text.""" - text = self.statusText() - tooltip = self.statusToolTip() - - self.textLabel.setText(text) - self.setToolTip(tooltip) - self.iconLabel.setToolTip(tooltip) - self.textLabel.setToolTip(tooltip) - - def setStatus(self, state: str, remark: str, errorString: str = ''): - """Set status.""" - self.currentState = state - self.currentRemark = remark - self.currentErrorString = errorString - - self.updateStatusText() - self.setProperty('networkState', state) - self.currentIconFileName = self.StateIconFileName.get( - state, self.DefaultIconFileName - ) - self.setIconByTheme(APP().theme()) - self.refreshStyle() - - def clearStatus(self): - """Clear status.""" - self.currentState = '' - self.currentRemark = '' - self.currentErrorString = '' - - self.updateStatusText() - self.setProperty('networkState', '') - self.currentIconFileName = self.DefaultIconFileName - self.setIconByTheme(APP().theme()) - self.setVisible(False) - self.refreshStyle() - - def refreshStyle(self): - """Refresh style.""" - for widget in [self, self.iconLabel, self.textLabel]: - widget.style().unpolish(widget) - widget.style().polish(widget) - widget.update() - - self.setVisible(bool(self.textLabel.text())) - - def themeChangedCallback(self, theme: str): - """Update the network state badge for a theme change.""" - self.setIconByTheme(theme) - - def retranslate(self): - """Refresh translated text for the network state badge.""" - self.updateStatusText() - - -class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): - """Display independently updated traffic speeds and usage.""" - - UploadIconFileName = 'cloud-upload.svg' - DownloadIconFileName = 'cloud-download.svg' - IconSize = QtCore.QSize(16, 16) - - def __init__(self, parent=None): - """Initialize dedicated traffic-direction icons and labels.""" - super().__init__(parent) - - self.setObjectName('TrafficStatsBadge') - self.setVisible(False) - - self.uploadIconLabel = QLabel(parent=self) - self.uploadTextLabel = AppQLabel(translatable=False, parent=self) - self.uploadUsageLabel = AppQLabel(translatable=False, parent=self) - self.downloadIconLabel = QLabel(parent=self) - self.downloadTextLabel = AppQLabel(translatable=False, parent=self) - self.downloadUsageLabel = AppQLabel(translatable=False, parent=self) - - self.uploadUsageLabel.setObjectName('TrafficUsageLabel') - self.downloadUsageLabel.setObjectName('TrafficUsageLabel') - - self._layout = QHBoxLayout(self) - self._layout.setContentsMargins(8, 3, 8, 3) - self._layout.setSpacing(6) - self._layout.addWidget(self.uploadIconLabel) - self._layout.addWidget(self.uploadTextLabel) - self._layout.addWidget(self.uploadUsageLabel) - self._layout.addSpacing(4) - self._layout.addWidget(self.downloadIconLabel) - self._layout.addWidget(self.downloadTextLabel) - self._layout.addWidget(self.downloadUsageLabel) - - self.setIconByTheme(APP().theme()) - self.updateToolTips() - - def updateToolTips(self): - """Apply translated descriptions to each traffic statistic.""" - uploadSpeed, downloadSpeed, uploadUsage, downloadUsage = ( - _('Upload Speed'), - _('Download Speed'), - _('Upload Traffic Usage'), - _('Download Traffic Usage'), - ) - - self.uploadIconLabel.setToolTip(uploadSpeed) - self.uploadTextLabel.setToolTip(uploadSpeed) - self.uploadUsageLabel.setToolTip(uploadUsage) - self.downloadIconLabel.setToolTip(downloadSpeed) - self.downloadTextLabel.setToolTip(downloadSpeed) - self.downloadUsageLabel.setToolTip(downloadUsage) - - def setIconByTheme(self, theme: str): - """Apply theme-appropriate upload and download icons.""" - iconFactory = ( - bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon - ) - - self.uploadIconLabel.setPixmap( - iconFactory(self.UploadIconFileName).pixmap(self.IconSize) - ) - self.downloadIconLabel.setPixmap( - iconFactory(self.DownloadIconFileName).pixmap(self.IconSize) - ) - - @QtCore.Slot(float, float) - def setSpeeds(self, upload: float, download: float): - """Display formatted upload and download byte rates.""" - self.uploadTextLabel.setText(formatTrafficSpeed(upload)) - self.downloadTextLabel.setText(formatTrafficSpeed(download)) - self.setVisible(True) - - @QtCore.Slot(object, object) - def setUsage(self, upload: int, download: int): - """Display formatted cumulative upload and download usage.""" - self.uploadUsageLabel.setText(f'({formatTrafficUsage(upload)})') - self.downloadUsageLabel.setText(f'({formatTrafficUsage(download)})') - self.setVisible(True) - - @QtCore.Slot() - def clearStatistics(self): - """Hide stale speed and usage values when statistics are unavailable.""" - self.uploadTextLabel.clear() - self.uploadUsageLabel.clear() - self.downloadTextLabel.clear() - self.downloadUsageLabel.clear() - self.setVisible(False) - - def themeChangedCallback(self, theme: str): - """Refresh traffic-direction icons after a theme change.""" - self.setIconByTheme(theme) - - def retranslate(self): - """Refresh translated traffic-statistic tooltips.""" - self.updateToolTips() - - -class ConnectionStatusWidget(QWidget): - """Compose network state and traffic speed as one permanent status item.""" - - def __init__(self, parent=None): - """Initialize separate network-state and traffic-speed components.""" - super().__init__(parent) - - self.setObjectName('ConnectionStatusWidget') - self.networkState = NetworkStateBadge(parent=self) - self.trafficStats = TrafficStatsBadge(parent=self) - - self._layout = QHBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.setSpacing(6) - self._layout.addWidget(self.trafficStats) - self._layout.addWidget(self.networkState) - - -class SearchButton(AppQPushButton): - """Represent search button.""" - - def __init__(self, *args, **kwargs): - """Initialize the SearchButton.""" - super().__init__(*args, **kwargs) - - self.setText(self.customText()) - self.setIcon(bootstrapIcon('search.svg')) - - @staticmethod - def customText(): - """Return the user-facing message text for the search button.""" - return ' ' * 2 + _('Search') - - def retranslate(self): - """Refresh translated text for the search button.""" - self.setText(self.customText()) +_TRANSLATABLE_NAVIGATION_LABELS = (_('Home'), _('Log')) class MainWindow(AppQMainWindow): - """Present the app main window.""" + """Manage application pages, navigation, and global window state.""" DEFAULT_WINDOW_SIZE_DARWIN = QtCore.QSize(1500, 780) DEFAULT_WINDOW_SIZE = ( QtCore.QSize(1800, 960) if PLATFORM != 'Darwin' else DEFAULT_WINDOW_SIZE_DARWIN ) - @staticmethod - def serverImportActions(): - """Build import actions contributed to the server table context menu.""" - return ( - ImportFromFileAction(), - ImportURIFromClipboardAction( - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_V, - ), - ), - ImportJSONFromClipboardAction( - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_J, - ), - ), - ImportQRCodeOnTheScreenAction(), - ) - def __init__(self, *args, **kwargs): - """Initialize the main window.""" + """Create and register the built-in application pages.""" super().__init__(*args, **kwargs) self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + self.menuBar().hide() - if SystemRuntime.isAdmin(): - self.setWindowTitle(f'{_(APPLICATION_NAME)} ({_(ADMINISTRATOR_NAME)})') - else: - self.setWindowTitle(f'{_(APPLICATION_NAME)}') + self.navigationView = NavigationView(parent=self) + self.homePage = HomePage(parent=self.navigationView) + self.logPage = AppLogPage() - # TODO: Need this? - # self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating) + if not isinstance(self.logPage, LogPage): + raise TypeError('application log page must be a LogPage') - self.updatesManager = UpdateManager(parent=self) - self.networkConnectivityManager = AppConnectivityManager(parent=self) - - self.userServersQTableWidget = ServerTableView( - parent=self, - configurationEditorFactory=lambda: TextEditorWindow(parent=self), - qrCodeWindowFactory=QRCodeWindow, - importActionsFactory=self.serverImportActions, + self.navigationView.addPage( + 'home', + self.homePage, + 'Home', + 'house.svg', ) - self.userSubsWindow = SubscriptionWindow( - parent=self, - deleteUniqueCallback=lambda unique: self.userServersQTableWidget.deleteItemByIndex( - list( - index - for index, server in enumerate(Storage.UserServers()) - if server.itemSubscription == unique - ), - showProgress=False, - ), + self.navigationView.addPage( + 'log', + self.logPage, + 'Log', + 'terminal.svg', ) - pluginRegistry = getPluginRegistry() - self.customizeProxyBypassDialog = ProxyBypassDialog(parent=self) - self.customizeNetworkTestDialog = NetworkTestDialog(parent=self) + self.navigationView.pageChanged.connect(self._pageChanged) + self.navigationView.expandedChanged.connect(self._navigationExpandedChanged) - self.mainTab = AppQTabWidget() - self.mainTab.addTab(self.userServersQTableWidget, _('Server')) - - serverActions = [] - - for descriptor in pluginRegistry.protocolDescriptors(): - if ( - not descriptor.addActionText - or pluginRegistry.editorForProtocol(descriptor.id) is None - ): - continue - - if descriptor.separatorBefore and serverActions: - serverActions.append(AppQSeperator()) - - actionText = ( - _(descriptor.addActionText) - if descriptor.translatable - else descriptor.addActionText - ) - serverActions.append( - AppQAction( - actionText, - callback=functools.partial( - self.userServersQTableWidget.addServerViaGui, - descriptor.id, - actionText, - ), - ) - ) - - if serverActions: - serverActions.append(AppQSeperator()) - serverActions.append( - AppQAction( - _('New Empty Configuration'), - callback=lambda: self.userServersQTableWidget.newEmptyItem(), - ) + self.navigationView.setExpanded( + AppSettings.isStateON_('AppMainWindowNavigationExpanded') ) - - subsActions = [ - AppQAction( - _('Update Subscription (Use Current Proxy)'), - callback=lambda: self.userServersQTableWidget.updateSubs( - Storage.Extras.UserHttpProxy(), - parent=self, - ), - ), - AppQAction( - _('Update Subscription (Force Proxy)'), - callback=lambda: self.userServersQTableWidget.updateSubs( - '127.0.0.1:10809', - parent=self, - ), - ), - AppQAction( - _('Update Subscription (No Proxy)'), - callback=lambda: self.userServersQTableWidget.updateSubs( - None, - parent=self, - ), - ), - AppQSeperator(), - AppQAction( - _('Edit Subscription...'), - icon=bootstrapIcon('star.svg'), - callback=lambda: self.userSubsWindow.show(), - ), - ] - - if SystemRuntime.flatpakID(): - customizeTUNSettingsAction = [] - else: - customizeTUNSettingsAction = [ - AppQAction( - _('Customize Tun2socks Settings...'), - icon=bootstrapIcon('diagram-3.svg'), - checkable=False, - callback=lambda: self.getGuiTUNSettings().open(), - ), - ] - - if PLATFORM == 'Windows' or PLATFORM == 'Darwin': - _TRANSLATABLE_RESTART_AS_ADMIN = [ - _('Restart The Application As Administrator'), - _('Restart The Application As Superuser'), - ] - - restartAsAdminAction = [ - AppQAction( - _(f'Restart The Application As {ADMINISTRATOR_NAME}'), - icon=bootstrapIcon('arrow-clockwise.svg'), - checkable=False, - callback=lambda: self.restartAsAdmin(), - ), - ] - else: - restartAsAdminAction = [] - - if PLATFORM == 'Darwin' or SystemRuntime.flatpakID(): - openAppFolderAction = [] - else: - openAppFolderAction = [ - AppQAction( - _('Open Application Folder'), - icon=bootstrapIcon('folder2-open.svg'), - checkable=False, - callback=lambda: self.openApplicationFolder(), - ), - ] - - toolsActions = [ - *customizeTUNSettingsAction, - AppQAction( - _('Customize System Proxy Bypass Address...'), - checkable=False, - callback=lambda: self.customizeProxyBypassDialog.open(), - ), - AppQAction( - _('Customize Network Test URL...'), - icon=bootstrapIcon('speedometer2.svg'), - checkable=False, - callback=lambda: self.customizeNetworkTestDialog.open(), - ), - ] - systemTools = [*restartAsAdminAction, *openAppFolderAction] - - if systemTools: - toolsActions.extend([AppQSeperator(), *systemTools]) - - corePluginActions = [] - - for plugin in pluginRegistry.pluginsWithCapability( - CapabilityKind.KernelFactory - ): - pluginMetadata = pluginRegistry.metadataFor(plugin) - managementActions = pluginRegistry.managementActions( - plugin, - parent=self, - isCoreActive=_isCoreActive, - ) - if managementActions: - corePluginActions.append( - AppQAction( - pluginMetadata.displayName, - menu=AppQMenu(*managementActions), - useActionGroup=False, - checkable=False, - ) - ) - else: - corePluginActions.append( - AppQAction( - pluginMetadata.displayName, - checkable=False, - ) - ) - - extensionPluginActions = [] - - corePlugins = set( - pluginRegistry.pluginsWithCapability(CapabilityKind.KernelFactory) + self.navigationView.setCurrentPage( + str(AppSettings.get('AppMainWindowSelectedPage')) ) + self.setCentralWidget(self.navigationView) - for plugin in pluginRegistry.pluginsWithCapability( - CapabilityKind.ActionProvider - ): - if plugin in corePlugins: - continue + # Preserve the established application-facing server-management API. + self.userServersQTableWidget = self.homePage.userServersQTableWidget + self.userSubsWindow = self.homePage.userSubsWindow + self.networkConnectivityManager = self.homePage.networkConnectivityManager + self.networkState = self.homePage.networkState + self.trafficStats = self.homePage.trafficStats + self.trafficStatsManager = self.homePage.trafficStatsManager - managementActions = pluginRegistry.managementActions( - plugin, - parent=self, - isCoreActive=_isCoreActive, - ) - - if managementActions: - pluginMetadata = pluginRegistry.metadataFor(plugin) - extensionPluginActions.append( - AppQAction( - pluginMetadata.displayName, - menu=AppQMenu(*managementActions), - useActionGroup=False, - checkable=False, - ) - ) - - pluginActions = [ - AppQAction( - _('Core'), - menu=AppQMenu(*corePluginActions), - useActionGroup=False, - checkable=False, - ), - ] - - if extensionPluginActions: - pluginActions.append( - AppQAction( - _('Extensions'), - menu=AppQMenu(*extensionPluginActions), - useActionGroup=False, - checkable=False, - ) - ) - - pluginsToolbarActions = [ - AppQSeperator(), - AppQAction( - _('Plugins'), - icon=bootstrapIcon('plugin.svg'), - menu=AppQMenu(*pluginActions), - useSetMenu=False, - useActionGroup=False, - checkable=False, - ), - ] - - if hasattr(AppQAction, 'setMenu'): - self.toolbar = AppQToolBar( - AppQAction( - _('Log'), - icon=bootstrapIcon('pin-angle.svg'), - checkable=False, - callback=lambda: AppLogWindow().showMaximized(), - ), - AppQSeperator(), - AppQAction( - _('Server'), - icon=bootstrapIcon('server.svg'), - menu=AppQMenu(*serverActions), - useSetMenu=False, - useActionGroup=False, - checkable=False, - ), - AppQSeperator(), - AppQAction( - _('Subscription'), - icon=bootstrapIcon('collection.svg'), - menu=AppQMenu(*subsActions), - useSetMenu=False, - useActionGroup=False, - checkable=False, - ), - *pluginsToolbarActions, - AppQSeperator(), - AppQAction( - _('Tools'), - icon=bootstrapIcon('tools.svg'), - menu=AppQMenu(*toolsActions), - useSetMenu=False, - useActionGroup=False, - checkable=False, - ), - AppQSeperator(), - AppQAction( - _('Check For Updates'), - icon=bootstrapIcon('download.svg'), - checkable=False, - callback=lambda: self.checkForUpdates(parent=self), - ), - AppQSeperator(), - AppQAction( - _('About'), - icon=bootstrapIcon('info-circle.svg'), - checkable=False, - callback=lambda: self.openAboutPage(), - ), - ) - self.toolbar.setObjectName('AppMainWindow_AppQToolBar') - self.toolbar.setIconSize(QtCore.QSize(64, 32)) - self.toolbar.setToolButtonStyle( - QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon - ) - self.addToolBar(self.toolbar) - else: - # Menu actions - logMenu = { - 'name': 'Log', - 'actions': [ - AppQAction( - _('Show Logs...'), - callback=lambda: AppLogWindow().showMaximized(), - ) - ], - } - - serverMenu = { - 'name': 'Server', - 'actions': [*serverActions], - } - - subsMenu = { - 'name': 'Subscription', - 'actions': [*subsActions], - } - - pluginsMenu = { - 'name': 'Plugins', - 'actions': [*pluginActions], - } - - toolsMenu = { - 'name': 'Tools', - 'actions': [*toolsActions], - } - - helpMenu = { - 'name': 'Help', - 'actions': [ - AppQAction( - _('Check For Updates'), - icon=bootstrapIcon('download.svg'), - checkable=False, - callback=lambda: self.checkForUpdates(parent=self), - ), - AppQSeperator(), - AppQAction( - _('About'), - icon=bootstrapIcon('info-circle.svg'), - checkable=False, - callback=lambda: self.openAboutPage(), - ), - ], - } - - # Corresponds to menus defined above - _TRANSLATABLE_MENU_NAME = [ - _('Log'), - _('Server'), - _('Subscription'), - _('Plugins'), - _('Tools'), - _('Help'), - ] - - # Menus - for menuDict in ( - logMenu, - serverMenu, - subsMenu, - pluginsMenu, - toolsMenu, - helpMenu, - ): - menuName = menuDict['name'] - menuObjName = f'_{menuName}Menu' - menu = AppQMenu( - *menuDict['actions'], title=_(menuName), parent=self.menuBar() - ) - - # Set reference - setattr(self, menuObjName, menu) - - self.menuBar().addMenu(menu) - - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) - - # TODO: Custom status tip - # self.setStatusBar(QStatusBar(self)) - - self.connectionStatus = ConnectionStatusWidget(parent=self) - self.networkState = self.connectionStatus.networkState - self.trafficStats = self.connectionStatus.trafficStats - self.trafficStatsManager = TrafficStatsManager(parent=self) - self.trafficStatsManager.speedChanged.connect(self.trafficStats.setSpeeds) - self.trafficStatsManager.usageChanged.connect(self.trafficStats.setUsage) - self.trafficStatsManager.statisticsUnavailable.connect( - self.trafficStats.clearStatistics - ) - - self.statusBar().addPermanentWidget(self.connectionStatus) - - self._widget = QWidget() - self._layout = QVBoxLayout(self._widget) - - self.searchLayout = QHBoxLayout() - - self.searchLineEdit = AppQLineEdit() - self.searchLineEdit.setPlaceholderText( - _( - 'Search servers with text or regex, e.g. trojan, hk|jp, ^vmess, (us|sg).*tls' - ) - ) - - self.searchButton = SearchButton() - - self.searchLayout.addWidget(self.searchLineEdit) - self.searchLayout.addWidget(self.searchButton) - - self._layout.addLayout(self.searchLayout) - self._layout.addWidget(self.mainTab) - - self.searchButton.clicked.connect( - lambda: self.userServersQTableWidget.search(self.searchLineEdit.text()) - ) - self.searchLineEdit.returnPressed.connect( - lambda: self.userServersQTableWidget.search(self.searchLineEdit.text()) - ) - self.searchLineEdit.textChanged.connect(self.handleUserServersSearchTextChanged) - - self.setCentralWidget(self._widget) + self.retranslate() @QtCore.Slot(str) - def handleUserServersSearchTextChanged(self, text: str): - """Handle user servers search text changed.""" - if not text: - self.userServersQTableWidget.clearSearch() + def _pageChanged(self, pageId: str): + """Persist the current page selected through navigation.""" + AppSettings.set('AppMainWindowSelectedPage', pageId) + + @QtCore.Slot(bool) + def _navigationExpandedChanged(self, expanded: bool): + """Persist the navigation menu expansion state.""" + if expanded: + AppSettings.turnON_('AppMainWindowNavigationExpanded') + else: + AppSettings.turnOFF('AppMainWindowNavigationExpanded') + + def showPage(self, pageId: str): + """Navigate to a registered application page.""" + self.navigationView.setCurrentPage(pageId) + + def showLogPage(self): + """Navigate directly to the unified logging page.""" + self.showPage('log') def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs): - """Update subs by unique.""" - self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs) + """Forward a subscription update to the home page.""" + self.homePage.updateSubsByUnique(unique, httpProxy, **kwargs) def appendNewItemByFactory(self, factory: ConfigFactory | ServerProfile): - """Append new item by factory.""" - self.userServersQTableWidget.appendNewItemByFactory(factory) + """Forward a new server profile to the home page.""" + self.homePage.appendNewItemByFactory(factory) def flushRow(self, row: int, item: ServerProfile): - """Refresh row.""" - self.userServersQTableWidget.flushRow(row, item) + """Forward a server-row refresh to the home page.""" + self.homePage.flushRow(row, item) def showTabAndSpaces(self): - """Show tab and spaces.""" - self.userServersQTableWidget.showTabAndSpaces() + """Enable whitespace markers in home-page editors.""" + self.homePage.showTabAndSpaces() def hideTabAndSpaces(self): - """Hide tab and spaces.""" - self.userServersQTableWidget.hideTabAndSpaces() + """Disable whitespace markers in home-page editors.""" + self.homePage.hideTabAndSpaces() def getGuiTUNSettings(self, **kwargs): - """Return GUI TUN settings.""" - - @functools.lru_cache(None) - def cachedGuiTUNSettings(): - """Return the cached GUI TUN settings value used by the app main window.""" - parent = kwargs.pop('parent', self) - - return TunSettingsDialog(parent=parent, **kwargs) - - guiTUNSettings = cachedGuiTUNSettings() - guiTUNSettings.factoryToInput(Storage.UserTUNSettings()) - - return guiTUNSettings + """Return the home page's TUN settings editor.""" + return self.homePage.getGuiTUNSettings(**kwargs) def checkForUpdates(self, **kwargs): - """Check for updates.""" - self.updatesManager.configureHttpProxy(Storage.Extras.UserHttpProxy()) - self.updatesManager.checkForUpdates(**kwargs) + """Forward update checks to the home page.""" + self.homePage.checkForUpdates(**kwargs) def resetNetworkState(self): - """Reset network state.""" - self.networkState.clearStatus() + """Clear the home page network state.""" + self.homePage.resetNetworkState() def setNetworkState(self, success: bool, **kwargs): - """Set network state.""" - remark = Storage.Extras.UserServerRemark() - - if not remark: - self.resetNetworkState() - - return - - if success: - self.networkState.setStatus( - 'success', - remark, - ) - - return - - errorString = kwargs.pop('errorString', '') - - self.networkState.setStatus( - 'failure', - remark, - errorString, - ) - - @staticmethod - def openApplicationFolder(): - """Open application folder.""" - appFolder = os.path.dirname(APP().applicationFilePath()) - - if QDesktopServices.openUrl(QtCore.QUrl(appFolder)): - logger.info(f'open application folder \'{appFolder}\' success') - else: - logger.error(f'open application folder \'{appFolder}\' failed') - - @staticmethod - def restartAsAdmin(): - """Handle restart as admin for the app main window.""" - if not SystemRuntime.isScriptMode(): - if not SystemRuntime.isAdmin(): - process = QtCore.QProcess() - - if PLATFORM == 'Windows': - process.startDetached( - 'powershell', - arguments=[ - '-Command', - f'Start-Process \'{APP().applicationFilePath()}\' ' - f'\'{AppBuiltinCommand.RunAs.value}\' -Verb runAs', - ], - ) - elif PLATFORM == 'Darwin': - process.startDetached( - 'osascript', - arguments=[ - '-e', - f'do shell script \"{APP().applicationFilePath()} ' - f'{AppBuiltinCommand.RunAs.value}\" with administrator privileges', - ], - ) - else: - mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) - mbox.setWindowTitle(_(APPLICATION_NAME)) - mbox.setText(_('I am the supreme authority 👑')) - - # Show the MessageBox asynchronously - mbox.open() - - logger.info('ignored request to restart as admin as already is') - else: - logger.info(f'ignored request to restart as admin in script mode') - - @staticmethod - def openAboutPage(): - """Open about page.""" - if QDesktopServices.openUrl(QtCore.QUrl(APPLICATION_ABOUT_PAGE)): - logger.info('open about page success') - else: - logger.error('open about page failed') + """Update the home page network state.""" + self.homePage.setNetworkState(success, **kwargs) def setWidthAndHeight(self): - """Apply the default size for the app main window.""" + """Restore the saved application window geometry and state.""" if AppSettings.get('AppMainWindowGeometry') is None: - # Migrate legacy settings try: windowSize = AppSettings.get('ServerWidgetWindowSize').split(',') - width, height = tuple(int(size) for size in windowSize) if (width, height) == (640, 480): - self.resize(MainWindow.DEFAULT_WINDOW_SIZE) + self.resize(self.DEFAULT_WINDOW_SIZE) else: self.resize(width, height) except Exception: # Any non-exit exceptions - self.resize(MainWindow.DEFAULT_WINDOW_SIZE) + self.resize(self.DEFAULT_WINDOW_SIZE) else: try: self.restoreGeometry(AppSettings.get('AppMainWindowGeometry')) except Exception: # Any non-exit exceptions - self.resize(MainWindow.DEFAULT_WINDOW_SIZE) + self.resize(self.DEFAULT_WINDOW_SIZE) try: self.restoreState(AppSettings.get('AppMainWindowState')) @@ -1030,16 +191,15 @@ class MainWindow(AppQMainWindow): if PLATFORM == 'Darwin': APP().processEvents() - size = self.size() if size == QtCore.QSize(640, 480): logger.error( f'detected unresolve Qt bug on macOS. ' f'Resizing main window to default ' - f'{MainWindow.DEFAULT_WINDOW_SIZE.toTuple()}' + f'{self.DEFAULT_WINDOW_SIZE.toTuple()}' ) - self.resize(MainWindow.DEFAULT_WINDOW_SIZE) + self.resize(self.DEFAULT_WINDOW_SIZE) else: logger.info( f'restore main window size on macOS success: ' @@ -1053,13 +213,13 @@ class MainWindow(AppQMainWindow): self.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) def retranslate(self): - """Refresh translated text for the app main window.""" + """Refresh the application window title.""" if SystemRuntime.isAdmin(): self.setWindowTitle(f'{_(APPLICATION_NAME)} ({_(ADMINISTRATOR_NAME)})') else: - self.setWindowTitle(f'{_(APPLICATION_NAME)}') + self.setWindowTitle(_(APPLICATION_NAME)) def cleanup(self): - """Release resources owned by the app main window.""" + """Persist application-level window state.""" AppSettings.set('AppMainWindowGeometry', self.saveGeometry()) AppSettings.set('AppMainWindowState', self.saveState()) diff --git a/Furious/Window/__init__.py b/Furious/Window/__init__.py index 29c98d4..9502052 100644 --- a/Furious/Window/__init__.py +++ b/Furious/Window/__init__.py @@ -20,7 +20,8 @@ from __future__ import annotations from .IndentDialog import IndentDialog -from .LogWindow import LogWindow +from .HomePage import HomePage +from .LogPage import LogPage from .MainWindow import MainWindow from .NetworkTestDialog import NetworkTestDialog from .ProxyBypassDialog import ProxyBypassDialog @@ -31,7 +32,8 @@ from .TunSettingsDialog import TunSettingsDialog __all__ = [ 'IndentDialog', - 'LogWindow', + 'HomePage', + 'LogPage', 'MainWindow', 'NetworkTestDialog', 'ProxyBypassDialog',