From 5d41657bc958438cda5157fc1f2b27c2edd5c780 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Fri, 18 Sep 2026 23:15:05 +0800 Subject: [PATCH] Add paused log reading Signed-off-by: Loren Eteval --- Furious/Externals/GenTranslation.py | 19 ++++- Furious/Window/LogPage.py | 60 +++++++++++++- tests/test_ui_behavior.py | 118 ++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 5 deletions(-) diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index d559ab8..e78deda 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -1618,7 +1618,8 @@ TRANSLATION = { }, "Search": { "source": [ - "Furious.Window.HomePage" + "Furious.Window.HomePage", + "Furious.Window.LogPage" ], "RU": "Поиск", "ZH": "查找", @@ -3366,5 +3367,21 @@ TRANSLATION = { "RU": "Остановить обновления", "ZH": "停止更新", "isReviewed": "True" + }, + "Pause Updates": { + "source": [ + "Furious.Window.LogPage" + ], + "RU": "Приостановить обновление", + "ZH": "暂停刷新", + "isReviewed": "True" + }, + "Resume Updates": { + "source": [ + "Furious.Window.LogPage" + ], + "RU": "Возобновить обновление", + "ZH": "继续刷新", + "isReviewed": "True" } } diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index 64d49b4..983b14b 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -218,6 +218,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): ) self._searchRegex = None + self._paused = False self.textBrowser = DraculaTextBrowser( fontFamily=fontFamily, @@ -384,6 +385,22 @@ class LogPage(Mixins.QTranslatable, QMainWindow): popupMenu=self._viewMenu, ) + self.pauseButton = AppQPushButton( + _('Pause Updates'), icon=bootstrapIcon('pause-circle.svg'), parent=self + ) + self.pauseButton.setCheckable(True) + self.pauseButton.toggled.connect(self._pauseChanged) + + self.findAction = AppQAction( + _('Search'), + parent=self, + callback=self.focusSearch, + shortcut=QtGui.QKeySequence.StandardKey.Find, + ) + self.findAction.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) + + self.addAction(self.findAction) + self.autoScrollLabel = AppQLabel(_('Auto Scroll Down')) self.autoScrollSwitch = AppQSwitch() @@ -400,6 +417,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): actionLayout.addWidget(self.fileButton) actionLayout.addWidget(self.editButton) actionLayout.addWidget(self.viewButton) + actionLayout.addWidget(self.pauseButton) actionLayout.addStretch(1) actionLayout.addWidget(self.autoScrollLabel) actionLayout.addWidget(self.autoScrollSwitch) @@ -435,18 +453,50 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.retranslate() def _registerMenuShortcuts(self, menu): - """Associate popup actions with the page so shortcuts stay active.""" + """Keep document shortcuts local to the log browser and its children.""" for action in menu.actions(): if action.isSeparator(): continue - self.addAction(action) + action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) + + self.textBrowser.addAction(action) submenu = action.menu() if hasattr(action, 'menu') else None if submenu is not None: self._registerMenuShortcuts(submenu) + @QtCore.Slot() + def focusSearch(self): + """Focus the enabled search field without interrupting a paused view.""" + if self.searchLineEdit.isEnabled(): + self.searchLineEdit.setFocus(QtCore.Qt.ShortcutFocusReason) + self.searchLineEdit.selectAll() + + @QtCore.Slot(bool) + def _pauseChanged(self, paused): + """Freeze only presentation; the manager retains collection ownership.""" + self._paused = paused + + self.pauseButton.setText(_('Resume Updates') if paused else _('Pause Updates')) + self.pauseButton.setIcon( + bootstrapIcon('play-circle.svg' if paused else 'pause-circle.svg') + ) + + for widget in [self.searchLineEdit, self.filterComboBox, self.filterLabel]: + widget.setEnabled(not paused) + + if paused: + self._updateTimer.stop() + self._highlightTimer.stop() + self._scrollTimer.stop() + self._followStateTimer.stop() + self._setHighlightBusy(False) + self._entriesDirty = True + else: + self._requestRefresh(invalidate=True, immediate=True) + def _categoryText(self, category) -> str: """Return a category's translated or literal display label.""" if category.id == APPLICATION_LOG_CATEGORY: @@ -480,8 +530,10 @@ class LogPage(Mixins.QTranslatable, QMainWindow): """Return whether document work can currently reach the screen.""" window = self.window() - return self.isVisible() and not ( - hasattr(window, 'isMinimized') and window.isMinimized() + return ( + not self._paused + and self.isVisible() + and not (hasattr(window, 'isMinimized') and window.isMinimized()) ) def _requestRefresh(self, *, invalidate=False, immediate=False): diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index b40b7c6..c6a3cc8 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -1548,6 +1548,124 @@ class UnifiedLogPageTest(unittest.TestCase): page.close() page.deleteLater() + def testPausedViewPreservesSelectionAcrossEvictionClearAndHide(self): + """Pause presentation while the shared bounded stream keeps collecting.""" + with isolatedSettings(): + manager = LogManager(maximumEntries=3) + page = LogPage(manager=manager) + self.addCleanup(self.disposePage, page) + manager.append('original text', APPLICATION_LOG_CATEGORY) + page.show() + self.assertRendered(page) + page.textBrowser.selectAll() + selected = page.textBrowser.textCursor().selectedText() + + QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton) + manager.clear() + for number in range(10): + manager.append(f'new {number}', APPLICATION_LOG_CATEGORY) + processQtEvents() + page.hide() + page.show() + processQtEvents() + + self.assertEqual(page.plainText(), 'original text') + self.assertEqual(page.textBrowser.textCursor().selectedText(), selected) + self.assertFalse(page.searchLineEdit.isEnabled()) + self.assertFalse(page.filterComboBox.isEnabled()) + self.assertFalse(page._updateTimer.isActive()) + self.assertFalse(page._highlightTimer.isActive()) + self.assertFalse(page._scrollTimer.isActive()) + + QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton) + self.assertRendered(page) + self.assertEqual(page.plainText().splitlines(), ['new 7', 'new 8', 'new 9']) + self.assertTrue(page.searchLineEdit.isEnabled()) + self.assertTrue(page.filterComboBox.isEnabled()) + + def testPausedFilteredViewExportsItsSnapshotAndRetranslates(self): + """Export the frozen filtered document, then resume the same filter.""" + with isolatedSettings(): + AppSettings.set('Language', 'EN') + manager = LogManager(maximumEntries=4) + page = LogPage(manager=manager) + self.addCleanup(self.disposePage, page) + manager.append('core match', CORE_LOG_CATEGORY) + manager.append('application match', APPLICATION_LOG_CATEGORY) + page.filterComboBox.setCurrentIndex( + page.filterComboBox.findData(CORE_LOG_CATEGORY) + ) + page.searchLineEdit.setText('match') + page.show() + self.assertRendered(page) + QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton) + manager.append('core later match', CORE_LOG_CATEGORY) + processQtEvents() + with mock.patch('Furious.Window.LogPage.saveAsFile') as save: + page._fileMenu.actions()[0].trigger() + save.assert_called_once_with('core match') + for language in ('ZH', 'RU', 'EN'): + AppSettings.set('Language', language) + page.pauseButton.retranslate() + self.assertEqual(page.pauseButton.text(), _('Resume Updates')) + QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton) + self.assertRendered(page) + self.assertEqual( + page.plainText().splitlines(), ['core match', 'core later match'] + ) + self.assertEqual(page.pauseButton.text(), _('Pause Updates')) + + def testSearchKeyboardKeepsEditingSeparateFromLogSelection(self): + """Find focuses search; editing shortcuts act on the focused widget.""" + with isolatedSettings(): + manager = LogManager(maximumEntries=3) + page = LogPage(manager=manager) + self.addCleanup(self.disposePage, page) + self.addCleanup(QTest.keyRelease, page.textBrowser, QtCore.Qt.Key_Control) + manager.append('alpha log', APPLICATION_LOG_CATEGORY) + page.show() + page.activateWindow() + self.assertRendered(page) + page.textBrowser.setFocus() + processQtEvents() + QTest.keyClick(page.textBrowser, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier) + self.assertTrue(waitFor(page.searchLineEdit.hasFocus)) + QTest.keyClicks(page.searchLineEdit, 'alpha') + QTest.keyClick( + page.searchLineEdit, QtCore.Qt.Key_A, QtCore.Qt.ControlModifier + ) + QTest.keyClick( + page.searchLineEdit, QtCore.Qt.Key_C, QtCore.Qt.ControlModifier + ) + self.assertEqual(application().clipboard().text(), 'alpha') + self.assertFalse(page.textBrowser.textCursor().hasSelection()) + self.assertRendered(page) + page.textBrowser.setFocus() + QTest.keyClick(page.textBrowser, QtCore.Qt.Key_A, QtCore.Qt.ControlModifier) + QTest.keyClick(page.textBrowser, QtCore.Qt.Key_C, QtCore.Qt.ControlModifier) + self.assertEqual(application().clipboard().text(), 'alpha log') + + def testFindShortcutFollowsTheVisiblePage(self): + """Two persistent pages share one window without ambiguous Find actions.""" + with isolatedSettings(): + stack = QStackedWidget() + pages = [LogPage(manager=LogManager(maximumEntries=3)) for _ in range(2)] + for page in pages: + stack.addWidget(page) + self.addCleanup(stack.deleteLater) + self.addCleanup(stack.close) + self.addCleanup(QTest.keyRelease, stack, QtCore.Qt.Key_Control) + stack.show() + stack.activateWindow() + for page in (pages[0], pages[1], pages[0]): + stack.setCurrentWidget(page) + page.textBrowser.setFocus() + processQtEvents() + QTest.keyClick( + page.textBrowser, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier + ) + self.assertTrue(waitFor(page.searchLineEdit.hasFocus)) + def testLabelsOwnTheirDynamicTranslation(self): """Let each AppQLabel translate itself without page-level setters.""" with isolatedSettings():