Introduce page-based navigation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:50 +08:00
parent b3d696200d
commit eb32952c01
13 changed files with 1463 additions and 1030 deletions
+3 -3
View File
@@ -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,
)
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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)
+69 -62
View File
@@ -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": "下载流量",
+3 -3
View File
@@ -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'),
)
+2 -2
View File
@@ -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',
+49 -3
View File
@@ -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']};
+270
View File
@@ -0,0 +1,270 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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)
+2
View File
@@ -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',
+960
View File
@@ -0,0 +1,960 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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
@@ -15,11 +15,11 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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()
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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',