diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index eed9b35f..494784b1 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -3018,16 +3018,24 @@ TRANSLATION = { "source": [ "Furious.Window.LogPage" ], + "RU": "Автопрокрутка вниз", "ZH": "自动滚动到底部", - "isReviewed": "True", - "RU": "Автопрокрутка вниз" + "isReviewed": "True" }, "Auto Clear Log": { "source": [ "Furious.Window.LogPage" ], + "RU": "Автоочистка журнала", "ZH": "自动清理日志", - "isReviewed": "True", - "RU": "Автоочистка журнала" + "isReviewed": "True" + }, + "Processing...": { + "source": [ + "Furious.Window.LogPage" + ], + "RU": "Обработка...", + "ZH": "处理中...", + "isReviewed": "True" } } diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index bc5ee782..e483fd44 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -34,8 +34,9 @@ from Furious.Service.LogManager import ( LogManager, formatLogEntry, ) +from Furious.Widget.WaitingSpinner import WaitingSpinner -from PySide6 import QtCore +from PySide6 import QtCore, QtGui from PySide6.QtGui import QTextCursor from PySide6.QtWidgets import * @@ -122,7 +123,10 @@ class LogPage(Mixins.QTranslatable, QMainWindow): """Present and filter the unified application log stream as a page.""" UpdateDelay = 25 - HighlightBatchSize = 250 + HighlightBatchSize = 64 + HighlightTimeBudget = 8 + HighlightBusyThreshold = 100 + BusyIndicatorDelay = 16 TailTolerance = 10 def __init__(self, *args, **kwargs): @@ -161,6 +165,34 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.textBrowser.setUndoRedoEnabled(False) self.textBrowser.document().setMaximumBlockCount(manager.maximumEntries) + self.highlightOverlay = QFrame(self.textBrowser.viewport()) + self.highlightOverlay.setObjectName('LogHighlightOverlay') + self.highlightOverlay.setAutoFillBackground(True) + self.highlightOverlay.setBackgroundRole(QtGui.QPalette.ColorRole.Base) + self.highlightOverlay.setAttribute( + QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents + ) + + self.highlightSpinner = WaitingSpinner( + self.highlightOverlay, + center_on_parent=False, + lines=12, + line_length=7, + line_width=3, + radius=5, + ) + self.highlightStatusLabel = AppQLabel(translatable=False) + + highlightLayout = QHBoxLayout(self.highlightOverlay) + highlightLayout.setContentsMargins(16, 16, 16, 16) + highlightLayout.setSpacing(10) + highlightLayout.addStretch(1) + highlightLayout.addWidget(self.highlightSpinner) + highlightLayout.addWidget(self.highlightStatusLabel) + highlightLayout.addStretch(1) + + self.highlightOverlay.hide() + self._entriesDirty = True self._representationInvalid = True self._renderedSequence = 0 @@ -379,12 +411,37 @@ class LogPage(Mixins.QTranslatable, QMainWindow): if not self._pageCanRender(): return + if self._representationInvalid: + self._setHighlightBusy(True) + if immediate: self._updateTimer.stop() - self._updateTimer.start(0) + self._updateTimer.start( + self.BusyIndicatorDelay if self.highlightSpinner.is_spinning else 0 + ) elif not self._updateTimer.isActive(): self._updateTimer.start(self.UpdateDelay) + def _syncHighlightOverlayGeometry(self): + """Cover the log viewport without affecting its layout or scrollbars.""" + self.highlightOverlay.setGeometry(self.textBrowser.viewport().rect()) + + def _setHighlightBusy(self, busy: bool): + """Show or hide the cooperative highlighting progress presentation.""" + if busy and self._pageCanRender(): + self.highlightSpinner.color = self.palette().color( + QtGui.QPalette.ColorRole.Text + ) + + self._syncHighlightOverlayGeometry() + + self.highlightOverlay.raise_() + self.highlightOverlay.show() + self.highlightSpinner.start() + else: + self.highlightSpinner.stop() + self.highlightOverlay.hide() + @staticmethod def _incrementalPlan(oldSequences, currentSequences): """Return leading removals and the first new snapshot entry. @@ -549,6 +606,8 @@ class LogPage(Mixins.QTranslatable, QMainWindow): if firstHighlightBlock is not None: self._scheduleHighlight(firstHighlightBlock) + else: + self._setHighlightBusy(False) if self._followTail: self._scheduleScrollRestore() @@ -579,6 +638,12 @@ class LogPage(Mixins.QTranslatable, QMainWindow): else: self._highlightNextBlock = min(self._highlightNextBlock, firstBlock) + if ( + self.textBrowser.document().blockCount() - self._highlightNextBlock + >= self.HighlightBusyThreshold + ): + self._setHighlightBusy(True) + if self._pageCanRender() and not self._highlightTimer.isActive(): self._highlightTimer.start(0) @@ -590,19 +655,28 @@ class LogPage(Mixins.QTranslatable, QMainWindow): document = self.textBrowser.document() start = self._highlightNextBlock - end = min(document.blockCount(), start + self.HighlightBatchSize) + maximumEnd = min(document.blockCount(), start + self.HighlightBatchSize) + elapsed = QtCore.QElapsedTimer() + elapsed.start() + end = start - for blockNumber in range(start, end): - block = document.findBlockByNumber(blockNumber) + while end < maximumEnd: + block = document.findBlockByNumber(end) if block.isValid(): self.textBrowser.rehighlightBlock(block) + end += 1 + + if elapsed.elapsed() >= self.HighlightTimeBudget: + break + if end < document.blockCount(): self._highlightNextBlock = end self._highlightTimer.start(0) else: self._highlightNextBlock = None + self._setHighlightBusy(False) if self._followTail: self._scheduleScrollRestore() @@ -724,8 +798,10 @@ class LogPage(Mixins.QTranslatable, QMainWindow): super().showEvent(event) if self._entriesDirty: + self._setHighlightBusy(True) self._requestRefresh(immediate=True) elif self._highlightNextBlock is not None: + self._setHighlightBusy(True) self._highlightTimer.start(0) if self._followTail: @@ -738,9 +814,17 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self._highlightTimer.stop() self._scrollTimer.stop() self._followStateTimer.stop() + self._setHighlightBusy(False) super().hideEvent(event) + def resizeEvent(self, event): + """Keep the highlighting overlay aligned with the log viewport.""" + super().resizeEvent(event) + + if hasattr(self, 'highlightOverlay'): + self._syncHighlightOverlayGeometry() + def plainText(self) -> str: """Return the plain text currently shown by the selected filter.""" return self.textBrowser.toPlainText() @@ -758,6 +842,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.filterLabel.setText(_('Log Type')) self.autoScrollLabel.setText(_('Auto Scroll Down')) self.autoClearLabel.setText(_('Auto Clear Log')) + self.highlightStatusLabel.setText(_('Processing...')) self._populateFilters(selectedCategoryId) self._requestRefresh(immediate=True) diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 8a32a20f..52c1d954 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -262,9 +262,14 @@ class UnifiedLogPageTest(unittest.TestCase): # build and highlight thousands of QTextDocument blocks. self.assertTrue(page._entriesDirty) self.assertEqual(page.textBrowser.toPlainText(), '') + self.assertTrue(page.highlightOverlay.isVisible()) + self.assertTrue(page.highlightSpinner.is_spinning) self.assertRendered(page) + self.assertFalse(page.highlightOverlay.isVisible()) + self.assertFalse(page.highlightSpinner.is_spinning) + lines = page.textBrowser.toPlainText().splitlines() self.assertEqual(len(lines), entryCount) @@ -630,6 +635,9 @@ class UnifiedLogPageTest(unittest.TestCase): page._followStateTimer, page.autoScrollSwitch, page.autoClearSwitch, + page.highlightOverlay, + page.highlightSpinner, + page.highlightStatusLabel, ) )