gajim3/gajim/gtk/css_config.py

549 lines
19 KiB
Python
Raw Normal View History

# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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 <http://www.gnu.org/licenses/>.
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 didnt 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]