diff --git a/docs/source/project_overview.rst b/docs/source/project_overview.rst index 6f8d197b..eb169b47 100644 --- a/docs/source/project_overview.rst +++ b/docs/source/project_overview.rst @@ -388,3 +388,16 @@ application like for instance Libre Office Calc or Excel. definition of idle here is that the novelWriter main window loses focus, or the user hasn't made any changes to the currently open document in five minutes. The number of minutes can be altered in **Preferences**. + + +Session Timer +------------- + +A session timer is by default visible in the status bar. The icon will show you a clock icon when +you are active, and a pause icon when you are considered "idle" per the criteria mentioned above. + +If you do not wish to see the timer, you can click on it once to hide it. The icon will still be +visible. Click the icon once more to display the timer again. + +.. versionadded:: 2.6 + As of version 2.6, clicking the timer text or icon in the status bar will toggle its visibility. diff --git a/novelwriter/config.py b/novelwriter/config.py index d1ae57b5..bbe8ebd2 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -194,6 +194,7 @@ class Config: # State self.showViewerPanel = True # The panel for the viewer is visible self.showEditToolBar = False # The document editor toolbar visibility + self.showSessionTime = True # Show the session time in the status bar self.viewComments = True # Comments are shown in the viewer self.viewSynopsis = True # Synopsis is shown in the viewer @@ -674,6 +675,7 @@ class Config: sec = "State" self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel) self.showEditToolBar = conf.rdBool(sec, "showedittoolbar", self.showEditToolBar) + self.showSessionTime = conf.rdBool(sec, "showsessiontime", self.showSessionTime) self.viewComments = conf.rdBool(sec, "viewcomments", self.viewComments) self.viewSynopsis = conf.rdBool(sec, "viewsynopsis", self.viewSynopsis) self.searchCase = conf.rdBool(sec, "searchcase", self.searchCase) @@ -784,6 +786,7 @@ class Config: conf["State"] = { "showviewerpanel": str(self.showViewerPanel), "showedittoolbar": str(self.showEditToolBar), + "showsessiontime": str(self.showSessionTime), "viewcomments": str(self.viewComments), "viewsynopsis": str(self.viewSynopsis), "searchcase": str(self.searchCase), diff --git a/novelwriter/extensions/modified.py b/novelwriter/extensions/modified.py index f9062b39..1805db5b 100644 --- a/novelwriter/extensions/modified.py +++ b/novelwriter/extensions/modified.py @@ -30,14 +30,15 @@ from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING -from PyQt5.QtCore import QSize, Qt, pyqtSlot -from PyQt5.QtGui import QWheelEvent +from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot +from PyQt5.QtGui import QMouseEvent, QWheelEvent from PyQt5.QtWidgets import ( - QApplication, QComboBox, QDialog, QDoubleSpinBox, QSpinBox, QToolButton, - QWidget + QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox, + QToolButton, QWidget ) from novelwriter import CONFIG, SHARED +from novelwriter.types import QtMouseLeft if TYPE_CHECKING: # pragma: no cover from novelwriter.guimain import GuiMain @@ -196,3 +197,14 @@ class NIconToggleButton(QToolButton): iconSize = self.iconSize() self.setIcon(SHARED.theme.getToggleIcon(iconKey, (iconSize.width(), iconSize.height()))) return + + +class NClickableLabel(QLabel): + + mouseClicked = pyqtSignal() + + def mousePressEvent(self, event: QMouseEvent) -> None: + """Capture a left mouse click and emit its signal.""" + if event.button() == QtMouseLeft: + self.mouseClicked.emit() + return super().mousePressEvent(event) diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index e7459c53..647e90ba 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -35,6 +35,7 @@ from novelwriter import CONFIG, SHARED from novelwriter.common import formatTime from novelwriter.constants import nwConst from novelwriter.enum import nwTrinary +from novelwriter.extensions.modified import NClickableLabel from novelwriter.extensions.statusled import StatusLED logger = logging.getLogger(__name__) @@ -92,12 +93,16 @@ class GuiMainStatus(QStatusBar): # The Session Clock # Set the minimum width so the label doesn't rescale every second - self.timeIcon = QLabel(self) - self.timeText = QLabel("", self) + self.timeIcon = NClickableLabel(self) + self.timeIcon.mouseClicked.connect(self._onClickTimerLabel) + + self.timeText = NClickableLabel("", self) self.timeText.setToolTip(self.tr("Session Time")) self.timeText.setMinimumWidth(SHARED.theme.getTextWidth("00:00:00:")) self.timeIcon.setContentsMargins(0, 0, 0, 0) self.timeText.setContentsMargins(0, 0, 0, 0) + self.timeText.setVisible(CONFIG.showSessionTime) + self.timeText.mouseClicked.connect(self._onClickTimerLabel) self.addPermanentWidget(self.timeIcon) self.addPermanentWidget(self.timeText) @@ -224,6 +229,18 @@ class GuiMainStatus(QStatusBar): self.setDocumentStatus(nwTrinary.NEGATIVE if status else nwTrinary.POSITIVE) return + ## + # Private Slots + ## + + @pyqtSlot() + def _onClickTimerLabel(self) -> None: + """Process mouse click on timer label.""" + state = not CONFIG.showSessionTime + self.timeText.setVisible(state) + CONFIG.showSessionTime = state + return + ## # Debug ## diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 046ac8c8..3bd03bbe 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -1,5 +1,5 @@ [Meta] -timestamp = 2024-11-03 21:45:08 +timestamp = 2024-12-29 17:30:10 [Main] font = @@ -71,6 +71,7 @@ useridletime = 300 [State] showviewerpanel = True showedittoolbar = False +showsessiontime = True viewcomments = True viewsynopsis = True searchcase = False diff --git a/tests/test_ext/test_ext_modified.py b/tests/test_ext/test_ext_modified.py index 65865b27..354d6e17 100644 --- a/tests/test_ext/test_ext_modified.py +++ b/tests/test_ext/test_ext_modified.py @@ -23,11 +23,13 @@ from __future__ import annotations import pytest from PyQt5.QtCore import QEvent, QPoint, QPointF, Qt -from PyQt5.QtGui import QKeyEvent, QWheelEvent +from PyQt5.QtGui import QKeyEvent, QMouseEvent, QWheelEvent from PyQt5.QtWidgets import QWidget -from novelwriter.extensions.modified import NComboBox, NDialog, NDoubleSpinBox, NSpinBox -from novelwriter.types import QtModNone, QtRejected +from novelwriter.extensions.modified import ( + NClickableLabel, NComboBox, NDialog, NDoubleSpinBox, NSpinBox +) +from novelwriter.types import QtModNone, QtMouseLeft, QtRejected from tests.tools import SimpleDialog @@ -139,3 +141,19 @@ def testExtModified_NDoubleSpinBox(qtbot, monkeypatch): assert event.ignored is True # qtbot.stop() + + +@pytest.mark.gui +def testExtModified_NClickableLabel(qtbot, monkeypatch): + """Test the NClickableLabel class.""" + widget = NClickableLabel() + dialog = SimpleDialog(widget) + dialog.show() + + position = widget.rect().center() + event = QMouseEvent( + QEvent.Type.MouseButtonPress, position, QtMouseLeft, QtMouseLeft, QtModNone + ) + + with qtbot.waitSignal(widget.mouseClicked): + widget.mousePressEvent(event) diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py index 71d0d0d1..f2813a02 100644 --- a/tests/test_gui/test_gui_statusbar.py +++ b/tests/test_gui/test_gui_statusbar.py @@ -39,56 +39,68 @@ def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd): newDoc.writeDocument("# A Note\n\n") nwGUI.rebuildIndex(beQuiet=True) + status = nwGUI.mainStatus + # Reference Time refTime = time.time() - nwGUI.mainStatus.setRefTime(refTime) - assert nwGUI.mainStatus._refTime == refTime + status.setRefTime(refTime) + assert status._refTime == refTime # Project Status - nwGUI.mainStatus.setProjectStatus(nwTrinary.NEUTRAL) - assert nwGUI.mainStatus.projIcon.state == nwTrinary.NEUTRAL - nwGUI.mainStatus.setProjectStatus(nwTrinary.NEGATIVE) - assert nwGUI.mainStatus.projIcon.state == nwTrinary.NEGATIVE - nwGUI.mainStatus.setProjectStatus(nwTrinary.POSITIVE) - assert nwGUI.mainStatus.projIcon.state == nwTrinary.POSITIVE + status.setProjectStatus(nwTrinary.NEUTRAL) + assert status.projIcon.state == nwTrinary.NEUTRAL + status.setProjectStatus(nwTrinary.NEGATIVE) + assert status.projIcon.state == nwTrinary.NEGATIVE + status.setProjectStatus(nwTrinary.POSITIVE) + assert status.projIcon.state == nwTrinary.POSITIVE # Document Status - nwGUI.mainStatus.setDocumentStatus(nwTrinary.NEUTRAL) - assert nwGUI.mainStatus.docIcon.state == nwTrinary.NEUTRAL - nwGUI.mainStatus.setDocumentStatus(nwTrinary.NEGATIVE) - assert nwGUI.mainStatus.docIcon.state == nwTrinary.NEGATIVE - nwGUI.mainStatus.setDocumentStatus(nwTrinary.POSITIVE) - assert nwGUI.mainStatus.docIcon.state == nwTrinary.POSITIVE + status.setDocumentStatus(nwTrinary.NEUTRAL) + assert status.docIcon.state == nwTrinary.NEUTRAL + status.setDocumentStatus(nwTrinary.NEGATIVE) + assert status.docIcon.state == nwTrinary.NEGATIVE + status.setDocumentStatus(nwTrinary.POSITIVE) + assert status.docIcon.state == nwTrinary.POSITIVE # Idle Status CONFIG.stopWhenIdle = False - nwGUI.mainStatus.setUserIdle(True) - nwGUI.mainStatus.updateTime() - assert nwGUI.mainStatus._userIdle is False - assert nwGUI.mainStatus.timeText.text() == "00:00:00" + status.setUserIdle(True) + status.updateTime() + assert status._userIdle is False + assert status.timeText.text() == "00:00:00" CONFIG.stopWhenIdle = True - nwGUI.mainStatus.setUserIdle(True) - nwGUI.mainStatus.updateTime(5) - assert nwGUI.mainStatus._userIdle is True - assert nwGUI.mainStatus.timeText.text() != "00:00:00" + status.setUserIdle(True) + status.updateTime(5) + assert status._userIdle is True + assert status.timeText.text() != "00:00:00" - nwGUI.mainStatus.setUserIdle(False) - nwGUI.mainStatus.updateTime(5) - assert nwGUI.mainStatus._userIdle is False - assert nwGUI.mainStatus.timeText.text() != "00:00:00" + status.setUserIdle(False) + status.updateTime(5) + assert status._userIdle is False + assert status.timeText.text() != "00:00:00" + + # Show/Hide Timer + assert status.timeText.isVisible() is True + assert CONFIG.showSessionTime is True + status._onClickTimerLabel() + assert status.timeText.isVisible() is False + assert CONFIG.showSessionTime is False + status._onClickTimerLabel() + assert status.timeText.isVisible() is True + assert CONFIG.showSessionTime is True # Language - nwGUI.mainStatus.setLanguage("None", "None") - assert nwGUI.mainStatus.langText.text() == "None" - nwGUI.mainStatus.setLanguage("en", "None") - assert nwGUI.mainStatus.langText.text() == "American English" + status.setLanguage("None", "None") + assert status.langText.text() == "None" + status.setLanguage("en", "None") + assert status.langText.text() == "American English" # Project Stats CONFIG.incNotesWCount = False nwGUI._lastTotalCount = 0 nwGUI._updateStatusWordCount() - assert nwGUI.mainStatus.statsText.text() == "Words: 9 (+9)" + assert status.statsText.text() == "Words: 9 (+9)" # Update again, but through time tick with monkeypatch.context() as mp: @@ -96,6 +108,6 @@ def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd): CONFIG.incNotesWCount = True nwGUI._lastTotalCount = 0 nwGUI._timeTick() - assert nwGUI.mainStatus.statsText.text() == "Words: 11 (+11)" + assert status.statsText.text() == "Words: 11 (+11)" # qtbot.stop()