themes.py 15 KB

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