mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-30 10:58:09 +03:00
330 lines
11 KiB
Python
330 lines
11 KiB
Python
# 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 unified logging page."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from Furious.Frozenlib import AppSettings, Mixins, registerAppSettings
|
||
from Furious.Qt import *
|
||
from Furious.Qt import gettext as _
|
||
from Furious.Service.LogManager import (
|
||
ALL_LOGS_FILTER,
|
||
APPLICATION_LOG_CATEGORY,
|
||
CORE_LOG_CATEGORY,
|
||
LogManager,
|
||
formatLogEntry,
|
||
)
|
||
|
||
from PySide6 import QtCore
|
||
from PySide6.QtWidgets import *
|
||
|
||
__all__ = ['LogPage']
|
||
|
||
registerAppSettings('LogViewerWidgetPointSize')
|
||
registerAppSettings('LogViewerSelectedCategory', default=ALL_LOGS_FILTER)
|
||
|
||
_LEGACY_POINT_SIZE_SETTINGS = (
|
||
'LogViewerWidgetPointSizeSelf',
|
||
'LogViewerWidgetPointSizeCore',
|
||
'LogViewerWidgetPointSizeTun_',
|
||
)
|
||
|
||
|
||
def _migratePointSizeSettings():
|
||
"""Preserve one legacy size and remove the obsolete per-source settings."""
|
||
settings = QtCore.QSettings()
|
||
|
||
if settings.value('LogViewerWidgetPointSize') is None:
|
||
for name in _LEGACY_POINT_SIZE_SETTINGS:
|
||
value = settings.value(name)
|
||
|
||
if value is not None:
|
||
settings.setValue('LogViewerWidgetPointSize', value)
|
||
|
||
break
|
||
|
||
for name in _LEGACY_POINT_SIZE_SETTINGS:
|
||
settings.remove(name)
|
||
|
||
|
||
class MBoxSaveError(AppQMessageBox):
|
||
"""Represent m box save error."""
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the MBoxSaveError."""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
self.saveError = ''
|
||
|
||
def customText(self):
|
||
"""Return the user-facing message text for the m box save error."""
|
||
if self.saveError:
|
||
return _('Unable to save log') + f'\n\n{self.saveError}'
|
||
else:
|
||
return _('Unable to save log')
|
||
|
||
def retranslate(self):
|
||
"""Refresh translated text for the m box save error."""
|
||
self.setWindowTitle(_(self.windowTitle()))
|
||
self.setText(self.customText())
|
||
|
||
# Ignore informative text, buttons
|
||
|
||
self.moveToCenter()
|
||
|
||
|
||
def saveAsFile(content: str):
|
||
"""Save as file."""
|
||
filename, selectedFilter = QFileDialog.getSaveFileName(
|
||
None, _('Save File'), filter=_('Text files (*.txt);;All files (*)')
|
||
)
|
||
|
||
if filename:
|
||
try:
|
||
with open(filename, 'w', encoding='utf-8') as file:
|
||
file.write(content)
|
||
except Exception as ex:
|
||
# Any non-exit exceptions
|
||
|
||
mbox = MBoxSaveError(icon=AppQMessageBox.Icon.Critical)
|
||
mbox.saveError = str(ex)
|
||
mbox.setWindowTitle(_('Error saving log'))
|
||
mbox.setText(mbox.customText())
|
||
|
||
# Show the MessageBox asynchronously
|
||
mbox.open()
|
||
|
||
|
||
class LogPage(Mixins.QTranslatable, QMainWindow):
|
||
"""Present and filter the unified application log stream as a page."""
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the logging page."""
|
||
manager, fontFamily = (
|
||
kwargs.pop('manager', None),
|
||
kwargs.pop('fontFamily', ''),
|
||
)
|
||
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if not isinstance(manager, LogManager):
|
||
raise TypeError('manager must be a LogManager')
|
||
|
||
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
|
||
|
||
_migratePointSizeSettings()
|
||
|
||
self.manager = manager
|
||
self._preferredFilter = str(AppSettings.get('LogViewerSelectedCategory'))
|
||
|
||
self.pageTitleLabel = AppQLabel(translatable=False)
|
||
self.pageTitleLabel.setObjectName('LogPageTitle')
|
||
self.filterLabel = AppQLabel(translatable=False)
|
||
self.filterComboBox = QComboBox()
|
||
self.filterComboBox.setMinimumWidth(180)
|
||
|
||
self.textBrowser = DraculaTextBrowser(
|
||
fontFamily=fontFamily,
|
||
pointSizeSettingsName='LogViewerWidgetPointSize',
|
||
)
|
||
self.textBrowser.setLineWrapMode(DraculaTextBrowser.LineWrapMode.NoWrap)
|
||
|
||
filterLayout = QHBoxLayout()
|
||
filterLayout.setContentsMargins(0, 0, 0, 0)
|
||
filterLayout.setSpacing(8)
|
||
filterLayout.addWidget(self.pageTitleLabel)
|
||
filterLayout.addStretch(1)
|
||
filterLayout.addWidget(self.filterLabel)
|
||
filterLayout.addWidget(self.filterComboBox)
|
||
|
||
centralLayout = QVBoxLayout()
|
||
centralLayout.setContentsMargins(20, 18, 20, 20)
|
||
centralLayout.setSpacing(14)
|
||
centralLayout.addLayout(filterLayout)
|
||
centralLayout.addWidget(self.textBrowser)
|
||
|
||
centralWidget = QWidget()
|
||
centralWidget.setObjectName('LogPageContent')
|
||
centralWidget.setLayout(centralLayout)
|
||
|
||
self.setCentralWidget(centralWidget)
|
||
|
||
self._fileMenu = AppQMenu(
|
||
AppQAction(
|
||
_('Save As...'),
|
||
callback=lambda: saveAsFile(self.textBrowser.toPlainText()),
|
||
),
|
||
title=_('File'),
|
||
parent=self,
|
||
)
|
||
|
||
self._editMenu = AppQMenu(
|
||
AppQAction(
|
||
_('Copy'),
|
||
icon=bootstrapIcon('files.svg'),
|
||
callback=lambda: self.textBrowser.copy(),
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_C,
|
||
),
|
||
),
|
||
AppQSeperator(),
|
||
AppQAction(
|
||
_('Select All'),
|
||
callback=lambda: self.textBrowser.selectAll(),
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_A,
|
||
),
|
||
),
|
||
title=_('Edit'),
|
||
parent=self,
|
||
)
|
||
|
||
self._viewMenu = AppQMenu(
|
||
AppQAction(
|
||
_('Zoom In'),
|
||
callback=lambda: self.textBrowser.zoomIn(),
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_Plus,
|
||
),
|
||
),
|
||
AppQAction(
|
||
_('Zoom Out'),
|
||
callback=lambda: self.textBrowser.zoomOut(),
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_Minus,
|
||
),
|
||
),
|
||
title=_('View'),
|
||
parent=self,
|
||
)
|
||
|
||
self.menuBar().addMenu(self._fileMenu)
|
||
self.menuBar().addMenu(self._editMenu)
|
||
self.menuBar().addMenu(self._viewMenu)
|
||
|
||
self._populateFilters(self._preferredFilter)
|
||
self._refreshEntries()
|
||
|
||
self.filterComboBox.currentIndexChanged.connect(self._filterChanged)
|
||
self.manager.categoryRegistered.connect(self._categoryRegistered)
|
||
self.manager.entryAdded.connect(self._entryAdded)
|
||
self.manager.entriesCleared.connect(self._entriesCleared)
|
||
|
||
self.retranslate()
|
||
|
||
def _categoryText(self, category) -> str:
|
||
"""Return a category's translated or literal display label."""
|
||
if category.id == APPLICATION_LOG_CATEGORY:
|
||
return _('Application')
|
||
|
||
return (
|
||
_(category.displayName) if category.translatable else category.displayName
|
||
)
|
||
|
||
def _populateFilters(self, selectedCategoryId: str):
|
||
"""Rebuild filter choices from the registered category collection."""
|
||
self.filterComboBox.blockSignals(True)
|
||
|
||
try:
|
||
self.filterComboBox.clear()
|
||
self.filterComboBox.addItem(_('All Logs'), ALL_LOGS_FILTER)
|
||
|
||
for category in self.manager.categories():
|
||
self.filterComboBox.addItem(
|
||
self._categoryText(category),
|
||
category.id,
|
||
)
|
||
|
||
index = self.filterComboBox.findData(selectedCategoryId)
|
||
|
||
self.filterComboBox.setCurrentIndex(max(index, 0))
|
||
finally:
|
||
self.filterComboBox.blockSignals(False)
|
||
|
||
def _refreshEntries(self):
|
||
"""Render the current immutable filtered-entry snapshot."""
|
||
selectedCategoryId = self.filterComboBox.currentData()
|
||
|
||
content = '\n'.join(
|
||
formatLogEntry(entry) for entry in self.manager.entries(selectedCategoryId)
|
||
)
|
||
self.textBrowser.setPlainText(content)
|
||
|
||
scrollbar = self.textBrowser.verticalScrollBar()
|
||
scrollbar.setValue(scrollbar.maximum())
|
||
|
||
@QtCore.Slot(int)
|
||
def _filterChanged(self, _index: int):
|
||
"""Persist and apply the category selected by the user."""
|
||
categoryId = self.filterComboBox.currentData()
|
||
|
||
if not isinstance(categoryId, str):
|
||
categoryId = ALL_LOGS_FILTER
|
||
|
||
self._preferredFilter = categoryId
|
||
|
||
AppSettings.set('LogViewerSelectedCategory', categoryId)
|
||
|
||
self._refreshEntries()
|
||
|
||
@QtCore.Slot(object)
|
||
def _categoryRegistered(self, category):
|
||
"""Add a newly registered component without rebuilding the page."""
|
||
if self.filterComboBox.findData(category.id) == -1:
|
||
self.filterComboBox.addItem(self._categoryText(category), category.id)
|
||
|
||
if category.id == self._preferredFilter:
|
||
self.filterComboBox.setCurrentIndex(
|
||
self.filterComboBox.findData(category.id)
|
||
)
|
||
|
||
@QtCore.Slot(object)
|
||
def _entryAdded(self, entry):
|
||
"""Append an entry when it matches the active filter."""
|
||
categoryId = self.filterComboBox.currentData()
|
||
|
||
if categoryId in (ALL_LOGS_FILTER, entry.categoryId):
|
||
self.textBrowser.appendLine(formatLogEntry(entry))
|
||
|
||
@QtCore.Slot(object)
|
||
def _entriesCleared(self, _categoryIds):
|
||
"""Refresh the presentation after the underlying collection changes."""
|
||
self._refreshEntries()
|
||
|
||
def plainText(self) -> str:
|
||
"""Return the plain text currently shown by the selected filter."""
|
||
return self.textBrowser.toPlainText()
|
||
|
||
def clear(self):
|
||
"""Remove all entries through the unified logging service."""
|
||
self.manager.clear()
|
||
|
||
def retranslate(self):
|
||
"""Refresh translated page controls and filter labels."""
|
||
|
||
selectedCategoryId = self.filterComboBox.currentData()
|
||
|
||
self.pageTitleLabel.setText(_('Log'))
|
||
self.filterLabel.setText(_('Log Type'))
|
||
self._populateFilters(selectedCategoryId)
|
||
self._refreshEntries()
|