novelWriter 2.7.4__py3-none-any.whl → 2.8b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. novelwriter/__init__.py +8 -7
  2. novelwriter/assets/icons/font_awesome.icons +22 -4
  3. novelwriter/assets/icons/material_filled_normal.icons +20 -2
  4. novelwriter/assets/icons/material_filled_thin.icons +20 -2
  5. novelwriter/assets/icons/material_rounded_normal.icons +20 -2
  6. novelwriter/assets/icons/material_rounded_thin.icons +20 -2
  7. novelwriter/assets/icons/material_sharp_normal.icons +20 -2
  8. novelwriter/assets/icons/material_sharp_thin.icons +20 -2
  9. novelwriter/assets/icons/remix_filled.icons +20 -2
  10. novelwriter/assets/icons/remix_outline.icons +20 -2
  11. novelwriter/assets/images/welcome.webp +0 -0
  12. novelwriter/assets/manual.pdf +0 -0
  13. novelwriter/assets/manual_fr.pdf +0 -0
  14. novelwriter/assets/sample.zip +0 -0
  15. novelwriter/assets/text/credits_en.htm +61 -11
  16. novelwriter/assets/themes/aura.conf +97 -0
  17. novelwriter/assets/themes/aura_bright.conf +95 -0
  18. novelwriter/assets/themes/aura_soft.conf +97 -0
  19. novelwriter/assets/themes/b2t_garden_dark.conf +97 -0
  20. novelwriter/assets/themes/b2t_garden_light.conf +97 -0
  21. novelwriter/assets/themes/b2t_suburb_dark.conf +97 -0
  22. novelwriter/assets/themes/b2t_suburb_light.conf +97 -0
  23. novelwriter/assets/themes/b4t_classic_o_dark.conf +97 -0
  24. novelwriter/assets/themes/b4t_classic_o_light.conf +97 -0
  25. novelwriter/assets/themes/b4t_modern_c_dark.conf +97 -0
  26. novelwriter/assets/themes/b4t_modern_c_light.conf +97 -0
  27. novelwriter/assets/themes/blue_streak_dark.conf +97 -0
  28. novelwriter/assets/themes/blue_streak_light.conf +97 -0
  29. novelwriter/assets/themes/castle_day.conf +95 -0
  30. novelwriter/assets/themes/castle_night.conf +95 -0
  31. novelwriter/assets/themes/catppuccin_latte.conf +97 -0
  32. novelwriter/assets/themes/catppuccin_mocha.conf +97 -0
  33. novelwriter/assets/themes/chalky_soil.conf +95 -0
  34. novelwriter/assets/themes/chernozem.conf +95 -0
  35. novelwriter/assets/themes/cyberpunk_night.conf +88 -40
  36. novelwriter/assets/themes/default_dark.conf +89 -41
  37. novelwriter/assets/themes/default_light.conf +89 -41
  38. novelwriter/assets/themes/dracula.conf +91 -42
  39. novelwriter/assets/themes/espresso.conf +97 -0
  40. novelwriter/assets/themes/everforest_dark.conf +97 -0
  41. novelwriter/assets/themes/everforest_light.conf +97 -0
  42. novelwriter/assets/themes/floral_daydream.conf +95 -0
  43. novelwriter/assets/themes/floral_midnight.conf +95 -0
  44. novelwriter/assets/themes/full_moon.conf +95 -0
  45. novelwriter/assets/themes/grey_dark.conf +97 -0
  46. novelwriter/assets/themes/grey_light.conf +97 -0
  47. novelwriter/assets/themes/horizon_dark.conf +97 -0
  48. novelwriter/assets/themes/horizon_light.conf +97 -0
  49. novelwriter/assets/themes/jewel_case_dark.conf +95 -0
  50. novelwriter/assets/themes/jewel_case_light.conf +95 -0
  51. novelwriter/assets/themes/lcars.conf +97 -0
  52. novelwriter/assets/themes/light_owl.conf +117 -0
  53. novelwriter/assets/themes/new_moon.conf +97 -0
  54. novelwriter/assets/themes/night_owl.conf +117 -0
  55. novelwriter/assets/themes/noctis.conf +129 -0
  56. novelwriter/assets/themes/noctis_lux.conf +129 -0
  57. novelwriter/assets/themes/nord.conf +97 -0
  58. novelwriter/assets/themes/nordlicht.conf +95 -0
  59. novelwriter/assets/themes/otium_dark.conf +95 -0
  60. novelwriter/assets/themes/otium_light.conf +95 -0
  61. novelwriter/assets/themes/paragon.conf +96 -0
  62. novelwriter/assets/themes/primer_light.conf +97 -0
  63. novelwriter/assets/themes/primer_night.conf +97 -0
  64. novelwriter/assets/themes/rose_pine.conf +97 -0
  65. novelwriter/assets/themes/rose_pine_dawn.conf +97 -0
  66. novelwriter/assets/themes/ruby_day.conf +95 -0
  67. novelwriter/assets/themes/ruby_night.conf +95 -0
  68. novelwriter/assets/themes/selenium_dark.conf +95 -0
  69. novelwriter/assets/themes/selenium_light.conf +95 -0
  70. novelwriter/assets/themes/sepia_dark.conf +95 -0
  71. novelwriter/assets/themes/sepia_light.conf +95 -0
  72. novelwriter/assets/themes/snazzy.conf +102 -40
  73. novelwriter/assets/themes/solarized_dark.conf +108 -40
  74. novelwriter/assets/themes/solarized_light.conf +108 -40
  75. novelwriter/assets/themes/sultana_light.conf +95 -0
  76. novelwriter/assets/themes/sultana_night.conf +95 -0
  77. novelwriter/assets/themes/tango_dark.conf +111 -0
  78. novelwriter/assets/themes/tango_light.conf +111 -0
  79. novelwriter/assets/themes/tomorrow.conf +117 -0
  80. novelwriter/assets/themes/tomorrow_night.conf +117 -0
  81. novelwriter/assets/themes/tomorrow_night_blue.conf +117 -0
  82. novelwriter/assets/themes/tomorrow_night_bright.conf +117 -0
  83. novelwriter/assets/themes/tomorrow_night_eighties.conf +117 -0
  84. novelwriter/assets/themes/vivid_black_green.conf +97 -0
  85. novelwriter/assets/themes/vivid_black_red.conf +97 -0
  86. novelwriter/assets/themes/vivid_white_green.conf +97 -0
  87. novelwriter/assets/themes/vivid_white_red.conf +97 -0
  88. novelwriter/assets/themes/warpgate.conf +96 -0
  89. novelwriter/assets/themes/waterlily_dark.conf +95 -0
  90. novelwriter/assets/themes/waterlily_light.conf +95 -0
  91. novelwriter/common.py +47 -17
  92. novelwriter/config.py +57 -62
  93. novelwriter/constants.py +32 -6
  94. novelwriter/core/buildsettings.py +3 -23
  95. novelwriter/core/coretools.py +21 -25
  96. novelwriter/core/docbuild.py +4 -9
  97. novelwriter/core/document.py +2 -6
  98. novelwriter/core/index.py +33 -53
  99. novelwriter/core/indexdata.py +17 -22
  100. novelwriter/core/item.py +11 -35
  101. novelwriter/core/itemmodel.py +5 -21
  102. novelwriter/core/novelmodel.py +3 -7
  103. novelwriter/core/options.py +3 -4
  104. novelwriter/core/project.py +31 -21
  105. novelwriter/core/projectdata.py +2 -21
  106. novelwriter/core/projectxml.py +13 -21
  107. novelwriter/core/sessions.py +2 -4
  108. novelwriter/core/spellcheck.py +12 -13
  109. novelwriter/core/status.py +27 -20
  110. novelwriter/core/storage.py +5 -10
  111. novelwriter/core/tree.py +6 -15
  112. novelwriter/dialogs/about.py +9 -10
  113. novelwriter/dialogs/docmerge.py +17 -14
  114. novelwriter/dialogs/docsplit.py +18 -14
  115. novelwriter/dialogs/editlabel.py +15 -9
  116. novelwriter/dialogs/preferences.py +69 -68
  117. novelwriter/dialogs/projectsettings.py +88 -67
  118. novelwriter/dialogs/quotes.py +15 -10
  119. novelwriter/dialogs/wordlist.py +18 -21
  120. novelwriter/enum.py +75 -30
  121. novelwriter/error.py +6 -11
  122. novelwriter/extensions/configlayout.py +8 -34
  123. novelwriter/extensions/eventfilters.py +3 -3
  124. novelwriter/extensions/modified.py +87 -32
  125. novelwriter/extensions/novelselector.py +13 -12
  126. novelwriter/extensions/pagedsidebar.py +10 -18
  127. novelwriter/extensions/progressbars.py +5 -11
  128. novelwriter/extensions/statusled.py +3 -6
  129. novelwriter/extensions/switch.py +8 -11
  130. novelwriter/extensions/switchbox.py +2 -11
  131. novelwriter/extensions/versioninfo.py +6 -7
  132. novelwriter/formats/shared.py +10 -2
  133. novelwriter/formats/todocx.py +15 -37
  134. novelwriter/formats/tohtml.py +52 -61
  135. novelwriter/formats/tokenizer.py +33 -64
  136. novelwriter/formats/tomarkdown.py +4 -11
  137. novelwriter/formats/toodt.py +12 -71
  138. novelwriter/formats/toqdoc.py +11 -21
  139. novelwriter/formats/toraw.py +2 -6
  140. novelwriter/gui/doceditor.py +207 -245
  141. novelwriter/gui/dochighlight.py +142 -101
  142. novelwriter/gui/docviewer.py +53 -84
  143. novelwriter/gui/docviewerpanel.py +18 -41
  144. novelwriter/gui/editordocument.py +12 -17
  145. novelwriter/gui/itemdetails.py +5 -14
  146. novelwriter/gui/mainmenu.py +24 -32
  147. novelwriter/gui/noveltree.py +13 -51
  148. novelwriter/gui/outline.py +20 -61
  149. novelwriter/gui/projtree.py +40 -96
  150. novelwriter/gui/search.py +9 -24
  151. novelwriter/gui/sidebar.py +54 -22
  152. novelwriter/gui/statusbar.py +7 -22
  153. novelwriter/gui/theme.py +482 -368
  154. novelwriter/guimain.py +87 -101
  155. novelwriter/shared.py +79 -48
  156. novelwriter/splash.py +9 -5
  157. novelwriter/text/comments.py +1 -1
  158. novelwriter/text/counting.py +9 -5
  159. novelwriter/text/patterns.py +20 -15
  160. novelwriter/tools/dictionaries.py +18 -16
  161. novelwriter/tools/lipsum.py +15 -17
  162. novelwriter/tools/manusbuild.py +25 -45
  163. novelwriter/tools/manuscript.py +94 -95
  164. novelwriter/tools/manussettings.py +149 -104
  165. novelwriter/tools/noveldetails.py +10 -24
  166. novelwriter/tools/welcome.py +24 -72
  167. novelwriter/tools/writingstats.py +17 -26
  168. novelwriter/types.py +25 -13
  169. {novelwriter-2.7.4.dist-info → novelwriter-2.8b1.dist-info}/METADATA +7 -7
  170. novelwriter-2.8b1.dist-info/RECORD +212 -0
  171. novelwriter/assets/images/welcome-dark.jpg +0 -0
  172. novelwriter/assets/images/welcome-light.jpg +0 -0
  173. novelwriter/assets/syntax/cyberpunk_night.conf +0 -28
  174. novelwriter/assets/syntax/default_dark.conf +0 -42
  175. novelwriter/assets/syntax/default_light.conf +0 -42
  176. novelwriter/assets/syntax/dracula.conf +0 -44
  177. novelwriter/assets/syntax/grey_dark.conf +0 -29
  178. novelwriter/assets/syntax/grey_light.conf +0 -29
  179. novelwriter/assets/syntax/light_owl.conf +0 -49
  180. novelwriter/assets/syntax/night_owl.conf +0 -49
  181. novelwriter/assets/syntax/snazzy.conf +0 -42
  182. novelwriter/assets/syntax/solarized_dark.conf +0 -29
  183. novelwriter/assets/syntax/solarized_light.conf +0 -29
  184. novelwriter/assets/syntax/tango.conf +0 -39
  185. novelwriter/assets/syntax/tomorrow.conf +0 -49
  186. novelwriter/assets/syntax/tomorrow_night.conf +0 -49
  187. novelwriter/assets/syntax/tomorrow_night_blue.conf +0 -49
  188. novelwriter/assets/syntax/tomorrow_night_bright.conf +0 -49
  189. novelwriter/assets/syntax/tomorrow_night_eighties.conf +0 -49
  190. novelwriter/assets/themes/default.conf +0 -3
  191. novelwriter-2.7.4.dist-info/RECORD +0 -163
  192. {novelwriter-2.7.4.dist-info → novelwriter-2.8b1.dist-info}/WHEEL +0 -0
  193. {novelwriter-2.7.4.dist-info → novelwriter-2.8b1.dist-info}/entry_points.txt +0 -0
  194. {novelwriter-2.7.4.dist-info → novelwriter-2.8b1.dist-info}/licenses/LICENSE.md +0 -0
  195. {novelwriter-2.7.4.dist-info → novelwriter-2.8b1.dist-info}/licenses/setup/LICENSE-Apache-2.0.txt +0 -0
  196. {novelwriter-2.7.4.dist-info → novelwriter-2.8b1.dist-info}/top_level.txt +0 -0
novelwriter/gui/theme.py CHANGED
@@ -21,28 +21,34 @@ General Public License for more details.
21
21
 
22
22
  You should have received a copy of the GNU General Public License
23
23
  along with this program. If not, see <https://www.gnu.org/licenses/>.
24
- """
24
+ """ # noqa
25
25
  from __future__ import annotations
26
26
 
27
27
  import logging
28
28
 
29
+ from configparser import ConfigParser
30
+ from dataclasses import dataclass
29
31
  from math import ceil
30
32
  from typing import TYPE_CHECKING, Final
31
33
 
32
- from PyQt6.QtCore import QSize, Qt
34
+ from PyQt6.QtCore import QT_TRANSLATE_NOOP, QCoreApplication, QSize, Qt
33
35
  from PyQt6.QtGui import (
34
- QColor, QFont, QFontDatabase, QFontMetrics, QIcon, QPainter, QPainterPath,
35
- QPalette, QPixmap
36
+ QColor, QFont, QFontDatabase, QFontMetrics, QGuiApplication, QIcon,
37
+ QPainter, QPainterPath, QPalette, QPixmap
36
38
  )
37
- from PyQt6.QtWidgets import QApplication
39
+ from PyQt6.QtWidgets import QApplication, QWidget
38
40
 
39
41
  from novelwriter import CONFIG
40
- from novelwriter.common import NWConfigParser, minmax
41
- from novelwriter.config import DEF_GUI, DEF_ICONS, DEF_SYNTAX
42
+ from novelwriter.common import checkInt, minmax
43
+ from novelwriter.config import DEF_GUI_DARK, DEF_GUI_LIGHT, DEF_ICONS, DEF_TREECOL
42
44
  from novelwriter.constants import nwLabels
43
- from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
45
+ from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType, nwStandardButton, nwTheme
44
46
  from novelwriter.error import logException
45
- from novelwriter.types import QtBlack, QtHexArgb, QtPaintAntiAlias, QtTransparent
47
+ from novelwriter.extensions.modified import NPushButton
48
+ from novelwriter.types import (
49
+ QtBlack, QtColActive, QtColDisabled, QtColInactive, QtHexArgb,
50
+ QtPaintAntiAlias, QtTransparent
51
+ )
46
52
 
47
53
  if TYPE_CHECKING:
48
54
  from pathlib import Path
@@ -53,26 +59,65 @@ STYLES_FLAT_TABS = "flatTabWidget"
53
59
  STYLES_MIN_TOOLBUTTON = "minimalToolButton"
54
60
  STYLES_BIG_TOOLBUTTON = "bigToolButton"
55
61
 
62
+ STANDARD_BUTTONS = {
63
+ nwStandardButton.OK: (QT_TRANSLATE_NOOP("Button", "OK"), "btn_ok", "action"),
64
+ nwStandardButton.CANCEL: (QT_TRANSLATE_NOOP("Button", "Cancel"), "btn_cancel", "reject"),
65
+ nwStandardButton.YES: (QT_TRANSLATE_NOOP("Button", "&Yes"), "btn_yes", "accept"),
66
+ nwStandardButton.NO: (QT_TRANSLATE_NOOP("Button", "&No"), "btn_no", "reject"),
67
+ nwStandardButton.OPEN: (QT_TRANSLATE_NOOP("Button", "Open"), "btn_open", "action"),
68
+ nwStandardButton.CLOSE: (QT_TRANSLATE_NOOP("Button", "Close"), "btn_close", "destroy"),
69
+ nwStandardButton.SAVE: (QT_TRANSLATE_NOOP("Button", "Save"), "btn_save", "action"),
70
+ nwStandardButton.BROWSE: (QT_TRANSLATE_NOOP("Button", "Browse"), "btn_browse", "systemio"),
71
+ nwStandardButton.LIST: (QT_TRANSLATE_NOOP("Button", "List"), "btn_list", "action"),
72
+ nwStandardButton.NEW: (QT_TRANSLATE_NOOP("Button", "New"), "btn_new", "apply"),
73
+ nwStandardButton.CREATE: (QT_TRANSLATE_NOOP("Button", "Create"), "btn_create", "create"),
74
+ nwStandardButton.RESET: (QT_TRANSLATE_NOOP("Button", "Reset"), "btn_reset", "reset"),
75
+ nwStandardButton.INSERT: (QT_TRANSLATE_NOOP("Button", "Insert"), "btn_insert", "action"),
76
+ nwStandardButton.APPLY: (QT_TRANSLATE_NOOP("Button", "Apply"), "btn_apply", "apply"),
77
+ nwStandardButton.BUILD: (QT_TRANSLATE_NOOP("Button", "Build"), "btn_build", "action"),
78
+ nwStandardButton.PRINT: (QT_TRANSLATE_NOOP("Button", "Print"), "btn_print", "action"),
79
+ nwStandardButton.PREVIEW: (QT_TRANSLATE_NOOP("Button", "Preview"), "btn_preview", "action"),
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class ThemeEntry:
85
+ """Theme data."""
86
+
87
+ name: str
88
+ dark: bool
89
+ path: Path
90
+
56
91
 
57
92
  class ThemeMeta:
93
+ """Theme meta data."""
94
+
95
+ name: str = ""
96
+ mode: str = ""
97
+ author: str = ""
98
+ credit: str = ""
99
+ url: str = ""
58
100
 
59
- name: str = ""
60
- description: str = ""
61
- author: str = ""
62
- credit: str = ""
63
- url: str = ""
64
- license: str = ""
65
- licenseUrl: str = ""
101
+
102
+ class IconsMeta:
103
+ """Icon theme meta data."""
104
+
105
+ name: str = ""
106
+ author: str = ""
107
+ license: str = ""
66
108
 
67
109
 
68
110
  class SyntaxColors:
111
+ """Colours for the syntax highlighter."""
69
112
 
70
113
  back: QColor = QColor(255, 255, 255)
71
114
  text: QColor = QColor(0, 0, 0)
115
+ line: QColor = QColor(0, 0, 0)
72
116
  link: QColor = QColor(0, 0, 0)
73
117
  head: QColor = QColor(0, 0, 0)
74
118
  headH: QColor = QColor(0, 0, 0)
75
119
  emph: QColor = QColor(0, 0, 0)
120
+ space: QColor = QColor(0, 0, 0)
76
121
  dialN: QColor = QColor(0, 0, 0)
77
122
  dialA: QColor = QColor(0, 0, 0)
78
123
  hidden: QColor = QColor(0, 0, 0)
@@ -90,57 +135,60 @@ class SyntaxColors:
90
135
 
91
136
 
92
137
  class GuiTheme:
93
- """Gui Theme Class
138
+ """Gui Theme Class.
94
139
 
95
140
  Handles the look and feel of novelWriter.
96
141
  """
97
142
 
98
143
  __slots__ = (
99
- "_availSyntax", "_availThemes", "_guiPalette", "_styleSheets", "_syntaxList", "_themeList",
100
- "baseButtonHeight", "baseIconHeight", "baseIconSize", "buttonIconSize", "errorText",
101
- "fadedText", "fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration",
102
- "getHeaderDecorationNarrow", "getIcon", "getIconColor", "getItemIcon", "getPixmap",
103
- "getToggleIcon", "guiFont", "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall",
104
- "helpText", "iconCache", "isDarkTheme", "syntaxMeta", "syntaxTheme", "textNHeight",
105
- "textNWidth", "themeMeta",
144
+ "_allThemes", "_currentTheme", "_darkThemes", "_guiPalette", "_lightThemes", "_meta",
145
+ "_qColors", "_styleSheets", "_svgColors", "_syntaxList", "accentCol", "baseButtonHeight",
146
+ "baseIconHeight", "baseIconSize", "errorText", "fadedText", "fontPixelSize",
147
+ "fontPointSize", "getDecoration", "getHeaderDecoration", "getHeaderDecorationNarrow",
148
+ "getIcon", "getItemIcon", "getPixmap", "getStandardButton", "getToggleIcon", "guiFont",
149
+ "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall", "helpText", "iconCache",
150
+ "isDarkTheme", "pushButtonIconSize", "sidebarIconSize", "syntaxTheme", "textNHeight",
151
+ "textNWidth", "toolButtonIconSize",
106
152
  )
107
153
 
108
154
  def __init__(self) -> None:
109
155
 
110
- self.iconCache = GuiIcons(self)
111
-
112
- # GUI Theme
113
- self.themeMeta = ThemeMeta()
156
+ # Theme Objects
157
+ self.iconCache = GuiIcons(self)
158
+ self.syntaxTheme = SyntaxColors()
114
159
  self.isDarkTheme = False
115
160
 
116
- # Special Text Colours
161
+ # Special Colours
117
162
  self.helpText = QColor(0, 0, 0)
118
163
  self.fadedText = QColor(0, 0, 0)
119
164
  self.errorText = QColor(255, 0, 0)
165
+ self.accentCol = QColor(255, 0, 255) # Needed until we move to Qt 6.6
120
166
 
121
- # Syntax Theme
122
- self.syntaxMeta = ThemeMeta()
123
- self.syntaxTheme = SyntaxColors()
124
-
125
- # Load Themes
167
+ # Theme Data
168
+ self._meta = ThemeMeta()
169
+ self._currentTheme = ""
126
170
  self._guiPalette = QPalette()
127
- self._themeList: list[tuple[str, str]] = []
128
- self._syntaxList: list[tuple[str, str]] = []
129
- self._availThemes: dict[str, Path] = {}
130
- self._availSyntax: dict[str, Path] = {}
171
+ self._allThemes: dict[str, ThemeEntry] = {}
131
172
  self._styleSheets: dict[str, str] = {}
173
+ self._svgColors: dict[str, bytes] = {}
174
+ self._qColors: dict[str, QColor] = {}
132
175
 
133
176
  # Icon Functions
134
177
  self.getIcon = self.iconCache.getIcon
135
178
  self.getPixmap = self.iconCache.getPixmap
136
179
  self.getItemIcon = self.iconCache.getItemIcon
137
- self.getIconColor = self.iconCache.getIconColor
138
180
  self.getToggleIcon = self.iconCache.getToggleIcon
139
181
  self.getDecoration = self.iconCache.getDecoration
182
+ self.getStandardButton = self.iconCache.getStandardButton
140
183
  self.getHeaderDecoration = self.iconCache.getHeaderDecoration
141
184
  self.getHeaderDecorationNarrow = self.iconCache.getHeaderDecorationNarrow
142
185
 
143
186
  # Fonts
187
+ sSmaller = 10.0/11.0
188
+ sLarger = 12.0/11.0
189
+ sLarge = 15.0/11.0
190
+ sXLarge = 19.0/11.0
191
+
144
192
  self.guiFont = QApplication.font()
145
193
  self.guiFontB = QApplication.font()
146
194
  self.guiFontB.setBold(True)
@@ -148,53 +196,58 @@ class GuiTheme:
148
196
  self.guiFontBU.setBold(True)
149
197
  self.guiFontBU.setUnderline(True)
150
198
  self.guiFontSmall = QApplication.font()
151
- self.guiFontSmall.setPointSizeF(0.9*self.guiFont.pointSizeF())
199
+ self.guiFontSmall.setPointSizeF(sSmaller*self.guiFont.pointSizeF())
152
200
 
153
201
  qMetric = QFontMetrics(self.guiFont)
154
202
  fHeight = qMetric.height()
155
203
  fAscent = qMetric.ascent()
204
+
156
205
  self.fontPointSize = self.guiFont.pointSizeF()
157
- self.fontPixelSize = round(fHeight)
158
- self.baseIconHeight = round(fAscent)
159
- self.baseButtonHeight = round(1.35*fAscent)
206
+ self.fontPixelSize = fHeight
207
+ self.baseIconHeight = fAscent
208
+ self.baseButtonHeight = round(sLarge*fAscent)
209
+
210
+ self.baseIconSize = QSize(fAscent, fAscent)
211
+ self.sidebarIconSize = QSize(round(sXLarge*fAscent), round(sXLarge*fAscent))
212
+ self.toolButtonIconSize = QSize(round(sSmaller*fAscent), round(sSmaller*fAscent))
213
+ self.pushButtonIconSize = QSize(round(sLarger*fAscent), round(sLarger*fAscent))
214
+
160
215
  self.textNHeight = qMetric.boundingRect("N").height()
161
216
  self.textNWidth = qMetric.boundingRect("N").width()
162
217
 
163
- self.baseIconSize = QSize(self.baseIconHeight, self.baseIconHeight)
164
- self.buttonIconSize = QSize(int(0.9*self.baseIconHeight), int(0.9*self.baseIconHeight))
165
-
166
218
  # Monospace Font
167
219
  self.guiFontFixed = QFont()
168
- self.guiFontFixed.setPointSizeF(0.95*self.fontPointSize)
220
+ self.guiFontFixed.setPointSizeF(sSmaller*self.fontPointSize)
169
221
  self.guiFontFixed.setFamily(
170
222
  QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family()
171
223
  )
172
224
 
173
225
  logger.debug("GUI Font Family: %s", self.guiFont.family())
174
- logger.debug("GUI Font Point Size: %.2f", self.fontPointSize)
175
- logger.debug("GUI Font Pixel Size: %d", self.fontPixelSize)
176
- logger.debug("GUI Base Icon Height: %d", self.baseIconHeight)
177
- logger.debug("GUI Base Button Height: %d", self.baseButtonHeight)
178
- logger.debug("Text 'N' Height: %d", self.textNHeight)
179
- logger.debug("Text 'N' Width: %d", self.textNWidth)
180
-
181
- # Process Themes
182
- _listConf(self._availSyntax, CONFIG.assetPath("syntax"), ".conf")
183
- _listConf(self._availThemes, CONFIG.assetPath("themes"), ".conf")
184
- _listConf(self._availSyntax, CONFIG.dataPath("syntax"), ".conf")
185
- _listConf(self._availThemes, CONFIG.dataPath("themes"), ".conf")
226
+ logger.debug("GUI Font Point Size: %.2f pt", self.fontPointSize)
227
+ logger.debug("GUI Font Pixel Size: %d px", self.fontPixelSize)
228
+ logger.debug("GUI Base Icon Height: %d px", self.baseIconHeight)
229
+ logger.debug("GUI Base Button Height: %d px", self.baseButtonHeight)
230
+ logger.debug("GUI Sidebar Icon Height: %s px", self.sidebarIconSize.height())
231
+ logger.debug("GUI ToolButton Icon Height: %s px", self.toolButtonIconSize.height())
232
+ logger.debug("GUI PushButton Icon Height: %s px", self.pushButtonIconSize.height())
233
+ logger.debug("Text 'N' Height: %d px", self.textNHeight)
234
+ logger.debug("Text 'N' Width: %d px", self.textNWidth)
186
235
 
187
- self.loadTheme()
188
- self.loadSyntax()
236
+ ##
237
+ # Properties
238
+ ##
189
239
 
190
- return
240
+ @property
241
+ def colourThemes(self) -> dict[str, ThemeEntry]:
242
+ """Return a dictionary of all themes."""
243
+ return self._allThemes
191
244
 
192
245
  ##
193
- # Methods
246
+ # Getters
194
247
  ##
195
248
 
196
249
  def getTextWidth(self, text: str, font: QFont | None = None) -> int:
197
- """Returns the width needed to contain a given piece of text in
250
+ """Return the width needed to contain a given piece of text in
198
251
  pixels.
199
252
  """
200
253
  if isinstance(font, QFont):
@@ -203,30 +256,80 @@ class GuiTheme:
203
256
  qMetrics = QFontMetrics(self.guiFont)
204
257
  return ceil(qMetrics.boundingRect(text).width())
205
258
 
259
+ def getBaseColor(self, name: str) -> QColor:
260
+ """Return a base color."""
261
+ return QColor(self._qColors.get(name) or QtBlack)
262
+
263
+ def getRawBaseColor(self, name: str) -> bytes:
264
+ """Return a base color."""
265
+ if color := self._svgColors.get(name):
266
+ return color
267
+ logger.warning("No colour named '%s'", name)
268
+ return self._svgColors.get("default", b"#000000")
269
+
206
270
  ##
207
271
  # Theme Methods
208
272
  ##
209
273
 
210
- def loadTheme(self) -> bool:
211
- """Load the currently specified GUI theme."""
212
- theme = CONFIG.guiTheme
213
- if theme not in self._availThemes:
214
- logger.error("Could not find GUI theme '%s'", theme)
215
- theme = DEF_GUI
216
- CONFIG.guiTheme = theme
274
+ def initThemes(self) -> None:
275
+ """Initialise themes."""
276
+ CONFIG.splashMessage("Scanning for colour themes ...")
277
+ themes: list[Path] = []
278
+ _listContent(themes, CONFIG.assetPath("themes"), ".conf")
279
+ _listContent(themes, CONFIG.dataPath("themes"), ".conf")
280
+ self._scanThemes(themes)
281
+
282
+ self.iconCache.initIcons()
283
+ self.loadTheme()
284
+
285
+ def isDesktopDarkMode(self) -> bool:
286
+ """Check if the desktop is in dark mode."""
287
+ if CONFIG.verQtValue >= 0x060500 and (hint := QGuiApplication.styleHints()):
288
+ return hint.colorScheme() == Qt.ColorScheme.Dark
289
+
290
+ palette = QPalette()
291
+ text = palette.windowText().color()
292
+ window = palette.window().color()
293
+ return text.lightnessF() > window.lightnessF()
294
+
295
+ def loadTheme(self, force: bool = False) -> bool:
296
+ """Load the currently specified GUI theme. The boolean return
297
+ can be used to determine if the GUI needs refreshing.
298
+ """
299
+ match CONFIG.themeMode:
300
+ case nwTheme.LIGHT:
301
+ darkMode = False
302
+ case nwTheme.DARK:
303
+ darkMode = True
304
+ case _:
305
+ darkMode = self.isDesktopDarkMode()
306
+
307
+ theme = CONFIG.darkTheme if darkMode else CONFIG.lightTheme
308
+ if theme not in self._allThemes:
309
+ logger.error("Could not find theme '%s'", theme)
310
+ if darkMode:
311
+ theme = DEF_GUI_DARK
312
+ CONFIG.darkTheme = DEF_GUI_DARK
313
+ else:
314
+ theme = DEF_GUI_LIGHT
315
+ CONFIG.lightTheme = DEF_GUI_LIGHT
316
+
317
+ if theme == self._currentTheme and not force:
318
+ logger.info("Theme '%s' is already loaded", theme)
319
+ return False
217
320
 
218
- if not (file := self._availThemes.get(theme)):
321
+ entry = self._allThemes.get(theme)
322
+ if not entry:
219
323
  logger.error("Could not load GUI theme")
220
324
  return False
221
325
 
222
- CONFIG.splashMessage("Loading GUI theme ...")
326
+ CONFIG.splashMessage(f"Loading colour theme: {entry.name}")
223
327
  logger.info("Loading GUI theme '%s'", theme)
224
- parser = NWConfigParser()
328
+ parser = ConfigParser()
225
329
  try:
226
- with open(file, mode="r", encoding="utf-8") as fo:
227
- parser.read_file(fo)
330
+ parser.read(entry.path, encoding="utf-8")
228
331
  except Exception:
229
- logger.error("Could not read file: %s", file)
332
+ logger.error("Could not read file: %s", entry.path)
230
333
  logException()
231
334
  return False
232
335
 
@@ -237,39 +340,64 @@ class GuiTheme:
237
340
  sec = "Main"
238
341
  meta = ThemeMeta()
239
342
  if parser.has_section(sec):
240
- meta.name = parser.rdStr(sec, "name", "")
241
- meta.description = parser.rdStr(sec, "description", "N/A")
242
- meta.author = parser.rdStr(sec, "author", "N/A")
243
- meta.credit = parser.rdStr(sec, "credit", "N/A")
244
- meta.url = parser.rdStr(sec, "url", "")
245
- meta.license = parser.rdStr(sec, "license", "N/A")
246
- meta.licenseUrl = parser.rdStr(sec, "licenseurl", "")
247
-
248
- self.themeMeta = meta
249
-
250
- # Icons
251
- sec = "Icons"
343
+ meta.name = parser.get(sec, "name", fallback="")
344
+ meta.mode = parser.get(sec, "mode", fallback="light")
345
+ meta.author = parser.get(sec, "author", fallback="")
346
+ meta.credit = parser.get(sec, "credit", fallback="")
347
+ meta.url = parser.get(sec, "url", fallback="")
348
+
349
+ self._meta = meta
350
+
351
+ # Base
352
+ sec = "Base"
252
353
  if parser.has_section(sec):
253
- self.iconCache.setIconColor("default", self._parseColor(parser, sec, "default"))
254
- self.iconCache.setIconColor("faded", self._parseColor(parser, sec, "faded"))
255
- self.iconCache.setIconColor("red", self._parseColor(parser, sec, "red"))
256
- self.iconCache.setIconColor("orange", self._parseColor(parser, sec, "orange"))
257
- self.iconCache.setIconColor("yellow", self._parseColor(parser, sec, "yellow"))
258
- self.iconCache.setIconColor("green", self._parseColor(parser, sec, "green"))
259
- self.iconCache.setIconColor("aqua", self._parseColor(parser, sec, "aqua"))
260
- self.iconCache.setIconColor("blue", self._parseColor(parser, sec, "blue"))
261
- self.iconCache.setIconColor("purple", self._parseColor(parser, sec, "purple"))
354
+ self._setBaseColor("base", self._readColor(parser, sec, "base"))
355
+ self._setBaseColor("default", self._readColor(parser, sec, "default"))
356
+ self._setBaseColor("faded", self._readColor(parser, sec, "faded"))
357
+ self._setBaseColor("red", self._readColor(parser, sec, "red"))
358
+ self._setBaseColor("orange", self._readColor(parser, sec, "orange"))
359
+ self._setBaseColor("yellow", self._readColor(parser, sec, "yellow"))
360
+ self._setBaseColor("green", self._readColor(parser, sec, "green"))
361
+ self._setBaseColor("cyan", self._readColor(parser, sec, "cyan"))
362
+ self._setBaseColor("blue", self._readColor(parser, sec, "blue"))
363
+ self._setBaseColor("purple", self._readColor(parser, sec, "purple"))
262
364
 
263
365
  # Project
264
366
  sec = "Project"
265
367
  if parser.has_section(sec):
266
- self.iconCache.setIconColor("root", self._parseColor(parser, sec, "root"))
267
- self.iconCache.setIconColor("folder", self._parseColor(parser, sec, "folder"))
268
- self.iconCache.setIconColor("file", self._parseColor(parser, sec, "file"))
269
- self.iconCache.setIconColor("title", self._parseColor(parser, sec, "title"))
270
- self.iconCache.setIconColor("chapter", self._parseColor(parser, sec, "chapter"))
271
- self.iconCache.setIconColor("scene", self._parseColor(parser, sec, "scene"))
272
- self.iconCache.setIconColor("note", self._parseColor(parser, sec, "note"))
368
+ self._setBaseColor("root", self._readColor(parser, sec, "root"))
369
+ self._setBaseColor("folder", self._readColor(parser, sec, "folder"))
370
+ self._setBaseColor("file", self._readColor(parser, sec, "file"))
371
+ self._setBaseColor("title", self._readColor(parser, sec, "title"))
372
+ self._setBaseColor("chapter", self._readColor(parser, sec, "chapter"))
373
+ self._setBaseColor("scene", self._readColor(parser, sec, "scene"))
374
+ self._setBaseColor("note", self._readColor(parser, sec, "note"))
375
+ self._setBaseColor("active", self._readColor(parser, sec, "active"))
376
+ self._setBaseColor("inactive", self._readColor(parser, sec, "inactive"))
377
+ self._setBaseColor("disabled", self._readColor(parser, sec, "disabled"))
378
+
379
+ # Icon
380
+ sec = "Icon"
381
+ if parser.has_section(sec):
382
+ self._setBaseColor("tool", self._readColor(parser, sec, "tool"))
383
+ self._setBaseColor("sidebar", self._readColor(parser, sec, "sidebar"))
384
+ self._setBaseColor("accept", self._readColor(parser, sec, "accept"))
385
+ self._setBaseColor("reject", self._readColor(parser, sec, "reject"))
386
+ self._setBaseColor("action", self._readColor(parser, sec, "action"))
387
+ self._setBaseColor("altaction", self._readColor(parser, sec, "altaction"))
388
+ self._setBaseColor("apply", self._readColor(parser, sec, "apply"))
389
+ self._setBaseColor("create", self._readColor(parser, sec, "create"))
390
+ self._setBaseColor("destroy", self._readColor(parser, sec, "destroy"))
391
+ self._setBaseColor("reset", self._readColor(parser, sec, "reset"))
392
+ self._setBaseColor("add", self._readColor(parser, sec, "add"))
393
+ self._setBaseColor("change", self._readColor(parser, sec, "change"))
394
+ self._setBaseColor("remove", self._readColor(parser, sec, "remove"))
395
+ self._setBaseColor("shortcode", self._readColor(parser, sec, "shortcode"))
396
+ self._setBaseColor("markdown", self._readColor(parser, sec, "markdown"))
397
+ self._setBaseColor("systemio", self._readColor(parser, sec, "systemio"))
398
+ self._setBaseColor("info", self._readColor(parser, sec, "info"))
399
+ self._setBaseColor("warning", self._readColor(parser, sec, "warning"))
400
+ self._setBaseColor("error", self._readColor(parser, sec, "error"))
273
401
 
274
402
  # Palette
275
403
  sec = "Palette"
@@ -288,24 +416,47 @@ class GuiTheme:
288
416
  self._setPalette(parser, sec, "highlightedtext", QPalette.ColorRole.HighlightedText)
289
417
  self._setPalette(parser, sec, "link", QPalette.ColorRole.Link)
290
418
  self._setPalette(parser, sec, "linkvisited", QPalette.ColorRole.LinkVisited)
419
+ self.accentCol = self._readColor(parser, sec, "accent") # Special handling 'til Qt 6.6
291
420
 
292
421
  # GUI
293
422
  sec = "GUI"
294
423
  if parser.has_section(sec):
295
- self.helpText = self._parseColor(parser, sec, "helptext")
296
- self.fadedText = self._parseColor(parser, sec, "fadedtext")
297
- self.errorText = self._parseColor(parser, sec, "errortext")
424
+ self.helpText = self._readColor(parser, sec, "helptext")
425
+ self.fadedText = self._readColor(parser, sec, "fadedtext")
426
+ self.errorText = self._readColor(parser, sec, "errortext")
427
+
428
+ # Syntax
429
+ sec = "Syntax"
430
+ self.syntaxTheme = SyntaxColors()
431
+ if parser.has_section(sec):
432
+ self.syntaxTheme.back = self._readColor(parser, sec, "background")
433
+ self.syntaxTheme.text = self._readColor(parser, sec, "text")
434
+ self.syntaxTheme.line = self._readColor(parser, sec, "line")
435
+ self.syntaxTheme.link = self._readColor(parser, sec, "link")
436
+ self.syntaxTheme.head = self._readColor(parser, sec, "headertext")
437
+ self.syntaxTheme.headH = self._readColor(parser, sec, "headertag")
438
+ self.syntaxTheme.emph = self._readColor(parser, sec, "emphasis")
439
+ self.syntaxTheme.space = self._readColor(parser, sec, "whitespace")
440
+ self.syntaxTheme.dialN = self._readColor(parser, sec, "dialog")
441
+ self.syntaxTheme.dialA = self._readColor(parser, sec, "altdialog")
442
+ self.syntaxTheme.hidden = self._readColor(parser, sec, "hidden")
443
+ self.syntaxTheme.note = self._readColor(parser, sec, "note")
444
+ self.syntaxTheme.code = self._readColor(parser, sec, "shortcode")
445
+ self.syntaxTheme.key = self._readColor(parser, sec, "keyword")
446
+ self.syntaxTheme.tag = self._readColor(parser, sec, "tag")
447
+ self.syntaxTheme.val = self._readColor(parser, sec, "value")
448
+ self.syntaxTheme.opt = self._readColor(parser, sec, "optional")
449
+ self.syntaxTheme.spell = self._readColor(parser, sec, "spellcheckline")
450
+ self.syntaxTheme.error = self._readColor(parser, sec, "errorline")
451
+ self.syntaxTheme.repTag = self._readColor(parser, sec, "replacetag")
452
+ self.syntaxTheme.mod = self._readColor(parser, sec, "modifier")
453
+ self.syntaxTheme.mark = self._readColor(parser, sec, "texthighlight")
298
454
 
299
455
  # Update Dependant Colours
300
456
  # Based on: https://github.com/qt/qtbase/blob/dev/src/gui/kernel/qplatformtheme.cpp
301
457
  text = self._guiPalette.text().color()
302
458
  window = self._guiPalette.window().color()
303
459
  highlight = self._guiPalette.highlight().color()
304
- isDark = text.lightnessF() > window.lightnessF()
305
-
306
- QtColActive = QPalette.ColorGroup.Active
307
- QtColInactive = QPalette.ColorGroup.Inactive
308
- QtColDisabled = QPalette.ColorGroup.Disabled
309
460
 
310
461
  if window.lightnessF() < 0.15:
311
462
  # If window is too dark, we need a lighter ref colour for shades
@@ -321,8 +472,8 @@ class GuiTheme:
321
472
  darkOff = dark.darker(150)
322
473
  shadowOff = ref.darker(150)
323
474
 
324
- grey = QColor(120, 120, 120) if isDark else QColor(140, 140, 140)
325
- dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190)
475
+ grey = QColor(120, 120, 120) if darkMode else QColor(140, 140, 140)
476
+ dimmed = QColor(130, 130, 130) if darkMode else QColor(190, 190, 190)
326
477
 
327
478
  placeholder = QColor(text)
328
479
  placeholder.setAlpha(128)
@@ -347,145 +498,93 @@ class GuiTheme:
347
498
  self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Highlight, grey)
348
499
 
349
500
  if CONFIG.verQtValue >= 0x060600:
350
- self._guiPalette.setBrush(QtColActive, QPalette.ColorRole.Accent, highlight)
351
- self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Accent, highlight)
501
+ self._guiPalette.setBrush(QtColActive, QPalette.ColorRole.Accent, self.accentCol)
502
+ self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Accent, self.accentCol)
352
503
  self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Accent, grey)
353
504
 
505
+ # Set project override colours
506
+ if (override := CONFIG.iconColTree) != DEF_TREECOL:
507
+ color = self._qColors.get(override, QtBlack)
508
+ self._setBaseColor("root", color)
509
+ self._setBaseColor("folder", color)
510
+ if not CONFIG.iconColDocs:
511
+ self._setBaseColor("file", color)
512
+ self._setBaseColor("title", color)
513
+ self._setBaseColor("chapter", color)
514
+ self._setBaseColor("scene", color)
515
+ self._setBaseColor("note", color)
516
+
517
+ self.isDarkTheme = darkMode
518
+ self._currentTheme = theme
519
+
354
520
  # Load icons after the theme is parsed
355
521
  self.iconCache.loadTheme(CONFIG.iconTheme)
356
522
 
357
523
  # Finalise
358
- self.isDarkTheme = isDark
359
524
  QApplication.setPalette(self._guiPalette)
360
525
  self._buildStyleSheets(self._guiPalette)
361
526
 
362
- CONFIG.splashMessage(f"Loaded GUI theme: {meta.name}")
363
-
364
527
  return True
365
528
 
366
- def loadSyntax(self) -> bool:
367
- """Load the currently specified syntax highlighter theme."""
368
- theme = CONFIG.guiSyntax
369
- if theme not in self._availSyntax:
370
- logger.error("Could not find syntax theme '%s'", theme)
371
- theme = DEF_SYNTAX
372
- CONFIG.guiSyntax = theme
373
-
374
- if not (file := self._availSyntax.get(theme)):
375
- logger.error("Could not load syntax theme")
376
- return False
377
-
378
- CONFIG.splashMessage("Loading syntax theme ...")
379
- logger.info("Loading syntax theme '%s'", theme)
380
- parser = NWConfigParser()
381
- try:
382
- with open(file, mode="r", encoding="utf-8") as fo:
383
- parser.read_file(fo)
384
- except Exception:
385
- logger.error("Could not read file: %s", file)
386
- logException()
387
- return False
388
-
389
- # Main
390
- sec = "Main"
391
- meta = ThemeMeta()
392
- if parser.has_section(sec):
393
- meta.name = parser.rdStr(sec, "name", "")
394
- meta.description = parser.rdStr(sec, "description", "N/A")
395
- meta.author = parser.rdStr(sec, "author", "N/A")
396
- meta.credit = parser.rdStr(sec, "credit", "N/A")
397
- meta.url = parser.rdStr(sec, "url", "")
398
- meta.license = parser.rdStr(sec, "license", "N/A")
399
- meta.licenseUrl = parser.rdStr(sec, "licenseurl", "")
400
-
401
- # Syntax
402
- sec = "Syntax"
403
- syntax = SyntaxColors()
404
- if parser.has_section(sec):
405
- syntax.back = self._parseColor(parser, sec, "background")
406
- syntax.text = self._parseColor(parser, sec, "text")
407
- syntax.link = self._parseColor(parser, sec, "link")
408
- syntax.head = self._parseColor(parser, sec, "headertext")
409
- syntax.headH = self._parseColor(parser, sec, "headertag")
410
- syntax.emph = self._parseColor(parser, sec, "emphasis")
411
- syntax.dialN = self._parseColor(parser, sec, "dialog")
412
- syntax.dialA = self._parseColor(parser, sec, "altdialog")
413
- syntax.hidden = self._parseColor(parser, sec, "hidden")
414
- syntax.note = self._parseColor(parser, sec, "note")
415
- syntax.code = self._parseColor(parser, sec, "shortcode")
416
- syntax.key = self._parseColor(parser, sec, "keyword")
417
- syntax.tag = self._parseColor(parser, sec, "tag")
418
- syntax.val = self._parseColor(parser, sec, "value")
419
- syntax.opt = self._parseColor(parser, sec, "optional")
420
- syntax.spell = self._parseColor(parser, sec, "spellcheckline")
421
- syntax.error = self._parseColor(parser, sec, "errorline")
422
- syntax.repTag = self._parseColor(parser, sec, "replacetag")
423
- syntax.mod = self._parseColor(parser, sec, "modifier")
424
- syntax.mark = self._parseColor(parser, sec, "texthighlight")
425
-
426
- CONFIG.splashMessage(f"Loaded syntax theme: {meta.name}")
427
-
428
- self.syntaxMeta = meta
429
- self.syntaxTheme = syntax
430
-
431
- return True
432
-
433
- def listThemes(self) -> list[tuple[str, str]]:
434
- """Scan the GUI themes folder and list all themes."""
435
- if self._themeList:
436
- return self._themeList
437
-
438
- themes = []
439
- parser = NWConfigParser()
440
- for key, path in self._availThemes.items():
441
- logger.debug("Checking theme config '%s'", key)
442
- if name := _loadInternalName(parser, path):
443
- themes.append((key, name))
444
-
445
- self._themeList = sorted(themes, key=_sortTheme)
446
-
447
- return self._themeList
448
-
449
- def listSyntax(self) -> list[tuple[str, str]]:
450
- """Scan the syntax themes folder and list all themes."""
451
- if self._syntaxList:
452
- return self._syntaxList
453
-
454
- themes = []
455
- parser = NWConfigParser()
456
- for key, path in self._availSyntax.items():
457
- logger.debug("Checking theme syntax '%s'", key)
458
- if name := _loadInternalName(parser, path):
459
- themes.append((key, name))
460
-
461
- self._syntaxList = sorted(themes, key=_sortTheme)
462
-
463
- return self._syntaxList
464
-
465
529
  def getStyleSheet(self, name: str) -> str:
466
530
  """Load a standard style sheet."""
467
531
  return self._styleSheets.get(name, "")
468
532
 
533
+ def parseColor(self, value: str, default: QColor = QtBlack) -> QColor:
534
+ """Parse a string as a colour value."""
535
+ if value in self._qColors:
536
+ # Named colour
537
+ return self._qColors[value]
538
+ elif value.startswith("#") and len(value) == 7:
539
+ # Assume #RRGGBB
540
+ return QColor.fromString(value)
541
+ elif value.startswith("#") and len(value) == 9:
542
+ # Assume #RRGGBBAA and convert to #AARRGGBB
543
+ return QColor.fromString(f"#{value[7:9]}{value[1:7]}")
544
+ elif ":" in value:
545
+ # Colour name and lighter, darker or alpha
546
+ name, _, adjust = value.partition(":")
547
+ color = QColor(self._qColors.get(name.strip(), default))
548
+ if adjust.startswith("L"):
549
+ color = color.lighter(checkInt(adjust[1:], 100))
550
+ elif adjust.startswith("D"):
551
+ color = color.darker(checkInt(adjust[1:], 100))
552
+ else:
553
+ color.setAlpha(checkInt(adjust, 255))
554
+ return color
555
+ elif "," in value:
556
+ # Integer red, green, blue, alpha
557
+ data = value.split(",")
558
+ result = [0, 0, 0, 255]
559
+ for i in range(min(len(data), 4)):
560
+ result[i] = checkInt(data[i].strip(), result[i])
561
+ return QColor(*result)
562
+ return default
563
+
469
564
  ##
470
565
  # Internal Functions
471
566
  ##
472
567
 
568
+ def _setBaseColor(self, key: str, color: QColor) -> None:
569
+ """Set the colour for a named colour."""
570
+ self._qColors[key] = QColor(color)
571
+ self._svgColors[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8")
572
+
473
573
  def _resetTheme(self) -> None:
474
574
  """Reset GUI colours to default values."""
475
575
  palette = QPalette()
476
-
477
- text = palette.color(QPalette.ColorRole.Text)
478
- window = palette.color(QPalette.ColorRole.Window)
479
- isDark = text.lightnessF() > window.lightnessF()
576
+ isDark = self.isDesktopDarkMode()
480
577
 
481
578
  # Reset GUI Palette
579
+ base = palette.color(QPalette.ColorRole.Base)
580
+ default = palette.color(QPalette.ColorRole.Text)
482
581
  faded = QColor(128, 128, 128)
483
582
  dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190)
484
583
  red = QColor(242, 119, 122) if isDark else QColor(240, 40, 41)
485
584
  orange = QColor(249, 145, 57) if isDark else QColor(245, 135, 31)
486
585
  yellow = QColor(255, 204, 102) if isDark else QColor(234, 183, 0)
487
586
  green = QColor(153, 204, 153) if isDark else QColor(113, 140, 0)
488
- aqua = QColor(102, 204, 204) if isDark else QColor(62, 153, 159)
587
+ cyan = QColor(102, 204, 204) if isDark else QColor(62, 153, 159)
489
588
  blue = QColor(102, 153, 204) if isDark else QColor(66, 113, 174)
490
589
  purple = QColor(204, 153, 204) if isDark else QColor(137, 89, 168)
491
590
 
@@ -496,38 +595,65 @@ class GuiTheme:
496
595
 
497
596
  self._guiPalette = palette
498
597
 
499
- # Reset Icons
500
- icons = self.iconCache
501
- icons.clear()
502
- icons.setIconColor("default", text)
503
- icons.setIconColor("faded", faded)
504
- icons.setIconColor("red", red)
505
- icons.setIconColor("orange", orange)
506
- icons.setIconColor("yellow", yellow)
507
- icons.setIconColor("green", green)
508
- icons.setIconColor("aqua", aqua)
509
- icons.setIconColor("blue", blue)
510
- icons.setIconColor("purple", purple)
511
- icons.setIconColor("root", blue)
512
- icons.setIconColor("folder", yellow)
513
- icons.setIconColor("file", text)
514
- icons.setIconColor("title", green)
515
- icons.setIconColor("chapter", red)
516
- icons.setIconColor("scene", blue)
517
- icons.setIconColor("note", yellow)
598
+ # Reset Base Colours and Icons
599
+ self.iconCache.clear()
600
+ self._svgColors = {}
601
+ self._qColors = {}
518
602
 
519
- return
603
+ # Base
604
+ self._setBaseColor("base", base)
605
+ self._setBaseColor("default", default)
606
+ self._setBaseColor("faded", faded)
607
+ self._setBaseColor("red", red)
608
+ self._setBaseColor("orange", orange)
609
+ self._setBaseColor("yellow", yellow)
610
+ self._setBaseColor("green", green)
611
+ self._setBaseColor("cyan", cyan)
612
+ self._setBaseColor("blue", blue)
613
+ self._setBaseColor("purple", purple)
520
614
 
521
- def _parseColor(self, parser: NWConfigParser, section: str, name: str) -> QColor:
615
+ # Project
616
+ self._setBaseColor("root", blue)
617
+ self._setBaseColor("folder", yellow)
618
+ self._setBaseColor("file", default)
619
+ self._setBaseColor("title", green)
620
+ self._setBaseColor("chapter", red)
621
+ self._setBaseColor("scene", blue)
622
+ self._setBaseColor("note", yellow)
623
+ self._setBaseColor("active", green)
624
+ self._setBaseColor("inactive", red)
625
+ self._setBaseColor("disabled", faded)
626
+
627
+ # Icon
628
+ self._setBaseColor("tool", default)
629
+ self._setBaseColor("sidebar", default)
630
+ self._setBaseColor("accept", green)
631
+ self._setBaseColor("reject", red)
632
+ self._setBaseColor("action", blue)
633
+ self._setBaseColor("altaction", orange)
634
+ self._setBaseColor("apply", green)
635
+ self._setBaseColor("create", yellow)
636
+ self._setBaseColor("destroy", faded)
637
+ self._setBaseColor("reset", green)
638
+ self._setBaseColor("add", green)
639
+ self._setBaseColor("change", green)
640
+ self._setBaseColor("remove", red)
641
+ self._setBaseColor("shortcode", default)
642
+ self._setBaseColor("markdown", orange)
643
+ self._setBaseColor("systemio", yellow)
644
+ self._setBaseColor("info", blue)
645
+ self._setBaseColor("warning", orange)
646
+ self._setBaseColor("error", red)
647
+
648
+ def _readColor(self, parser: ConfigParser, section: str, name: str) -> QColor:
522
649
  """Parse a colour value from a config string."""
523
- return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255]))
650
+ return self.parseColor(parser.get(section, name, fallback="default"))
524
651
 
525
652
  def _setPalette(
526
- self, parser: NWConfigParser, section: str, name: str, value: QPalette.ColorRole
653
+ self, parser: ConfigParser, section: str, name: str, value: QPalette.ColorRole
527
654
  ) -> None:
528
655
  """Set a palette colour value from a config string."""
529
- self._guiPalette.setBrush(value, self._parseColor(parser, section, name))
530
- return
656
+ self._guiPalette.setBrush(value, self._readColor(parser, section, name))
531
657
 
532
658
  def _buildStyleSheets(self, palette: QPalette) -> None:
533
659
  """Build default style sheets."""
@@ -559,7 +685,32 @@ class GuiTheme:
559
685
  "QToolButton::menu-indicator {image: none;} "
560
686
  )
561
687
 
562
- return
688
+ def _scanThemes(self, files: list[Path]) -> None:
689
+ """Scan the GUI themes folder and list all themes."""
690
+ parser = ConfigParser()
691
+ data: dict[str, tuple[str, str, bool, Path]] = {}
692
+ keys = []
693
+ for file in files:
694
+ try:
695
+ parser.clear()
696
+ parser.read(file, encoding="utf-8")
697
+ name = parser.get("Main", "name", fallback="")
698
+ mode = parser.get("Main", "mode", fallback="").lower()
699
+ if name and mode in ("light", "dark"):
700
+ key = file.stem
701
+ prefix = "*" if key.startswith("default") else ""
702
+ lookup = f"{prefix}{name} {key}"
703
+ keys.append(lookup)
704
+ data[lookup] = (file.stem, name, mode == "dark", file)
705
+ except Exception:
706
+ logger.error("Could not read file: %s", file)
707
+ logException()
708
+
709
+ self._allThemes = {}
710
+ for lookup in sorted(keys):
711
+ key, name, dark, item = data[lookup]
712
+ logger.debug("Checking theme config '%s'", key)
713
+ self._allThemes[key] = ThemeEntry(name, dark, item)
563
714
 
564
715
 
565
716
  class GuiIcons:
@@ -572,9 +723,8 @@ class GuiIcons:
572
723
  """
573
724
 
574
725
  __slots__ = (
575
- "_availThemes", "_headerDec", "_headerDecNarrow", "_noIcon",
576
- "_qColors", "_qIcons", "_svgColors", "_svgData", "_themeList",
577
- "mainTheme", "themeMeta",
726
+ "_allThemes", "_headerDec", "_headerDecNarrow", "_meta",
727
+ "_noIcon", "_qIcons", "_svgData", "_theme",
578
728
  )
579
729
 
580
730
  TOGGLE_ICON_KEYS: Final[dict[str, tuple[str, str]]] = {
@@ -582,69 +732,74 @@ class GuiIcons:
582
732
  "unfold": ("unfold-show", "unfold-hide"),
583
733
  }
584
734
  IMAGE_MAP: Final[dict[str, tuple[str, str]]] = {
585
- "welcome": ("welcome-light.jpg", "welcome-dark.jpg"),
735
+ "welcome": ("welcome.webp", "welcome.webp"),
586
736
  "nw-text": ("novelwriter-text-light.svg", "novelwriter-text-dark.svg"),
587
737
  }
588
738
 
589
739
  def __init__(self, mainTheme: GuiTheme) -> None:
590
740
 
591
- self.mainTheme = mainTheme
592
- self.themeMeta = ThemeMeta()
741
+ self._theme = mainTheme
742
+ self._meta = IconsMeta()
593
743
 
594
744
  # Storage
745
+ self._allThemes: dict[str, ThemeEntry] = {}
595
746
  self._svgData: dict[str, bytes] = {}
596
- self._svgColors: dict[str, bytes] = {}
597
- self._qColors: dict[str, QColor] = {}
598
747
  self._qIcons: dict[str, QIcon] = {}
599
748
  self._headerDec: list[QPixmap] = []
600
749
  self._headerDecNarrow: list[QPixmap] = []
601
750
 
602
- # Icon Theme Path
603
- self._availThemes: dict[str, Path] = {}
604
- self._themeList: list[tuple[str, str]] = []
605
-
606
751
  # None Icon
607
752
  self._noIcon = QIcon(str(CONFIG.assetPath("icons") / "none.svg"))
608
753
 
609
- _listConf(self._availThemes, CONFIG.assetPath("icons"), ".icons")
610
- _listConf(self._availThemes, CONFIG.dataPath("icons"), ".icons")
611
-
612
- return
613
-
614
754
  def clear(self) -> None:
615
755
  """Clear the icon cache."""
616
756
  self._svgData = {}
617
- self._svgColors = {}
618
- self._qColors = {}
619
757
  self._qIcons = {}
620
758
  self._headerDec = []
621
759
  self._headerDecNarrow = []
622
- self.themeMeta = ThemeMeta()
623
- return
760
+ self._meta = ThemeMeta()
761
+
762
+ ##
763
+ # Properties
764
+ ##
765
+
766
+ @property
767
+ def iconThemes(self) -> dict[str, ThemeEntry]:
768
+ """Return a dictionary of all icon themes."""
769
+ return self._allThemes
624
770
 
625
771
  ##
626
772
  # Actions
627
773
  ##
628
774
 
629
- def loadTheme(self, theme: str) -> bool:
775
+ def initIcons(self) -> None:
776
+ """Initialise icons."""
777
+ CONFIG.splashMessage("Scanning for icon themes ...")
778
+ icons: list[Path] = []
779
+ _listContent(icons, CONFIG.assetPath("icons"), ".icons")
780
+ _listContent(icons, CONFIG.dataPath("icons"), ".icons")
781
+ self._scanThemes(icons)
782
+
783
+ def loadTheme(self, theme: str) -> None:
630
784
  """Update the theme map. This is more of an init, since many of
631
785
  the GUI icons cannot really be replaced without writing specific
632
786
  update functions for the classes where they're used.
633
787
  """
634
- if theme not in self._availThemes:
788
+ if theme not in self._allThemes:
635
789
  logger.error("Could not find icon theme '%s'", theme)
636
790
  theme = DEF_ICONS
637
791
  CONFIG.iconTheme = theme
638
792
 
639
- if not (file := self._availThemes.get(theme)):
793
+ entry = self._allThemes.get(theme)
794
+ if not entry:
640
795
  logger.error("Could not load icon theme")
641
- return False
796
+ return
642
797
 
643
- CONFIG.splashMessage("Loading icon theme ...")
798
+ CONFIG.splashMessage(f"Loading icon theme: {entry.name}")
644
799
  logger.info("Loading icon theme '%s'", theme)
645
800
  try:
646
- meta = ThemeMeta()
647
- with open(file, mode="r", encoding="utf-8") as icons:
801
+ meta = IconsMeta()
802
+ with open(entry.path, mode="r", encoding="utf-8") as icons:
648
803
  for icon in icons:
649
804
  bits = icon.partition("=")
650
805
  key = bits[0].strip()
@@ -658,48 +813,24 @@ class GuiIcons:
658
813
  meta.author = value
659
814
  elif key == "meta:license":
660
815
  meta.license = value
661
- self.themeMeta = meta
816
+ self._meta = meta
662
817
  except Exception:
663
- logger.error("Could not read file: %s", file)
818
+ logger.error("Could not read file: %s", entry.path)
664
819
  logException()
665
- return False
666
-
667
- CONFIG.splashMessage(f"Loaded icon theme: {meta.name}")
668
- CONFIG.splashMessage("Generating additional icons ...")
669
-
670
- # Set colour overrides for project item icons
671
- if (override := CONFIG.iconColTree) != "theme":
672
- color = self._svgColors.get(override, b"#000000")
673
- self._svgColors["root"] = color
674
- self._svgColors["folder"] = color
675
- if not CONFIG.iconColDocs:
676
- self._svgColors["file"] = color
677
- self._svgColors["title"] = color
678
- self._svgColors["chapter"] = color
679
- self._svgColors["scene"] = color
680
- self._svgColors["note"] = color
820
+ return
681
821
 
682
822
  # Populate generated icons cache
823
+ CONFIG.splashMessage("Generating additional icons ...")
683
824
  self.getHeaderDecoration(0)
684
825
  self.getHeaderDecorationNarrow(0)
685
826
 
686
- return True
687
-
688
- def setIconColor(self, key: str, color: QColor) -> None:
689
- """Set an icon colour for a named colour."""
690
- self._qColors[key] = QColor(color)
691
- self._svgColors[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8")
692
827
  return
693
828
 
694
829
  ##
695
830
  # Access Functions
696
831
  ##
697
832
 
698
- def getIconColor(self, name: str) -> QColor:
699
- """Return an icon color."""
700
- return QColor(self._qColors.get(name) or QtBlack)
701
-
702
- def getIcon(self, name: str, color: str | None = None, w: int = 24, h: int = 24) -> QIcon:
833
+ def getIcon(self, name: str, color: str, w: int = 24, h: int = 24) -> QIcon:
703
834
  """Return an icon from the icon buffer, or load it."""
704
835
  variant = f"{name}-{color}" if color else name
705
836
  if (key := f"{variant}-{w}x{h}") in self._qIcons:
@@ -710,7 +841,7 @@ class GuiIcons:
710
841
  logger.debug("Icon: %s", key)
711
842
  return icon
712
843
 
713
- def getToggleIcon(self, name: str, size: tuple[int, int], color: str | None = None) -> QIcon:
844
+ def getToggleIcon(self, name: str, size: tuple[int, int], color: str) -> QIcon:
714
845
  """Return a toggle icon from the icon buffer, or load it."""
715
846
  if name in self.TOGGLE_ICON_KEYS:
716
847
  pOne = self.getPixmap(self.TOGGLE_ICON_KEYS[name][0], size, color)
@@ -725,7 +856,7 @@ class GuiIcons:
725
856
  self, tType: nwItemType, tClass: nwItemClass, tLayout: nwItemLayout, hLevel: str = "H0"
726
857
  ) -> QIcon:
727
858
  """Get the correct icon for a project item based on type, class
728
- and heading level
859
+ and heading level.
729
860
  """
730
861
  name = None
731
862
  color = "default"
@@ -762,14 +893,22 @@ class GuiIcons:
762
893
  doesn't exist, return an empty QPixmap.
763
894
  """
764
895
  w, h = size
765
- return self.getIcon(name, color, w, h).pixmap(w, h, QIcon.Mode.Normal)
896
+ return self.getIcon(name, color or "default", w, h).pixmap(w, h, QIcon.Mode.Normal)
897
+
898
+ def getStandardButton(self, button: nwStandardButton, parent: QWidget) -> NPushButton:
899
+ """Return a standard button with icon and text."""
900
+ text, icon, color = STANDARD_BUTTONS.get(button, ("", "", ""))
901
+ return NPushButton(
902
+ parent, QCoreApplication.translate("Button", text),
903
+ self._theme.pushButtonIconSize, icon, color
904
+ )
766
905
 
767
906
  def getDecoration(self, name: str, w: int | None = None, h: int | None = None) -> QPixmap:
768
907
  """Load graphical decoration element based on the decoration
769
908
  map or the icon map. This function always returns a QPixmap.
770
909
  """
771
910
  if name in self.IMAGE_MAP:
772
- idx = int(self.mainTheme.isDarkTheme)
911
+ idx = int(self._theme.isDarkTheme)
773
912
  imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx]
774
913
  else:
775
914
  logger.error("Decoration with name '%s' does not exist", name)
@@ -793,7 +932,7 @@ class GuiIcons:
793
932
  def getHeaderDecoration(self, hLevel: int) -> QPixmap:
794
933
  """Get the decoration for a specific heading level."""
795
934
  if not self._headerDec:
796
- iPx = self.mainTheme.baseIconHeight
935
+ iPx = self._theme.baseIconHeight
797
936
  self._headerDec = [
798
937
  self._generateDecoration("file", iPx, 0),
799
938
  self._generateDecoration("title", iPx, 0),
@@ -806,7 +945,7 @@ class GuiIcons:
806
945
  def getHeaderDecorationNarrow(self, hLevel: int) -> QPixmap:
807
946
  """Get the narrow decoration for a specific heading level."""
808
947
  if not self._headerDecNarrow:
809
- iPx = self.mainTheme.baseIconHeight
948
+ iPx = self._theme.baseIconHeight
810
949
  self._headerDecNarrow = [
811
950
  self._generateDecoration("file", iPx, 0),
812
951
  self._generateDecoration("title", iPx, 0),
@@ -817,21 +956,6 @@ class GuiIcons:
817
956
  ]
818
957
  return self._headerDecNarrow[minmax(hLevel, 0, 5)]
819
958
 
820
- def listThemes(self) -> list[tuple[str, str]]:
821
- """Scan the GUI icons folder and list all themes."""
822
- if self._themeList:
823
- return self._themeList
824
-
825
- themes = []
826
- for key, path in self._availThemes.items():
827
- logger.debug("Checking icon theme '%s'", key)
828
- if name := _loadIconName(path):
829
- themes.append((key, name))
830
-
831
- self._themeList = sorted(themes, key=_sortTheme)
832
-
833
- return self._themeList
834
-
835
959
  ##
836
960
  # Internal Functions
837
961
  ##
@@ -847,7 +971,7 @@ class GuiIcons:
847
971
  return QIcon(str(CONFIG.assetPath("icons") / "x-novelwriter-project.svg"))
848
972
 
849
973
  if svg := self._svgData.get(name, b""):
850
- if fill := self._svgColors.get(color or "default"):
974
+ if fill := self._theme.getRawBaseColor(color or "default"):
851
975
  svg = svg.replace(b"#000000", fill)
852
976
  pixmap = QPixmap(w, h)
853
977
  pixmap.fill(QtTransparent)
@@ -869,53 +993,43 @@ class GuiIcons:
869
993
 
870
994
  painter = QPainter(pixmap)
871
995
  painter.setRenderHint(QtPaintAntiAlias)
872
- if fill := self._svgColors.get(color or "default"):
996
+ if fill := self._theme.getRawBaseColor(color or "default"):
873
997
  painter.fillPath(path, QColor(fill.decode(encoding="utf-8")))
874
998
  painter.end()
875
999
 
876
1000
  tMode = Qt.TransformationMode.SmoothTransformation
877
1001
  return pixmap.scaledToHeight(height, tMode)
878
1002
 
1003
+ def _scanThemes(self, entries: list[Path]) -> None:
1004
+ """Scan the GUI themes folder and list all themes."""
1005
+ data: dict[str, tuple[str, str, Path]] = {}
1006
+ keys = []
1007
+ for entry in entries:
1008
+ try:
1009
+ with open(entry, mode="r", encoding="utf-8") as fo:
1010
+ for line in fo:
1011
+ key, _, value = line.partition("=")
1012
+ if key.strip() == "meta:name":
1013
+ if name := value.strip():
1014
+ lookup = entry.stem
1015
+ keys.append(lookup)
1016
+ data[lookup] = (lookup, name, entry)
1017
+ break
1018
+ except Exception:
1019
+ logger.error("Could not read file: %s", entry)
1020
+ logException()
1021
+
1022
+ self._allThemes = {}
1023
+ for lookup in sorted(keys):
1024
+ key, name, item = data[lookup]
1025
+ logger.debug("Checking icon theme '%s'", key)
1026
+ self._allThemes[key] = ThemeEntry(name, False, item)
1027
+
879
1028
 
880
1029
  # Module Functions
881
1030
  # ================
882
1031
 
883
- def _listConf(target: dict, path: Path, extension: str) -> None:
884
- """Scan for theme files and populate the dictionary."""
1032
+ def _listContent(data: list[Path], path: Path, extension: str) -> None:
1033
+ """List files of a specific type and extend the list."""
885
1034
  if path.is_dir():
886
- for item in path.iterdir():
887
- if item.is_file() and item.name.endswith(extension):
888
- target[item.stem] = item
889
- return
890
-
891
-
892
- def _sortTheme(data: tuple[str, str]) -> str:
893
- """Key function for theme sorting."""
894
- key, name = data
895
- return f"*{name}" if key.startswith("default_") else name
896
-
897
-
898
- def _loadInternalName(parser: NWConfigParser, path: str | Path) -> str:
899
- """Open a conf file and read the 'name' setting."""
900
- try:
901
- with open(path, mode="r", encoding="utf-8") as inFile:
902
- parser.read_file(inFile)
903
- return parser.rdStr("Main", "name", "")
904
- except Exception:
905
- logger.error("Could not read file: %s", path)
906
- logException()
907
- return ""
908
-
909
-
910
- def _loadIconName(path: Path) -> str:
911
- """Open an icons file and read the name setting."""
912
- try:
913
- with open(path, mode="r", encoding="utf-8") as icons:
914
- for icon in icons:
915
- key, _, value = icon.partition("=")
916
- if key.strip() == "meta:name":
917
- return value.strip()
918
- except Exception:
919
- logger.error("Could not read file: %s", path)
920
- logException()
921
- return ""
1035
+ data.extend(n for n in path.iterdir() if n.is_file() and n.suffix == extension)