themes.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. ########################################################################
  2. # Searx-Qt - Lightweight desktop application for Searx.
  3. # Copyright (C) 2020-2022 CYBERDEViL
  4. #
  5. # This file is part of Searx-Qt.
  6. #
  7. # Searx-Qt is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # Searx-Qt is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. #
  20. ########################################################################
  21. # Notes:
  22. # https://docs.python.org/3/library/json.html
  23. # https://docs.python.org/3/library/os.path.html
  24. # https://docs.python.org/3/library/sysconfig.html
  25. from PyQt5.QtWidgets import QApplication, QStyleFactory
  26. from PyQt5.QtCore import QResource
  27. import os
  28. import sysconfig # Get path prefix (example: /usr).
  29. import json # Theme manifest file is json.
  30. from searxqt import THEMES_PATH
  31. from searxqt.core.log import error, debug, warning
  32. # Paths
  33. USER_THEMES_PATH = os.path.join(
  34. sysconfig.get_config_var('userbase'),
  35. 'share/',
  36. THEMES_PATH
  37. )
  38. SYS_THEMES_PATH = os.path.join(
  39. sysconfig.get_config_var('prefix'),
  40. 'share/',
  41. THEMES_PATH
  42. )
  43. def replaceFileExt(filePath, ext):
  44. return os.path.splitext(filePath)[0] + ext
  45. class UserTheme:
  46. def __init__(
  47. self,
  48. key,
  49. name,
  50. cssFile,
  51. path,
  52. icons=None,
  53. resultsCssFile=None,
  54. failCssFile=None
  55. ):
  56. self.__key = key
  57. self.__name = name
  58. self.__path = path
  59. self.__icons = icons
  60. self.__cssFile = cssFile
  61. self.__resultsCssFile = resultsCssFile
  62. self.__failCssFile = failCssFile
  63. @property
  64. def key(self):
  65. """ This is also the directory name of the theme.
  66. """
  67. return self.__key
  68. @property
  69. def name(self):
  70. return self.__name
  71. @property
  72. def cssFile(self):
  73. return self.__cssFile
  74. @property
  75. def path(self):
  76. return self.__path
  77. @property
  78. def icons(self):
  79. return self.__icons
  80. @property
  81. def resultsCssFile(self):
  82. return self.__resultsCssFile
  83. @property
  84. def failCssFile(self):
  85. return self.__failCssFile
  86. def fullCssPath(self):
  87. return os.path.join(self.path, self.cssFile)
  88. def iconsPath(self, compiled=True):
  89. """
  90. @parem compiled: When set to True it will return path to .rcc else .qrc
  91. """
  92. if self.icons is not None:
  93. iconFile = self.icons
  94. if compiled:
  95. iconFile = replaceFileExt(iconFile, '.rcc')
  96. return os.path.join(self.path, iconFile)
  97. return ""
  98. def resultsCssPath(self):
  99. if self.resultsCssFile is not None:
  100. return os.path.join(self.path, self.resultsCssFile)
  101. return ""
  102. def failCssPath(self):
  103. if self.failCssFile is not None:
  104. return os.path.join(self.path, self.failCssFile)
  105. return ""
  106. class ThemesBase:
  107. def __init__(self):
  108. self.__currentTheme = ""
  109. self.__currentStyle = ""
  110. self.__themes = []
  111. self.__styles = []
  112. self.__resultsCss = ""
  113. self.__failCss = ""
  114. self.__loadedIcons = False
  115. """ properties
  116. """
  117. @property
  118. def currentTheme(self):
  119. return self.__currentTheme
  120. @property
  121. def currentStyle(self):
  122. return self.__currentStyle
  123. @property
  124. def themes(self):
  125. """ The available themes specific for searx-qt
  126. """
  127. return self.__themes
  128. @property
  129. def styles(self):
  130. """ The available system styles
  131. """
  132. return self.__styles
  133. @property
  134. def htmlCssResults(self):
  135. """ The css that will be includes in the results page (html).
  136. """
  137. return self.__resultsCss
  138. @property
  139. def htmlCssFail(self):
  140. """ The css that will be includes in the fail result page (html).
  141. """
  142. return self.__failCss
  143. """ class methods
  144. """
  145. def getTheme(self, key):
  146. for theme in self.themes:
  147. if theme.key == key:
  148. return theme
  149. return None
  150. def setStyle(self, name):
  151. debug(f"setSystemStyle {name}", self)
  152. qApp = QApplication.instance()
  153. if name not in self.styles:
  154. return
  155. qApp.setStyle(name)
  156. self.__currentStyle = name
  157. def setTheme(self, key):
  158. debug(f"setTheme {key}", self)
  159. qApp = QApplication.instance()
  160. # Unload old theme
  161. if self.currentTheme:
  162. # unload icons
  163. if self.__loadedIcons:
  164. self.__loadedIcons = False
  165. iconsPath = self.getTheme(self.currentTheme).iconsPath()
  166. if not QResource.unregisterResource(iconsPath):
  167. warning(f"Failed to unregister resource {iconsPath}",
  168. self)
  169. # reset values
  170. self.__resultsCss = ""
  171. self.__failCss = ""
  172. self.__loadedIcons = False
  173. self.__currentTheme = ""
  174. qApp.setStyleSheet("")
  175. if key not in [theme.key for theme in self.themes]:
  176. warning(f"Theme with key `{key}` requested but not found!", self)
  177. return
  178. if not key:
  179. # Do not set new theme.
  180. self.__currentTheme = ""
  181. return
  182. newTheme = self.getTheme(key)
  183. # Load icons
  184. if newTheme.icons:
  185. iconsPath = newTheme.iconsPath()
  186. if QResource.registerResource(iconsPath):
  187. self.__loadedIcons = True
  188. else:
  189. warning(f"Failed to register resource {iconsPath}", self)
  190. # Results CSS
  191. if newTheme.resultsCssFile:
  192. self.__resultsCss = ThemesBase.readCss(newTheme.resultsCssPath())
  193. # Fail CSS
  194. if newTheme.failCssFile:
  195. self.__failCss = ThemesBase.readCss(newTheme.failCssPath())
  196. # Load and apply the stylesheet
  197. cssFilePath = newTheme.fullCssPath()
  198. qApp.setStyleSheet(ThemesBase.readCss(cssFilePath))
  199. self.__currentTheme = key
  200. def serialize(self):
  201. return {
  202. "theme": self.currentTheme,
  203. "style": self.currentStyle
  204. }
  205. def deserialize(self, data):
  206. theme = data.get("theme", "")
  207. style = data.get("style", "")
  208. repolishNeeded = False
  209. if self.currentStyle != style and style in self.styles:
  210. self.setStyle(style)
  211. repolishNeeded = True
  212. if self.currentTheme != theme:
  213. self.setTheme(theme)
  214. repolishNeeded = True
  215. if repolishNeeded:
  216. ThemesBase.repolishAllWidgets()
  217. def populate(self):
  218. self.__themes = ThemesBase.getThemes()
  219. self.__styles = ThemesBase.getStyles()
  220. """ staticmethods
  221. """
  222. @staticmethod
  223. def readCss(path):
  224. css = ""
  225. try:
  226. cssFile = open(path, 'r')
  227. except OSError as err:
  228. warning(f"Failed to read file {path} error: {err}", ThemesBase)
  229. else:
  230. css = cssFile.read()
  231. cssFile.close()
  232. return css
  233. @staticmethod
  234. def getStyles():
  235. """ Returns a list with available system styles.
  236. """
  237. return QStyleFactory.keys()
  238. @staticmethod
  239. def getThemes():
  240. """ Will look for themes in the user's data location and system data
  241. location.
  242. Examples:
  243. user: ~/.local/searx-qt/themes
  244. sys: /usr/share/searx-qt/themes
  245. https://doc.qt.io/qt-5/qstandardpaths.html
  246. """
  247. return (
  248. ThemesBase.findThemes(USER_THEMES_PATH) +
  249. ThemesBase.findThemes(SYS_THEMES_PATH)
  250. )
  251. @staticmethod
  252. def getCurrentStyle():
  253. return QApplication.instance().style().objectName()
  254. @staticmethod
  255. def findThemes(path, lookForCompiledResource=True):
  256. """
  257. @param path: Full path to the themes directory.
  258. @type pathL str
  259. @param lookForCompiledResource:
  260. When set to True it will exclude themes that defined a .qrc file
  261. and there is no .rcc file found.
  262. When set to False it will exclude themes that defined a .qrc file
  263. but the .qrc file is not found.
  264. @type lookForCompiledResource: bool
  265. """
  266. themes = []
  267. if not os.path.exists(path):
  268. debug(f"Themes path {path} not found.", ThemesBase)
  269. return themes
  270. elif not os.path.isdir(path):
  271. debug(f"Themes path {path} is not a directory.", ThemesBase)
  272. return themes
  273. for themeDir in os.listdir(path):
  274. fullThemePath = os.path.join(path, themeDir)
  275. # Get theme manifest data
  276. manifestFilePath = os.path.join(fullThemePath, 'manifest.json')
  277. if not os.path.isfile(manifestFilePath):
  278. error(f"{manifestFilePath} not found.", ThemesBase)
  279. continue
  280. name = ""
  281. appCssFile = ""
  282. resultsCssFile = None
  283. failCssFile = None
  284. icons = None
  285. try:
  286. manifestFile = open(manifestFilePath, 'r')
  287. except OSError as err:
  288. error(f"Could not open manifest file {manifestFilePath} " \
  289. f"error: {err}", ThemesBase)
  290. continue
  291. else:
  292. try:
  293. manifestJson = json.load(manifestFile)
  294. except json.JSONDecodeError as err:
  295. error(f"Malformed manifest {manifestFilePath} {err}",
  296. ThemesBase)
  297. manifestFile.close()
  298. continue
  299. else:
  300. manifestFile.close()
  301. del manifestFile
  302. # manifest.json key: name (str)
  303. # - *name is a required key.
  304. name = manifestJson.get('name', None)
  305. if type(name) is not str:
  306. error(f"Malformed manifest {manifestFilePath} name " \
  307. "is not set or is not a string.", ThemesBase)
  308. continue
  309. styles = manifestJson.get('styles', None)
  310. if type(styles) is not dict:
  311. warning(
  312. "manifest.json key 'style' is not a dict",
  313. ThemesBase
  314. )
  315. continue
  316. # manifest.json key: stylesheet (str)
  317. # - *stylesheet is a required key.
  318. # - It should have the .css filename that should be
  319. # present in the root of the theme directory.
  320. appCssPath = styles.get('app', None)
  321. if type(appCssPath) is not str:
  322. error(
  323. "Please set a valid value for ['styles']['app']",
  324. ThemesBase
  325. )
  326. continue
  327. appCssFile = appCssPath
  328. appCssFilePath = os.path.join(fullThemePath, appCssFile)
  329. if not os.path.isfile(appCssFilePath):
  330. error(f"{appCssFilePath} not found.", ThemesBase)
  331. continue
  332. # manifest.json key: html_results (str)
  333. # manifest.json key: html_fail (str)
  334. resultsCssFile = styles.get('html_results', None)
  335. failCssFile = styles.get('html_fail', None)
  336. # manifest.json key: icons (str)
  337. # - icons is a optional key.
  338. # - Example: icons.qrc
  339. # - When set the icons.qrc file should exist in the root
  340. # of the themes directory.
  341. iconsFilePath = manifestJson.get('icons', None)
  342. if type(iconsFilePath) is str:
  343. fullIconsFilePath = os.path.join(
  344. fullThemePath,
  345. iconsFilePath
  346. )
  347. if lookForCompiledResource:
  348. # Look for .rcc instead of .qrc
  349. fullIconsFilePath = replaceFileExt(
  350. fullIconsFilePath,
  351. '.rcc'
  352. )
  353. if not os.path.isfile(fullIconsFilePath):
  354. warning("The theme defined a qrc/rcc resource " \
  355. "file but it could not be located! " \
  356. f"{fullIconsFilePath}", ThemesBase)
  357. continue
  358. icons = iconsFilePath
  359. del manifestFilePath
  360. theme = UserTheme(
  361. themeDir,
  362. name,
  363. appCssFile,
  364. fullThemePath,
  365. icons=icons,
  366. resultsCssFile=resultsCssFile,
  367. failCssFile=failCssFile
  368. )
  369. themes.append(theme)
  370. return themes
  371. @staticmethod
  372. def repolishAllWidgets():
  373. """ Call this after another style or theme is set to update the view.
  374. """
  375. qApp = QApplication.instance()
  376. for widget in qApp.allWidgets():
  377. widget.style().unpolish(widget)
  378. widget.style().polish(widget)
  379. widget.update()
  380. if __name__ == '__main__':
  381. pass
  382. else:
  383. Themes = ThemesBase()