themes.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. ########################################################################
  2. # Searx-qt - Lightweight desktop application for SearX.
  3. # Copyright (C) 2020 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. 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(
  190. "Failed to register resource {0}".format(iconsPath),
  191. self
  192. )
  193. # Results CSS
  194. if newTheme.resultsCssFile:
  195. self.__resultsCss = ThemesBase.readCss(newTheme.resultsCssPath())
  196. # Fail CSS
  197. if newTheme.failCssFile:
  198. self.__failCss = ThemesBase.readCss(newTheme.failCssPath())
  199. # Load and apply the stylesheet
  200. cssFilePath = newTheme.fullCssPath()
  201. qApp.setStyleSheet(ThemesBase.readCss(cssFilePath))
  202. self.__currentTheme = key
  203. def serialize(self):
  204. return {
  205. "theme": self.currentTheme,
  206. "style": self.currentStyle
  207. }
  208. def deserialize(self, data):
  209. theme = data.get("theme", "")
  210. style = data.get("style", "")
  211. repolishNeeded = False
  212. if self.currentStyle != style and style in self.styles:
  213. self.setStyle(style)
  214. repolishNeeded = True
  215. if self.currentTheme != theme:
  216. self.setTheme(theme)
  217. repolishNeeded = True
  218. if repolishNeeded:
  219. ThemesBase.repolishAllWidgets()
  220. def populate(self):
  221. self.__themes = ThemesBase.getThemes()
  222. self.__styles = ThemesBase.getStyles()
  223. """ staticmethods
  224. """
  225. @staticmethod
  226. def readCss(path):
  227. css = ""
  228. try:
  229. cssFile = open(path, 'r')
  230. except OSError as err:
  231. warning(
  232. "Failed to read file {0} error: {1}".format(path, err),
  233. ThemesBase
  234. )
  235. else:
  236. css = cssFile.read()
  237. cssFile.close()
  238. return css
  239. @staticmethod
  240. def getStyles():
  241. """ Returns a list with available system styles.
  242. """
  243. return QStyleFactory.keys()
  244. @staticmethod
  245. def getThemes():
  246. """ Will look for themes in the user's data location and system data
  247. location.
  248. Examples:
  249. user: ~/.local/searx-qt/themes
  250. sys: /usr/share/searx-qt/themes
  251. https://doc.qt.io/qt-5/qstandardpaths.html
  252. """
  253. return (
  254. ThemesBase.findThemes(USER_THEMES_PATH) +
  255. ThemesBase.findThemes(SYS_THEMES_PATH)
  256. )
  257. @staticmethod
  258. def getCurrentStyle():
  259. return QApplication.instance().style().objectName()
  260. @staticmethod
  261. def findThemes(path, lookForCompiledResource=True):
  262. """
  263. @param path: Full path to the themes directory.
  264. @type pathL str
  265. @param lookForCompiledResource:
  266. When set to True it will exclude themes that defined a .qrc file
  267. and there is no .rcc file found.
  268. When set to False it will exclude themes that defined a .qrc file
  269. but the .qrc file is not found.
  270. @type lookForCompiledResource: bool
  271. """
  272. themes = []
  273. if not os.path.exists(path):
  274. debug("Themes path {} not found.".format(path), ThemesBase)
  275. return themes
  276. elif not os.path.isdir(path):
  277. debug(
  278. "Themes path {} is not a directory.".format(path),
  279. ThemesBase
  280. )
  281. return themes
  282. for themeDir in os.listdir(path):
  283. fullThemePath = os.path.join(path, themeDir)
  284. # Get theme manifest data
  285. manifestFilePath = os.path.join(fullThemePath, 'manifest.json')
  286. if not os.path.isfile(manifestFilePath):
  287. error("{0} not found.".format(manifestFilePath), ThemesBase)
  288. continue
  289. name = ""
  290. appCssFile = ""
  291. resultsCssFile = None
  292. failCssFile = None
  293. icons = None
  294. try:
  295. manifestFile = open(manifestFilePath, 'r')
  296. except OSError as err:
  297. error(
  298. "Could not open manifest file {0} error: {1}"
  299. .format(manifestFilePath, err),
  300. ThemesBase
  301. )
  302. continue
  303. else:
  304. try:
  305. manifestJson = json.load(manifestFile)
  306. except json.JSONDecodeError as err:
  307. error(
  308. "Malformed manifest {0} {1}"
  309. .format(manifestFilePath, err),
  310. ThemesBase
  311. )
  312. manifestFile.close()
  313. continue
  314. else:
  315. manifestFile.close()
  316. del manifestFile
  317. # manifest.json key: name (str)
  318. # - *name is a required key.
  319. name = manifestJson.get('name', None)
  320. if type(name) is not str:
  321. error(
  322. "Malformed manifest {0} name is not set or is not"
  323. " a string.".format(manifestFilePath),
  324. ThemesBase
  325. )
  326. continue
  327. styles = manifestJson.get('styles', None)
  328. if type(styles) is not dict:
  329. warning(
  330. "manifest.json key 'style' is not a dict",
  331. ThemesBase
  332. )
  333. continue
  334. # manifest.json key: stylesheet (str)
  335. # - *stylesheet is a required key.
  336. # - It should have the .css filename that should be
  337. # present in the root of the theme directory.
  338. appCssPath = styles.get('app', None)
  339. if type(appCssPath) is not str:
  340. error(
  341. "Please set a valid value for ['styles']['app']",
  342. ThemesBase
  343. )
  344. continue
  345. appCssFile = appCssPath
  346. appCssFilePath = os.path.join(fullThemePath, appCssFile)
  347. if not os.path.isfile(appCssFilePath):
  348. error(
  349. "{0} not found.".format(appCssFilePath),
  350. ThemesBase
  351. )
  352. continue
  353. # manifest.json key: html_results (str)
  354. # manifest.json key: html_fail (str)
  355. resultsCssFile = styles.get('html_results', None)
  356. failCssFile = styles.get('html_fail', None)
  357. # manifest.json key: icons (str)
  358. # - icons is a optional key.
  359. # - Example: icons.qrc
  360. # - When set the icons.qrc file should exist in the root
  361. # of the themes directory.
  362. iconsFilePath = manifestJson.get('icons', None)
  363. if type(iconsFilePath) is str:
  364. fullIconsFilePath = os.path.join(
  365. fullThemePath,
  366. iconsFilePath
  367. )
  368. if lookForCompiledResource:
  369. # Look for .rcc instead of .qrc
  370. fullIconsFilePath = replaceFileExt(
  371. fullIconsFilePath,
  372. '.rcc'
  373. )
  374. if not os.path.isfile(fullIconsFilePath):
  375. warning(
  376. "The theme defined a qrc/rcc resource file but"
  377. " it could not be located! {0}"
  378. .format(fullIconsFilePath),
  379. ThemesBase
  380. )
  381. continue
  382. icons = iconsFilePath
  383. del manifestFilePath
  384. theme = UserTheme(
  385. themeDir,
  386. name,
  387. appCssFile,
  388. fullThemePath,
  389. icons=icons,
  390. resultsCssFile=resultsCssFile,
  391. failCssFile=failCssFile
  392. )
  393. themes.append(theme)
  394. return themes
  395. @staticmethod
  396. def repolishAllWidgets():
  397. """ Call this after another style or theme is set to update the view.
  398. """
  399. qApp = QApplication.instance()
  400. for widget in qApp.allWidgets():
  401. widget.style().unpolish(widget)
  402. widget.style().polish(widget)
  403. widget.update()
  404. if __name__ == '__main__':
  405. pass
  406. else:
  407. Themes = ThemesBase()