# -*- coding: utf-8 -*- # Copyright © 2011-2014, 2016 marmuta # # This file is part of Onboard. # # Onboard 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. # # Onboard 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 this program. If not, see . """ Module for theme related classes. """ from __future__ import division, print_function, unicode_literals ### Logging ### import logging _logger = logging.getLogger("Appearance") ############### import xml from xml.dom import minidom import sys import os import re import colorsys from math import log from Onboard import Exceptions from Onboard.utils import hexstring_to_float, brighten, toprettyxml, \ TreeItem, Version, unicode_str, open_utf8, \ XDGDirs import Onboard.utils as utils ### Config Singleton ### from Onboard.Config import Config config = Config() ######################## class Theme: """ Theme controls the visual appearance of Onboards keyboard window. """ # onboard 0.95 THEME_FORMAT_INITIAL = Version(1, 0) # onboard 0.97, added key_size, switch most int values to float, # changed range of key_gradient_direction THEME_FORMAT_1_1 = Version(1, 1) # onboard 0.98, added shadow keys THEME_FORMAT_1_2 = Version(1, 2) # onboard 0.99, added key_stroke_width THEME_FORMAT_1_3 = Version(1, 3) THEME_FORMAT = THEME_FORMAT_1_3 # core theme members # name, type, default attributes = [ ["color_scheme_basename", "s", ""], ["background_gradient", "d", 0.0], ["key_style", "s", "flat"], ["roundrect_radius", "d", 0.0], ["key_size", "d", 100.0], ["key_stroke_width", "d", 100.0], ["key_fill_gradient", "d", 0.0], ["key_stroke_gradient", "d", 0.0], ["key_gradient_direction", "d", 0.0], ["key_label_font", "s", ""], ["key_label_overrides", "a{s[ss]}", {}], # dict {name:(key:group)} ["key_shadow_strength", "d", 0.0], ["key_shadow_size", "d", 0.0], ] def __init__(self): self._modified = False self._filename = "" self._is_system = False # True if this a system theme self._system_exists = False # True if there exists a system # theme with the same basename self._name = "" # create attributes for name, _type, default in self.attributes: setattr(self, name, default) @property def basename(self): """ Returns the file base name of the theme. """ return os.path.splitext(os.path.basename(self._filename))[0] @property def filename(self): """ Returns the filename of the theme. """ return self._filename def __eq__(self, other): if not other: return False for name, _type, _default in self.attributes: if getattr(self, name) != getattr(other, name): return False return True def __str__(self): return "name=%s, colors=%s, font=%s, radius=%d" % (self._name, self.color_scheme_basename, self.key_label_font, self.roundrect_radius) def apply(self, save=True): """ Applies the theme to config properties/gsettings. """ filename = self.get_color_scheme_filename() if not filename: _logger.error(_format("Color scheme for theme '{filename}' not found", \ filename=self._filename)) return False config.theme_settings.set_color_scheme_filename(filename, save) for name, _type, _default in self.attributes: if name != "color_scheme_basename": getattr(config.theme_settings, "set_" + name) \ (getattr(self, name), save) return True def get_color_scheme_filename(self): """ Returns the filename of the themes color scheme.""" filename = os.path.join(Theme.user_path(), self.color_scheme_basename) + \ "." + ColorScheme.extension() if not os.path.isfile(filename): filename = os.path.join(Theme.system_path(), self.color_scheme_basename) + \ "." + ColorScheme.extension() if not os.path.isfile(filename): return None return filename def set_color_scheme_filename(self, filename): """ Set the filename of the color_scheme. """ self.color_scheme_basename = \ os.path.splitext(os.path.basename(filename ))[0] def get_superkey_label(self): """ Returns the (potentially overridden) label of the super keys. """ override = self.key_label_overrides.get("LWIN") if override: return override[0] # assumes RWIN=LWIN return None def get_superkey_size_group(self): """ Returns the (potentially overridden) size group of the super keys. """ override = self.key_label_overrides.get("LWIN") if override: return override[1] # assumes RWIN=LWIN return None def set_superkey_label(self, label, size_group): """ Sets or clears the override for left and right super key labels. """ tuples = self.key_label_overrides if label is None: if "LWIN" in tuples: del tuples["LWIN"] if "RWIN" in tuples: del tuples["RWIN"] else: tuples["LWIN"] = (label, size_group) tuples["RWIN"] = (label, size_group) self.key_label_overrides = tuples @staticmethod def system_to_user_filename(filename): """ Returns the user filename for the given system filename. """ basename = os.path.splitext(os.path.basename(filename ))[0] return os.path.join(Theme.user_path(), basename) + "." + Theme.extension() @staticmethod def build_user_filename(basename): """ Returns a fully qualified filename pointing into the user directory """ return os.path.join(Theme.user_path(), basename) + "." + Theme.extension() @staticmethod def build_system_filename(basename): """ Returns a fully qualified filename pointing into the system directory """ return os.path.join(Theme.system_path(), basename) + "." + Theme.extension() @staticmethod def user_path(): """ Returns the path of the user directory for themes. """ return os.path.join(config.user_dir, "themes") @staticmethod def system_path(): """ Returns the path of the system directory for themes. """ return os.path.join(config.install_dir, "themes") @staticmethod def extension(): """ Returns the file extension of theme files """ return "theme" @staticmethod def load_merged_themes(): """ Merge system and user themes. User themes take precedence and hide system themes. """ system_themes = Theme.load_themes(True) user_themes = Theme.load_themes(False) themes = dict((t.basename, (t, None)) for t in system_themes) for theme in user_themes: # system theme hidden behind user theme? if theme.basename in themes: # keep the system theme behind the user theme themes[theme.basename] = (theme, themes[theme.basename][0]) else: themes[theme.basename] = (theme, None) return themes @staticmethod def load_themes(is_system=False): """ Load all themes from either the user or the system directory. """ themes = [] if is_system: path = Theme.system_path() else: path = Theme.user_path() filenames = Theme.find_themes(path) for filename in filenames: theme = Theme.load(filename, is_system) if theme: themes.append(theme) return themes @staticmethod def find_themes(path): """ Returns the full path names of all themes found in the given path. """ themes = [] try: files = os.listdir(path) except OSError: files = [] for filename in files: if filename.endswith(Theme.extension()): themes.append(os.path.join(path, filename)) return themes @staticmethod def load(filename, is_system=False): """ Load a theme and return a new theme object. """ result = None _file = open_utf8(filename) try: domdoc = minidom.parse(_file).documentElement try: theme = Theme() node = domdoc.attributes.get("format") format = Version.from_string(node.value) \ if node else Theme.THEME_FORMAT_INITIAL theme.name = domdoc.attributes["name"].value # "color_scheme" is the base file name of the color scheme text = utils.xml_get_text(domdoc, "color_scheme") if not text is None: theme.color_scheme_basename = text # get key label overrides nodes = domdoc.getElementsByTagName("key_label_overrides") if nodes: overrides = nodes[0] tuples = {} for override in overrides.getElementsByTagName("key"): key_id = override.attributes["id"].value node = override.attributes.get("label") label = node.value if node else "" node = override.attributes.get("group") group = node.value if node else "" tuples[key_id] = (label, group) theme.key_label_overrides = tuples # read all other members for name, _type, _default in Theme.attributes: if not name in ["color_scheme_basename", "key_label_overrides"]: value = utils.xml_get_text(domdoc, name) if not value is None: if _type == "i": value = int(value) if _type == "d": value = float(value) if _type == "ad": value = [float(s) for s in value.split(",")] # upgrade to current file format if format < Theme.THEME_FORMAT_1_1: # direction was 0..360, ccw # is now -180..180, cw if name == "key_gradient_direction": value = -(value % 360) if value <= -180: value += 360 setattr(theme, name, value) theme._filename = filename theme.is_system = is_system theme.system_exists = is_system result = theme finally: domdoc.unlink() except (Exceptions.ThemeFileError, xml.parsers.expat.ExpatError) as ex: _logger.error(_format("Error loading theme '{filename}'. " "{exception}: {cause}", filename = filename, exception = type(ex).__name__, cause = unicode_str(ex))) result = None finally: _file.close() return result def save_as(self, basename, name): """ Save this theme under a new name. """ self._filename = self.build_user_filename(basename) self._name = name self.save() def save(self): """ Save this theme. """ domdoc = minidom.Document() try: theme_element = domdoc.createElement("theme") theme_element.setAttribute("name", self._name) theme_element.setAttribute("format", str(self.THEME_FORMAT)) domdoc.appendChild(theme_element) for name, _type, _default in self.attributes: if name == "color_scheme_basename": element = domdoc.createElement("color_scheme") text = domdoc.createTextNode(self.color_scheme_basename) element.appendChild(text) theme_element.appendChild(element) elif name == "key_label_overrides": overrides_element = \ domdoc.createElement("key_label_overrides") theme_element.appendChild(overrides_element) tuples = self.key_label_overrides for key_id, values in list(tuples.items()): element = domdoc.createElement("key") element.setAttribute("id", key_id) element.setAttribute("label", values[0]) element.setAttribute("group", values[1]) overrides_element.appendChild(element) else: value = getattr(self, name) if _type == "s": pass elif _type == "i": value = str(value) elif _type == "d": value = str(round(float(value), 2)) elif _type == "ad": value = ", ".join(str(d) for d in value) else: assert(False) # attribute of unknown type element = domdoc.createElement(name) text = domdoc.createTextNode(value) element.appendChild(text) theme_element.appendChild(element) pretty_xml = toprettyxml(domdoc) XDGDirs.assure_user_dir_exists(self.user_path()) with open_utf8(self._filename, "w") as _file: if sys.version_info.major >= 3: _file.write(pretty_xml) else: _file.write(pretty_xml.encode("UTF-8")) except Exception as xxx_todo_changeme2: (ex) = xxx_todo_changeme2 raise Exceptions.ThemeFileError(_("Error saving ") + self._filename, chained_exception = ex) finally: domdoc.unlink() class ColorScheme(object): """ ColorScheme defines the colors of onboards keyboard. Each key or groups of keys may have their own individual colors. Any color definition may be omitted. Undefined colors fall back to color scheme defaults first, then to hard coded default colors. """ # onboard 0.95 COLOR_SCHEME_FORMAT_LEGACY = Version(1, 0) # onboard 0.97, tree format, rule-based color matching COLOR_SCHEME_FORMAT_TREE = Version(2, 0) # onboard 0.99, added window colors COLOR_SCHEME_WINDOW_COLORS = Version(2, 1) COLOR_SCHEME_FORMAT = COLOR_SCHEME_WINDOW_COLORS def __init__(self): self._filename = "" self._is_system = False self._root = None # tree root @property def basename(self): """ Returns the file base name of the color scheme. """ return os.path.splitext(os.path.basename(self._filename))[0] @property def filename(self): """ Returns the filename of the color scheme. """ return self._filename def is_key_in_scheme(self, key): for id in [key.theme_id, key.id]: if self._root.find_key_id(id): return True return False def get_key_rgba(self, key, element, state = None): """ Get the color for the given key element and optionally key state. If is None the key state is retrieved from . """ if state is None: state = key.get_state() state["insensitive"] = not key.sensitive del state["sensitive"] rgb = None opacity = None root_rgb = None root_opacity = None key_group = None # First try to find the theme_id then fall back to the generic id ids = [key.theme_id, key.id] # Let numbered keys fall back to their base id, e.g. instead # of prediction0, prediction1,... have only "prediction" in # the color scheme. if key.id == "correctionsbg": ids.append("wordlist") elif key.id == "predictionsbg": ids.append("wordlist") elif key.is_prediction_key(): ids.append("prediction") elif key.is_correction_key(): ids.append("correction") elif key.is_layer_button(): ids.append(key.get_similar_theme_id("layer")) ids.append("layer") # look for a matching key_group and color in the color scheme for id in ids: key_group = self._root.find_key_id(id) if key_group: rgb, opacity = key_group.find_element_color(element, state) break # Get root colors as fallback for the case when key id # wasn't mentioned anywhere in the color scheme. root_key_group = self._root.get_default_key_group() if root_key_group: root_rgb, root_opacity = \ root_key_group.find_element_color(element, state) # Special case for layer buttons: # don't take fill color from the root group, # we want the layer fill color instead (via get_key_default_rgba()). if element == "fill" and key.is_layer_button() or \ element == "label" and key.is_correction_key(): # Don't pick layer fill opacity when there is # an rgb color defined in the color scheme. if not rgb is None and \ opacity is None: opacity = root_opacity if opacity is None: opacity = 1.0 elif key_group is None: # All other colors fall back to the root group's colors rgb = root_rgb opacity = root_opacity if rgb is None: rgb = self.get_key_default_rgba(key, element, state)[:3] if opacity is None: opacity = self.get_key_default_rgba(key, element, state)[3] rgba = rgb + [opacity] return rgba def get_key_default_rgba(self, key, element, state): colors = { "fill": [0.9, 0.85, 0.7, 1.0], "prelight": [0.0, 0.0, 0.0, 1.0], "pressed": [0.6, 0.6, 0.6, 1.0], "active": [0.5, 0.5, 0.5, 1.0], "locked": [1.0, 0.0, 0.0, 1.0], "scanned": [0.45, 0.45, 0.7, 1.0], "stroke": [0.0, 0.0, 0.0, 1.0], "label": [0.0, 0.0, 0.0, 1.0], "secondary-label": [0.5, 0.5, 0.5, 1.0], "dwell-progress": [0.82, 0.19, 0.25, 1.0], "correction-label": [1.0, 0.5, 0.5, 1.0], } rgba = [0.0, 0.0, 0.0, 1.0] if element == "fill": if key.is_layer_button() and \ not any(state.values()): # Special case for base fill color of layer buttons: # default color is layer fill color (as in onboard <=0.95). layer_index = key.get_layer_index() rgba = self.get_layer_fill_rgba(layer_index) elif state.get("pressed"): new_state = dict(list(state.items())) new_state["pressed"] = False rgba = self.get_key_rgba(key, element, new_state) # Make the default pressed color a slightly darker # or brighter variation of the unpressed color. h, l, s = colorsys.rgb_to_hls(*rgba[:3]) # boost lightness changes for very dark and very bright colors # Ad-hoc formula, purly for aesthetics amount = -(log((l+.001)*(1-(l-.001))))*0.05 + 0.08 if l < .5: # dark color? rgba = brighten(+amount, *rgba) # brigther else: rgba = brighten(-amount, *rgba) # darker elif state.get("scanned"): rgba = colors["scanned"] # Make scanned active modifier keys stick out by blending # scanned color with non-scanned color. if state.get("active"): # includes locked # inactive scanned color new_state = dict(list(state.items())) new_state["active"] = False new_state["locked"] = False scanned = self.get_key_rgba(key, element, new_state) # unscanned fill color new_state = dict(list(state.items())) new_state["scanned"] = False fill = self.get_key_rgba(key, element, new_state) # blend inactive scanned color with unscanned fill color for i in range(4): rgba[i] = (scanned[i] + fill[i]) / 2.0 elif state.get("prelight"): rgba = colors["prelight"] elif state.get("locked"): rgba = colors["locked"] elif state.get("active"): rgba = colors["active"] else: rgba = colors["fill"] elif element == "stroke": rgba == colors["stroke"] elif element == "label": if key.is_correction_key(): rgba = colors["correction-label"] else: rgba = colors["label"] # dim label color for insensitive keys if state.get("insensitive"): rgba = self._get_insensitive_color(key, state, element) elif element == "secondary-label": rgba = colors["secondary-label"] # dim label color for insensitive keys if state.get("insensitive"): rgba = self._get_insensitive_color(key, state, element) elif element == "dwell-progress": rgba = colors["dwell-progress"] else: assert(False) # unknown element return rgba def _get_insensitive_color(self, key, state, element): new_state = state.copy() new_state["insensitive"] = False fill = self.get_key_rgba(key, "fill", new_state) rgba = self.get_key_rgba(key, element, new_state) h, lf, s = colorsys.rgb_to_hls(*fill[:3]) h, ll, s = colorsys.rgb_to_hls(*rgba[:3]) # Leave only one third of the lightness difference # between label and fill color. amount = (ll - lf) * 2.0 / 3.0 return brighten(-amount, *rgba) def get_window_rgba(self, window_type, element): """ Returns window colors. window_type may be "keyboard" or "key-popup". element may be "border" """ rgb = None opacity = None windows = self._root.get_windows() window = None for item in windows: if item.type == window_type: window = item break if window: for item in window.items: if item.is_color() and \ item.element == element: rgb = item.rgb opacity = item.opacity break if rgb is None: rgb = [1.0, 1.0, 1.0] if opacity is None: opacity = 1.0 rgba = rgb + [opacity] return rgba def get_layer_fill_rgba(self, layer_index): """ Returns the background fill color of the layer with the given index. """ rgb = None opacity = None layers = self._root.get_layers() # If there is no layer definition for this index, # repeat the last defined layer color. layer_index = min(layer_index, len(layers) - 1) if layer_index >= 0 and layer_index < len(layers): for item in layers[layer_index].items: if item.is_color() and \ item.element == "background": rgb = item.rgb opacity = item.opacity break if rgb is None: rgb = [0.5, 0.5, 0.5] if opacity is None: opacity = 1.0 rgba = rgb + [opacity] return rgba def get_icon_rgba(self, element): """ Returns the color for the given element of the icon. """ rgb = None opacity = None icons = self._root.get_icons() for icon in icons: for item in icon.items: if item.is_color() and \ item.element == element: rgb = item.rgb opacity = item.opacity break # default icon background is layer0 background if element == "background": # hard-coded default is the most common color rgba_default = [0.88, 0.88, 0.88, 1.0] else: assert(False) if rgb is None: rgb = rgba_default[:3] if opacity is None: opacity = rgba_default[3] if rgb is None: rgb = [0.5, 0.5, 0.5] if opacity is None: opacity = 1.0 rgba = rgb + [opacity] return rgba @staticmethod def user_path(): """ Returns the path of the user directory for color schemes. """ return os.path.join(config.user_dir, "themes") @staticmethod def system_path(): """ Returns the path of the system directory for color schemes. """ return os.path.join(config.install_dir, "themes") @staticmethod def extension(): """ Returns the file extension of color scheme files """ return "colors" @staticmethod def get_merged_color_schemes(): """ Merge system and user color schemes. User color schemes take precedence and hide system color schemes. """ system_color_schemes = ColorScheme.load_color_schemes(True) user_color_schemes = ColorScheme.load_color_schemes(False) color_schemes = dict((t.basename, t) for t in system_color_schemes) for scheme in user_color_schemes: color_schemes[scheme.basename] = scheme return color_schemes @staticmethod def load_color_schemes(is_system=False): """ Load all color schemes from either the user or the system directory. """ color_schemes = [] if is_system: path = ColorScheme.system_path() else: path = ColorScheme.user_path() filenames = ColorScheme.find_color_schemes(path) for filename in filenames: color_scheme = ColorScheme.load(filename, is_system) if color_scheme: color_schemes.append(color_scheme) return color_schemes @staticmethod def find_color_schemes(path): """ Returns the full path names of all color schemes found in the given path. """ color_schemes = [] try: files = os.listdir(path) except OSError: files = [] for filename in files: if filename.endswith(ColorScheme.extension()): color_schemes.append(os.path.join(path, filename)) return color_schemes @staticmethod def load(filename, is_system=False): """ Load a color scheme and return it as a new instance. """ color_scheme = None f = open_utf8(filename) try: dom = minidom.parse(f).documentElement name = dom.attributes["name"].value # check layout format format = ColorScheme.COLOR_SCHEME_FORMAT_LEGACY if dom.hasAttribute("format"): format = Version.from_string(dom.attributes["format"].value) if format >= ColorScheme.COLOR_SCHEME_FORMAT_TREE: # tree format? items = ColorScheme._parse_dom_node(dom, None, {}) else: _logger.warning(_format( \ "Loading legacy color scheme format '{old_format}', " "please consider upgrading to current format " "'{new_format}': '{filename}'", old_format = format, new_format = ColorScheme.COLOR_SCHEME_FORMAT, filename = filename)) items = ColorScheme._parse_legacy_color_scheme(dom) if not items is None: root = Root() root.set_items(items) color_scheme = ColorScheme() color_scheme.name = name color_scheme._filename = filename color_scheme.is_system = is_system color_scheme._root = root #print(root.dumps()) except xml.parsers.expat.ExpatError as ex: _logger.error(_format("Error loading color scheme '{filename}'. " "{exception}: {cause}", filename = filename, exception = type(ex).__name__, cause = unicode_str(ex))) finally: f.close() return color_scheme @staticmethod def _parse_dom_node(dom_node, parent_item, used_keys): """ Recursive function to parse all dom nodes of the layout tree """ items = [] for child in dom_node.childNodes: if child.nodeType == minidom.Node.ELEMENT_NODE: if child.tagName == "window": item = ColorScheme._parse_window(child) elif child.tagName == "layer": item = ColorScheme._parse_layer(child) elif child.tagName == "icon": item = ColorScheme._parse_icon(child) elif child.tagName == "key_group": item = ColorScheme._parse_key_group(child, used_keys) elif child.tagName == "color": item = ColorScheme._parse_color(child) else: item = None if item: item.parent = parent_item item.items = ColorScheme._parse_dom_node(child, item, used_keys) items.append(item) return items @staticmethod def _parse_dom_node_item(node, item): """ Parses common properties of all items """ if node.hasAttribute("id"): item.id = node.attributes["id"].value @staticmethod def _parse_window(node): item = Window() if node.hasAttribute("type"): item.type = node.attributes["type"].value ColorScheme._parse_dom_node_item(node, item) return item @staticmethod def _parse_layer(node): item = Layer() ColorScheme._parse_dom_node_item(node, item) return item @staticmethod def _parse_icon(node): item = Icon() ColorScheme._parse_dom_node_item(node, item) return item _key_ids_pattern = re.compile('[\w-]+(?:[.][\w-]+)?', re.UNICODE) @staticmethod def _parse_key_group(node, used_keys): item = KeyGroup() ColorScheme._parse_dom_node_item(node, item) # read key ids text = "".join([n.data for n in node.childNodes \ if n.nodeType == n.TEXT_NODE]) ids = [id for id in ColorScheme._key_ids_pattern.findall(text) if id] # check for duplicate key definitions for key_id in ids: if key_id in used_keys: raise ValueError(_format("Duplicate key_id '{}' found " "in color scheme file. " "Key_ids must occur only once.", key_id)) used_keys.update(list(zip(ids, ids))) item.key_ids = ids return item @staticmethod def _parse_color(node): item = KeyColor() ColorScheme._parse_dom_node_item(node, item) if node.hasAttribute("element"): item.element = node.attributes["element"].value if node.hasAttribute("rgb"): value = node.attributes["rgb"].value item.rgb = [hexstring_to_float(value[1:3])/255, hexstring_to_float(value[3:5])/255, hexstring_to_float(value[5:7])/255] if node.hasAttribute("opacity"): item.opacity = float(node.attributes["opacity"].value) state = {} ColorScheme._parse_state_attibute(node, "prelight", state) ColorScheme._parse_state_attibute(node, "pressed", state) ColorScheme._parse_state_attibute(node, "active", state) ColorScheme._parse_state_attibute(node, "locked", state) ColorScheme._parse_state_attibute(node, "insensitive", state) ColorScheme._parse_state_attibute(node, "scanned", state) item.state = state return item @staticmethod def _parse_state_attibute(node, name, state): if node.hasAttribute(name): value = node.attributes[name].value == "true" state[name] = value if name == "locked" and value: state["active"] = True # locked implies active ########################################################################### @staticmethod def _parse_legacy_color_scheme(dom_node): """ Load a color scheme and return it as a new object. """ color_defaults = { "fill": [0.0, 0.0, 0.0, 1.0], "hovered": [0.0, 0.0, 0.0, 1.0], "pressed": [0.6, 0.6, 0.6, 1.0], "pressed-latched": [0.6, 0.6, 0.6, 1.0], "pressed-locked": [0.6, 0.6, 0.6, 1.0], "latched": [0.5, 0.5, 0.5, 1.0], "locked": [1.0, 0.0, 0.0, 1.0], "scanned": [0.45, 0.45, 0.7, 1.0], "stroke": [0.0, 0.0, 0.0, 1.0], "stroke-hovered": [0.0, 0.0, 0.0, 1.0], "stroke-pressed": [0.0, 0.0, 0.0, 1.0], "stroke-pressed-latched": [0.0, 0.0, 0.0, 1.0], "stroke-pressed-locked": [0.0, 0.0, 0.0, 1.0], "stroke-latched": [0.0, 0.0, 0.0, 1.0], "stroke-locked": [0.0, 0.0, 0.0, 1.0], "stroke-scanned": [0.0, 0.0, 0.0, 1.0], "label": [0.0, 0.0, 0.0, 1.0], "label-hovered": [0.0, 0.0, 0.0, 1.0], "label-pressed": [0.0, 0.0, 0.0, 1.0], "label-pressed-latched": [0.0, 0.0, 0.0, 1.0], "label-pressed-locked": [0.0, 0.0, 0.0, 1.0], "label-latched": [0.0, 0.0, 0.0, 1.0], "label-locked": [0.0, 0.0, 0.0, 1.0], "label-scanned": [0.0, 0.0, 0.0, 1.0], "dwell-progress": [0.82, 0.19, 0.25, 1.0], } items = [] # layer colors layers = dom_node.getElementsByTagName("layer") if not layers: # Still accept "pane" for backwards compatibility layers = dom_node.getElementsByTagName("pane") for i, layer in enumerate(layers): attrib = "fill" rgb = None opacity = None color = KeyColor() if layer.hasAttribute(attrib): value = layer.attributes[attrib].value color.rgb = [hexstring_to_float(value[1:3])/255, hexstring_to_float(value[3:5])/255, hexstring_to_float(value[5:7])/255] oattrib = attrib + "-opacity" if layer.hasAttribute(oattrib): color.opacity = float(layer.attributes[oattrib].value) color.element = "background" layer = Layer() layer.set_items([color]) items.append(layer) # key groups used_keys = {} root_key_group = None key_groups = [] for group in dom_node.getElementsByTagName("key_group"): # Check for default flag. # Default colors are applied to all keys # not found in the color scheme. default_group = False if group.hasAttribute("default"): default_group = bool(group.attributes["default"].value) # read key ids text = "".join([n.data for n in group.childNodes]) key_ids = [x for x in re.findall('\w+(?:[.][\w-]+)?', text) if x] # check for duplicate key definitions for key_id in key_ids: if key_id in used_keys: raise ValueError(_format("Duplicate key_id '{}' found " "in color scheme file. " "Key_ids must occur only once.", key_id)) used_keys.update(list(zip(key_ids, key_ids))) colors = [] for attrib in list(color_defaults.keys()): rgb = None opacity = None # read color attribute if group.hasAttribute(attrib): value = group.attributes[attrib].value rgb = [hexstring_to_float(value[1:3])/255, hexstring_to_float(value[3:5])/255, hexstring_to_float(value[5:7])/255] # read opacity attribute oattrib = attrib + "-opacity" if group.hasAttribute(oattrib): opacity = float(group.attributes[oattrib].value) if not rgb is None or not opacity is None: elements = ["fill", "stroke", "label", "dwell-progress"] for element in elements: if attrib.startswith(element): break else: element = "fill" if attrib.startswith(element): state_attrib = attrib[len(element):] if state_attrib.startswith("-"): state_attrib = state_attrib[1:] else: state_attrib = attrib color = KeyColor() color.rgb = rgb color.opacity = opacity color.element = element if state_attrib: color.state = {state_attrib : True} else: color.state = {} colors.append(color) key_group = KeyGroup() key_group.set_items(colors) key_group.key_ids = key_ids if default_group: root_key_group = key_group else: key_groups.append(key_group) if root_key_group: root_key_group.append_items(key_groups) items.append(root_key_group) return items class ColorSchemeItem(TreeItem): """ Base class of color scheme items """ def dumps(self): """ Recursively dumps the (sub-) tree starting from self. Returns a multi-line string. """ global _level if not "_level" in globals(): _level = -1 _level += 1 s = " "*_level + repr(self) + "\n" + \ "".join(item.dumps() for item in self.items) _level -= 1 return s def is_window(self): return False def is_layer(self): return False def is_icon(self): return False def is_key_group(self): return False def is_color(self): return False def find_key_id(self, key_id): """ Find the key group that has key_id """ if self.is_key_group(): if key_id in self.key_ids: return self for child in self.items: item = child.find_key_id(key_id) if item: return item return None class Root(ColorSchemeItem): """ Container for a layers colors """ def get_windows(self): """ Get list of window in order of appearance in the color scheme file. """ windows = [] for item in self.items: if item.is_window(): windows.append(item) return windows def get_layers(self): """ Get list of layer items in order of appearance in the color scheme file. """ layers = [] for item in self.items: if item.is_layer(): layers.append(item) return layers def get_icons(self): """ Get list of the icon items in order of appearance in the color scheme file. """ icons = [] for item in self.items: if item.is_icon(): icons.append(item) return icons def get_default_key_group(self): """ Default key group for keys that aren't part of any key group """ for child in self.items: if child.is_key_group(): return child return None class Window(ColorSchemeItem): """ Container for a window's colors """ type = "" # keyboard, key-popup def is_window(self): return True class Layer(ColorSchemeItem): """ Container for a layer's colors """ def is_layer(self): return True class Icon(ColorSchemeItem): """ Container for a Icon's' colors """ def is_icon(self): return True class Color(ColorSchemeItem): """ A single color, rgb + opacity """ element = None rgb = None opacity = None def __repr__(self): return "{} element={} rgb={} opacity={}".format( \ ColorSchemeItem.__repr__(self), repr(self.element), repr(self.rgb), repr(self.opacity)) def is_color(self): return True def matches(self, element, *args): """ Returns true if self matches the given parameters. """ return self.element == element class KeyColor(Color): """ A single key (or layer) color. """ state = None # dict whith "pressed"=True, "active"=False, etc. def __repr__(self): return "{} element={} rgb={} opacity={} state={}".format( \ ColorSchemeItem.__repr__(self), repr(self.element), repr(self.rgb), repr(self.opacity), repr(self.state)) def matches(self, element, state): """ Returns true if self matches the given parameters. state attributes match if they are equal or None, i.e. an empty state dict always matches. """ result = True if not self.element == element: return False for attr, value in list(state.items()): # Special case for fill color # By default the fill color is only applied to the single # state where nothing is pressed, active, locked, etc. # All other elements apply to all state permutations if # not asked to do otherwise. # Allows for hard coded default fill colors to take over without # doing anything special in the color scheme files. default = value # "don't care", always match unspecified states if element == "fill" and \ attr in ["active", "locked", "pressed", "scanned"] and \ not attr in self.state: default = False # consider unspecified states to be False if (element == "label" or element == "secondary-label") and \ attr in ["insensitive"] and \ not attr in self.state: default = False # consider unspecified states to be False if self.state.get(attr, default) != value: result = False return result class KeyGroup(ColorSchemeItem): """ A group of key ids and their colors """ key_ids = () def __repr__(self): return "{} key_ids={}".format(ColorSchemeItem.__repr__(self), repr(self.key_ids)) def is_key_group(self): return True def find_element_color(self, element, state): rgb = None opacity = None # walk key groups from self down to the root for key_group in self.iter_to_root(): if key_group.is_key_group(): # run through all colors of the key group, top to bottom for child in key_group.items: if child.is_color(): for color in child.iter_depth_first(): # matching color found? if color.matches(element, state): if rgb is None: rgb = color.rgb if opacity is None: opacity = color.opacity if not rgb is None and not opacity is None: return rgb, opacity # break early return rgb, opacity