diff --git a/novelwriter/assets/icons/none.svg b/novelwriter/assets/icons/none.svg new file mode 100644 index 00000000..15cac73d --- /dev/null +++ b/novelwriter/assets/icons/none.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/common.py b/novelwriter/common.py index 55742b94..c148e75d 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -36,7 +36,7 @@ from configparser import ConfigParser from urllib.parse import urljoin from urllib.request import pathname2url -from PyQt5.QtGui import QDesktopServices +from PyQt5.QtGui import QColor, QDesktopServices from PyQt5.QtCore import QCoreApplication, QUrl from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout @@ -382,6 +382,11 @@ def numberToRoman(value: int, toLower: bool = False) -> str: return roman.lower() if toLower else roman +def cssCol(col: QColor, alpha: int | None = None) -> str: + """Convert a QColor object to an rgba entry to use in CSS.""" + return f"rgba({col.red()}, {col.green()}, {col.blue()}, {alpha or col.alpha()})" + + ## # Encoder Functions ## diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 36b9d80d..108dc9f1 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -41,6 +41,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG, SHARED +from novelwriter.common import cssCol from novelwriter.constants import nwHeaders, nwUnicode from novelwriter.core.tohtml import ToHtml from novelwriter.enum import nwItemType, nwDocAction, nwDocMode @@ -467,35 +468,25 @@ class GuiDocViewer(QTextBrowser): """Generate an appropriate style sheet for the document viewer, based on the current syntax highlighter theme. """ - colText = SHARED.theme.colText - colHead = SHARED.theme.colHead - colVals = SHARED.theme.colVal - colMark = SHARED.theme.colMark - colKeys = SHARED.theme.colKey - colHide = SHARED.theme.colHidden - colMods = SHARED.theme.colMod - colOpts = SHARED.theme.colOpt - styleSheet = ( - "body {{color: rgb({rT}, {gT}, {bT});}}\n" - "h1, h2, h3, h4 {{color: rgb({rH}, {gH}, {bH});}}\n" - "a {{color: rgb({rA}, {gA}, {bA});}}\n" - "mark {{background-color: rgba({rE}, {gE}, {bE}, {aE});}}\n" - ".tags {{color: rgb({rK}, {gK}, {bK});}}\n" - ".optional {{color: rgb({rO}, {gO}, {bO});}}\n" - ".comment {{color: rgb({rC}, {gC}, {bC});}}\n" - ".synopsis {{color: rgb({rM}, {gM}, {bM});}}\n" - ".title {{text-align: center;}}\n" - ).format( - rT=colText.red(), gT=colText.green(), bT=colText.blue(), - rH=colHead.red(), gH=colHead.green(), bH=colHead.blue(), - rA=colVals.red(), gA=colVals.green(), bA=colVals.blue(), - rE=colMark.red(), gE=colMark.green(), bE=colMark.blue(), aE=colMark.alpha(), - rK=colKeys.red(), gK=colKeys.green(), bK=colKeys.blue(), - rC=colHide.red(), gC=colHide.green(), bC=colHide.blue(), - rM=colMods.red(), gM=colMods.green(), bM=colMods.blue(), - rO=colOpts.red(), gO=colOpts.green(), bO=colOpts.blue(), + colText = cssCol(SHARED.theme.colText) + colHead = cssCol(SHARED.theme.colHead) + colVals = cssCol(SHARED.theme.colVal) + colMark = cssCol(SHARED.theme.colMark) + colKeys = cssCol(SHARED.theme.colKey) + colOpts = cssCol(SHARED.theme.colOpt) + colHide = cssCol(SHARED.theme.colHidden) + colMods = cssCol(SHARED.theme.colMod) + self.document().setDefaultStyleSheet( + f"body {{color: {colText};}}\n" + f"h1, h2, h3, h4 {{color: {colHead};}}\n" + f"a {{color: {colVals};}}\n" + f"mark {{background-color: {colMark};}}\n" + f".tags {{color: {colKeys};}}\n" + f".optional {{color: {colOpts};}}\n" + f".comment {{color: {colHide});}}\n" + f".synopsis {{color: {colMods};}}\n" + ".title {text-align: center;}\n" ) - self.document().setDefaultStyleSheet(styleSheet) return diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index d78b84d5..30958582 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -565,7 +565,7 @@ class GuiProjectTree(QTreeWidget): # Auto Scroll self._scrollMargin = SHARED.theme.baseIconSize self._scrollDirection = 0 - self._scrollTimer = QTimer() + self._scrollTimer = QTimer(self) self._scrollTimer.timeout.connect(self._doAutoScroll) self._scrollTimer.setInterval(250) diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py index 27ef4b4b..fae4ac65 100644 --- a/novelwriter/gui/search.py +++ b/novelwriter/gui/search.py @@ -28,14 +28,14 @@ import logging from time import time from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot -from PyQt5.QtGui import QCursor, QKeyEvent, QPalette +from PyQt5.QtGui import QCursor, QKeyEvent from PyQt5.QtWidgets import ( - QHBoxLayout, QHeaderView, QLabel, QLineEdit, QToolBar, QTreeWidget, + QFrame, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, qApp ) from novelwriter import CONFIG, SHARED -from novelwriter.common import checkInt +from novelwriter.common import checkInt, cssCol from novelwriter.core.coretools import DocSearch from novelwriter.core.item import NWItem @@ -63,6 +63,7 @@ class GuiProjectSearch(QWidget): iPx = SHARED.theme.baseIconSize mPx = CONFIG.pxInt(2) + tPx = CONFIG.pxInt(4) self._time = time() self._search = DocSearch() @@ -71,7 +72,7 @@ class GuiProjectSearch(QWidget): # Header self.viewLabel = QLabel(self.tr("Project Search")) self.viewLabel.setFont(SHARED.theme.guiFontB) - self.viewLabel.setContentsMargins(mPx, mPx, 0, mPx) + self.viewLabel.setContentsMargins(mPx, tPx, 0, mPx) # Options self.searchOpt = QToolBar(self) @@ -96,7 +97,7 @@ class GuiProjectSearch(QWidget): # Search Box self.searchText = QLineEdit(self) - self.searchText.setPlaceholderText(self.tr("Search text ...")) + self.searchText.setPlaceholderText(self.tr("Search")) self.searchText.setClearButtonEnabled(True) self.searchAction = self.searchText.addAction( @@ -110,6 +111,9 @@ class GuiProjectSearch(QWidget): self.searchResult.setColumnCount(2) self.searchResult.setIconSize(QSize(iPx, iPx)) self.searchResult.setIndentation(iPx) + self.searchResult.setFrameStyle(QFrame.Shape.NoFrame) + self.searchResult.setUniformRowHeights(True) + self.searchResult.setAllColumnsShowFocus(True) self.searchResult.itemDoubleClicked.connect(self._searchResultDoubleClicked) self.searchResult.itemSelectionChanged.connect(self._searchResultSelected) @@ -121,11 +125,16 @@ class GuiProjectSearch(QWidget): # Assemble self.headerBox = QHBoxLayout() self.headerBox.addWidget(self.viewLabel, 1) - self.headerBox.addWidget(self.searchOpt, 0) + self.headerBox.addWidget(self.searchOpt, 0, Qt.AlignmentFlag.AlignVCenter) self.headerBox.setContentsMargins(0, 0, 0, 0) + self.headerBox.setSpacing(0) + + self.headerWidget = QWidget(self) + self.headerWidget.setLayout(self.headerBox) + self.headerWidget.setContentsMargins(0, 0, 0, 0) self.outerBox = QVBoxLayout() - self.outerBox.addLayout(self.headerBox, 0) + self.outerBox.addWidget(self.headerWidget, 0) self.outerBox.addWidget(self.searchText, 0) self.outerBox.addWidget(self.searchResult, 1) self.outerBox.setContentsMargins(0, 0, 0, 0) @@ -144,9 +153,21 @@ class GuiProjectSearch(QWidget): def updateTheme(self) -> None: """Update theme elements.""" + bPx = CONFIG.pxInt(1) + mPx = CONFIG.pxInt(2) + qPalette = self.palette() - qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base()) - self.setPalette(qPalette) + colBase = cssCol(qPalette.base().color()) + colFocus = cssCol(qPalette.highlight().color()) + + self.headerWidget.setStyleSheet(f"background: {colBase};") + self.headerWidget.setAutoFillBackground(True) + + self.setStyleSheet( + "QToolBar {padding: 0; background: none;} " + f"QLineEdit {{border: {bPx}px solid {colBase}; padding: {mPx}px;}} " + f"QLineEdit:focus {{border: {bPx}px solid {colFocus};}} " + ) self.searchAction.setIcon(SHARED.theme.getIcon("search")) self.toggleCase.setIcon(SHARED.theme.getIcon("search_case")) diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index f40642c4..7a841ad6 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -38,7 +38,7 @@ from PyQt5.QtGui import ( from novelwriter import CONFIG from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.error import logException -from novelwriter.common import NWConfigParser, minmax +from novelwriter.common import NWConfigParser, cssCol, minmax from novelwriter.constants import nwLabels logger = logging.getLogger(__name__) @@ -440,24 +440,24 @@ class GuiTheme: # Flat Tab Widget and Tab Bar: self._styleSheets[STYLES_FLAT_TABS] = ( - "QTabWidget::pane {{border: 0;}} " - "QTabWidget QTabBar::tab {{border: 0; padding: {0}px {1}px;}} " - "QTabWidget QTabBar::tab:selected {{color: rgb({2}, {3}, {4});}} " - ).format(bPx, dPx, hCol.red(), hCol.green(), hCol.blue()) + "QTabWidget::pane {border: 0;} " + f"QTabWidget QTabBar::tab {{border: 0; padding: {bPx}px {dPx}px;}} " + f"QTabWidget QTabBar::tab:selected {{color: {cssCol(hCol)};}} " + ) # Minimal Tool Button self._styleSheets[STYLES_MIN_TOOLBUTTON] = ( - "QToolButton {{padding: {0}px; margin: 0; border: none; background: transparent;}} " - "QToolButton:hover {{border: none; background: rgba({1}, {2}, {3}, 0.2);}} " - "QToolButton::menu-indicator {{image: none;}} " - ).format(aPx, tCol.red(), tCol.green(), tCol.blue()) + f"QToolButton {{padding: {aPx}px; margin: 0; border: none; background: transparent;}} " + f"QToolButton:hover {{border: none; background: {cssCol(tCol, 48)};}} " + "QToolButton::menu-indicator {image: none;} " + ) # Big Tool Button self._styleSheets[STYLES_BIG_TOOLBUTTON] = ( - "QToolButton {{padding: {0}px; margin: 0; border: none; background: transparent;}} " - "QToolButton:hover {{border: none; background: rgba({1}, {2}, {3}, 0.2);}} " - "QToolButton::menu-indicator {{image: none;}} " - ).format(cPx, tCol.red(), tCol.green(), tCol.blue()) + f"QToolButton {{padding: {cPx}px; margin: 0; border: none; background: transparent;}} " + f"QToolButton:hover {{border: none; background: {cssCol(tCol, 48)};}} " + "QToolButton::menu-indicator {image: none;} " + ) return @@ -544,6 +544,9 @@ class GuiIcons: self._confName = "icons.conf" self._iconPath = CONFIG.assetPath("icons") + # None Icon + self._noIcon = QIcon(str(self._iconPath / "none.svg")) + # Icon Theme Meta self.themeName = "" self.themeDescription = "" @@ -680,7 +683,7 @@ class GuiIcons: icon.addPixmap(pOne, QIcon.Mode.Normal, QIcon.State.On) icon.addPixmap(pTwo, QIcon.Mode.Normal, QIcon.State.Off) return icon - return QIcon() + return self._noIcon def getPixmap(self, name: str, size: tuple[int, int]) -> QPixmap: """Return an icon from the icon buffer as a QPixmap. If it @@ -712,7 +715,7 @@ class GuiIcons: elif tLayout == nwItemLayout.NOTE: iconName = "proj_note" if iconName is None: - return QIcon() + return self._noIcon return self.getIcon(iconName) @@ -753,7 +756,7 @@ class GuiIcons: """ if name not in self.ICON_KEYS: logger.error("Requested unknown icon name '%s'", name) - return QIcon() + return self._noIcon # If we just want the app icons, return right away if name == "novelwriter": @@ -769,7 +772,7 @@ class GuiIcons: # If we didn't find one, give up and return an empty icon logger.warning("Did not load an icon for '%s'", name) - return QIcon() + return self._noIcon # END Class GuiIcons diff --git a/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py index ec7869f5..c71d7855 100644 --- a/novelwriter/tools/welcome.py +++ b/novelwriter/tools/welcome.py @@ -42,7 +42,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.enum import nwItemClass -from novelwriter.common import formatInt, makeFileNameSafe +from novelwriter.common import cssCol, formatInt, makeFileNameSafe from novelwriter.constants import nwFiles from novelwriter.core.coretools import ProjectBuilder from novelwriter.extensions.configlayout import NWrappedWidgetBox @@ -52,7 +52,7 @@ from novelwriter.extensions.versioninfo import VersionInfoWidget logger = logging.getLogger(__name__) -PANEL_ALPHA = 0.7 +PANEL_ALPHA = 178 class GuiWelcome(QDialog): @@ -306,12 +306,12 @@ class _OpenProjectPage(QWidget): self._selectFirstItem() - baseCol = self.palette().base().color() - self.setStyleSheet(( - "QListView {{border: none; background: rgba({r},{g},{b},{a});}} " - "QLineEdit {{border: none; background: rgba({r},{g},{b},{a}); padding: {m}px;}} " - ).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue(), - a=PANEL_ALPHA, m=CONFIG.pxInt(4))) + mPx = CONFIG.pxInt(4) + baseCol = cssCol(self.palette().base().color(), PANEL_ALPHA) + self.setStyleSheet( + f"QListView {{border: none; background: {baseCol};}} " + f"QLineEdit {{border: none; background: {baseCol}; padding: {mPx}px;}} " + ) return @@ -518,11 +518,11 @@ class _NewProjectPage(QWidget): # Styles # ====== - baseCol = self.palette().base().color() - self.setStyleSheet(( - "QScrollArea {{border: none; background: rgba({r},{g},{b},{a});}} " - "_NewProjectForm {{border: none; background: rgba({r},{g},{b},{a});}} " - ).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue(), a=PANEL_ALPHA)) + baseCol = cssCol(self.palette().base().color(), PANEL_ALPHA) + self.setStyleSheet( + f"QScrollArea {{border: none; background: {baseCol};}} " + f"_NewProjectForm {{border: none; background: {baseCol};}} " + ) return diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 7cadfe9e..1b2f7f9e 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -29,16 +29,16 @@ from xml.etree import ElementTree as ET from tools import writeFile from mocked import causeOSError -from PyQt5.QtGui import QDesktopServices +from PyQt5.QtGui import QColor, QDesktopServices from PyQt5.QtCore import QUrl from novelwriter.common import ( checkBool, checkFloat, checkInt, checkIntTuple, checkPath, checkString, - checkStringNone, checkUuid, formatFileFilter, formatInt, formatTime, - formatTimeStamp, formatVersion, fuzzyTime, getFileSize, hexToInt, isHandle, - isItemClass, isItemLayout, isItemType, isTitleTag, jsonEncode, - makeFileNameSafe, minmax, numberToRoman, NWConfigParser, openExternalPath, - readTextFile, simplified, transferCase, xmlIndent, yesNo + checkStringNone, checkUuid, cssCol, formatFileFilter, formatInt, + formatTime, formatTimeStamp, formatVersion, fuzzyTime, getFileSize, + hexToInt, isHandle, isItemClass, isItemLayout, isItemType, isTitleTag, + jsonEncode, makeFileNameSafe, minmax, numberToRoman, NWConfigParser, + openExternalPath, readTextFile, simplified, transferCase, xmlIndent, yesNo ) @@ -494,6 +494,15 @@ def testBaseCommon_numberToRoman(): # END Test testBaseCommon_numberToRoman +@pytest.mark.base +def testBaseCommon_cssCol(): + """Test the cssCol function.""" + assert cssCol(QColor(0, 0, 0, 0)) == "rgba(0, 0, 0, 0)" + assert cssCol(QColor(10, 20, 30, 40)) == "rgba(10, 20, 30, 40)" + +# END Test testBaseCommon_cssCol + + @pytest.mark.base def testBaseCommon_jsonEncode(): """Test the jsonEncode function.""" diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index f4050c0d..84ade0f8 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -332,7 +332,7 @@ def testGuiTheme_LoadIcons(qtbot): # Load an unknown icon qIcon = iconCache.getIcon("stuff") assert isinstance(qIcon, QIcon) - assert qIcon.isNull() is True + assert qIcon == iconCache._noIcon # Load an icon, it is likely already cached qIcon = iconCache.getIcon("add") @@ -349,17 +349,17 @@ def testGuiTheme_LoadIcons(qtbot): # Load app icon qIcon = iconCache.getIcon("novelwriter") assert isinstance(qIcon, QIcon) - assert qIcon.isNull() is False + assert qIcon != iconCache._noIcon # Load mime icon qIcon = iconCache.getIcon("proj_nwx") assert isinstance(qIcon, QIcon) - assert qIcon.isNull() is False + assert qIcon != iconCache._noIcon # Toggle icon qIcon = iconCache.getToggleIcon("bullet", (24, 24)) assert isinstance(qIcon, QIcon) - assert qIcon.isNull() is False + assert qIcon != iconCache._noIcon pOn = qIcon.pixmap(24, 24, QIcon.Mode.Normal, QIcon.State.On) pOff = qIcon.pixmap(24, 24, QIcon.Mode.Normal, QIcon.State.Off) assert pOn != pOff @@ -367,7 +367,7 @@ def testGuiTheme_LoadIcons(qtbot): # Unknown toggle icon qIcon = iconCache.getToggleIcon("stuff", (24, 24)) assert isinstance(qIcon, QIcon) - assert qIcon.isNull() is True + assert qIcon == iconCache._noIcon # Load Item Icons # =============== @@ -420,7 +420,7 @@ def testGuiTheme_LoadIcons(qtbot): # No Type -> Null assert iconCache.getItemIcon( nwItemType.NO_TYPE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H0" - ).isNull() is True + ) == iconCache._noIcon # qtbot.stop()