Merge branch 'main' into unicode_fix
This commit is contained in:
@@ -45,11 +45,11 @@ from PyQt6.QtCore import (
|
||||
from PyQt6.QtGui import (
|
||||
QAction, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeyEvent,
|
||||
QKeySequence, QMouseEvent, QPalette, QPixmap, QResizeEvent, QShortcut,
|
||||
QTextBlock, QTextCursor, QTextDocument, QTextOption
|
||||
QTextBlock, QTextCursor, QTextDocument, QTextFormat, QTextOption
|
||||
)
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu,
|
||||
QPlainTextEdit, QToolBar, QVBoxLayout, QWidget
|
||||
QPlainTextEdit, QTextEdit, QToolBar, QVBoxLayout, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
@@ -75,7 +75,8 @@ from novelwriter.tools.lipsum import GuiLipsum
|
||||
from novelwriter.types import (
|
||||
QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop,
|
||||
QtAlignRight, QtKeepAnchor, QtModCtrl, QtModNone, QtModShift, QtMouseLeft,
|
||||
QtMoveAnchor, QtMoveLeft, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded
|
||||
QtMoveAnchor, QtMoveLeft, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded,
|
||||
QtTransparent
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -145,6 +146,8 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
self._lastActive = 0.0 # Timestamp of last activity
|
||||
self._lastFind = None # Position of the last found search word
|
||||
self._doReplace = False # Switch to temporarily disable auto-replace
|
||||
self._lineColor = QtTransparent
|
||||
self._selection = QTextEdit.ExtraSelection()
|
||||
|
||||
# Auto-Replace
|
||||
self._autoReplace = TextAutoReplace()
|
||||
@@ -283,6 +286,7 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
self.docHeader.clearHeader()
|
||||
self.docFooter.setHandle(self._docHandle)
|
||||
self.docToolBar.setVisible(False)
|
||||
self.setExtraSelections([])
|
||||
|
||||
self.itemHandleChanged.emit("")
|
||||
|
||||
@@ -315,6 +319,10 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
self.docHeader.matchColors()
|
||||
self.docFooter.matchColors()
|
||||
|
||||
self._lineColor = syntax.line
|
||||
self._selection.format.setBackground(self._lineColor)
|
||||
self._selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
|
||||
|
||||
return
|
||||
|
||||
def initEditor(self) -> None:
|
||||
@@ -372,6 +380,8 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
# Refresh sizes
|
||||
self.setTabStopDistance(CONFIG.tabWidth)
|
||||
self.setCursorWidth(CONFIG.cursorWidth)
|
||||
self.setExtraSelections([])
|
||||
self._cursorMoved()
|
||||
|
||||
# If we have a document open, we should refresh it in case the
|
||||
# font changed, otherwise we just clear the editor entirely,
|
||||
@@ -733,6 +743,8 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
self._toggleFormat(2, "*")
|
||||
elif action == nwDocAction.MD_STRIKE:
|
||||
self._toggleFormat(2, "~")
|
||||
elif action == nwDocAction.MD_MARK:
|
||||
self._toggleFormat(2, "=")
|
||||
elif action == nwDocAction.S_QUOTE:
|
||||
self._wrapSelection(CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose)
|
||||
elif action == nwDocAction.D_QUOTE:
|
||||
@@ -1110,6 +1122,10 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
def _cursorMoved(self) -> None:
|
||||
"""Triggered when the cursor moved in the editor."""
|
||||
self.docFooter.updateLineCount(self.textCursor())
|
||||
if CONFIG.lineHighlight:
|
||||
self._selection.cursor = self.textCursor()
|
||||
self._selection.cursor.clearSelection()
|
||||
self.setExtraSelections([self._selection])
|
||||
return
|
||||
|
||||
@pyqtSlot(int, int, str)
|
||||
@@ -2410,6 +2426,12 @@ class GuiDocToolBar(QWidget):
|
||||
qtLambda(self.requestDocAction.emit, nwDocAction.MD_STRIKE)
|
||||
)
|
||||
|
||||
self.tbMarkMD = NIconToolButton(self, iSz)
|
||||
self.tbMarkMD.setToolTip(self.tr("Markdown Highlight"))
|
||||
self.tbMarkMD.clicked.connect(
|
||||
qtLambda(self.requestDocAction.emit, nwDocAction.MD_MARK)
|
||||
)
|
||||
|
||||
self.tbBold = NIconToolButton(self, iSz)
|
||||
self.tbBold.setToolTip(self.tr("Shortcode Bold"))
|
||||
self.tbBold.clicked.connect(
|
||||
@@ -2459,6 +2481,7 @@ class GuiDocToolBar(QWidget):
|
||||
self.outerBox.addWidget(self.tbBoldMD)
|
||||
self.outerBox.addWidget(self.tbItalicMD)
|
||||
self.outerBox.addWidget(self.tbStrikeMD)
|
||||
self.outerBox.addWidget(self.tbMarkMD)
|
||||
self.outerBox.addSpacing(4)
|
||||
self.outerBox.addWidget(self.tbBold)
|
||||
self.outerBox.addWidget(self.tbItalic)
|
||||
@@ -2493,6 +2516,7 @@ class GuiDocToolBar(QWidget):
|
||||
self.tbBoldMD.setThemeIcon("fmt_bold", "orange")
|
||||
self.tbItalicMD.setThemeIcon("fmt_italic", "orange")
|
||||
self.tbStrikeMD.setThemeIcon("fmt_strike", "orange")
|
||||
self.tbMarkMD.setThemeIcon("fmt_mark", "orange")
|
||||
self.tbBold.setThemeIcon("fmt_bold")
|
||||
self.tbItalic.setThemeIcon("fmt_italic")
|
||||
self.tbStrike.setThemeIcon("fmt_strike")
|
||||
|
||||
@@ -113,6 +113,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._addCharFormat("bold", colEmph, "b")
|
||||
self._addCharFormat("italic", colEmph, "i")
|
||||
self._addCharFormat("strike", syntax.hidden, "s")
|
||||
self._addCharFormat("mark", syntax.mark, "bg")
|
||||
self._addCharFormat("mspaces", syntax.error, "err")
|
||||
self._addCharFormat("nobreak", colBreak, "bg")
|
||||
self._addCharFormat("altdialog", syntax.dialA)
|
||||
@@ -200,6 +201,17 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# Markdown Highlight
|
||||
rxRule = REGEX_PATTERNS.markdownMark
|
||||
hlRule = {
|
||||
1: self._hStyles["markup"],
|
||||
2: self._hStyles["mark"],
|
||||
3: self._hStyles["markup"],
|
||||
}
|
||||
self._minRules.append((rxRule, hlRule))
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# Shortcodes
|
||||
rxRule = QRegularExpression(REGEX_PATTERNS.shortcodePlain.pattern, RX_UNICODE)
|
||||
hlRule = {
|
||||
@@ -460,9 +472,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
blockMerge = name == "markup"
|
||||
charFormat.setProperty(QtTextUserProperty, blockMerge)
|
||||
|
||||
if color:
|
||||
charFormat.setForeground(color)
|
||||
|
||||
if style:
|
||||
styles = style.split(",")
|
||||
if "b" in styles:
|
||||
@@ -478,6 +487,10 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
charFormat.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
|
||||
if "bg" in styles and color is not None:
|
||||
charFormat.setBackground(QBrush(color, Qt.BrushStyle.SolidPattern))
|
||||
color = None
|
||||
|
||||
if color:
|
||||
charFormat.setForeground(color)
|
||||
|
||||
if size:
|
||||
charFormat.setFontPointSize(round(size*CONFIG.textFont.pointSize()))
|
||||
|
||||
@@ -228,11 +228,11 @@ class GuiDocViewer(QTextBrowser):
|
||||
qDoc.setTheme(self._docTheme)
|
||||
qDoc.initDocument()
|
||||
qDoc.setKeywords(True)
|
||||
qDoc.setCommentType(nwComment.NOTE, CONFIG.viewComments)
|
||||
qDoc.setCommentType(nwComment.STORY, CONFIG.viewComments)
|
||||
qDoc.setCommentType(nwComment.PLAIN, CONFIG.viewComments)
|
||||
qDoc.setCommentType(nwComment.SYNOPSIS, CONFIG.viewSynopsis)
|
||||
qDoc.setCommentType(nwComment.SHORT, CONFIG.viewSynopsis)
|
||||
qDoc.setCommentType(nwComment.STORY, CONFIG.viewNotes)
|
||||
qDoc.setCommentType(nwComment.NOTE, CONFIG.viewNotes)
|
||||
|
||||
# Be extra careful here to prevent crashes when first opening a
|
||||
# project as a crash here leaves no way of recovering.
|
||||
@@ -913,12 +913,23 @@ class GuiDocViewFooter(QWidget):
|
||||
self.showSynopsis.toggled.connect(self._doToggleSynopsis)
|
||||
self.showSynopsis.setToolTip(self.tr("Show Synopsis Comments"))
|
||||
|
||||
# Show Notes
|
||||
self.showNotes = QToolButton(self)
|
||||
self.showNotes.setText(self.tr("Notes"))
|
||||
self.showNotes.setCheckable(True)
|
||||
self.showNotes.setChecked(CONFIG.viewNotes)
|
||||
self.showNotes.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
||||
self.showNotes.setIconSize(iSz)
|
||||
self.showNotes.toggled.connect(self._doToggleNotes)
|
||||
self.showNotes.setToolTip(self.tr("Show Notes"))
|
||||
|
||||
# Assemble Layout
|
||||
self.outerBox = QHBoxLayout()
|
||||
self.outerBox.addWidget(self.showHide, 0)
|
||||
self.outerBox.addStretch(1)
|
||||
self.outerBox.addWidget(self.showComments, 0)
|
||||
self.outerBox.addWidget(self.showSynopsis, 0)
|
||||
self.outerBox.addWidget(self.showNotes, 0)
|
||||
self.outerBox.setSpacing(4)
|
||||
self.setLayout(self.outerBox)
|
||||
|
||||
@@ -944,6 +955,7 @@ class GuiDocViewFooter(QWidget):
|
||||
self.setFont(SHARED.theme.guiFont)
|
||||
self.showComments.setFont(SHARED.theme.guiFontSmall)
|
||||
self.showSynopsis.setFont(SHARED.theme.guiFontSmall)
|
||||
self.showNotes.setFont(SHARED.theme.guiFontSmall)
|
||||
return
|
||||
|
||||
def updateTheme(self) -> None:
|
||||
@@ -955,11 +967,13 @@ class GuiDocViewFooter(QWidget):
|
||||
self.showHide.setThemeIcon("panel")
|
||||
self.showComments.setIcon(bulletIcon)
|
||||
self.showSynopsis.setIcon(bulletIcon)
|
||||
self.showNotes.setIcon(bulletIcon)
|
||||
|
||||
buttonStyle = SHARED.theme.getStyleSheet(STYLES_MIN_TOOLBUTTON)
|
||||
self.showHide.setStyleSheet(buttonStyle)
|
||||
self.showComments.setStyleSheet(buttonStyle)
|
||||
self.showSynopsis.setStyleSheet(buttonStyle)
|
||||
self.showNotes.setStyleSheet(buttonStyle)
|
||||
|
||||
self.matchColors()
|
||||
|
||||
@@ -994,3 +1008,10 @@ class GuiDocViewFooter(QWidget):
|
||||
CONFIG.viewSynopsis = state
|
||||
self.docViewer.reloadText()
|
||||
return
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def _doToggleNotes(self, state: bool) -> None:
|
||||
"""Toggle the view notes button and reload the document."""
|
||||
CONFIG.viewNotes = state
|
||||
self.docViewer.reloadText()
|
||||
return
|
||||
|
||||
@@ -233,14 +233,8 @@ class GuiItemDetails(QWidget):
|
||||
# Label
|
||||
# =====
|
||||
|
||||
if nwItem.isFileType():
|
||||
if nwItem.isActive:
|
||||
self.labelIcon.setPixmap(SHARED.theme.getPixmap("checked", (iPx, iPx), "green"))
|
||||
else:
|
||||
self.labelIcon.setPixmap(SHARED.theme.getPixmap("unchecked", (iPx, iPx), "red"))
|
||||
else:
|
||||
self.labelIcon.setPixmap(SHARED.theme.getPixmap("noncheckable", (iPx, iPx), "faded"))
|
||||
|
||||
_, icon = nwItem.getActiveStatus()
|
||||
self.labelIcon.setPixmap(icon.pixmap(iPx, iPx))
|
||||
self.labelData.setText(elide(nwItem.itemName, 100))
|
||||
|
||||
# Status
|
||||
|
||||
@@ -675,6 +675,14 @@ class GuiMainMenu(QMenuBar):
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtStrike)
|
||||
|
||||
# Format > Highlight
|
||||
self.aFmtMark = qtAddAction(self.fmtMenu, self.tr("Highlight"))
|
||||
self.aFmtMark.setShortcut("Ctrl+M")
|
||||
self.aFmtMark.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.MD_MARK)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtStrike)
|
||||
|
||||
# Edit > Separator
|
||||
self.fmtMenu.addSeparator()
|
||||
|
||||
|
||||
@@ -27,12 +27,13 @@ import logging
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt6.QtCore import QEvent, QPoint, QSize, pyqtSignal
|
||||
from PyQt6.QtCore import QEvent, QPoint, QSize, pyqtSignal, pyqtSlot
|
||||
from PyQt6.QtWidgets import QMenu, QVBoxLayout, QWidget
|
||||
|
||||
from novelwriter import SHARED
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import qtLambda
|
||||
from novelwriter.enum import nwView
|
||||
from novelwriter.constants import nwLabels, trConst
|
||||
from novelwriter.enum import nwTheme, nwView
|
||||
from novelwriter.extensions.eventfilters import StatusTipFilter
|
||||
from novelwriter.extensions.modified import NIconToolButton
|
||||
from novelwriter.gui.theme import STYLES_BIG_TOOLBUTTON
|
||||
@@ -77,9 +78,9 @@ class GuiSideBar(QWidget):
|
||||
self.tbOutline.setToolTip("{0} [Ctrl+Shift+T]".format(self.tr("Novel Outline View")))
|
||||
self.tbOutline.clicked.connect(qtLambda(self.requestViewChange.emit, nwView.OUTLINE))
|
||||
|
||||
self.tbBuild = NIconToolButton(self, iSz)
|
||||
self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript")))
|
||||
self.tbBuild.clicked.connect(self.mainGui.showBuildManuscriptDialog)
|
||||
self.tbTheme = NIconToolButton(self, iSz)
|
||||
self.tbTheme.setToolTip(self.tr("Switch Colour Theme"))
|
||||
self.tbTheme.clicked.connect(self._cycleColurTheme)
|
||||
|
||||
self.tbDetails = NIconToolButton(self, iSz)
|
||||
self.tbDetails.setToolTip("{0} [Shift+F6]".format(self.tr("Novel Details")))
|
||||
@@ -89,6 +90,10 @@ class GuiSideBar(QWidget):
|
||||
self.tbStats.setToolTip("{0} [F6]".format(self.tr("Writing Statistics")))
|
||||
self.tbStats.clicked.connect(self.mainGui.showWritingStatsDialog)
|
||||
|
||||
self.tbBuild = NIconToolButton(self, iSz)
|
||||
self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript")))
|
||||
self.tbBuild.clicked.connect(self.mainGui.showBuildManuscriptDialog)
|
||||
|
||||
# Settings Menu
|
||||
self.tbSettings = NIconToolButton(self, iSz)
|
||||
self.tbSettings.setToolTip(self.tr("Settings"))
|
||||
@@ -111,6 +116,7 @@ class GuiSideBar(QWidget):
|
||||
self.outerBox.addStretch(1)
|
||||
self.outerBox.addWidget(self.tbDetails)
|
||||
self.outerBox.addWidget(self.tbStats)
|
||||
self.outerBox.addWidget(self.tbTheme)
|
||||
self.outerBox.addWidget(self.tbSettings)
|
||||
self.outerBox.setContentsMargins(0, 0, 0, 0)
|
||||
self.outerBox.setSpacing(6)
|
||||
@@ -133,6 +139,7 @@ class GuiSideBar(QWidget):
|
||||
self.tbBuild.setStyleSheet(buttonStyle)
|
||||
self.tbDetails.setStyleSheet(buttonStyle)
|
||||
self.tbStats.setStyleSheet(buttonStyle)
|
||||
self.tbTheme.setStyleSheet(buttonStyle)
|
||||
self.tbSettings.setStyleSheet(buttonStyle)
|
||||
|
||||
self.tbProject.setThemeIcon("sb_project")
|
||||
@@ -144,6 +151,36 @@ class GuiSideBar(QWidget):
|
||||
self.tbStats.setThemeIcon("sb_stats")
|
||||
self.tbSettings.setThemeIcon("settings")
|
||||
|
||||
self._setThemeModeIcon()
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Private Slots
|
||||
##
|
||||
|
||||
@pyqtSlot()
|
||||
def _cycleColurTheme(self) -> None:
|
||||
"""Go to nex colour theme."""
|
||||
match CONFIG.themeMode:
|
||||
case nwTheme.AUTO:
|
||||
CONFIG.themeMode = nwTheme.LIGHT
|
||||
case nwTheme.LIGHT:
|
||||
CONFIG.themeMode = nwTheme.DARK
|
||||
case nwTheme.DARK:
|
||||
CONFIG.themeMode = nwTheme.AUTO
|
||||
self.mainGui.checkThemeUpdate()
|
||||
self._setThemeModeIcon()
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _setThemeModeIcon(self) -> None:
|
||||
"""Set the theme button icon."""
|
||||
self.tbTheme.setThemeIcon(nwLabels.THEME_MODE_ICON[CONFIG.themeMode])
|
||||
self.tbTheme.setToolTip(trConst(nwLabels.THEME_MODE_LABEL[CONFIG.themeMode]))
|
||||
return
|
||||
|
||||
|
||||
|
||||
@@ -143,9 +143,9 @@ class GuiMainStatus(QStatusBar):
|
||||
self.idlePixmap = SHARED.theme.getPixmap("timer_off", (iPx, iPx))
|
||||
self.timeIcon.setPixmap(self.timePixmap)
|
||||
|
||||
colNone = SHARED.theme.getIconColor("default").darker(150)
|
||||
colSaved = SHARED.theme.getIconColor("green").darker(150)
|
||||
colUnsaved = SHARED.theme.getIconColor("red").darker(150)
|
||||
colNone = SHARED.theme.getBaseColor("default").darker(150)
|
||||
colSaved = SHARED.theme.getBaseColor("green").darker(150)
|
||||
colUnsaved = SHARED.theme.getBaseColor("red").darker(150)
|
||||
self.docIcon.setColors(colNone, colSaved, colUnsaved)
|
||||
self.projIcon.setColors(colNone, colSaved, colUnsaved)
|
||||
|
||||
|
||||
+362
-326
@@ -26,21 +26,23 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from configparser import ConfigParser
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from PyQt6.QtCore import QSize, Qt
|
||||
from PyQt6.QtGui import (
|
||||
QColor, QFont, QFontDatabase, QFontMetrics, QIcon, QPainter, QPainterPath,
|
||||
QPalette, QPixmap
|
||||
QColor, QFont, QFontDatabase, QFontMetrics, QGuiApplication, QIcon,
|
||||
QPainter, QPainterPath, QPalette, QPixmap
|
||||
)
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from novelwriter import CONFIG
|
||||
from novelwriter.common import NWConfigParser, minmax
|
||||
from novelwriter.config import DEF_GUI, DEF_ICONS, DEF_SYNTAX
|
||||
from novelwriter.common import checkInt, minmax
|
||||
from novelwriter.config import DEF_GUI_DARK, DEF_GUI_LIGHT, DEF_ICONS, DEF_TREECOL
|
||||
from novelwriter.constants import nwLabels
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType, nwTheme
|
||||
from novelwriter.error import logException
|
||||
from novelwriter.types import QtBlack, QtHexArgb, QtPaintAntiAlias, QtTransparent
|
||||
|
||||
@@ -54,21 +56,36 @@ STYLES_MIN_TOOLBUTTON = "minimalToolButton"
|
||||
STYLES_BIG_TOOLBUTTON = "bigToolButton"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThemeEntry:
|
||||
|
||||
name: str
|
||||
dark: bool
|
||||
path: Path
|
||||
|
||||
|
||||
class ThemeMeta:
|
||||
|
||||
name: str = ""
|
||||
mode: str = ""
|
||||
description: str = ""
|
||||
author: str = ""
|
||||
credit: str = ""
|
||||
url: str = ""
|
||||
license: str = ""
|
||||
licenseUrl: str = ""
|
||||
|
||||
|
||||
class IconsMeta:
|
||||
|
||||
name: str = ""
|
||||
author: str = ""
|
||||
license: str = ""
|
||||
|
||||
|
||||
class SyntaxColors:
|
||||
|
||||
back: QColor = QColor(255, 255, 255)
|
||||
text: QColor = QColor(0, 0, 0)
|
||||
line: QColor = QColor(0, 0, 0)
|
||||
link: QColor = QColor(0, 0, 0)
|
||||
head: QColor = QColor(0, 0, 0)
|
||||
headH: QColor = QColor(0, 0, 0)
|
||||
@@ -96,45 +113,41 @@ class GuiTheme:
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_availSyntax", "_availThemes", "_guiPalette", "_styleSheets", "_syntaxList", "_themeList",
|
||||
"baseButtonHeight", "baseIconHeight", "baseIconSize", "buttonIconSize", "errorText",
|
||||
"fadedText", "fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration",
|
||||
"getHeaderDecorationNarrow", "getIcon", "getIconColor", "getItemIcon", "getPixmap",
|
||||
"getToggleIcon", "guiFont", "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall",
|
||||
"helpText", "iconCache", "isDarkTheme", "syntaxMeta", "syntaxTheme", "textNHeight",
|
||||
"textNWidth", "themeMeta",
|
||||
"_allThemes", "_currentTheme", "_darkThemes", "_guiPalette", "_lightThemes", "_meta",
|
||||
"_qColors", "_styleSheets", "_svgColors", "_syntaxList", "accentCol", "baseButtonHeight",
|
||||
"baseIconHeight", "baseIconSize", "buttonIconSize", "errorText", "fadedText",
|
||||
"fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration",
|
||||
"getHeaderDecorationNarrow", "getIcon", "getItemIcon", "getPixmap", "getToggleIcon",
|
||||
"guiFont", "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall", "helpText",
|
||||
"iconCache", "isDarkTheme", "syntaxTheme", "textNHeight", "textNWidth",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
self.iconCache = GuiIcons(self)
|
||||
|
||||
# GUI Theme
|
||||
self.themeMeta = ThemeMeta()
|
||||
# Theme Objects
|
||||
self.iconCache = GuiIcons(self)
|
||||
self.syntaxTheme = SyntaxColors()
|
||||
self.isDarkTheme = False
|
||||
|
||||
# Special Text Colours
|
||||
# Special Colours
|
||||
self.helpText = QColor(0, 0, 0)
|
||||
self.fadedText = QColor(0, 0, 0)
|
||||
self.errorText = QColor(255, 0, 0)
|
||||
self.accentCol = QColor(255, 0, 255) # Needed until we move to Qt 6.6
|
||||
|
||||
# Syntax Theme
|
||||
self.syntaxMeta = ThemeMeta()
|
||||
self.syntaxTheme = SyntaxColors()
|
||||
|
||||
# Load Themes
|
||||
# Theme Data
|
||||
self._meta = ThemeMeta()
|
||||
self._currentTheme = ""
|
||||
self._guiPalette = QPalette()
|
||||
self._themeList: list[tuple[str, str]] = []
|
||||
self._syntaxList: list[tuple[str, str]] = []
|
||||
self._availThemes: dict[str, Path] = {}
|
||||
self._availSyntax: dict[str, Path] = {}
|
||||
self._allThemes: dict[str, ThemeEntry] = {}
|
||||
self._styleSheets: dict[str, str] = {}
|
||||
self._svgColors: dict[str, bytes] = {}
|
||||
self._qColors: dict[str, QColor] = {}
|
||||
|
||||
# Icon Functions
|
||||
self.getIcon = self.iconCache.getIcon
|
||||
self.getPixmap = self.iconCache.getPixmap
|
||||
self.getItemIcon = self.iconCache.getItemIcon
|
||||
self.getIconColor = self.iconCache.getIconColor
|
||||
self.getToggleIcon = self.iconCache.getToggleIcon
|
||||
self.getDecoration = self.iconCache.getDecoration
|
||||
self.getHeaderDecoration = self.iconCache.getHeaderDecoration
|
||||
@@ -178,19 +191,19 @@ class GuiTheme:
|
||||
logger.debug("Text 'N' Height: %d", self.textNHeight)
|
||||
logger.debug("Text 'N' Width: %d", self.textNWidth)
|
||||
|
||||
# Process Themes
|
||||
_listConf(self._availSyntax, CONFIG.assetPath("syntax"), ".conf")
|
||||
_listConf(self._availThemes, CONFIG.assetPath("themes"), ".conf")
|
||||
_listConf(self._availSyntax, CONFIG.dataPath("syntax"), ".conf")
|
||||
_listConf(self._availThemes, CONFIG.dataPath("themes"), ".conf")
|
||||
|
||||
self.loadTheme()
|
||||
self.loadSyntax()
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Methods
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def colourThemes(self) -> dict[str, ThemeEntry]:
|
||||
"""Return a dictionary of all themes."""
|
||||
return self._allThemes
|
||||
|
||||
##
|
||||
# Getters
|
||||
##
|
||||
|
||||
def getTextWidth(self, text: str, font: QFont | None = None) -> int:
|
||||
@@ -203,30 +216,79 @@ class GuiTheme:
|
||||
qMetrics = QFontMetrics(self.guiFont)
|
||||
return ceil(qMetrics.boundingRect(text).width())
|
||||
|
||||
def getBaseColor(self, name: str) -> QColor:
|
||||
"""Return a base color."""
|
||||
return QColor(self._qColors.get(name) or QtBlack)
|
||||
|
||||
def getRawBaseColor(self, name: str) -> bytes:
|
||||
"""Return a base color."""
|
||||
return self._svgColors.get(name, self._svgColors.get("default", b"#000000"))
|
||||
|
||||
##
|
||||
# Theme Methods
|
||||
##
|
||||
|
||||
def loadTheme(self) -> bool:
|
||||
"""Load the currently specified GUI theme."""
|
||||
theme = CONFIG.guiTheme
|
||||
if theme not in self._availThemes:
|
||||
logger.error("Could not find GUI theme '%s'", theme)
|
||||
theme = DEF_GUI
|
||||
CONFIG.guiTheme = theme
|
||||
def initThemes(self) -> None:
|
||||
"""Initialise themes."""
|
||||
CONFIG.splashMessage("Scanning for colour themes ...")
|
||||
themes: list[Path] = []
|
||||
_listContent(themes, CONFIG.assetPath("themes"), ".conf")
|
||||
_listContent(themes, CONFIG.dataPath("themes"), ".conf")
|
||||
self._scanThemes(themes)
|
||||
|
||||
if not (file := self._availThemes.get(theme)):
|
||||
self.iconCache.initIcons()
|
||||
self.loadTheme()
|
||||
|
||||
return
|
||||
|
||||
def isDesktopDarkMode(self) -> bool:
|
||||
"""Check if the desktop is in dark mode."""
|
||||
if CONFIG.verQtValue >= 0x060500 and (hint := QGuiApplication.styleHints()):
|
||||
return hint.colorScheme() == Qt.ColorScheme.Dark
|
||||
|
||||
palette = QPalette()
|
||||
text = palette.windowText().color()
|
||||
window = palette.window().color()
|
||||
return text.lightnessF() > window.lightnessF()
|
||||
|
||||
def loadTheme(self, force: bool = False) -> bool:
|
||||
"""Load the currently specified GUI theme. The boolean return
|
||||
can be used to determine if the GUI needs refreshing.
|
||||
"""
|
||||
match CONFIG.themeMode:
|
||||
case nwTheme.LIGHT:
|
||||
darkMode = False
|
||||
case nwTheme.DARK:
|
||||
darkMode = True
|
||||
case _:
|
||||
darkMode = self.isDesktopDarkMode()
|
||||
|
||||
theme = CONFIG.darkTheme if darkMode else CONFIG.lightTheme
|
||||
if theme not in self._allThemes:
|
||||
logger.error("Could not find theme '%s'", theme)
|
||||
if darkMode:
|
||||
theme = DEF_GUI_DARK
|
||||
CONFIG.darkTheme = DEF_GUI_DARK
|
||||
else:
|
||||
theme = DEF_GUI_LIGHT
|
||||
CONFIG.lightTheme = DEF_GUI_LIGHT
|
||||
|
||||
if theme == self._currentTheme and not force:
|
||||
logger.info("Theme '%s' is already loaded", theme)
|
||||
return False
|
||||
|
||||
entry = self._allThemes.get(theme)
|
||||
if not entry:
|
||||
logger.error("Could not load GUI theme")
|
||||
return False
|
||||
|
||||
CONFIG.splashMessage("Loading GUI theme ...")
|
||||
CONFIG.splashMessage(f"Loading colour theme: {entry.name}")
|
||||
logger.info("Loading GUI theme '%s'", theme)
|
||||
parser = NWConfigParser()
|
||||
parser = ConfigParser()
|
||||
try:
|
||||
with open(file, mode="r", encoding="utf-8") as fo:
|
||||
parser.read_file(fo)
|
||||
parser.read(entry.path, encoding="utf-8")
|
||||
except Exception:
|
||||
logger.error("Could not read file: %s", file)
|
||||
logger.error("Could not read file: %s", entry.path)
|
||||
logException()
|
||||
return False
|
||||
|
||||
@@ -237,39 +299,42 @@ class GuiTheme:
|
||||
sec = "Main"
|
||||
meta = ThemeMeta()
|
||||
if parser.has_section(sec):
|
||||
meta.name = parser.rdStr(sec, "name", "")
|
||||
meta.description = parser.rdStr(sec, "description", "N/A")
|
||||
meta.author = parser.rdStr(sec, "author", "N/A")
|
||||
meta.credit = parser.rdStr(sec, "credit", "N/A")
|
||||
meta.url = parser.rdStr(sec, "url", "")
|
||||
meta.license = parser.rdStr(sec, "license", "N/A")
|
||||
meta.licenseUrl = parser.rdStr(sec, "licenseurl", "")
|
||||
meta.name = parser.get(sec, "name", fallback="")
|
||||
meta.mode = parser.get(sec, "mode", fallback="light")
|
||||
meta.description = parser.get(sec, "description", fallback="")
|
||||
meta.author = parser.get(sec, "author", fallback="")
|
||||
meta.credit = parser.get(sec, "credit", fallback="")
|
||||
meta.url = parser.get(sec, "url", fallback="")
|
||||
|
||||
self.themeMeta = meta
|
||||
self._meta = meta
|
||||
|
||||
# Icons
|
||||
sec = "Icons"
|
||||
# Base
|
||||
sec = "Base"
|
||||
if parser.has_section(sec):
|
||||
self.iconCache.setIconColor("default", self._parseColor(parser, sec, "default"))
|
||||
self.iconCache.setIconColor("faded", self._parseColor(parser, sec, "faded"))
|
||||
self.iconCache.setIconColor("red", self._parseColor(parser, sec, "red"))
|
||||
self.iconCache.setIconColor("orange", self._parseColor(parser, sec, "orange"))
|
||||
self.iconCache.setIconColor("yellow", self._parseColor(parser, sec, "yellow"))
|
||||
self.iconCache.setIconColor("green", self._parseColor(parser, sec, "green"))
|
||||
self.iconCache.setIconColor("aqua", self._parseColor(parser, sec, "aqua"))
|
||||
self.iconCache.setIconColor("blue", self._parseColor(parser, sec, "blue"))
|
||||
self.iconCache.setIconColor("purple", self._parseColor(parser, sec, "purple"))
|
||||
self._setBaseColor("base", self._readColor(parser, sec, "base"))
|
||||
self._setBaseColor("default", self._readColor(parser, sec, "default"))
|
||||
self._setBaseColor("faded", self._readColor(parser, sec, "faded"))
|
||||
self._setBaseColor("red", self._readColor(parser, sec, "red"))
|
||||
self._setBaseColor("orange", self._readColor(parser, sec, "orange"))
|
||||
self._setBaseColor("yellow", self._readColor(parser, sec, "yellow"))
|
||||
self._setBaseColor("green", self._readColor(parser, sec, "green"))
|
||||
self._setBaseColor("cyan", self._readColor(parser, sec, "cyan"))
|
||||
self._setBaseColor("blue", self._readColor(parser, sec, "blue"))
|
||||
self._setBaseColor("purple", self._readColor(parser, sec, "purple"))
|
||||
|
||||
# Project
|
||||
sec = "Project"
|
||||
if parser.has_section(sec):
|
||||
self.iconCache.setIconColor("root", self._parseColor(parser, sec, "root"))
|
||||
self.iconCache.setIconColor("folder", self._parseColor(parser, sec, "folder"))
|
||||
self.iconCache.setIconColor("file", self._parseColor(parser, sec, "file"))
|
||||
self.iconCache.setIconColor("title", self._parseColor(parser, sec, "title"))
|
||||
self.iconCache.setIconColor("chapter", self._parseColor(parser, sec, "chapter"))
|
||||
self.iconCache.setIconColor("scene", self._parseColor(parser, sec, "scene"))
|
||||
self.iconCache.setIconColor("note", self._parseColor(parser, sec, "note"))
|
||||
self._setBaseColor("root", self._readColor(parser, sec, "root"))
|
||||
self._setBaseColor("folder", self._readColor(parser, sec, "folder"))
|
||||
self._setBaseColor("file", self._readColor(parser, sec, "file"))
|
||||
self._setBaseColor("title", self._readColor(parser, sec, "title"))
|
||||
self._setBaseColor("chapter", self._readColor(parser, sec, "chapter"))
|
||||
self._setBaseColor("scene", self._readColor(parser, sec, "scene"))
|
||||
self._setBaseColor("note", self._readColor(parser, sec, "note"))
|
||||
self._setBaseColor("active", self._readColor(parser, sec, "active"))
|
||||
self._setBaseColor("inactive", self._readColor(parser, sec, "inactive"))
|
||||
self._setBaseColor("disabled", self._readColor(parser, sec, "disabled"))
|
||||
|
||||
# Palette
|
||||
sec = "Palette"
|
||||
@@ -288,20 +353,46 @@ class GuiTheme:
|
||||
self._setPalette(parser, sec, "highlightedtext", QPalette.ColorRole.HighlightedText)
|
||||
self._setPalette(parser, sec, "link", QPalette.ColorRole.Link)
|
||||
self._setPalette(parser, sec, "linkvisited", QPalette.ColorRole.LinkVisited)
|
||||
self.accentCol = self._readColor(parser, sec, "accent") # Special handling 'til Qt 6.6
|
||||
|
||||
# GUI
|
||||
sec = "GUI"
|
||||
if parser.has_section(sec):
|
||||
self.helpText = self._parseColor(parser, sec, "helptext")
|
||||
self.fadedText = self._parseColor(parser, sec, "fadedtext")
|
||||
self.errorText = self._parseColor(parser, sec, "errortext")
|
||||
self.helpText = self._readColor(parser, sec, "helptext")
|
||||
self.fadedText = self._readColor(parser, sec, "fadedtext")
|
||||
self.errorText = self._readColor(parser, sec, "errortext")
|
||||
|
||||
# Syntax
|
||||
sec = "Syntax"
|
||||
self.syntaxTheme = SyntaxColors()
|
||||
if parser.has_section(sec):
|
||||
self.syntaxTheme.back = self._readColor(parser, sec, "background")
|
||||
self.syntaxTheme.text = self._readColor(parser, sec, "text")
|
||||
self.syntaxTheme.line = self._readColor(parser, sec, "line")
|
||||
self.syntaxTheme.link = self._readColor(parser, sec, "link")
|
||||
self.syntaxTheme.head = self._readColor(parser, sec, "headertext")
|
||||
self.syntaxTheme.headH = self._readColor(parser, sec, "headertag")
|
||||
self.syntaxTheme.emph = self._readColor(parser, sec, "emphasis")
|
||||
self.syntaxTheme.dialN = self._readColor(parser, sec, "dialog")
|
||||
self.syntaxTheme.dialA = self._readColor(parser, sec, "altdialog")
|
||||
self.syntaxTheme.hidden = self._readColor(parser, sec, "hidden")
|
||||
self.syntaxTheme.note = self._readColor(parser, sec, "note")
|
||||
self.syntaxTheme.code = self._readColor(parser, sec, "shortcode")
|
||||
self.syntaxTheme.key = self._readColor(parser, sec, "keyword")
|
||||
self.syntaxTheme.tag = self._readColor(parser, sec, "tag")
|
||||
self.syntaxTheme.val = self._readColor(parser, sec, "value")
|
||||
self.syntaxTheme.opt = self._readColor(parser, sec, "optional")
|
||||
self.syntaxTheme.spell = self._readColor(parser, sec, "spellcheckline")
|
||||
self.syntaxTheme.error = self._readColor(parser, sec, "errorline")
|
||||
self.syntaxTheme.repTag = self._readColor(parser, sec, "replacetag")
|
||||
self.syntaxTheme.mod = self._readColor(parser, sec, "modifier")
|
||||
self.syntaxTheme.mark = self._readColor(parser, sec, "texthighlight")
|
||||
|
||||
# Update Dependant Colours
|
||||
# Based on: https://github.com/qt/qtbase/blob/dev/src/gui/kernel/qplatformtheme.cpp
|
||||
text = self._guiPalette.text().color()
|
||||
window = self._guiPalette.window().color()
|
||||
highlight = self._guiPalette.highlight().color()
|
||||
isDark = text.lightnessF() > window.lightnessF()
|
||||
|
||||
QtColActive = QPalette.ColorGroup.Active
|
||||
QtColInactive = QPalette.ColorGroup.Inactive
|
||||
@@ -321,8 +412,8 @@ class GuiTheme:
|
||||
darkOff = dark.darker(150)
|
||||
shadowOff = ref.darker(150)
|
||||
|
||||
grey = QColor(120, 120, 120) if isDark else QColor(140, 140, 140)
|
||||
dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190)
|
||||
grey = QColor(120, 120, 120) if darkMode else QColor(140, 140, 140)
|
||||
dimmed = QColor(130, 130, 130) if darkMode else QColor(190, 190, 190)
|
||||
|
||||
placeholder = QColor(text)
|
||||
placeholder.setAlpha(128)
|
||||
@@ -347,145 +438,94 @@ class GuiTheme:
|
||||
self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Highlight, grey)
|
||||
|
||||
if CONFIG.verQtValue >= 0x060600:
|
||||
self._guiPalette.setBrush(QtColActive, QPalette.ColorRole.Accent, highlight)
|
||||
self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Accent, highlight)
|
||||
self._guiPalette.setBrush(QtColActive, QPalette.ColorRole.Accent, self.accentCol)
|
||||
self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Accent, self.accentCol)
|
||||
self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Accent, grey)
|
||||
|
||||
# Set project override colours
|
||||
if (override := CONFIG.iconColTree) != DEF_TREECOL:
|
||||
color = self._qColors.get(override, QtBlack)
|
||||
self._setBaseColor("root", color)
|
||||
self._setBaseColor("folder", color)
|
||||
if not CONFIG.iconColDocs:
|
||||
self._setBaseColor("file", color)
|
||||
self._setBaseColor("title", color)
|
||||
self._setBaseColor("chapter", color)
|
||||
self._setBaseColor("scene", color)
|
||||
self._setBaseColor("note", color)
|
||||
|
||||
self.isDarkTheme = darkMode
|
||||
self._currentTheme = theme
|
||||
|
||||
# Load icons after the theme is parsed
|
||||
self.iconCache.loadTheme(CONFIG.iconTheme)
|
||||
|
||||
# Finalise
|
||||
self.isDarkTheme = isDark
|
||||
QApplication.setPalette(self._guiPalette)
|
||||
self._buildStyleSheets(self._guiPalette)
|
||||
|
||||
CONFIG.splashMessage(f"Loaded GUI theme: {meta.name}")
|
||||
|
||||
return True
|
||||
|
||||
def loadSyntax(self) -> bool:
|
||||
"""Load the currently specified syntax highlighter theme."""
|
||||
theme = CONFIG.guiSyntax
|
||||
if theme not in self._availSyntax:
|
||||
logger.error("Could not find syntax theme '%s'", theme)
|
||||
theme = DEF_SYNTAX
|
||||
CONFIG.guiSyntax = theme
|
||||
|
||||
if not (file := self._availSyntax.get(theme)):
|
||||
logger.error("Could not load syntax theme")
|
||||
return False
|
||||
|
||||
CONFIG.splashMessage("Loading syntax theme ...")
|
||||
logger.info("Loading syntax theme '%s'", theme)
|
||||
parser = NWConfigParser()
|
||||
try:
|
||||
with open(file, mode="r", encoding="utf-8") as fo:
|
||||
parser.read_file(fo)
|
||||
except Exception:
|
||||
logger.error("Could not read file: %s", file)
|
||||
logException()
|
||||
return False
|
||||
|
||||
# Main
|
||||
sec = "Main"
|
||||
meta = ThemeMeta()
|
||||
if parser.has_section(sec):
|
||||
meta.name = parser.rdStr(sec, "name", "")
|
||||
meta.description = parser.rdStr(sec, "description", "N/A")
|
||||
meta.author = parser.rdStr(sec, "author", "N/A")
|
||||
meta.credit = parser.rdStr(sec, "credit", "N/A")
|
||||
meta.url = parser.rdStr(sec, "url", "")
|
||||
meta.license = parser.rdStr(sec, "license", "N/A")
|
||||
meta.licenseUrl = parser.rdStr(sec, "licenseurl", "")
|
||||
|
||||
# Syntax
|
||||
sec = "Syntax"
|
||||
syntax = SyntaxColors()
|
||||
if parser.has_section(sec):
|
||||
syntax.back = self._parseColor(parser, sec, "background")
|
||||
syntax.text = self._parseColor(parser, sec, "text")
|
||||
syntax.link = self._parseColor(parser, sec, "link")
|
||||
syntax.head = self._parseColor(parser, sec, "headertext")
|
||||
syntax.headH = self._parseColor(parser, sec, "headertag")
|
||||
syntax.emph = self._parseColor(parser, sec, "emphasis")
|
||||
syntax.dialN = self._parseColor(parser, sec, "dialog")
|
||||
syntax.dialA = self._parseColor(parser, sec, "altdialog")
|
||||
syntax.hidden = self._parseColor(parser, sec, "hidden")
|
||||
syntax.note = self._parseColor(parser, sec, "note")
|
||||
syntax.code = self._parseColor(parser, sec, "shortcode")
|
||||
syntax.key = self._parseColor(parser, sec, "keyword")
|
||||
syntax.tag = self._parseColor(parser, sec, "tag")
|
||||
syntax.val = self._parseColor(parser, sec, "value")
|
||||
syntax.opt = self._parseColor(parser, sec, "optional")
|
||||
syntax.spell = self._parseColor(parser, sec, "spellcheckline")
|
||||
syntax.error = self._parseColor(parser, sec, "errorline")
|
||||
syntax.repTag = self._parseColor(parser, sec, "replacetag")
|
||||
syntax.mod = self._parseColor(parser, sec, "modifier")
|
||||
syntax.mark = self._parseColor(parser, sec, "texthighlight")
|
||||
|
||||
CONFIG.splashMessage(f"Loaded syntax theme: {meta.name}")
|
||||
|
||||
self.syntaxMeta = meta
|
||||
self.syntaxTheme = syntax
|
||||
|
||||
return True
|
||||
|
||||
def listThemes(self) -> list[tuple[str, str]]:
|
||||
"""Scan the GUI themes folder and list all themes."""
|
||||
if self._themeList:
|
||||
return self._themeList
|
||||
|
||||
themes = []
|
||||
parser = NWConfigParser()
|
||||
for key, path in self._availThemes.items():
|
||||
logger.debug("Checking theme config '%s'", key)
|
||||
if name := _loadInternalName(parser, path):
|
||||
themes.append((key, name))
|
||||
|
||||
self._themeList = sorted(themes, key=_sortTheme)
|
||||
|
||||
return self._themeList
|
||||
|
||||
def listSyntax(self) -> list[tuple[str, str]]:
|
||||
"""Scan the syntax themes folder and list all themes."""
|
||||
if self._syntaxList:
|
||||
return self._syntaxList
|
||||
|
||||
themes = []
|
||||
parser = NWConfigParser()
|
||||
for key, path in self._availSyntax.items():
|
||||
logger.debug("Checking theme syntax '%s'", key)
|
||||
if name := _loadInternalName(parser, path):
|
||||
themes.append((key, name))
|
||||
|
||||
self._syntaxList = sorted(themes, key=_sortTheme)
|
||||
|
||||
return self._syntaxList
|
||||
|
||||
def getStyleSheet(self, name: str) -> str:
|
||||
"""Load a standard style sheet."""
|
||||
return self._styleSheets.get(name, "")
|
||||
|
||||
def parseColor(self, value: str, default: QColor = QtBlack) -> QColor:
|
||||
"""Parse a string as a colour value."""
|
||||
if value in self._qColors:
|
||||
# Named colour
|
||||
return self._qColors[value]
|
||||
elif value.startswith("#") and len(value) == 7:
|
||||
# Assume #RRGGBB
|
||||
return QColor.fromString(value)
|
||||
elif value.startswith("#") and len(value) == 9:
|
||||
# Assume #RRGGBBAA and convert to #AARRGGBB
|
||||
return QColor.fromString(f"#{value[7:9]}{value[1:7]}")
|
||||
elif ":" in value:
|
||||
# Colour name and lighter, darker or alpha
|
||||
name, _, adjust = value.partition(":")
|
||||
color = QColor(self._qColors.get(name.strip(), default))
|
||||
if adjust.startswith("L"):
|
||||
color = color.lighter(checkInt(adjust[1:], 100))
|
||||
elif adjust.startswith("D"):
|
||||
color = color.darker(checkInt(adjust[1:], 100))
|
||||
else:
|
||||
color.setAlpha(checkInt(adjust, 255))
|
||||
return color
|
||||
elif "," in value:
|
||||
# Integer red, green, blue, alpha
|
||||
data = value.split(",")
|
||||
result = [0, 0, 0, 255]
|
||||
for i in range(min(len(data), 4)):
|
||||
result[i] = checkInt(data[i].strip(), result[i])
|
||||
return QColor(*result)
|
||||
return default
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _setBaseColor(self, key: str, color: QColor) -> None:
|
||||
"""Set the colour for a named colour."""
|
||||
self._qColors[key] = QColor(color)
|
||||
self._svgColors[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8")
|
||||
return
|
||||
|
||||
def _resetTheme(self) -> None:
|
||||
"""Reset GUI colours to default values."""
|
||||
palette = QPalette()
|
||||
|
||||
text = palette.color(QPalette.ColorRole.Text)
|
||||
window = palette.color(QPalette.ColorRole.Window)
|
||||
isDark = text.lightnessF() > window.lightnessF()
|
||||
isDark = self.isDesktopDarkMode()
|
||||
|
||||
# Reset GUI Palette
|
||||
base = palette.color(QPalette.ColorRole.Base)
|
||||
default = palette.color(QPalette.ColorRole.Text)
|
||||
faded = QColor(128, 128, 128)
|
||||
dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190)
|
||||
red = QColor(242, 119, 122) if isDark else QColor(240, 40, 41)
|
||||
orange = QColor(249, 145, 57) if isDark else QColor(245, 135, 31)
|
||||
yellow = QColor(255, 204, 102) if isDark else QColor(234, 183, 0)
|
||||
green = QColor(153, 204, 153) if isDark else QColor(113, 140, 0)
|
||||
aqua = QColor(102, 204, 204) if isDark else QColor(62, 153, 159)
|
||||
cyan = QColor(102, 204, 204) if isDark else QColor(62, 153, 159)
|
||||
blue = QColor(102, 153, 204) if isDark else QColor(66, 113, 174)
|
||||
purple = QColor(204, 153, 204) if isDark else QColor(137, 89, 168)
|
||||
|
||||
@@ -496,37 +536,42 @@ class GuiTheme:
|
||||
|
||||
self._guiPalette = palette
|
||||
|
||||
# Reset Icons
|
||||
icons = self.iconCache
|
||||
icons.clear()
|
||||
icons.setIconColor("default", text)
|
||||
icons.setIconColor("faded", faded)
|
||||
icons.setIconColor("red", red)
|
||||
icons.setIconColor("orange", orange)
|
||||
icons.setIconColor("yellow", yellow)
|
||||
icons.setIconColor("green", green)
|
||||
icons.setIconColor("aqua", aqua)
|
||||
icons.setIconColor("blue", blue)
|
||||
icons.setIconColor("purple", purple)
|
||||
icons.setIconColor("root", blue)
|
||||
icons.setIconColor("folder", yellow)
|
||||
icons.setIconColor("file", text)
|
||||
icons.setIconColor("title", green)
|
||||
icons.setIconColor("chapter", red)
|
||||
icons.setIconColor("scene", blue)
|
||||
icons.setIconColor("note", yellow)
|
||||
# Reset Base Colours and Icons
|
||||
self.iconCache.clear()
|
||||
self._svgColors = {}
|
||||
self._qColors = {}
|
||||
self._setBaseColor("base", base)
|
||||
self._setBaseColor("default", default)
|
||||
self._setBaseColor("faded", faded)
|
||||
self._setBaseColor("red", red)
|
||||
self._setBaseColor("orange", orange)
|
||||
self._setBaseColor("yellow", yellow)
|
||||
self._setBaseColor("green", green)
|
||||
self._setBaseColor("cyan", cyan)
|
||||
self._setBaseColor("blue", blue)
|
||||
self._setBaseColor("purple", purple)
|
||||
self._setBaseColor("root", blue)
|
||||
self._setBaseColor("folder", yellow)
|
||||
self._setBaseColor("file", default)
|
||||
self._setBaseColor("title", green)
|
||||
self._setBaseColor("chapter", red)
|
||||
self._setBaseColor("scene", blue)
|
||||
self._setBaseColor("note", yellow)
|
||||
self._setBaseColor("active", green)
|
||||
self._setBaseColor("inactive", red)
|
||||
self._setBaseColor("disabled", faded)
|
||||
|
||||
return
|
||||
|
||||
def _parseColor(self, parser: NWConfigParser, section: str, name: str) -> QColor:
|
||||
def _readColor(self, parser: ConfigParser, section: str, name: str) -> QColor:
|
||||
"""Parse a colour value from a config string."""
|
||||
return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255]))
|
||||
return self.parseColor(parser.get(section, name, fallback="default"))
|
||||
|
||||
def _setPalette(
|
||||
self, parser: NWConfigParser, section: str, name: str, value: QPalette.ColorRole
|
||||
self, parser: ConfigParser, section: str, name: str, value: QPalette.ColorRole
|
||||
) -> None:
|
||||
"""Set a palette colour value from a config string."""
|
||||
self._guiPalette.setBrush(value, self._parseColor(parser, section, name))
|
||||
self._guiPalette.setBrush(value, self._readColor(parser, section, name))
|
||||
return
|
||||
|
||||
def _buildStyleSheets(self, palette: QPalette) -> None:
|
||||
@@ -561,6 +606,35 @@ class GuiTheme:
|
||||
|
||||
return
|
||||
|
||||
def _scanThemes(self, files: list[Path]) -> None:
|
||||
"""Scan the GUI themes folder and list all themes."""
|
||||
parser = ConfigParser()
|
||||
data: dict[str, tuple[str, str, bool, Path]] = {}
|
||||
keys = []
|
||||
for file in files:
|
||||
try:
|
||||
parser.clear()
|
||||
parser.read(file, encoding="utf-8")
|
||||
name = parser.get("Main", "name", fallback="")
|
||||
mode = parser.get("Main", "mode", fallback="").lower()
|
||||
if name and mode in ("light", "dark"):
|
||||
key = file.stem
|
||||
prefix = "*" if key.startswith("default") else ""
|
||||
lookup = f"{prefix}{name} {key}"
|
||||
keys.append(lookup)
|
||||
data[lookup] = (file.stem, name, mode == "dark", file)
|
||||
except Exception: # noqa: PERF203
|
||||
logger.error("Could not read file: %s", file)
|
||||
logException()
|
||||
|
||||
self._allThemes = {}
|
||||
for lookup in sorted(keys):
|
||||
key, name, dark, item = data[lookup]
|
||||
logger.debug("Checking theme config '%s'", key)
|
||||
self._allThemes[key] = ThemeEntry(name, dark, item)
|
||||
|
||||
return
|
||||
|
||||
|
||||
class GuiIcons:
|
||||
"""The icon class manages the content of the assets/icons folder,
|
||||
@@ -572,9 +646,8 @@ class GuiIcons:
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_availThemes", "_headerDec", "_headerDecNarrow", "_noIcon",
|
||||
"_qColors", "_qIcons", "_svgColors", "_svgData", "_themeList",
|
||||
"mainTheme", "themeMeta",
|
||||
"_allThemes", "_headerDec", "_headerDecNarrow", "_meta",
|
||||
"_noIcon", "_qIcons", "_svgData", "_theme",
|
||||
)
|
||||
|
||||
TOGGLE_ICON_KEYS: Final[dict[str, tuple[str, str]]] = {
|
||||
@@ -582,69 +655,78 @@ class GuiIcons:
|
||||
"unfold": ("unfold-show", "unfold-hide"),
|
||||
}
|
||||
IMAGE_MAP: Final[dict[str, tuple[str, str]]] = {
|
||||
"welcome": ("welcome-light.jpg", "welcome-dark.jpg"),
|
||||
"welcome": ("welcome.webp", "welcome.webp"),
|
||||
"nw-text": ("novelwriter-text-light.svg", "novelwriter-text-dark.svg"),
|
||||
}
|
||||
|
||||
def __init__(self, mainTheme: GuiTheme) -> None:
|
||||
|
||||
self.mainTheme = mainTheme
|
||||
self.themeMeta = ThemeMeta()
|
||||
self._theme = mainTheme
|
||||
self._meta = IconsMeta()
|
||||
|
||||
# Storage
|
||||
self._allThemes: dict[str, ThemeEntry] = {}
|
||||
self._svgData: dict[str, bytes] = {}
|
||||
self._svgColors: dict[str, bytes] = {}
|
||||
self._qColors: dict[str, QColor] = {}
|
||||
self._qIcons: dict[str, QIcon] = {}
|
||||
self._headerDec: list[QPixmap] = []
|
||||
self._headerDecNarrow: list[QPixmap] = []
|
||||
|
||||
# Icon Theme Path
|
||||
self._availThemes: dict[str, Path] = {}
|
||||
self._themeList: list[tuple[str, str]] = []
|
||||
|
||||
# None Icon
|
||||
self._noIcon = QIcon(str(CONFIG.assetPath("icons") / "none.svg"))
|
||||
|
||||
_listConf(self._availThemes, CONFIG.assetPath("icons"), ".icons")
|
||||
_listConf(self._availThemes, CONFIG.dataPath("icons"), ".icons")
|
||||
|
||||
return
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the icon cache."""
|
||||
self._svgData = {}
|
||||
self._svgColors = {}
|
||||
self._qColors = {}
|
||||
self._qIcons = {}
|
||||
self._headerDec = []
|
||||
self._headerDecNarrow = []
|
||||
self.themeMeta = ThemeMeta()
|
||||
self._meta = ThemeMeta()
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def iconThemes(self) -> dict[str, ThemeEntry]:
|
||||
"""Return a dictionary of all icon themes."""
|
||||
return self._allThemes
|
||||
|
||||
##
|
||||
# Actions
|
||||
##
|
||||
|
||||
def loadTheme(self, theme: str) -> bool:
|
||||
def initIcons(self) -> None:
|
||||
"""Initialise icons."""
|
||||
CONFIG.splashMessage("Scanning for icon themes ...")
|
||||
icons: list[Path] = []
|
||||
_listContent(icons, CONFIG.assetPath("icons"), ".icons")
|
||||
_listContent(icons, CONFIG.dataPath("icons"), ".icons")
|
||||
self._scanThemes(icons)
|
||||
return
|
||||
|
||||
def loadTheme(self, theme: str) -> None:
|
||||
"""Update the theme map. This is more of an init, since many of
|
||||
the GUI icons cannot really be replaced without writing specific
|
||||
update functions for the classes where they're used.
|
||||
"""
|
||||
if theme not in self._availThemes:
|
||||
if theme not in self._allThemes:
|
||||
logger.error("Could not find icon theme '%s'", theme)
|
||||
theme = DEF_ICONS
|
||||
CONFIG.iconTheme = theme
|
||||
|
||||
if not (file := self._availThemes.get(theme)):
|
||||
entry = self._allThemes.get(theme)
|
||||
if not entry:
|
||||
logger.error("Could not load icon theme")
|
||||
return False
|
||||
return
|
||||
|
||||
CONFIG.splashMessage("Loading icon theme ...")
|
||||
CONFIG.splashMessage(f"Loading icon theme: {entry.name}")
|
||||
logger.info("Loading icon theme '%s'", theme)
|
||||
try:
|
||||
meta = ThemeMeta()
|
||||
with open(file, mode="r", encoding="utf-8") as icons:
|
||||
meta = IconsMeta()
|
||||
with open(entry.path, mode="r", encoding="utf-8") as icons:
|
||||
for icon in icons:
|
||||
bits = icon.partition("=")
|
||||
key = bits[0].strip()
|
||||
@@ -658,47 +740,23 @@ class GuiIcons:
|
||||
meta.author = value
|
||||
elif key == "meta:license":
|
||||
meta.license = value
|
||||
self.themeMeta = meta
|
||||
self._meta = meta
|
||||
except Exception:
|
||||
logger.error("Could not read file: %s", file)
|
||||
logger.error("Could not read file: %s", entry.path)
|
||||
logException()
|
||||
return False
|
||||
|
||||
CONFIG.splashMessage(f"Loaded icon theme: {meta.name}")
|
||||
CONFIG.splashMessage("Generating additional icons ...")
|
||||
|
||||
# Set colour overrides for project item icons
|
||||
if (override := CONFIG.iconColTree) != "theme":
|
||||
color = self._svgColors.get(override, b"#000000")
|
||||
self._svgColors["root"] = color
|
||||
self._svgColors["folder"] = color
|
||||
if not CONFIG.iconColDocs:
|
||||
self._svgColors["file"] = color
|
||||
self._svgColors["title"] = color
|
||||
self._svgColors["chapter"] = color
|
||||
self._svgColors["scene"] = color
|
||||
self._svgColors["note"] = color
|
||||
return
|
||||
|
||||
# Populate generated icons cache
|
||||
CONFIG.splashMessage("Generating additional icons ...")
|
||||
self.getHeaderDecoration(0)
|
||||
self.getHeaderDecorationNarrow(0)
|
||||
|
||||
return True
|
||||
|
||||
def setIconColor(self, key: str, color: QColor) -> None:
|
||||
"""Set an icon colour for a named colour."""
|
||||
self._qColors[key] = QColor(color)
|
||||
self._svgColors[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8")
|
||||
return
|
||||
|
||||
##
|
||||
# Access Functions
|
||||
##
|
||||
|
||||
def getIconColor(self, name: str) -> QColor:
|
||||
"""Return an icon color."""
|
||||
return QColor(self._qColors.get(name) or QtBlack)
|
||||
|
||||
def getIcon(self, name: str, color: str | None = None, w: int = 24, h: int = 24) -> QIcon:
|
||||
"""Return an icon from the icon buffer, or load it."""
|
||||
variant = f"{name}-{color}" if color else name
|
||||
@@ -769,7 +827,7 @@ class GuiIcons:
|
||||
map or the icon map. This function always returns a QPixmap.
|
||||
"""
|
||||
if name in self.IMAGE_MAP:
|
||||
idx = int(self.mainTheme.isDarkTheme)
|
||||
idx = int(self._theme.isDarkTheme)
|
||||
imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx]
|
||||
else:
|
||||
logger.error("Decoration with name '%s' does not exist", name)
|
||||
@@ -793,7 +851,7 @@ class GuiIcons:
|
||||
def getHeaderDecoration(self, hLevel: int) -> QPixmap:
|
||||
"""Get the decoration for a specific heading level."""
|
||||
if not self._headerDec:
|
||||
iPx = self.mainTheme.baseIconHeight
|
||||
iPx = self._theme.baseIconHeight
|
||||
self._headerDec = [
|
||||
self._generateDecoration("file", iPx, 0),
|
||||
self._generateDecoration("title", iPx, 0),
|
||||
@@ -806,7 +864,7 @@ class GuiIcons:
|
||||
def getHeaderDecorationNarrow(self, hLevel: int) -> QPixmap:
|
||||
"""Get the narrow decoration for a specific heading level."""
|
||||
if not self._headerDecNarrow:
|
||||
iPx = self.mainTheme.baseIconHeight
|
||||
iPx = self._theme.baseIconHeight
|
||||
self._headerDecNarrow = [
|
||||
self._generateDecoration("file", iPx, 0),
|
||||
self._generateDecoration("title", iPx, 0),
|
||||
@@ -817,21 +875,6 @@ class GuiIcons:
|
||||
]
|
||||
return self._headerDecNarrow[minmax(hLevel, 0, 5)]
|
||||
|
||||
def listThemes(self) -> list[tuple[str, str]]:
|
||||
"""Scan the GUI icons folder and list all themes."""
|
||||
if self._themeList:
|
||||
return self._themeList
|
||||
|
||||
themes = []
|
||||
for key, path in self._availThemes.items():
|
||||
logger.debug("Checking icon theme '%s'", key)
|
||||
if name := _loadIconName(path):
|
||||
themes.append((key, name))
|
||||
|
||||
self._themeList = sorted(themes, key=_sortTheme)
|
||||
|
||||
return self._themeList
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
@@ -847,7 +890,7 @@ class GuiIcons:
|
||||
return QIcon(str(CONFIG.assetPath("icons") / "x-novelwriter-project.svg"))
|
||||
|
||||
if svg := self._svgData.get(name, b""):
|
||||
if fill := self._svgColors.get(color or "default"):
|
||||
if fill := self._theme.getRawBaseColor(color or "default"):
|
||||
svg = svg.replace(b"#000000", fill)
|
||||
pixmap = QPixmap(w, h)
|
||||
pixmap.fill(QtTransparent)
|
||||
@@ -869,53 +912,46 @@ class GuiIcons:
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QtPaintAntiAlias)
|
||||
if fill := self._svgColors.get(color or "default"):
|
||||
if fill := self._theme.getRawBaseColor(color or "default"):
|
||||
painter.fillPath(path, QColor(fill.decode(encoding="utf-8")))
|
||||
painter.end()
|
||||
|
||||
tMode = Qt.TransformationMode.SmoothTransformation
|
||||
return pixmap.scaledToHeight(height, tMode)
|
||||
|
||||
def _scanThemes(self, entries: list[Path]) -> None:
|
||||
"""Scan the GUI themes folder and list all themes."""
|
||||
data: dict[str, tuple[str, str, Path]] = {}
|
||||
keys = []
|
||||
for entry in entries:
|
||||
try:
|
||||
with open(entry, mode="r", encoding="utf-8") as fo:
|
||||
for line in fo:
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip() == "meta:name":
|
||||
if name := value.strip():
|
||||
lookup = entry.stem
|
||||
keys.append(lookup)
|
||||
data[lookup] = (lookup, name, entry)
|
||||
break
|
||||
except Exception:
|
||||
logger.error("Could not read file: %s", entry)
|
||||
logException()
|
||||
|
||||
self._allThemes = {}
|
||||
for lookup in sorted(keys):
|
||||
key, name, item = data[lookup]
|
||||
logger.debug("Checking icon theme '%s'", key)
|
||||
self._allThemes[key] = ThemeEntry(name, False, item)
|
||||
|
||||
return
|
||||
|
||||
|
||||
# Module Functions
|
||||
# ================
|
||||
|
||||
def _listConf(target: dict, path: Path, extension: str) -> None:
|
||||
"""Scan for theme files and populate the dictionary."""
|
||||
def _listContent(data: list[Path], path: Path, extension: str) -> None:
|
||||
"""List files of a specific type and extend the list."""
|
||||
if path.is_dir():
|
||||
for item in path.iterdir():
|
||||
if item.is_file() and item.name.endswith(extension):
|
||||
target[item.stem] = item
|
||||
data.extend(n for n in path.iterdir() if n.is_file() and n.suffix == extension)
|
||||
return
|
||||
|
||||
|
||||
def _sortTheme(data: tuple[str, str]) -> str:
|
||||
"""Key function for theme sorting."""
|
||||
key, name = data
|
||||
return f"*{name}" if key.startswith("default_") else name
|
||||
|
||||
|
||||
def _loadInternalName(parser: NWConfigParser, path: str | Path) -> str:
|
||||
"""Open a conf file and read the 'name' setting."""
|
||||
try:
|
||||
with open(path, mode="r", encoding="utf-8") as inFile:
|
||||
parser.read_file(inFile)
|
||||
return parser.rdStr("Main", "name", "")
|
||||
except Exception:
|
||||
logger.error("Could not read file: %s", path)
|
||||
logException()
|
||||
return ""
|
||||
|
||||
|
||||
def _loadIconName(path: Path) -> str:
|
||||
"""Open an icons file and read the name setting."""
|
||||
try:
|
||||
with open(path, mode="r", encoding="utf-8") as icons:
|
||||
for icon in icons:
|
||||
key, _, value = icon.partition("=")
|
||||
if key.strip() == "meta:name":
|
||||
return value.strip()
|
||||
except Exception:
|
||||
logger.error("Could not read file: %s", path)
|
||||
logException()
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user