# Copyright (C) 2018 Philipp Hörist # # This file is part of Gajim. # # Gajim is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Gajim is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Gajim. If not, see . import math import logging from gi.repository import Gtk from gi.repository import Gdk from gi.repository import Pango import css_parser from gajim.common import app from gajim.common import configpaths from gajim.common.const import StyleAttr, CSSPriority from .const import Theme log = logging.getLogger('gajim.gui.css') settings = Gtk.Settings.get_default() class CSSConfig(): def __init__(self): """ CSSConfig handles loading and storing of all relevant Gajim style files The order in which CSSConfig loads the styles 1. gajim.css 2. gajim-dark.css (Only if gtk-application-prefer-dark-theme = True) 3. default.css or default-dark.css (from gajim/data/style) 4. user-theme.css (from ~/.config/Gajim/theme) # gajim.css: This is the main style and the application default # gajim-dark.css Has only entries which we want to override in gajim.css # default.css or default-dark.css Has all the values that are changeable via UI (see themes.py). Depending on `gtk-application-prefer-dark-theme` either default.css or default-dark.css gets loaded # user-theme.css These are the themes the Themes Dialog stores. Because they are loaded at last they overwrite everything else. Users should add custom css here.""" # Delete empty rules css_parser.ser.prefs.keepEmptyRules = False # Holds the currently selected theme in the Theme Editor self._pre_css = None self._pre_css_path = None # Holds the default theme, its used if values are not found # in the selected theme self._default_css = None self._default_css_path = None # Holds the currently selected theme self._css = None self._css_path = None # User Theme CSS Provider self._provider = Gtk.CssProvider() # Used for dynamic classes like account colors self._dynamic_provider = Gtk.CssProvider() self._dynamic_dict = {} self.refresh() Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), self._dynamic_provider, CSSPriority.APPLICATION) # Cache of recently requested values self._cache = {} # Holds all currently available themes self.themes = [] self.set_dark_theme() self._load_css() self._gather_available_themes() self._load_default() self._load_selected() self._activate_theme() Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), self._provider, CSSPriority.USER_THEME) @property def prefer_dark(self): setting = app.settings.get('dark_theme') if setting == Theme.SYSTEM: if settings is None: return False return settings.get_property('gtk-application-prefer-dark-theme') return setting == Theme.DARK def set_dark_theme(self, value=None): if value is None: value = app.settings.get('dark_theme') else: app.settings.set('dark_theme', value) if settings is None: return if value == Theme.SYSTEM: settings.reset_property('gtk-application-prefer-dark-theme') return settings.set_property('gtk-application-prefer-dark-theme', bool(value)) self._load_css() def _load_css(self): self._load_css_from_file('gajim.css', CSSPriority.APPLICATION) if self.prefer_dark: self._load_css_from_file('gajim-dark.css', CSSPriority.APPLICATION_DARK) self._load_css_from_file('default.css', CSSPriority.DEFAULT_THEME) if self.prefer_dark: self._load_css_from_file('default-dark.css', CSSPriority.DEFAULT_THEME_DARK) def _load_css_from_file(self, filename, priority): path = configpaths.get('STYLE') / filename try: with open(path, "r") as file_: css = file_.read() except Exception as exc: log.error('Error loading css: %s', exc) return self._activate_css(css, priority) def _activate_css(self, css, priority): try: provider = Gtk.CssProvider() provider.load_from_data(bytes(css.encode('utf-8'))) Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, priority) self._load_selected() self._activate_theme() except Exception: log.exception('Error loading application css') @staticmethod def _pango_to_css_weight(number): # Pango allows for weight values between 100 and 1000 # CSS allows only full hundred numbers like 100, 200 .. number = int(number) if number < 100: return 100 if number > 900: return 900 return int(math.ceil(number / 100.0)) * 100 def _gather_available_themes(self): files = configpaths.get('MY_THEME').iterdir() self.themes = [file.stem for file in files if file.suffix == '.css'] if 'default' in self.themes: # Ignore user created themes that are named 'default' self.themes.remove('default') def get_theme_path(self, theme, user=True): if theme == 'default' and self.prefer_dark: theme = 'default-dark' if user: return configpaths.get('MY_THEME') / f'{theme}.css' return configpaths.get('STYLE') / f'{theme}.css' def _determine_theme_path(self): # Gets the path of the currently active theme. # If it does not exist, it falls back to the default theme theme = app.settings.get('roster_theme') if theme == 'default': return self.get_theme_path(theme, user=False) theme_path = self.get_theme_path(theme) if not theme or not theme_path.exists(): log.warning('Theme %s not found, fallback to default', theme) app.settings.set('roster_theme', 'default') log.info('Use Theme: default') return self.get_theme_path('default', user=False) log.info('Use Theme: %s', theme) return theme_path def _load_selected(self, new_path=None): if new_path is None: self._css_path = self._determine_theme_path() else: self._css_path = new_path self._css = css_parser.parseFile(self._css_path) def _load_default(self): self._default_css_path = self.get_theme_path('default', user=False) self._default_css = css_parser.parseFile(self._default_css_path) def _load_pre(self, theme): log.info('Preload theme %s', theme) self._pre_css_path = self.get_theme_path(theme) self._pre_css = css_parser.parseFile(self._pre_css_path) def _write(self, pre): path = self._css_path css = self._css if pre: path = self._pre_css_path css = self._pre_css with open(path, 'w', encoding='utf-8') as file: file.write(css.cssText.decode('utf-8')) active = self._pre_css_path == self._css_path if not pre or active: self._load_selected() self._activate_theme() def set_value(self, selector, attr, value, pre=False): if attr == StyleAttr.FONT: # forward to set_font() for convenience return self.set_font(selector, value, pre) if isinstance(attr, StyleAttr): attr = attr.value css = self._css if pre: css = self._pre_css for rule in css: if rule.type != rule.STYLE_RULE: continue if rule.selectorText == selector: log.info('Set %s %s %s', selector, attr, value) rule.style[attr] = value if not pre: self._add_to_cache(selector, attr, value) self._write(pre) return None # The rule was not found, so we add it to this theme log.info('Set %s %s %s', selector, attr, value) rule = css_parser.css.CSSStyleRule(selectorText=selector) rule.style[attr] = value css.add(rule) self._write(pre) return None def set_font(self, selector, description, pre=False): css = self._css if pre: css = self._pre_css family, size, style, weight = self._get_attr_from_description( description) for rule in css: if rule.type != rule.STYLE_RULE: continue if rule.selectorText == selector: log.info('Set Font for: %s %s %s %s %s', selector, family, size, style, weight) rule.style['font-family'] = family rule.style['font-style'] = style rule.style['font-size'] = '%spt' % size rule.style['font-weight'] = weight if not pre: self._add_to_cache( selector, 'fontdescription', description) self._write(pre) return # The rule was not found, so we add it to this theme log.info('Set Font for: %s %s %s %s %s', selector, family, size, style, weight) rule = css_parser.css.CSSStyleRule(selectorText=selector) rule.style['font-family'] = family rule.style['font-style'] = style rule.style['font-size'] = '%spt' % size rule.style['font-weight'] = weight css.add(rule) self._write(pre) def _get_attr_from_description(self, description): size = description.get_size() / Pango.SCALE style = self._get_string_from_pango_style(description.get_style()) weight = self._pango_to_css_weight(int(description.get_weight())) family = description.get_family() return family, size, style, weight def _get_default_rule(self, selector, _attr): for rule in self._default_css: if rule.type != rule.STYLE_RULE: continue if rule.selectorText == selector: log.info('Get Default Rule %s', selector) return rule return None def get_font(self, selector, pre=False): if pre: css = self._pre_css else: css = self._css try: return self._get_from_cache(selector, 'fontdescription') except KeyError: pass if css is None: return None for rule in css: if rule.type != rule.STYLE_RULE: continue if rule.selectorText == selector: log.info('Get Font for: %s', selector) style = rule.style.getPropertyValue('font-style') or None size = rule.style.getPropertyValue('font-size') or None weight = rule.style.getPropertyValue('font-weight') or None family = rule.style.getPropertyValue('font-family') or None desc = self._get_description_from_css( family, size, style, weight) if not pre: self._add_to_cache(selector, 'fontdescription', desc) return desc self._add_to_cache(selector, 'fontdescription', None) return None def _get_description_from_css(self, family, size, style, weight): if family is None: return None desc = Pango.FontDescription() desc.set_family(family) if weight is not None: desc.set_weight(Pango.Weight(int(weight))) if style is not None: desc.set_style(self._get_pango_style_from_string(style)) if size is not None: desc.set_size(int(size[:-2]) * Pango.SCALE) return desc @staticmethod def _get_pango_style_from_string(style: str) -> int: if style == 'normal': return Pango.Style(0) if style == 'oblique': return Pango.Style(1) # Pango.Style.ITALIC: return Pango.Style(2) @staticmethod def _get_string_from_pango_style(style: Pango.Style) -> str: if style == Pango.Style.NORMAL: return 'normal' if style == Pango.Style.ITALIC: return 'italic' # Pango.Style.OBLIQUE: return 'oblique' def get_value(self, selector, attr, pre=False): if attr == StyleAttr.FONT: # forward to get_font() for convenience return self.get_font(selector, pre) if isinstance(attr, StyleAttr): attr = attr.value if pre: css = self._pre_css else: css = self._css try: return self._get_from_cache(selector, attr) except KeyError: pass if css is not None: for rule in css: if rule.type != rule.STYLE_RULE: continue if rule.selectorText == selector: log.info('Get %s %s: %s', selector, attr, rule.style[attr] or None) value = rule.style.getPropertyValue(attr) or None if not pre: self._add_to_cache(selector, attr, value) return value # We didn’t find the selector in the selected theme # search in default theme if not pre: rule = self._get_default_rule(selector, attr) value = rule if rule is None else rule.style[attr] self._add_to_cache(selector, attr, value) return value return None def remove_value(self, selector, attr, pre=False): if attr == StyleAttr.FONT: # forward to remove_font() for convenience return self.remove_font(selector, pre) if isinstance(attr, StyleAttr): attr = attr.value css = self._css if pre: css = self._pre_css for rule in css: if rule.type != rule.STYLE_RULE: continue if rule.selectorText == selector: log.info('Remove %s %s', selector, attr) rule.style.removeProperty(attr) break self._write(pre) return None def remove_font(self, selector, pre=False): css = self._css if pre: css = self._pre_css for rule in css: if rule.type != rule.STYLE_RULE: continue if rule.selectorText == selector: log.info('Remove Font from: %s', selector) rule.style.removeProperty('font-family') rule.style.removeProperty('font-size') rule.style.removeProperty('font-style') rule.style.removeProperty('font-weight') break self._write(pre) def change_theme(self, theme): user = not theme == 'default' theme_path = self.get_theme_path(theme, user=user) if not theme_path.exists(): log.error('Change Theme: Theme %s does not exist', theme_path) return False self._load_selected(theme_path) self._activate_theme() app.settings.set('roster_theme', theme) log.info('Change Theme: Successful switched to %s', theme) return True def change_preload_theme(self, theme): theme_path = self.get_theme_path(theme) if not theme_path.exists(): log.error('Change Preload Theme: Theme %s does not exist', theme_path) return False self._load_pre(theme) log.info('Successful switched to %s', theme) return True def rename_theme(self, old_theme, new_theme): if old_theme not in self.themes: log.error('Rename Theme: Old theme %s not found', old_theme) return False if new_theme in self.themes: log.error('Rename Theme: New theme %s exists already', new_theme) return False old_theme_path = self.get_theme_path(old_theme) new_theme_path = self.get_theme_path(new_theme) old_theme_path.rename(new_theme_path) self.themes.remove(old_theme) self.themes.append(new_theme) self._load_pre(new_theme) log.info('Rename Theme: Successful renamed theme from %s to %s', old_theme, new_theme) return True def _activate_theme(self): log.info('Activate theme') self._invalidate_cache() self._provider.load_from_data(self._css.cssText) def add_new_theme(self, theme): theme_path = self.get_theme_path(theme) if theme_path.exists(): log.error('Add Theme: %s exists already', theme_path) return False with open(theme_path, 'w', encoding='utf8'): pass self.themes.append(theme) log.info('Add Theme: Successful added theme %s', theme) return True def remove_theme(self, theme): theme_path = self.get_theme_path(theme) if theme_path.exists(): theme_path.unlink() self.themes.remove(theme) log.info('Remove Theme: Successful removed theme %s', theme) def _add_to_cache(self, selector, attr, value): self._cache[selector + attr] = value def _get_from_cache(self, selector, attr): return self._cache[selector + attr] def _invalidate_cache(self): self._cache = {} def refresh(self): css = '' accounts = app.settings.get_accounts() for index, account in enumerate(accounts): color = app.settings.get_account_setting(account, 'account_color') css_class = 'gajim_class_%s' % index css += '.%s { background-color: %s }\n' % (css_class, color) self._dynamic_dict[account] = css_class self._dynamic_provider.load_from_data(css.encode()) def get_dynamic_class(self, name): return self._dynamic_dict[name]