themes.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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(os.getcwd() + "/themes/") +
  249. ThemesBase.findThemes(USER_THEMES_PATH) +
  250. ThemesBase.findThemes(SYS_THEMES_PATH)
  251. )
  252. @staticmethod
  253. def getCurrentStyle():
  254. return QApplication.instance().style().objectName()
  255. @staticmethod
  256. def findThemes(path, lookForCompiledResource=True):
  257. """
  258. @param path: Full path to the themes directory.
  259. @type pathL str
  260. @param lookForCompiledResource:
  261. When set to True it will exclude themes that defined a .qrc file
  262. and there is no .rcc file found.
  263. When set to False it will exclude themes that defined a .qrc file
  264. but the .qrc file is not found.
  265. @type lookForCompiledResource: bool
  266. """
  267. themes = []
  268. if not os.path.exists(path):
  269. debug(f"Themes path {path} not found.", ThemesBase)
  270. return themes
  271. elif not os.path.isdir(path):
  272. debug(f"Themes path {path} is not a directory.", ThemesBase)
  273. return themes
  274. for themeDir in os.listdir(path):
  275. fullThemePath = os.path.join(path, themeDir)
  276. # Get theme manifest data
  277. manifestFilePath = os.path.join(fullThemePath, 'manifest.json')
  278. if not os.path.isfile(manifestFilePath):
  279. error(f"{manifestFilePath} not found.", ThemesBase)
  280. continue
  281. name = ""
  282. appCssFile = ""
  283. resultsCssFile = None
  284. failCssFile = None
  285. icons = None
  286. try:
  287. manifestFile = open(manifestFilePath, 'r')
  288. except OSError as err:
  289. error(f"Could not open manifest file {manifestFilePath} " \
  290. f"error: {err}", ThemesBase)
  291. continue
  292. else:
  293. try:
  294. manifestJson = json.load(manifestFile)
  295. except json.JSONDecodeError as err:
  296. error(f"Malformed manifest {manifestFilePath} {err}",
  297. ThemesBase)
  298. manifestFile.close()
  299. continue
  300. else:
  301. manifestFile.close()
  302. del manifestFile
  303. # manifest.json key: name (str)
  304. # - *name is a required key.
  305. name = manifestJson.get('name', None)
  306. if type(name) is not str:
  307. error(f"Malformed manifest {manifestFilePath} name " \
  308. "is not set or is not a string.", ThemesBase)
  309. continue
  310. styles = manifestJson.get('styles', None)
  311. if type(styles) is not dict:
  312. warning(
  313. "manifest.json key 'style' is not a dict",
  314. ThemesBase
  315. )
  316. continue
  317. # manifest.json key: stylesheet (str)
  318. # - *stylesheet is a required key.
  319. # - It should have the .css filename that should be
  320. # present in the root of the theme directory.
  321. appCssPath = styles.get('app', None)
  322. if type(appCssPath) is not str:
  323. error(
  324. "Please set a valid value for ['styles']['app']",
  325. ThemesBase
  326. )
  327. continue
  328. appCssFile = appCssPath
  329. appCssFilePath = os.path.join(fullThemePath, appCssFile)
  330. if not os.path.isfile(appCssFilePath):
  331. error(f"{appCssFilePath} not found.", ThemesBase)
  332. continue
  333. # manifest.json key: html_results (str)
  334. # manifest.json key: html_fail (str)
  335. resultsCssFile = styles.get('html_results', None)
  336. failCssFile = styles.get('html_fail', None)
  337. # manifest.json key: icons (str)
  338. # - icons is a optional key.
  339. # - Example: icons.qrc
  340. # - When set the icons.qrc file should exist in the root
  341. # of the themes directory.
  342. iconsFilePath = manifestJson.get('icons', None)
  343. if type(iconsFilePath) is str:
  344. fullIconsFilePath = os.path.join(
  345. fullThemePath,
  346. iconsFilePath
  347. )
  348. if lookForCompiledResource:
  349. # Look for .rcc instead of .qrc
  350. fullIconsFilePath = replaceFileExt(
  351. fullIconsFilePath,
  352. '.rcc'
  353. )
  354. if not os.path.isfile(fullIconsFilePath):
  355. warning("The theme defined a qrc/rcc resource " \
  356. "file but it could not be located! " \
  357. f"{fullIconsFilePath}", ThemesBase)
  358. continue
  359. icons = iconsFilePath
  360. del manifestFilePath
  361. theme = UserTheme(
  362. themeDir,
  363. name,
  364. appCssFile,
  365. fullThemePath,
  366. icons=icons,
  367. resultsCssFile=resultsCssFile,
  368. failCssFile=failCssFile
  369. )
  370. themes.append(theme)
  371. return themes
  372. @staticmethod
  373. def repolishAllWidgets():
  374. """ Call this after another style or theme is set to update the view.
  375. """
  376. qApp = QApplication.instance()
  377. for widget in qApp.allWidgets():
  378. widget.style().unpolish(widget)
  379. widget.style().polish(widget)
  380. widget.update()
  381. if __name__ == '__main__':
  382. pass
  383. else:
  384. Themes = ThemesBase()