novelWriter 2.6.2__py3-none-any.whl → 2.7__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 (348) hide show
  1. novelwriter/__init__.py +98 -75
  2. novelwriter/assets/i18n/nw_cs_CZ.qm +0 -0
  3. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  4. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  5. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  6. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  7. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  8. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  9. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  10. novelwriter/assets/i18n/nw_nl_NL.qm +0 -0
  11. novelwriter/assets/i18n/nw_pl_PL.qm +0 -0
  12. novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
  13. novelwriter/assets/i18n/nw_ru_RU.qm +0 -0
  14. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  15. novelwriter/assets/i18n/project_de_DE.json +3 -1
  16. novelwriter/assets/i18n/project_en_GB.json +2 -0
  17. novelwriter/assets/i18n/project_en_US.json +2 -0
  18. novelwriter/assets/i18n/project_it_IT.json +2 -0
  19. novelwriter/assets/i18n/project_ja_JP.json +2 -0
  20. novelwriter/assets/i18n/project_nb_NO.json +2 -0
  21. novelwriter/assets/i18n/project_nn_NO.json +5 -0
  22. novelwriter/assets/i18n/project_pl_PL.json +2 -0
  23. novelwriter/assets/i18n/project_pt_BR.json +2 -0
  24. novelwriter/assets/i18n/project_ru_RU.json +2 -0
  25. novelwriter/assets/i18n/project_zh_CN.json +2 -0
  26. novelwriter/assets/icons/font_awesome.icons +109 -0
  27. novelwriter/assets/icons/material_filled_bold.icons +109 -0
  28. novelwriter/assets/icons/material_filled_normal.icons +109 -0
  29. novelwriter/assets/icons/material_filled_thin.icons +109 -0
  30. novelwriter/assets/icons/material_rounded_bold.icons +109 -0
  31. novelwriter/assets/icons/material_rounded_normal.icons +109 -0
  32. novelwriter/assets/icons/material_rounded_thin.icons +109 -0
  33. novelwriter/assets/icons/remix_filled.icons +109 -0
  34. novelwriter/assets/icons/remix_outline.icons +109 -0
  35. novelwriter/assets/images/splash.png +0 -0
  36. novelwriter/assets/manual.pdf +0 -0
  37. novelwriter/assets/{manual_fr_FR.pdf → manual_fr.pdf} +0 -0
  38. novelwriter/assets/sample.zip +0 -0
  39. novelwriter/assets/syntax/cyberpunk_night.conf +1 -1
  40. novelwriter/assets/syntax/snazzy.conf +3 -3
  41. novelwriter/assets/text/credits_en.htm +12 -6
  42. novelwriter/assets/themes/cyberpunk_night.conf +23 -7
  43. novelwriter/assets/themes/default_dark.conf +20 -4
  44. novelwriter/assets/themes/default_light.conf +21 -5
  45. novelwriter/assets/themes/dracula.conf +20 -4
  46. novelwriter/assets/themes/snazzy.conf +48 -0
  47. novelwriter/assets/themes/solarized_dark.conf +24 -8
  48. novelwriter/assets/themes/solarized_light.conf +22 -6
  49. novelwriter/common.py +43 -27
  50. novelwriter/config.py +201 -139
  51. novelwriter/constants.py +92 -56
  52. novelwriter/core/buildsettings.py +26 -17
  53. novelwriter/core/coretools.py +52 -41
  54. novelwriter/core/docbuild.py +20 -13
  55. novelwriter/core/document.py +2 -2
  56. novelwriter/core/index.py +166 -432
  57. novelwriter/core/indexdata.py +406 -0
  58. novelwriter/core/item.py +50 -32
  59. novelwriter/core/itemmodel.py +43 -38
  60. novelwriter/core/novelmodel.py +225 -0
  61. novelwriter/core/options.py +1 -1
  62. novelwriter/core/project.py +24 -25
  63. novelwriter/core/projectdata.py +47 -29
  64. novelwriter/core/projectxml.py +18 -8
  65. novelwriter/core/sessions.py +32 -15
  66. novelwriter/core/spellcheck.py +4 -3
  67. novelwriter/core/status.py +12 -15
  68. novelwriter/core/storage.py +1 -1
  69. novelwriter/core/tree.py +55 -13
  70. novelwriter/dialogs/about.py +19 -22
  71. novelwriter/dialogs/docmerge.py +23 -24
  72. novelwriter/dialogs/docsplit.py +26 -26
  73. novelwriter/dialogs/editlabel.py +19 -20
  74. novelwriter/dialogs/preferences.py +143 -69
  75. novelwriter/dialogs/projectsettings.py +51 -54
  76. novelwriter/dialogs/quotes.py +14 -19
  77. novelwriter/dialogs/wordlist.py +25 -30
  78. novelwriter/enum.py +8 -0
  79. novelwriter/error.py +16 -16
  80. novelwriter/extensions/configlayout.py +24 -20
  81. novelwriter/extensions/eventfilters.py +9 -5
  82. novelwriter/extensions/modified.py +34 -15
  83. novelwriter/extensions/novelselector.py +18 -5
  84. novelwriter/extensions/pagedsidebar.py +39 -49
  85. novelwriter/extensions/progressbars.py +10 -8
  86. novelwriter/extensions/statusled.py +6 -13
  87. novelwriter/extensions/switch.py +31 -41
  88. novelwriter/extensions/switchbox.py +8 -3
  89. novelwriter/extensions/versioninfo.py +4 -4
  90. novelwriter/formats/shared.py +1 -1
  91. novelwriter/formats/todocx.py +14 -10
  92. novelwriter/formats/tohtml.py +7 -5
  93. novelwriter/formats/tokenizer.py +37 -33
  94. novelwriter/formats/tomarkdown.py +6 -2
  95. novelwriter/formats/toodt.py +27 -22
  96. novelwriter/formats/toqdoc.py +19 -14
  97. novelwriter/formats/toraw.py +6 -2
  98. novelwriter/gui/doceditor.py +314 -305
  99. novelwriter/gui/dochighlight.py +46 -45
  100. novelwriter/gui/docviewer.py +102 -104
  101. novelwriter/gui/docviewerpanel.py +47 -51
  102. novelwriter/gui/editordocument.py +8 -5
  103. novelwriter/gui/itemdetails.py +15 -18
  104. novelwriter/gui/mainmenu.py +147 -146
  105. novelwriter/gui/noveltree.py +246 -406
  106. novelwriter/gui/outline.py +145 -76
  107. novelwriter/gui/projtree.py +139 -132
  108. novelwriter/gui/search.py +34 -31
  109. novelwriter/gui/sidebar.py +13 -18
  110. novelwriter/gui/statusbar.py +27 -21
  111. novelwriter/gui/theme.py +554 -436
  112. novelwriter/guimain.py +56 -38
  113. novelwriter/shared.py +32 -23
  114. novelwriter/splash.py +74 -0
  115. novelwriter/text/comments.py +70 -0
  116. novelwriter/text/patterns.py +4 -4
  117. novelwriter/tools/dictionaries.py +20 -29
  118. novelwriter/tools/lipsum.py +18 -18
  119. novelwriter/tools/manusbuild.py +39 -42
  120. novelwriter/tools/manuscript.py +114 -127
  121. novelwriter/tools/manussettings.py +129 -102
  122. novelwriter/tools/noveldetails.py +60 -72
  123. novelwriter/tools/welcome.py +57 -75
  124. novelwriter/tools/writingstats.py +112 -102
  125. novelwriter/types.py +5 -7
  126. {novelWriter-2.6.2.dist-info → novelwriter-2.7.dist-info}/METADATA +6 -6
  127. novelwriter-2.7.dist-info/RECORD +162 -0
  128. {novelWriter-2.6.2.dist-info → novelwriter-2.7.dist-info}/WHEEL +1 -1
  129. novelWriter-2.6.2.dist-info/RECORD +0 -363
  130. novelwriter/assets/icons/typicons_dark/README.md +0 -29
  131. novelwriter/assets/icons/typicons_dark/icons.conf +0 -134
  132. novelwriter/assets/icons/typicons_dark/mixed_copy.svg +0 -4
  133. novelwriter/assets/icons/typicons_dark/mixed_document-chapter.svg +0 -12
  134. novelwriter/assets/icons/typicons_dark/mixed_document-new.svg +0 -6
  135. novelwriter/assets/icons/typicons_dark/mixed_document-note.svg +0 -12
  136. novelwriter/assets/icons/typicons_dark/mixed_document-scene.svg +0 -12
  137. novelwriter/assets/icons/typicons_dark/mixed_document-section.svg +0 -12
  138. novelwriter/assets/icons/typicons_dark/mixed_document-title.svg +0 -12
  139. novelwriter/assets/icons/typicons_dark/mixed_edit.svg +0 -4
  140. novelwriter/assets/icons/typicons_dark/mixed_import.svg +0 -5
  141. novelwriter/assets/icons/typicons_dark/mixed_input-checked.svg +0 -5
  142. novelwriter/assets/icons/typicons_dark/mixed_input-none.svg +0 -5
  143. novelwriter/assets/icons/typicons_dark/mixed_input-unchecked.svg +0 -5
  144. novelwriter/assets/icons/typicons_dark/mixed_margin-bottom.svg +0 -6
  145. novelwriter/assets/icons/typicons_dark/mixed_margin-left.svg +0 -6
  146. novelwriter/assets/icons/typicons_dark/mixed_margin-right.svg +0 -6
  147. novelwriter/assets/icons/typicons_dark/mixed_margin-top.svg +0 -6
  148. novelwriter/assets/icons/typicons_dark/mixed_search-replace.svg +0 -6
  149. novelwriter/assets/icons/typicons_dark/mixed_size-height.svg +0 -6
  150. novelwriter/assets/icons/typicons_dark/mixed_size-width.svg +0 -6
  151. novelwriter/assets/icons/typicons_dark/nw_deco-h0.svg +0 -4
  152. novelwriter/assets/icons/typicons_dark/nw_deco-h1.svg +0 -4
  153. novelwriter/assets/icons/typicons_dark/nw_deco-h2-narrow.svg +0 -4
  154. novelwriter/assets/icons/typicons_dark/nw_deco-h2.svg +0 -4
  155. novelwriter/assets/icons/typicons_dark/nw_deco-h3-narrow.svg +0 -4
  156. novelwriter/assets/icons/typicons_dark/nw_deco-h3.svg +0 -4
  157. novelwriter/assets/icons/typicons_dark/nw_deco-h4-narrow.svg +0 -4
  158. novelwriter/assets/icons/typicons_dark/nw_deco-h4.svg +0 -4
  159. novelwriter/assets/icons/typicons_dark/nw_deco-note.svg +0 -4
  160. novelwriter/assets/icons/typicons_dark/nw_deco-noveltree-more.svg +0 -4
  161. novelwriter/assets/icons/typicons_dark/nw_font.svg +0 -4
  162. novelwriter/assets/icons/typicons_dark/nw_panel.svg +0 -4
  163. novelwriter/assets/icons/typicons_dark/nw_quote.svg +0 -4
  164. novelwriter/assets/icons/typicons_dark/nw_search-case.svg +0 -4
  165. novelwriter/assets/icons/typicons_dark/nw_search-preserve.svg +0 -4
  166. novelwriter/assets/icons/typicons_dark/nw_search-regex.svg +0 -4
  167. novelwriter/assets/icons/typicons_dark/nw_search-word.svg +0 -4
  168. novelwriter/assets/icons/typicons_dark/nw_tb-bold-md.svg +0 -4
  169. novelwriter/assets/icons/typicons_dark/nw_tb-bold.svg +0 -6
  170. novelwriter/assets/icons/typicons_dark/nw_tb-italic-md.svg +0 -4
  171. novelwriter/assets/icons/typicons_dark/nw_tb-italic.svg +0 -6
  172. novelwriter/assets/icons/typicons_dark/nw_tb-mark.svg +0 -7
  173. novelwriter/assets/icons/typicons_dark/nw_tb-strike-md.svg +0 -4
  174. novelwriter/assets/icons/typicons_dark/nw_tb-strike.svg +0 -6
  175. novelwriter/assets/icons/typicons_dark/nw_tb-subscript.svg +0 -7
  176. novelwriter/assets/icons/typicons_dark/nw_tb-superscript.svg +0 -7
  177. novelwriter/assets/icons/typicons_dark/nw_tb-underline.svg +0 -7
  178. novelwriter/assets/icons/typicons_dark/nw_toolbar.svg +0 -5
  179. novelwriter/assets/icons/typicons_dark/typ_arrow-down-thick-grey.svg +0 -4
  180. novelwriter/assets/icons/typicons_dark/typ_arrow-forward.svg +0 -4
  181. novelwriter/assets/icons/typicons_dark/typ_arrow-maximise.svg +0 -4
  182. novelwriter/assets/icons/typicons_dark/typ_arrow-minimise.svg +0 -4
  183. novelwriter/assets/icons/typicons_dark/typ_arrow-repeat-grey.svg +0 -4
  184. novelwriter/assets/icons/typicons_dark/typ_book-grey.svg +0 -4
  185. novelwriter/assets/icons/typicons_dark/typ_book.svg +0 -6
  186. novelwriter/assets/icons/typicons_dark/typ_bookmark.svg +0 -4
  187. novelwriter/assets/icons/typicons_dark/typ_calendar.svg +0 -4
  188. novelwriter/assets/icons/typicons_dark/typ_cancel-grey.svg +0 -4
  189. novelwriter/assets/icons/typicons_dark/typ_cancel.svg +0 -4
  190. novelwriter/assets/icons/typicons_dark/typ_chart-bar-grey.svg +0 -4
  191. novelwriter/assets/icons/typicons_dark/typ_chevron-down.svg +0 -4
  192. novelwriter/assets/icons/typicons_dark/typ_chevron-left.svg +0 -4
  193. novelwriter/assets/icons/typicons_dark/typ_chevron-right.svg +0 -4
  194. novelwriter/assets/icons/typicons_dark/typ_chevron-up.svg +0 -4
  195. novelwriter/assets/icons/typicons_dark/typ_cog.svg +0 -4
  196. novelwriter/assets/icons/typicons_dark/typ_delete-full.svg +0 -4
  197. novelwriter/assets/icons/typicons_dark/typ_delete.svg +0 -4
  198. novelwriter/assets/icons/typicons_dark/typ_directions-full.svg +0 -4
  199. novelwriter/assets/icons/typicons_dark/typ_document-add.svg +0 -4
  200. novelwriter/assets/icons/typicons_dark/typ_document-text.svg +0 -8
  201. novelwriter/assets/icons/typicons_dark/typ_document.svg +0 -4
  202. novelwriter/assets/icons/typicons_dark/typ_export-grey.svg +0 -4
  203. novelwriter/assets/icons/typicons_dark/typ_export.svg +0 -4
  204. novelwriter/assets/icons/typicons_dark/typ_eye.svg +0 -4
  205. novelwriter/assets/icons/typicons_dark/typ_flag.svg +0 -4
  206. novelwriter/assets/icons/typicons_dark/typ_folder-open.svg +0 -4
  207. novelwriter/assets/icons/typicons_dark/typ_folder.svg +0 -5
  208. novelwriter/assets/icons/typicons_dark/typ_globe-grey.svg +0 -4
  209. novelwriter/assets/icons/typicons_dark/typ_key.svg +0 -4
  210. novelwriter/assets/icons/typicons_dark/typ_lightbulb-full.svg +0 -4
  211. novelwriter/assets/icons/typicons_dark/typ_location.svg +0 -4
  212. novelwriter/assets/icons/typicons_dark/typ_media-pause-grey.svg +0 -4
  213. novelwriter/assets/icons/typicons_dark/typ_media-record-outline.svg +0 -4
  214. novelwriter/assets/icons/typicons_dark/typ_media-record.svg +0 -4
  215. novelwriter/assets/icons/typicons_dark/typ_minus.svg +0 -4
  216. novelwriter/assets/icons/typicons_dark/typ_pencil.svg +0 -5
  217. novelwriter/assets/icons/typicons_dark/typ_pin-outline.svg +0 -4
  218. novelwriter/assets/icons/typicons_dark/typ_pin.svg +0 -4
  219. novelwriter/assets/icons/typicons_dark/typ_plus.svg +0 -4
  220. novelwriter/assets/icons/typicons_dark/typ_puzzle-outline.svg +0 -4
  221. novelwriter/assets/icons/typicons_dark/typ_puzzle.svg +0 -4
  222. novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg +0 -4
  223. novelwriter/assets/icons/typicons_dark/typ_refresh.svg +0 -4
  224. novelwriter/assets/icons/typicons_dark/typ_search-grey.svg +0 -4
  225. novelwriter/assets/icons/typicons_dark/typ_search.svg +0 -4
  226. novelwriter/assets/icons/typicons_dark/typ_star.svg +0 -4
  227. novelwriter/assets/icons/typicons_dark/typ_stopwatch-grey.svg +0 -4
  228. novelwriter/assets/icons/typicons_dark/typ_th-dot-menu.svg +0 -4
  229. novelwriter/assets/icons/typicons_dark/typ_th-dot-more.svg +0 -4
  230. novelwriter/assets/icons/typicons_dark/typ_th-list-grey.svg +0 -4
  231. novelwriter/assets/icons/typicons_dark/typ_th-list.svg +0 -9
  232. novelwriter/assets/icons/typicons_dark/typ_times.svg +0 -4
  233. novelwriter/assets/icons/typicons_dark/typ_trash.svg +0 -5
  234. novelwriter/assets/icons/typicons_dark/typ_unfold-hidden.svg +0 -4
  235. novelwriter/assets/icons/typicons_dark/typ_unfold-visible.svg +0 -4
  236. novelwriter/assets/icons/typicons_dark/typ_user.svg +0 -5
  237. novelwriter/assets/icons/typicons_dark/typ_warning-full.svg +0 -4
  238. novelwriter/assets/icons/typicons_light/README.md +0 -29
  239. novelwriter/assets/icons/typicons_light/icons.conf +0 -134
  240. novelwriter/assets/icons/typicons_light/mixed_copy.svg +0 -4
  241. novelwriter/assets/icons/typicons_light/mixed_document-chapter.svg +0 -12
  242. novelwriter/assets/icons/typicons_light/mixed_document-new.svg +0 -6
  243. novelwriter/assets/icons/typicons_light/mixed_document-note.svg +0 -12
  244. novelwriter/assets/icons/typicons_light/mixed_document-scene.svg +0 -12
  245. novelwriter/assets/icons/typicons_light/mixed_document-section.svg +0 -12
  246. novelwriter/assets/icons/typicons_light/mixed_document-title.svg +0 -12
  247. novelwriter/assets/icons/typicons_light/mixed_edit.svg +0 -4
  248. novelwriter/assets/icons/typicons_light/mixed_import.svg +0 -5
  249. novelwriter/assets/icons/typicons_light/mixed_input-checked.svg +0 -5
  250. novelwriter/assets/icons/typicons_light/mixed_input-none.svg +0 -5
  251. novelwriter/assets/icons/typicons_light/mixed_input-unchecked.svg +0 -5
  252. novelwriter/assets/icons/typicons_light/mixed_margin-bottom.svg +0 -6
  253. novelwriter/assets/icons/typicons_light/mixed_margin-left.svg +0 -6
  254. novelwriter/assets/icons/typicons_light/mixed_margin-right.svg +0 -6
  255. novelwriter/assets/icons/typicons_light/mixed_margin-top.svg +0 -6
  256. novelwriter/assets/icons/typicons_light/mixed_search-replace.svg +0 -6
  257. novelwriter/assets/icons/typicons_light/mixed_size-height.svg +0 -6
  258. novelwriter/assets/icons/typicons_light/mixed_size-width.svg +0 -6
  259. novelwriter/assets/icons/typicons_light/nw_deco-h0.svg +0 -4
  260. novelwriter/assets/icons/typicons_light/nw_deco-h1.svg +0 -4
  261. novelwriter/assets/icons/typicons_light/nw_deco-h2-narrow.svg +0 -4
  262. novelwriter/assets/icons/typicons_light/nw_deco-h2.svg +0 -4
  263. novelwriter/assets/icons/typicons_light/nw_deco-h3-narrow.svg +0 -4
  264. novelwriter/assets/icons/typicons_light/nw_deco-h3.svg +0 -4
  265. novelwriter/assets/icons/typicons_light/nw_deco-h4-narrow.svg +0 -4
  266. novelwriter/assets/icons/typicons_light/nw_deco-h4.svg +0 -4
  267. novelwriter/assets/icons/typicons_light/nw_deco-note.svg +0 -4
  268. novelwriter/assets/icons/typicons_light/nw_deco-noveltree-more.svg +0 -4
  269. novelwriter/assets/icons/typicons_light/nw_font.svg +0 -4
  270. novelwriter/assets/icons/typicons_light/nw_panel.svg +0 -4
  271. novelwriter/assets/icons/typicons_light/nw_quote.svg +0 -4
  272. novelwriter/assets/icons/typicons_light/nw_search-case.svg +0 -4
  273. novelwriter/assets/icons/typicons_light/nw_search-preserve.svg +0 -4
  274. novelwriter/assets/icons/typicons_light/nw_search-regex.svg +0 -4
  275. novelwriter/assets/icons/typicons_light/nw_search-word.svg +0 -4
  276. novelwriter/assets/icons/typicons_light/nw_tb-bold-md.svg +0 -4
  277. novelwriter/assets/icons/typicons_light/nw_tb-bold.svg +0 -6
  278. novelwriter/assets/icons/typicons_light/nw_tb-italic-md.svg +0 -4
  279. novelwriter/assets/icons/typicons_light/nw_tb-italic.svg +0 -6
  280. novelwriter/assets/icons/typicons_light/nw_tb-mark.svg +0 -7
  281. novelwriter/assets/icons/typicons_light/nw_tb-strike-md.svg +0 -4
  282. novelwriter/assets/icons/typicons_light/nw_tb-strike.svg +0 -6
  283. novelwriter/assets/icons/typicons_light/nw_tb-subscript.svg +0 -7
  284. novelwriter/assets/icons/typicons_light/nw_tb-superscript.svg +0 -7
  285. novelwriter/assets/icons/typicons_light/nw_tb-underline.svg +0 -7
  286. novelwriter/assets/icons/typicons_light/nw_toolbar.svg +0 -5
  287. novelwriter/assets/icons/typicons_light/typ_arrow-down-thick-grey.svg +0 -4
  288. novelwriter/assets/icons/typicons_light/typ_arrow-forward.svg +0 -4
  289. novelwriter/assets/icons/typicons_light/typ_arrow-maximise.svg +0 -4
  290. novelwriter/assets/icons/typicons_light/typ_arrow-minimise.svg +0 -4
  291. novelwriter/assets/icons/typicons_light/typ_arrow-repeat-grey.svg +0 -4
  292. novelwriter/assets/icons/typicons_light/typ_book-grey.svg +0 -4
  293. novelwriter/assets/icons/typicons_light/typ_book.svg +0 -6
  294. novelwriter/assets/icons/typicons_light/typ_bookmark.svg +0 -4
  295. novelwriter/assets/icons/typicons_light/typ_calendar.svg +0 -4
  296. novelwriter/assets/icons/typicons_light/typ_cancel-grey.svg +0 -4
  297. novelwriter/assets/icons/typicons_light/typ_cancel.svg +0 -4
  298. novelwriter/assets/icons/typicons_light/typ_chart-bar-grey.svg +0 -4
  299. novelwriter/assets/icons/typicons_light/typ_chevron-down.svg +0 -4
  300. novelwriter/assets/icons/typicons_light/typ_chevron-left.svg +0 -4
  301. novelwriter/assets/icons/typicons_light/typ_chevron-right.svg +0 -4
  302. novelwriter/assets/icons/typicons_light/typ_chevron-up.svg +0 -4
  303. novelwriter/assets/icons/typicons_light/typ_cog.svg +0 -4
  304. novelwriter/assets/icons/typicons_light/typ_delete-full.svg +0 -4
  305. novelwriter/assets/icons/typicons_light/typ_delete.svg +0 -4
  306. novelwriter/assets/icons/typicons_light/typ_directions-full.svg +0 -4
  307. novelwriter/assets/icons/typicons_light/typ_document-add.svg +0 -4
  308. novelwriter/assets/icons/typicons_light/typ_document-text.svg +0 -5
  309. novelwriter/assets/icons/typicons_light/typ_document.svg +0 -4
  310. novelwriter/assets/icons/typicons_light/typ_export-grey.svg +0 -4
  311. novelwriter/assets/icons/typicons_light/typ_export.svg +0 -4
  312. novelwriter/assets/icons/typicons_light/typ_eye.svg +0 -4
  313. novelwriter/assets/icons/typicons_light/typ_flag.svg +0 -4
  314. novelwriter/assets/icons/typicons_light/typ_folder-open.svg +0 -4
  315. novelwriter/assets/icons/typicons_light/typ_folder.svg +0 -5
  316. novelwriter/assets/icons/typicons_light/typ_globe-grey.svg +0 -4
  317. novelwriter/assets/icons/typicons_light/typ_key.svg +0 -4
  318. novelwriter/assets/icons/typicons_light/typ_lightbulb-full.svg +0 -4
  319. novelwriter/assets/icons/typicons_light/typ_location.svg +0 -4
  320. novelwriter/assets/icons/typicons_light/typ_media-pause-grey.svg +0 -4
  321. novelwriter/assets/icons/typicons_light/typ_media-record-outline.svg +0 -4
  322. novelwriter/assets/icons/typicons_light/typ_media-record.svg +0 -4
  323. novelwriter/assets/icons/typicons_light/typ_minus.svg +0 -4
  324. novelwriter/assets/icons/typicons_light/typ_pencil.svg +0 -5
  325. novelwriter/assets/icons/typicons_light/typ_pin-outline.svg +0 -4
  326. novelwriter/assets/icons/typicons_light/typ_pin.svg +0 -4
  327. novelwriter/assets/icons/typicons_light/typ_plus.svg +0 -4
  328. novelwriter/assets/icons/typicons_light/typ_puzzle-outline.svg +0 -4
  329. novelwriter/assets/icons/typicons_light/typ_puzzle.svg +0 -4
  330. novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg +0 -4
  331. novelwriter/assets/icons/typicons_light/typ_refresh.svg +0 -4
  332. novelwriter/assets/icons/typicons_light/typ_search-grey.svg +0 -4
  333. novelwriter/assets/icons/typicons_light/typ_search.svg +0 -4
  334. novelwriter/assets/icons/typicons_light/typ_star.svg +0 -4
  335. novelwriter/assets/icons/typicons_light/typ_stopwatch-grey.svg +0 -4
  336. novelwriter/assets/icons/typicons_light/typ_th-dot-menu.svg +0 -4
  337. novelwriter/assets/icons/typicons_light/typ_th-dot-more.svg +0 -4
  338. novelwriter/assets/icons/typicons_light/typ_th-list-grey.svg +0 -4
  339. novelwriter/assets/icons/typicons_light/typ_th-list.svg +0 -9
  340. novelwriter/assets/icons/typicons_light/typ_times.svg +0 -4
  341. novelwriter/assets/icons/typicons_light/typ_trash.svg +0 -5
  342. novelwriter/assets/icons/typicons_light/typ_unfold-hidden.svg +0 -4
  343. novelwriter/assets/icons/typicons_light/typ_unfold-visible.svg +0 -4
  344. novelwriter/assets/icons/typicons_light/typ_user.svg +0 -5
  345. novelwriter/assets/icons/typicons_light/typ_warning-full.svg +0 -4
  346. {novelWriter-2.6.2.dist-info → novelwriter-2.7.dist-info}/entry_points.txt +0 -0
  347. {novelWriter-2.6.2.dist-info → novelwriter-2.7.dist-info/licenses}/LICENSE.md +0 -0
  348. {novelWriter-2.6.2.dist-info → novelwriter-2.7.dist-info}/top_level.txt +0 -0
novelwriter/gui/theme.py CHANGED
@@ -27,17 +27,25 @@ from __future__ import annotations
27
27
  import logging
28
28
 
29
29
  from math import ceil
30
- from pathlib import Path
30
+ from typing import TYPE_CHECKING, Final
31
31
 
32
- from PyQt5.QtCore import QSize, Qt
33
- from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics, QIcon, QPalette, QPixmap
34
- from PyQt5.QtWidgets import QApplication
32
+ from PyQt6.QtCore import QSize, Qt
33
+ from PyQt6.QtGui import (
34
+ QColor, QFont, QFontDatabase, QFontMetrics, QIcon, QPainter, QPainterPath,
35
+ QPalette, QPixmap
36
+ )
37
+ from PyQt6.QtWidgets import QApplication
35
38
 
36
39
  from novelwriter import CONFIG
37
- from novelwriter.common import NWConfigParser, cssCol, minmax
40
+ from novelwriter.common import NWConfigParser, minmax
41
+ from novelwriter.config import DEF_GUI, DEF_ICONS, DEF_SYNTAX
38
42
  from novelwriter.constants import nwLabels
39
43
  from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
40
44
  from novelwriter.error import logException
45
+ from novelwriter.types import QtBlack, QtHexArgb, QtPaintAntiAlias, QtTransparent
46
+
47
+ if TYPE_CHECKING:
48
+ from pathlib import Path
41
49
 
42
50
  logger = logging.getLogger(__name__)
43
51
 
@@ -46,74 +54,73 @@ STYLES_MIN_TOOLBUTTON = "minimalToolButton"
46
54
  STYLES_BIG_TOOLBUTTON = "bigToolButton"
47
55
 
48
56
 
57
+ class ThemeMeta:
58
+
59
+ name: str = ""
60
+ description: str = ""
61
+ author: str = ""
62
+ credit: str = ""
63
+ url: str = ""
64
+ license: str = ""
65
+ licenseUrl: str = ""
66
+
67
+
68
+ class SyntaxColors:
69
+
70
+ back: QColor = QColor(255, 255, 255)
71
+ text: QColor = QColor(0, 0, 0)
72
+ link: QColor = QColor(0, 0, 0)
73
+ head: QColor = QColor(0, 0, 0)
74
+ headH: QColor = QColor(0, 0, 0)
75
+ emph: QColor = QColor(0, 0, 0)
76
+ dialN: QColor = QColor(0, 0, 0)
77
+ dialA: QColor = QColor(0, 0, 0)
78
+ hidden: QColor = QColor(0, 0, 0)
79
+ note: QColor = QColor(0, 0, 0)
80
+ code: QColor = QColor(0, 0, 0)
81
+ key: QColor = QColor(0, 0, 0)
82
+ tag: QColor = QColor(0, 0, 0)
83
+ val: QColor = QColor(0, 0, 0)
84
+ opt: QColor = QColor(0, 0, 0)
85
+ spell: QColor = QColor(0, 0, 0)
86
+ error: QColor = QColor(0, 0, 0)
87
+ repTag: QColor = QColor(0, 0, 0)
88
+ mod: QColor = QColor(0, 0, 0)
89
+ mark: QColor = QColor(255, 255, 255, 128)
90
+
91
+
49
92
  class GuiTheme:
50
93
  """Gui Theme Class
51
94
 
52
95
  Handles the look and feel of novelWriter.
53
96
  """
54
97
 
98
+ __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",
106
+ )
107
+
55
108
  def __init__(self) -> None:
56
109
 
57
110
  self.iconCache = GuiIcons(self)
58
111
 
59
- # Loaded Theme Settings
60
- # =====================
112
+ # GUI Theme
113
+ self.themeMeta = ThemeMeta()
114
+ self.isDarkTheme = False
61
115
 
62
- # Theme
63
- self.themeName = ""
64
- self.themeDescription = ""
65
- self.themeAuthor = ""
66
- self.themeCredit = ""
67
- self.themeUrl = ""
68
- self.themeLicense = ""
69
- self.themeLicenseUrl = ""
70
- self.themeIcons = ""
71
- self.isLightTheme = True
116
+ # Special Text Colours
117
+ self.helpText = QColor(0, 0, 0)
118
+ self.fadedText = QColor(0, 0, 0)
119
+ self.errorText = QColor(255, 0, 0)
72
120
 
73
- # GUI
74
- self.statNone = QColor(0, 0, 0)
75
- self.statUnsaved = QColor(0, 0, 0)
76
- self.statSaved = QColor(0, 0, 0)
77
- self.helpText = QColor(0, 0, 0)
78
- self.fadedText = QColor(0, 0, 0)
79
- self.errorText = QColor(255, 0, 0)
80
-
81
- # Loaded Syntax Settings
82
- # ======================
83
-
84
- # Main
85
- self.syntaxName = ""
86
- self.syntaxDescription = ""
87
- self.syntaxAuthor = ""
88
- self.syntaxCredit = ""
89
- self.syntaxUrl = ""
90
- self.syntaxLicense = ""
91
- self.syntaxLicenseUrl = ""
92
-
93
- # Colours
94
- self.colBack = QColor(255, 255, 255)
95
- self.colText = QColor(0, 0, 0)
96
- self.colLink = QColor(0, 0, 0)
97
- self.colHead = QColor(0, 0, 0)
98
- self.colHeadH = QColor(0, 0, 0)
99
- self.colEmph = QColor(0, 0, 0)
100
- self.colDialN = QColor(0, 0, 0)
101
- self.colDialA = QColor(0, 0, 0)
102
- self.colHidden = QColor(0, 0, 0)
103
- self.colNote = QColor(0, 0, 0)
104
- self.colCode = QColor(0, 0, 0)
105
- self.colKey = QColor(0, 0, 0)
106
- self.colTag = QColor(0, 0, 0)
107
- self.colVal = QColor(0, 0, 0)
108
- self.colOpt = QColor(0, 0, 0)
109
- self.colSpell = QColor(0, 0, 0)
110
- self.colError = QColor(0, 0, 0)
111
- self.colRepTag = QColor(0, 0, 0)
112
- self.colMod = QColor(0, 0, 0)
113
- self.colMark = QColor(255, 255, 255, 128)
114
-
115
- # Class Setup
116
- # ===========
121
+ # Syntax Theme
122
+ self.syntaxMeta = ThemeMeta()
123
+ self.syntaxTheme = SyntaxColors()
117
124
 
118
125
  # Load Themes
119
126
  self._guiPalette = QPalette()
@@ -123,30 +130,16 @@ class GuiTheme:
123
130
  self._availSyntax: dict[str, Path] = {}
124
131
  self._styleSheets: dict[str, str] = {}
125
132
 
126
- self._listConf(self._availSyntax, CONFIG.assetPath("syntax"))
127
- self._listConf(self._availThemes, CONFIG.assetPath("themes"))
128
- self._listConf(self._availSyntax, CONFIG.dataPath("syntax"))
129
- self._listConf(self._availThemes, CONFIG.dataPath("themes"))
130
-
131
- self.loadTheme()
132
- self.loadSyntax()
133
-
134
133
  # Icon Functions
135
134
  self.getIcon = self.iconCache.getIcon
136
135
  self.getPixmap = self.iconCache.getPixmap
137
136
  self.getItemIcon = self.iconCache.getItemIcon
137
+ self.getIconColor = self.iconCache.getIconColor
138
138
  self.getToggleIcon = self.iconCache.getToggleIcon
139
- self.loadDecoration = self.iconCache.loadDecoration
139
+ self.getDecoration = self.iconCache.getDecoration
140
140
  self.getHeaderDecoration = self.iconCache.getHeaderDecoration
141
141
  self.getHeaderDecorationNarrow = self.iconCache.getHeaderDecorationNarrow
142
142
 
143
- # Extract Other Info
144
- self.guiDPI = QApplication.primaryScreen().logicalDotsPerInchX()
145
- self.guiScale = QApplication.primaryScreen().logicalDotsPerInchX()/96.0
146
- CONFIG.guiScale = self.guiScale
147
- logger.debug("GUI DPI: %.1f", self.guiDPI)
148
- logger.debug("GUI Scale: %.2f", self.guiScale)
149
-
150
143
  # Fonts
151
144
  self.guiFont = QApplication.font()
152
145
  self.guiFontB = QApplication.font()
@@ -161,9 +154,9 @@ class GuiTheme:
161
154
  fHeight = qMetric.height()
162
155
  fAscent = qMetric.ascent()
163
156
  self.fontPointSize = self.guiFont.pointSizeF()
164
- self.fontPixelSize = int(round(fHeight))
165
- self.baseIconHeight = int(round(fAscent))
166
- self.baseButtonHeight = int(round(1.35*fAscent))
157
+ self.fontPixelSize = round(fHeight)
158
+ self.baseIconHeight = round(fAscent)
159
+ self.baseButtonHeight = round(1.35*fAscent)
167
160
  self.textNHeight = qMetric.boundingRect("N").height()
168
161
  self.textNWidth = qMetric.boundingRect("N").width()
169
162
 
@@ -185,6 +178,15 @@ class GuiTheme:
185
178
  logger.debug("Text 'N' Height: %d", self.textNHeight)
186
179
  logger.debug("Text 'N' Width: %d", self.textNWidth)
187
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")
186
+
187
+ self.loadTheme()
188
+ self.loadSyntax()
189
+
188
190
  return
189
191
 
190
192
  ##
@@ -199,7 +201,7 @@ class GuiTheme:
199
201
  qMetrics = QFontMetrics(font)
200
202
  else:
201
203
  qMetrics = QFontMetrics(self.guiFont)
202
- return int(ceil(qMetrics.boundingRect(text).width()))
204
+ return ceil(qMetrics.boundingRect(text).width())
203
205
 
204
206
  ##
205
207
  # Theme Methods
@@ -207,43 +209,67 @@ class GuiTheme:
207
209
 
208
210
  def loadTheme(self) -> bool:
209
211
  """Load the currently specified GUI theme."""
210
- guiTheme = CONFIG.guiTheme
211
- if guiTheme not in self._availThemes:
212
- logger.error("Could not find GUI theme '%s'", guiTheme)
213
- guiTheme = "default"
214
- CONFIG.guiTheme = guiTheme
215
-
216
- themeFile = self._availThemes.get(guiTheme, None)
217
- if themeFile is None:
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
217
+
218
+ if not (file := self._availThemes.get(theme)):
218
219
  logger.error("Could not load GUI theme")
219
220
  return False
220
221
 
221
- # Config File
222
- logger.info("Loading GUI theme '%s'", guiTheme)
222
+ CONFIG.splashMessage("Loading GUI theme ...")
223
+ logger.info("Loading GUI theme '%s'", theme)
223
224
  parser = NWConfigParser()
224
225
  try:
225
- with open(themeFile, mode="r", encoding="utf-8") as inFile:
226
- parser.read_file(inFile)
226
+ with open(file, mode="r", encoding="utf-8") as fo:
227
+ parser.read_file(fo)
227
228
  except Exception:
228
- logger.error("Could not load theme settings from: %s", themeFile)
229
+ logger.error("Could not read file: %s", file)
229
230
  logException()
230
231
  return False
231
232
 
232
233
  # Reset Palette
233
- self._guiPalette = QApplication.style().standardPalette()
234
- self._resetGuiColors()
234
+ self._resetTheme()
235
235
 
236
236
  # Main
237
237
  sec = "Main"
238
+ meta = ThemeMeta()
238
239
  if parser.has_section(sec):
239
- self.themeName = parser.rdStr(sec, "name", "")
240
- self.themeDescription = parser.rdStr(sec, "description", "N/A")
241
- self.themeAuthor = parser.rdStr(sec, "author", "N/A")
242
- self.themeCredit = parser.rdStr(sec, "credit", "N/A")
243
- self.themeUrl = parser.rdStr(sec, "url", "")
244
- self.themeLicense = parser.rdStr(sec, "license", "N/A")
245
- self.themeLicenseUrl = parser.rdStr(sec, "licenseurl", "")
246
- self.themeIcons = parser.rdStr(sec, "icontheme", "")
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"
252
+ 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"))
262
+
263
+ # Project
264
+ sec = "Project"
265
+ 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"))
247
273
 
248
274
  # Palette
249
275
  sec = "Palette"
@@ -266,101 +292,141 @@ class GuiTheme:
266
292
  # GUI
267
293
  sec = "GUI"
268
294
  if parser.has_section(sec):
269
- self.helpText = self._parseColour(parser, sec, "helptext")
270
- self.fadedText = self._parseColour(parser, sec, "fadedtext")
271
- self.errorText = self._parseColour(parser, sec, "errortext")
272
- self.statNone = self._parseColour(parser, sec, "statusnone")
273
- self.statUnsaved = self._parseColour(parser, sec, "statusunsaved")
274
- self.statSaved = self._parseColour(parser, sec, "statussaved")
295
+ self.helpText = self._parseColor(parser, sec, "helptext")
296
+ self.fadedText = self._parseColor(parser, sec, "fadedtext")
297
+ self.errorText = self._parseColor(parser, sec, "errortext")
275
298
 
276
299
  # Update Dependant Colours
277
- backCol = self._guiPalette.window().color()
278
- textCol = self._guiPalette.windowText().color()
279
-
280
- backLNess = backCol.lightnessF()
281
- textLNess = textCol.lightnessF()
282
- self.isLightTheme = backLNess > textLNess
283
- if self.helpText == QColor(0, 0, 0):
284
- if self.isLightTheme:
285
- helpLCol = textLNess + 0.35*(backLNess - textLNess)
286
- else:
287
- helpLCol = backLNess + 0.65*(textLNess - backLNess)
288
- self.helpText = QColor.fromHsl(0, 0, int(255*helpLCol))
289
- logger.debug(
290
- "Computed help text colour: rgb(%d, %d, %d)",
291
- self.helpText.red(), self.helpText.green(), self.helpText.blue()
292
- )
293
-
294
- # Icons
295
- defaultIcons = "typicons_light" if backLNess >= 0.5 else "typicons_dark"
296
- self.iconCache.loadTheme(self.themeIcons or defaultIcons)
297
-
298
- # Apply Styles
300
+ # Based on: https://github.com/qt/qtbase/blob/dev/src/gui/kernel/qplatformtheme.cpp
301
+ text = self._guiPalette.text().color()
302
+ window = self._guiPalette.window().color()
303
+ 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
+
310
+ if window.lightnessF() < 0.15:
311
+ # If window is too dark, we need a lighter ref colour for shades
312
+ ref = QColor.fromHslF(window.hueF(), window.saturationF(), 0.15, window.alphaF())
313
+ else:
314
+ ref = window
315
+
316
+ light = ref.lighter(150)
317
+ mid = ref.darker(130)
318
+ midLight = mid.lighter(110)
319
+ dark = ref.darker(150)
320
+ shadow = dark.darker(135)
321
+ darkOff = dark.darker(150)
322
+ shadowOff = ref.darker(150)
323
+
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)
326
+
327
+ placeholder = QColor(text)
328
+ placeholder.setAlpha(128)
329
+
330
+ self._guiPalette.setBrush(QPalette.ColorRole.Light, light)
331
+ self._guiPalette.setBrush(QPalette.ColorRole.Mid, mid)
332
+ self._guiPalette.setBrush(QPalette.ColorRole.Midlight, midLight)
333
+ self._guiPalette.setBrush(QPalette.ColorRole.Dark, dark)
334
+ self._guiPalette.setBrush(QPalette.ColorRole.Shadow, shadow)
335
+
336
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Text, dimmed)
337
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.WindowText, dimmed)
338
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.ButtonText, dimmed)
339
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Base, window)
340
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Dark, darkOff)
341
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Shadow, shadowOff)
342
+
343
+ self._guiPalette.setBrush(QPalette.ColorRole.PlaceholderText, placeholder)
344
+
345
+ self._guiPalette.setBrush(QtColActive, QPalette.ColorRole.Highlight, highlight)
346
+ self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Highlight, highlight)
347
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Highlight, grey)
348
+
349
+ if CONFIG.verQtValue >= 0x060600:
350
+ self._guiPalette.setBrush(QtColActive, QPalette.ColorRole.Accent, highlight)
351
+ self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Accent, highlight)
352
+ self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Accent, grey)
353
+
354
+ # Load icons after the theme is parsed
355
+ self.iconCache.loadTheme(CONFIG.iconTheme)
356
+
357
+ # Finalise
358
+ self.isDarkTheme = isDark
299
359
  QApplication.setPalette(self._guiPalette)
300
-
301
- # Reset stylesheets so that they are regenerated
302
360
  self._buildStyleSheets(self._guiPalette)
303
361
 
362
+ CONFIG.splashMessage(f"Loaded GUI theme: {meta.name}")
363
+
304
364
  return True
305
365
 
306
366
  def loadSyntax(self) -> bool:
307
367
  """Load the currently specified syntax highlighter theme."""
308
- guiSyntax = CONFIG.guiSyntax
309
- if guiSyntax not in self._availSyntax:
310
- logger.error("Could not find syntax theme '%s'", guiSyntax)
311
- guiSyntax = "default_light"
312
- CONFIG.guiSyntax = guiSyntax
313
-
314
- syntaxFile = self._availSyntax.get(guiSyntax, None)
315
- if syntaxFile is None:
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)):
316
375
  logger.error("Could not load syntax theme")
317
376
  return False
318
377
 
319
- logger.info("Loading syntax theme '%s'", guiSyntax)
320
-
321
- confParser = NWConfigParser()
378
+ CONFIG.splashMessage("Loading syntax theme ...")
379
+ logger.info("Loading syntax theme '%s'", theme)
380
+ parser = NWConfigParser()
322
381
  try:
323
- with open(syntaxFile, mode="r", encoding="utf-8") as inFile:
324
- confParser.read_file(inFile)
382
+ with open(file, mode="r", encoding="utf-8") as fo:
383
+ parser.read_file(fo)
325
384
  except Exception:
326
- logger.error("Could not load syntax colours from: %s", syntaxFile)
385
+ logger.error("Could not read file: %s", file)
327
386
  logException()
328
387
  return False
329
388
 
330
389
  # Main
331
- cnfSec = "Main"
332
- if confParser.has_section(cnfSec):
333
- self.syntaxName = confParser.rdStr(cnfSec, "name", "")
334
- self.syntaxDescription = confParser.rdStr(cnfSec, "description", "N/A")
335
- self.syntaxAuthor = confParser.rdStr(cnfSec, "author", "N/A")
336
- self.syntaxCredit = confParser.rdStr(cnfSec, "credit", "N/A")
337
- self.syntaxUrl = confParser.rdStr(cnfSec, "url", "")
338
- self.syntaxLicense = confParser.rdStr(cnfSec, "license", "N/A")
339
- self.syntaxLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "")
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", "")
340
400
 
341
401
  # Syntax
342
- cnfSec = "Syntax"
343
- if confParser.has_section(cnfSec):
344
- self.colBack = self._parseColour(confParser, cnfSec, "background")
345
- self.colText = self._parseColour(confParser, cnfSec, "text")
346
- self.colLink = self._parseColour(confParser, cnfSec, "link")
347
- self.colHead = self._parseColour(confParser, cnfSec, "headertext")
348
- self.colHeadH = self._parseColour(confParser, cnfSec, "headertag")
349
- self.colEmph = self._parseColour(confParser, cnfSec, "emphasis")
350
- self.colDialN = self._parseColour(confParser, cnfSec, "dialog")
351
- self.colDialA = self._parseColour(confParser, cnfSec, "altdialog")
352
- self.colHidden = self._parseColour(confParser, cnfSec, "hidden")
353
- self.colNote = self._parseColour(confParser, cnfSec, "note")
354
- self.colCode = self._parseColour(confParser, cnfSec, "shortcode")
355
- self.colKey = self._parseColour(confParser, cnfSec, "keyword")
356
- self.colTag = self._parseColour(confParser, cnfSec, "tag")
357
- self.colVal = self._parseColour(confParser, cnfSec, "value")
358
- self.colOpt = self._parseColour(confParser, cnfSec, "optional")
359
- self.colSpell = self._parseColour(confParser, cnfSec, "spellcheckline")
360
- self.colError = self._parseColour(confParser, cnfSec, "errorline")
361
- self.colRepTag = self._parseColour(confParser, cnfSec, "replacetag")
362
- self.colMod = self._parseColour(confParser, cnfSec, "modifier")
363
- self.colMark = self._parseColour(confParser, cnfSec, "texthighlight")
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
364
430
 
365
431
  return True
366
432
 
@@ -369,14 +435,14 @@ class GuiTheme:
369
435
  if self._themeList:
370
436
  return self._themeList
371
437
 
372
- confParser = NWConfigParser()
373
- for themeKey, themePath in self._availThemes.items():
374
- logger.debug("Checking theme config for '%s'", themeKey)
375
- themeName = _loadInternalName(confParser, themePath)
376
- if themeName:
377
- self._themeList.append((themeKey, themeName))
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))
378
444
 
379
- self._themeList = sorted(self._themeList, key=_sortTheme)
445
+ self._themeList = sorted(themes, key=_sortTheme)
380
446
 
381
447
  return self._themeList
382
448
 
@@ -385,14 +451,14 @@ class GuiTheme:
385
451
  if self._syntaxList:
386
452
  return self._syntaxList
387
453
 
388
- confParser = NWConfigParser()
389
- for syntaxKey, syntaxPath in self._availSyntax.items():
390
- logger.debug("Checking theme syntax for '%s'", syntaxKey)
391
- syntaxName = _loadInternalName(confParser, syntaxPath)
392
- if syntaxName:
393
- self._syntaxList.append((syntaxKey, syntaxName))
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))
394
460
 
395
- self._syntaxList = sorted(self._syntaxList, key=_sortTheme)
461
+ self._syntaxList = sorted(themes, key=_sortTheme)
396
462
 
397
463
  return self._syntaxList
398
464
 
@@ -404,67 +470,92 @@ class GuiTheme:
404
470
  # Internal Functions
405
471
  ##
406
472
 
407
- def _resetGuiColors(self) -> None:
473
+ def _resetTheme(self) -> None:
408
474
  """Reset GUI colours to default values."""
409
- self.statNone = QColor(120, 120, 120)
410
- self.statUnsaved = QColor(200, 15, 39)
411
- self.statSaved = QColor(2, 133, 37)
412
- self.helpText = QColor(0, 0, 0)
413
- self.fadedText = QColor(128, 128, 128)
414
- self.errorText = QColor(255, 0, 0)
415
- return
416
-
417
- def _listConf(self, targetDict: dict, checkDir: Path) -> bool:
418
- """Scan for theme config files and populate the dictionary."""
419
- if not checkDir.is_dir():
420
- return False
475
+ palette = QPalette()
476
+
477
+ text = palette.color(QPalette.ColorRole.Text)
478
+ window = palette.color(QPalette.ColorRole.Window)
479
+ isDark = text.lightnessF() > window.lightnessF()
480
+
481
+ # Reset GUI Palette
482
+ faded = QColor(128, 128, 128)
483
+ dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190)
484
+ red = QColor(242, 119, 122) if isDark else QColor(240, 40, 41)
485
+ orange = QColor(249, 145, 57) if isDark else QColor(245, 135, 31)
486
+ yellow = QColor(255, 204, 102) if isDark else QColor(234, 183, 0)
487
+ 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)
489
+ blue = QColor(102, 153, 204) if isDark else QColor(66, 113, 174)
490
+ purple = QColor(204, 153, 204) if isDark else QColor(137, 89, 168)
491
+
492
+ # Text Colours
493
+ self.helpText = dimmed
494
+ self.fadedText = faded
495
+ self.errorText = red
496
+
497
+ self._guiPalette = palette
498
+
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)
421
518
 
422
- for checkFile in checkDir.iterdir():
423
- if checkFile.is_file() and checkFile.name.endswith(".conf"):
424
- targetDict[checkFile.name[:-5]] = checkFile
425
-
426
- return True
519
+ return
427
520
 
428
- def _parseColour(self, parser: NWConfigParser, section: str, name: str) -> QColor:
521
+ def _parseColor(self, parser: NWConfigParser, section: str, name: str) -> QColor:
429
522
  """Parse a colour value from a config string."""
430
523
  return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255]))
431
524
 
432
- def _setPalette(self, parser: NWConfigParser, section: str,
433
- name: str, value: QPalette.ColorRole) -> None:
525
+ def _setPalette(
526
+ self, parser: NWConfigParser, section: str, name: str, value: QPalette.ColorRole
527
+ ) -> None:
434
528
  """Set a palette colour value from a config string."""
435
- self._guiPalette.setColor(value, self._parseColour(parser, section, name))
529
+ self._guiPalette.setBrush(value, self._parseColor(parser, section, name))
436
530
  return
437
531
 
438
532
  def _buildStyleSheets(self, palette: QPalette) -> None:
439
533
  """Build default style sheets."""
440
534
  self._styleSheets = {}
441
535
 
442
- aPx = CONFIG.pxInt(2)
443
- bPx = CONFIG.pxInt(4)
444
- cPx = CONFIG.pxInt(6)
445
- dPx = CONFIG.pxInt(8)
446
-
447
- tCol = palette.text().color()
448
- hCol = palette.highlight().color()
536
+ text = palette.text().color()
537
+ text.setAlpha(48)
538
+ tCol = text.name(QtHexArgb)
539
+ hCol = palette.highlight().color().name(QtHexArgb)
449
540
 
450
541
  # Flat Tab Widget and Tab Bar:
451
542
  self._styleSheets[STYLES_FLAT_TABS] = (
452
543
  "QTabWidget::pane {border: 0;} "
453
- f"QTabWidget QTabBar::tab {{border: 0; padding: {bPx}px {dPx}px;}} "
454
- f"QTabWidget QTabBar::tab:selected {{color: {cssCol(hCol)};}} "
544
+ "QTabWidget QTabBar::tab {border: 0; padding: 4px 8px;} "
545
+ f"QTabWidget QTabBar::tab:selected {{color: {hCol};}} "
455
546
  )
456
547
 
457
548
  # Minimal Tool Button
458
549
  self._styleSheets[STYLES_MIN_TOOLBUTTON] = (
459
- f"QToolButton {{padding: {aPx}px; margin: 0; border: none; background: transparent;}} "
460
- f"QToolButton:hover {{border: none; background: {cssCol(tCol, 48)};}} "
550
+ "QToolButton {padding: 2px; margin: 0; border: none; background: transparent;} "
551
+ f"QToolButton:hover {{border: none; background: {tCol};}} "
461
552
  "QToolButton::menu-indicator {image: none;} "
462
553
  )
463
554
 
464
555
  # Big Tool Button
465
556
  self._styleSheets[STYLES_BIG_TOOLBUTTON] = (
466
- f"QToolButton {{padding: {cPx}px; margin: 0; border: none; background: transparent;}} "
467
- f"QToolButton:hover {{border: none; background: {cssCol(tCol, 48)};}} "
557
+ "QToolButton {padding: 6px; margin: 0; border: none; background: transparent;} "
558
+ f"QToolButton:hover {{border: none; background: {tCol};}} "
468
559
  "QToolButton::menu-indicator {image: none;} "
469
560
  )
470
561
 
@@ -473,64 +564,24 @@ class GuiTheme:
473
564
 
474
565
  class GuiIcons:
475
566
  """The icon class manages the content of the assets/icons folder,
476
- and provides a simple interface for requesting icons. Only icons
477
- listed in the ICON_KEYS are handled.
567
+ and provides a simple interface for requesting icons.
478
568
 
479
- Icons are loaded on first request, and then cached for further
480
- requests. Each icon key in the ICON_KEYS set has standard icon set
481
- in the icon theme conf file. The existence of the file, and the
482
- definition of all keys are checked when the theme is loaded.
483
-
484
- When an icon is requested, the icon is loaded and cached. If it is
485
- missing, a blank icon is returned and a warning issued.
569
+ Icons are generated from SVG on first request, and then cached for
570
+ further requests. If the icon is not defined, a placeholder icon is
571
+ returned instead.
486
572
  """
487
573
 
488
- ICON_KEYS: set[str] = {
489
- # Project and GUI Icons
490
- "novelwriter", "alert_error", "alert_info", "alert_question", "alert_warn",
491
- "build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details",
492
- "proj_document", "proj_folder", "proj_note", "proj_nwx", "proj_section", "proj_scene",
493
- "proj_stats", "proj_title", "status_idle", "status_lang", "status_lines", "status_stats",
494
- "status_time", "view_build", "view_editor", "view_novel", "view_outline", "view_search",
495
-
496
- # Class Icons
497
- "cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel",
498
- "cls_object", "cls_plot", "cls_template", "cls_timeline", "cls_trash", "cls_world",
499
-
500
- # Search Icons
501
- "search_cancel", "search_case", "search_loop", "search_preserve", "search_project",
502
- "search_regex", "search_word",
503
-
504
- # Format Icons
505
- "fmt_bold", "fmt_bold-md", "fmt_italic", "fmt_italic-md", "fmt_mark", "fmt_strike",
506
- "fmt_strike-md", "fmt_subscript", "fmt_superscript", "fmt_underline", "margin_bottom",
507
- "margin_left", "margin_right", "margin_top", "size_height", "size_width",
508
-
509
- # General Button Icons
510
- "add", "add_document", "backward", "bookmark", "browse", "checked", "close", "copy",
511
- "cross", "document", "down", "edit", "export", "font", "forward", "import", "list",
512
- "maximise", "menu", "minimise", "more", "noncheckable", "open", "panel", "quote",
513
- "refresh", "remove", "revert", "search_replace", "search", "settings", "star", "toolbar",
514
- "unchecked", "up", "view",
515
-
516
- # Switches
517
- "sticky-on", "sticky-off",
518
- "bullet-on", "bullet-off",
519
- "unfold-show", "unfold-hide",
520
-
521
- # Decorations
522
- "deco_doc_h0", "deco_doc_h1", "deco_doc_h2", "deco_doc_h3", "deco_doc_h4", "deco_doc_more",
523
- "deco_doc_h0_n", "deco_doc_h1_n", "deco_doc_h2_n", "deco_doc_h3_n", "deco_doc_h4_n",
524
- "deco_doc_nt_n",
525
- }
574
+ __slots__ = (
575
+ "_availThemes", "_headerDec", "_headerDecNarrow", "_noIcon",
576
+ "_qColors", "_qIcons", "_svgColors", "_svgData", "_themeList",
577
+ "mainTheme", "themeMeta",
578
+ )
526
579
 
527
- TOGGLE_ICON_KEYS: dict[str, tuple[str, str]] = {
528
- "sticky": ("sticky-on", "sticky-off"),
580
+ TOGGLE_ICON_KEYS: Final[dict[str, tuple[str, str]]] = {
529
581
  "bullet": ("bullet-on", "bullet-off"),
530
582
  "unfold": ("unfold-show", "unfold-hide"),
531
583
  }
532
-
533
- IMAGE_MAP: dict[str, tuple[str, str]] = {
584
+ IMAGE_MAP: Final[dict[str, tuple[str, str]]] = {
534
585
  "welcome": ("welcome-light.jpg", "welcome-dark.jpg"),
535
586
  "nw-text": ("novelwriter-text-light.svg", "novelwriter-text-dark.svg"),
536
587
  }
@@ -538,202 +589,217 @@ class GuiIcons:
538
589
  def __init__(self, mainTheme: GuiTheme) -> None:
539
590
 
540
591
  self.mainTheme = mainTheme
592
+ self.themeMeta = ThemeMeta()
541
593
 
542
594
  # Storage
595
+ self._svgData: dict[str, bytes] = {}
596
+ self._svgColors: dict[str, bytes] = {}
597
+ self._qColors: dict[str, QColor] = {}
543
598
  self._qIcons: dict[str, QIcon] = {}
544
- self._themeMap: dict[str, Path] = {}
545
599
  self._headerDec: list[QPixmap] = []
546
600
  self._headerDecNarrow: list[QPixmap] = []
547
601
 
548
602
  # Icon Theme Path
549
- self._confName = "icons.conf"
550
- self._iconPath = CONFIG.assetPath("icons")
603
+ self._availThemes: dict[str, Path] = {}
604
+ self._themeList: list[tuple[str, str]] = []
551
605
 
552
606
  # None Icon
553
- self._noIcon = QIcon(str(self._iconPath / "none.svg"))
607
+ self._noIcon = QIcon(str(CONFIG.assetPath("icons") / "none.svg"))
608
+
609
+ _listConf(self._availThemes, CONFIG.assetPath("icons"), ".icons")
610
+ _listConf(self._availThemes, CONFIG.dataPath("icons"), ".icons")
554
611
 
555
- # Icon Theme Meta
556
- self.themeName = ""
557
- self.themeDescription = ""
558
- self.themeAuthor = ""
559
- self.themeCredit = ""
560
- self.themeUrl = ""
561
- self.themeLicense = ""
562
- self.themeLicenseUrl = ""
612
+ return
563
613
 
614
+ def clear(self) -> None:
615
+ """Clear the icon cache."""
616
+ self._svgData = {}
617
+ self._svgColors = {}
618
+ self._qColors = {}
619
+ self._qIcons = {}
620
+ self._headerDec = []
621
+ self._headerDecNarrow = []
622
+ self.themeMeta = ThemeMeta()
564
623
  return
565
624
 
566
625
  ##
567
626
  # Actions
568
627
  ##
569
628
 
570
- def loadTheme(self, iconTheme: str) -> bool:
629
+ def loadTheme(self, theme: str) -> bool:
571
630
  """Update the theme map. This is more of an init, since many of
572
631
  the GUI icons cannot really be replaced without writing specific
573
632
  update functions for the classes where they're used.
574
633
  """
575
- self._themeMap = {}
576
- themePath = self._iconPath / iconTheme
577
- if not themePath.is_dir():
578
- themePath = CONFIG.dataPath("icons") / iconTheme
579
- if not themePath.is_dir():
580
- logger.warning("No icons loaded for '%s'", iconTheme)
581
- return False
582
-
583
- themeConf = themePath / self._confName
584
- logger.info("Loading icon theme '%s'", iconTheme)
585
-
586
- # Config File
587
- confParser = NWConfigParser()
634
+ if theme not in self._availThemes:
635
+ logger.error("Could not find icon theme '%s'", theme)
636
+ theme = DEF_ICONS
637
+ CONFIG.iconTheme = theme
638
+
639
+ if not (file := self._availThemes.get(theme)):
640
+ logger.error("Could not load icon theme")
641
+ return False
642
+
643
+ CONFIG.splashMessage("Loading icon theme ...")
644
+ logger.info("Loading icon theme '%s'", theme)
588
645
  try:
589
- with open(themeConf, mode="r", encoding="utf-8") as inFile:
590
- confParser.read_file(inFile)
646
+ meta = ThemeMeta()
647
+ with open(file, mode="r", encoding="utf-8") as icons:
648
+ for icon in icons:
649
+ bits = icon.partition("=")
650
+ key = bits[0].strip()
651
+ value = bits[2].strip()
652
+ if key and value:
653
+ if key.startswith("icon:"):
654
+ self._svgData[key[5:]] = value.encode("utf-8")
655
+ elif key == "meta:name":
656
+ meta.name = value
657
+ elif key == "meta:author":
658
+ meta.author = value
659
+ elif key == "meta:license":
660
+ meta.license = value
661
+ self.themeMeta = meta
591
662
  except Exception:
592
- logger.error("Could not load icon theme settings from: %s", themeConf)
663
+ logger.error("Could not read file: %s", file)
593
664
  logException()
594
665
  return False
595
666
 
596
- # Main
597
- cnfSec = "Main"
598
- if confParser.has_section(cnfSec):
599
- self.themeName = confParser.rdStr(cnfSec, "name", "")
600
- self.themeDescription = confParser.rdStr(cnfSec, "description", "")
601
- self.themeAuthor = confParser.rdStr(cnfSec, "author", "N/A")
602
- self.themeCredit = confParser.rdStr(cnfSec, "credit", "N/A")
603
- self.themeUrl = confParser.rdStr(cnfSec, "url", "")
604
- self.themeLicense = confParser.rdStr(cnfSec, "license", "N/A")
605
- self.themeLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "")
606
-
607
- # Populate Icon Map
608
- cnfSec = "Map"
609
- if confParser.has_section(cnfSec):
610
- for iconName, iconFile in confParser.items(cnfSec):
611
- if iconName not in self.ICON_KEYS:
612
- logger.error("Unknown icon name '%s' in config file", iconName)
613
- else:
614
- iconPath = themePath / iconFile
615
- if iconPath.is_file():
616
- self._themeMap[iconName] = iconPath
617
- logger.debug("Icon slot '%s' using file '%s'", iconName, iconFile)
618
- else:
619
- logger.error("Icon file '%s' not in theme folder", iconFile)
620
-
621
- # Check that icons have been defined
622
- logger.debug("Scanning theme icons")
623
- for iconKey in self.ICON_KEYS:
624
- if iconKey in ("novelwriter", "proj_nwx"):
625
- # These are not part of the theme itself
626
- continue
627
- if iconKey not in self._themeMap:
628
- logger.error("No icon file specified for '%s'", iconKey)
629
-
630
- # Refresh icons
631
- for iconKey in self._qIcons:
632
- logger.debug("Reloading icon: '%s'", iconKey)
633
- qIcon = self._loadIcon(iconKey)
634
- self._qIcons[iconKey] = qIcon
635
-
636
- self._headerDec = []
637
- self._headerDecNarrow = []
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
681
+
682
+ # Populate generated icons cache
683
+ self.getHeaderDecoration(0)
684
+ self.getHeaderDecorationNarrow(0)
638
685
 
639
686
  return True
640
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
+ return
693
+
641
694
  ##
642
695
  # Access Functions
643
696
  ##
644
697
 
645
- def loadDecoration(self, name: str, w: int | None = None, h: int | None = None) -> QPixmap:
646
- """Load graphical decoration element based on the decoration
647
- map or the icon map. This function always returns a QPixmap.
648
- """
649
- if name in self._themeMap:
650
- imgPath = self._themeMap[name]
651
- elif name in self.IMAGE_MAP:
652
- idx = 0 if self.mainTheme.isLightTheme else 1
653
- imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx]
654
- else:
655
- logger.error("Decoration with name '%s' does not exist", name)
656
- return QPixmap()
657
-
658
- if not imgPath.is_file():
659
- logger.error("Asset not found: %s", imgPath)
660
- return QPixmap()
698
+ def getIconColor(self, name: str) -> QColor:
699
+ """Return an icon color."""
700
+ return QColor(self._qColors.get(name) or QtBlack)
661
701
 
662
- pixmap = QPixmap(str(imgPath))
663
- tMode = Qt.TransformationMode.SmoothTransformation
664
- if w is not None and h is not None:
665
- return pixmap.scaled(w, h, Qt.AspectRatioMode.IgnoreAspectRatio, tMode)
666
- elif w is None and h is not None:
667
- return pixmap.scaledToHeight(h, tMode)
668
- elif w is not None and h is None:
669
- return pixmap.scaledToWidth(w, tMode)
670
-
671
- return pixmap
672
-
673
- def getIcon(self, name: str) -> QIcon:
702
+ def getIcon(self, name: str, color: str | None = None, w: int = 24, h: int = 24) -> QIcon:
674
703
  """Return an icon from the icon buffer, or load it."""
675
- if name in self._qIcons:
676
- return self._qIcons[name]
704
+ variant = f"{name}-{color}" if color else name
705
+ if (key := f"{variant}-{w}x{h}") in self._qIcons:
706
+ return self._qIcons[key]
677
707
  else:
678
- icon = self._loadIcon(name)
679
- self._qIcons[name] = icon
708
+ icon = self._loadIcon(name, color, w, h)
709
+ self._qIcons[key] = icon
710
+ logger.debug("Icon: %s", key)
680
711
  return icon
681
712
 
682
- def getToggleIcon(self, name: str, size: tuple[int, int]) -> QIcon:
683
- """Return a toggle icon from the icon buffer. or load it."""
713
+ def getToggleIcon(self, name: str, size: tuple[int, int], color: str | None = None) -> QIcon:
714
+ """Return a toggle icon from the icon buffer, or load it."""
684
715
  if name in self.TOGGLE_ICON_KEYS:
685
- pOne = self.getPixmap(self.TOGGLE_ICON_KEYS[name][0], size)
686
- pTwo = self.getPixmap(self.TOGGLE_ICON_KEYS[name][1], size)
716
+ pOne = self.getPixmap(self.TOGGLE_ICON_KEYS[name][0], size, color)
717
+ pTwo = self.getPixmap(self.TOGGLE_ICON_KEYS[name][1], size, color)
687
718
  icon = QIcon()
688
719
  icon.addPixmap(pOne, QIcon.Mode.Normal, QIcon.State.On)
689
720
  icon.addPixmap(pTwo, QIcon.Mode.Normal, QIcon.State.Off)
690
721
  return icon
691
722
  return self._noIcon
692
723
 
693
- def getPixmap(self, name: str, size: tuple[int, int]) -> QPixmap:
694
- """Return an icon from the icon buffer as a QPixmap. If it
695
- doesn't exist, return an empty QPixmap.
696
- """
697
- return self.getIcon(name).pixmap(size[0], size[1], QIcon.Mode.Normal)
698
-
699
- def getItemIcon(self, tType: nwItemType, tClass: nwItemClass,
700
- tLayout: nwItemLayout, hLevel: str = "H0") -> QIcon:
724
+ def getItemIcon(
725
+ self, tType: nwItemType, tClass: nwItemClass, tLayout: nwItemLayout, hLevel: str = "H0"
726
+ ) -> QIcon:
701
727
  """Get the correct icon for a project item based on type, class
702
728
  and heading level
703
729
  """
704
- iconName = None
730
+ name = None
731
+ color = "default"
705
732
  if tType == nwItemType.ROOT:
706
- iconName = nwLabels.CLASS_ICON[tClass]
733
+ name = nwLabels.CLASS_ICON[tClass]
734
+ color = "root"
707
735
  elif tType == nwItemType.FOLDER:
708
- iconName = "proj_folder"
736
+ name = "prj_folder"
737
+ color = "folder"
709
738
  elif tType == nwItemType.FILE:
710
- iconName = "proj_document"
711
739
  if tLayout == nwItemLayout.DOCUMENT:
712
740
  if hLevel == "H1":
713
- iconName = "proj_title"
741
+ name = "prj_title"
742
+ color = "title"
714
743
  elif hLevel == "H2":
715
- iconName = "proj_chapter"
744
+ name = "prj_chapter"
745
+ color = "chapter"
716
746
  elif hLevel == "H3":
717
- iconName = "proj_scene"
718
- elif hLevel == "H4":
719
- iconName = "proj_section"
747
+ name = "prj_scene"
748
+ color = "scene"
749
+ else:
750
+ name = "prj_document"
751
+ color = "file"
720
752
  elif tLayout == nwItemLayout.NOTE:
721
- iconName = "proj_note"
722
- if iconName is None:
753
+ name = "prj_note"
754
+ color = "note"
755
+ if name is None:
723
756
  return self._noIcon
724
757
 
725
- return self.getIcon(iconName)
758
+ return self.getIcon(name, color)
759
+
760
+ def getPixmap(self, name: str, size: tuple[int, int], color: str | None = None) -> QPixmap:
761
+ """Return an icon from the icon buffer as a QPixmap. If it
762
+ doesn't exist, return an empty QPixmap.
763
+ """
764
+ w, h = size
765
+ return self.getIcon(name, color, w, h).pixmap(w, h, QIcon.Mode.Normal)
766
+
767
+ def getDecoration(self, name: str, w: int | None = None, h: int | None = None) -> QPixmap:
768
+ """Load graphical decoration element based on the decoration
769
+ map or the icon map. This function always returns a QPixmap.
770
+ """
771
+ if name in self.IMAGE_MAP:
772
+ idx = int(self.mainTheme.isDarkTheme)
773
+ imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx]
774
+ else:
775
+ logger.error("Decoration with name '%s' does not exist", name)
776
+ return QPixmap()
777
+
778
+ if not imgPath.is_file():
779
+ logger.error("Asset not found: %s", imgPath)
780
+ return QPixmap()
781
+
782
+ pixmap = QPixmap(str(imgPath))
783
+ tMode = Qt.TransformationMode.SmoothTransformation
784
+ if w is not None and h is not None:
785
+ return pixmap.scaled(w, h, Qt.AspectRatioMode.IgnoreAspectRatio, tMode)
786
+ elif w is None and h is not None:
787
+ return pixmap.scaledToHeight(h, tMode)
788
+ elif w is not None and h is None:
789
+ return pixmap.scaledToWidth(w, tMode)
790
+
791
+ return pixmap
726
792
 
727
793
  def getHeaderDecoration(self, hLevel: int) -> QPixmap:
728
794
  """Get the decoration for a specific heading level."""
729
795
  if not self._headerDec:
730
796
  iPx = self.mainTheme.baseIconHeight
731
797
  self._headerDec = [
732
- self.loadDecoration("deco_doc_h0", h=iPx),
733
- self.loadDecoration("deco_doc_h1", h=iPx),
734
- self.loadDecoration("deco_doc_h2", h=iPx),
735
- self.loadDecoration("deco_doc_h3", h=iPx),
736
- self.loadDecoration("deco_doc_h4", h=iPx),
798
+ self._generateDecoration("file", iPx, 0),
799
+ self._generateDecoration("title", iPx, 0),
800
+ self._generateDecoration("chapter", iPx, 1),
801
+ self._generateDecoration("scene", iPx, 2),
802
+ self._generateDecoration("file", iPx, 3),
737
803
  ]
738
804
  return self._headerDec[minmax(hLevel, 0, 4)]
739
805
 
@@ -742,47 +808,86 @@ class GuiIcons:
742
808
  if not self._headerDecNarrow:
743
809
  iPx = self.mainTheme.baseIconHeight
744
810
  self._headerDecNarrow = [
745
- self.loadDecoration("deco_doc_h0_n", h=iPx),
746
- self.loadDecoration("deco_doc_h1_n", h=iPx),
747
- self.loadDecoration("deco_doc_h2_n", h=iPx),
748
- self.loadDecoration("deco_doc_h3_n", h=iPx),
749
- self.loadDecoration("deco_doc_h4_n", h=iPx),
750
- self.loadDecoration("deco_doc_nt_n", h=iPx),
811
+ self._generateDecoration("file", iPx, 0),
812
+ self._generateDecoration("title", iPx, 0),
813
+ self._generateDecoration("chapter", iPx, 0),
814
+ self._generateDecoration("scene", iPx, 0),
815
+ self._generateDecoration("file", iPx, 0),
816
+ self._generateDecoration("note", iPx, 0),
751
817
  ]
752
818
  return self._headerDecNarrow[minmax(hLevel, 0, 5)]
753
819
 
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
+
754
835
  ##
755
836
  # Internal Functions
756
837
  ##
757
838
 
758
- def _loadIcon(self, name: str) -> QIcon:
759
- """Load an icon from the assets themes folder. Is guaranteed to
760
- return a QIcon.
839
+ def _loadIcon(self, name: str, color: str | None = None, w: int = 24, h: int = 24) -> QIcon:
840
+ """Load an icon from the assets themes folder. This function is
841
+ guaranteed to return a QIcon.
761
842
  """
762
- if name not in self.ICON_KEYS:
763
- logger.error("Requested unknown icon name '%s'", name)
764
- return self._noIcon
765
-
766
843
  # If we just want the app icons, return right away
767
844
  if name == "novelwriter":
768
- return QIcon(str(self._iconPath / "novelwriter.svg"))
845
+ return QIcon(str(CONFIG.assetPath("icons") / "novelwriter.svg"))
769
846
  elif name == "proj_nwx":
770
- return QIcon(str(self._iconPath / "x-novelwriter-project.svg"))
847
+ return QIcon(str(CONFIG.assetPath("icons") / "x-novelwriter-project.svg"))
771
848
 
772
- # Otherwise, we load from the theme folder
773
- if name in self._themeMap:
774
- logger.debug("Loading: %s", self._themeMap[name].name)
775
- return QIcon(str(self._themeMap[name]))
849
+ if svg := self._svgData.get(name, b""):
850
+ if fill := self._svgColors.get(color or "default"):
851
+ svg = svg.replace(b"#000000", fill)
852
+ pixmap = QPixmap(w, h)
853
+ pixmap.fill(QtTransparent)
854
+ pixmap.loadFromData(svg, "svg")
855
+ return QIcon(pixmap)
776
856
 
777
857
  # If we didn't find one, give up and return an empty icon
778
858
  logger.warning("Did not load an icon for '%s'", name)
779
859
 
780
860
  return self._noIcon
781
861
 
862
+ def _generateDecoration(self, color: str, height: int, indent: int = 0) -> QPixmap:
863
+ """Generate a decoration pixmap for novel headers."""
864
+ pixmap = QPixmap(48*indent + 12, 48)
865
+ pixmap.fill(QtTransparent)
866
+
867
+ path = QPainterPath()
868
+ path.addRoundedRect(48.0*indent, 2.0, 12.0, 44.0, 4.0, 4.0)
869
+
870
+ painter = QPainter(pixmap)
871
+ painter.setRenderHint(QtPaintAntiAlias)
872
+ if fill := self._svgColors.get(color or "default"):
873
+ painter.fillPath(path, QColor(fill.decode(encoding="utf-8")))
874
+ painter.end()
875
+
876
+ tMode = Qt.TransformationMode.SmoothTransformation
877
+ return pixmap.scaledToHeight(height, tMode)
878
+
782
879
 
783
880
  # Module Functions
784
881
  # ================
785
882
 
883
+ def _listConf(target: dict, path: Path, extension: str) -> None:
884
+ """Scan for theme files and populate the dictionary."""
885
+ 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
+
786
891
 
787
892
  def _sortTheme(data: tuple[str, str]) -> str:
788
893
  """Key function for theme sorting."""
@@ -790,14 +895,27 @@ def _sortTheme(data: tuple[str, str]) -> str:
790
895
  return f"*{name}" if key.startswith("default_") else name
791
896
 
792
897
 
793
- def _loadInternalName(confParser: NWConfigParser, confFile: str | Path) -> str:
898
+ def _loadInternalName(parser: NWConfigParser, path: str | Path) -> str:
794
899
  """Open a conf file and read the 'name' setting."""
795
900
  try:
796
- with open(confFile, mode="r", encoding="utf-8") as inFile:
797
- confParser.read_file(inFile)
901
+ with open(path, mode="r", encoding="utf-8") as inFile:
902
+ parser.read_file(inFile)
903
+ return parser.rdStr("Main", "name", "")
798
904
  except Exception:
799
- logger.error("Could not load file: %s", confFile)
905
+ logger.error("Could not read file: %s", path)
800
906
  logException()
801
- return ""
907
+ return ""
802
908
 
803
- return confParser.rdStr("Main", "name", "")
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 ""