blob: a1a4f7ec60cac5fefc519eb806bf69c0770bc40c [file] [log] [blame]
# Copyright 2019 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import os
import sys
import collections
import re
import textwrap
import path_overrides
from color import Color
import copy
_FILE_PATH = os.path.dirname(os.path.realpath(__file__))
_JSON5_PATH = os.path.join(_FILE_PATH, os.pardir, os.pardir, 'third_party',
'pyjson5', 'src')
sys.path.insert(1, _JSON5_PATH)
import json5
_JINJA2_PATH = os.path.join(_FILE_PATH, os.pardir, os.pardir, 'third_party')
sys.path.insert(1, _JINJA2_PATH)
import jinja2
class Modes:
LIGHT = 'light'
DARK = 'dark'
# The mode that colors will fallback to when not specified in a
# non-default mode. An error will be raised if a color in any mode is
# not specified in the default mode.
DEFAULT = LIGHT
ALL = [LIGHT, DARK]
class VariableType:
COLOR = 'color'
OPACITY = 'opacity'
class ModeKeyedModel(object):
def Add(self, name, value_obj):
if name not in self.variables:
self.variables[name] = {}
if isinstance(value_obj, dict):
for mode in value_obj:
assert mode in Modes.ALL
self.variables[name][mode] = self._CreateValue(value_obj[mode])
else:
self.variables[name][Modes.DEFAULT] = self._CreateValue(value_obj)
# Returns the value that |name| will have in |mode|. Resolves to the default
# mode's value if the a value for |mode| isn't specified. Always returns a
# value.
def Resolve(self, name, mode):
if mode in self.variables[name]:
return self.variables[name][mode]
return self.variables[name][Modes.DEFAULT]
def keys(self):
return self.variables.keys()
def items(self):
return self.variables.items()
def __getitem__(self, key):
return self.variables[key]
class ColorModel(ModeKeyedModel):
'''A dictionary of color names to their values in each mode.
e.g ColorModel['blue'][Modes.LIGHT] = Color(...)
'''
def __init__(self, opacity_model):
super(ColorModel, self).__init__()
self.variables = collections.OrderedDict()
self.opacity_model = opacity_model
# Returns a value from 0-1 representing the final opacity of |color|.
def ResolveOpacity(self, color):
if color.a != -1:
return color.a
assert (color.opacity_var)
return self.opacity_model[color.opacity_var]
# Returns a Color that is the final RGBA value for |name| in |mode|.
def ResolveToRGBA(self, name, mode):
c = self.Resolve(name, mode)
if c.var:
return self.ResolveToRGBA(c.var, mode)
result = Color()
result.a = self.ResolveOpacity(c)
rgb = c
if c.rgb_var:
rgb = self.ResolveToRGBA(c.RGBVarToVar(), mode)
(result.r, result.g, result.b) = (rgb.r, rgb.g, rgb.b)
return result
def _CreateValue(self, value):
return Color(value)
class BaseGenerator:
'''A generic style variable generator.
Subclasses should provide format-specific generation templates, filters and
globals to render their output.
'''
@staticmethod
def GetName():
return None
def __init__(self):
self.out_file_path = None
# A map of input filepaths to their context object.
self.in_file_to_context = dict()
# If specified, only generates the given mode.
self.generate_single_mode = None
opacity_model = collections.OrderedDict()
color_model = ColorModel(opacity_model)
# A dictionary of |VariableType| to models containing mappings of
# variable names to values.
self.model = {
VariableType.COLOR: color_model,
VariableType.OPACITY: opacity_model,
}
# A dictionary of variable names to objects containing information about
# how the generator should run for that variable. All variables must
# populate this dictionary and as such, its keys can be used as a list
# of all variable names,
self.context_map = dict()
# A dictionary of options used to alter generator function. See
# ./README.md for each generators list of options.
self.generator_options = {}
def _SetVariableContext(self, name, context):
if name in self.context_map:
raise ValueError('Variable name "%s" is reused' % name)
self.context_map[name] = context or {}
def GetContextKey(self):
return self.GetName()
def AddColor(self, name, value_obj, context=None):
self._SetVariableContext(name, context)
try:
self.model[VariableType.COLOR].Add(name, value_obj)
except ValueError as err:
raise ValueError('Error parsing color "%s": %s' % (value_obj, err))
def AddOpacity(self, name, value_obj, context=None):
self._SetVariableContext(name, context)
if not isinstance(value_obj, float) and value_obj.startswith('$'):
raise ValueError('Opacities cannot point to other opacities. '
'File a bug if this would be useful for you.')
self.model[VariableType.OPACITY][name] = float(value_obj)
def AddJSONFileToModel(self, path):
try:
with open(path, 'r') as f:
return self.AddJSONToModel(f.read(), path)
except ValueError as err:
raise ValueError('\n%s:\n %s' % (path, err))
def AddJSONToModel(self, json_string, in_file=None):
'''Adds a |json_string| with variable definitions to the model.
See *test.json5 files for a defacto format reference.
|in_file| is used to populate a file-to-context map.
'''
# TODO(calamity): Add allow_duplicate_keys=False once pyjson5 is
# rolled.
data = json5.loads(json_string,
object_pairs_hook=collections.OrderedDict)
# Use the generator's name to get the generator-specific context from
# the input.
generator_context = data.get('options',
{}).get(self.GetContextKey(), None)
self.in_file_to_context[in_file] = generator_context
for name, value in data.get('colors', {}).items():
if not re.match('^[a-z0-9_]+$', name):
raise ValueError(
'%s is not a valid variable name (lower case, 0-9, _)' %
name)
self.AddColor(name, value, generator_context)
for name, value in data.get('opacities', {}).items():
if not re.match('^[a-z0-9_]+_opacity$', name):
raise ValueError(
name + ' is not a valid opacity name ' +
'(lower case, 0-9, _, must end with _opacity)')
self.AddOpacity(name, value, generator_context)
return generator_context
def ApplyTemplate(self, style_generator, path_to_template, params):
loader_root_dir = path_overrides.GetFileSystemLoaderRootDirectory()
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(loader_root_dir),
keep_trailing_newline=True)
jinja_env.globals.update(style_generator.GetGlobals())
jinja_env.filters.update(style_generator.GetFilters())
template = jinja_env.get_template(
path_overrides.GetPathToTemplate(path_to_template))
return template.render(params)
def Validate(self):
colors = self.model[VariableType.COLOR]
opacities = self.model[VariableType.OPACITY]
def CheckColorInDefaultMode(name):
if (name not in colors.variables
or Modes.DEFAULT not in colors.variables[name]):
raise ValueError("%s not defined in default mode '%s'" %
(name, Modes.DEFAULT))
# Check all colors in all modes refer to colors that exist in the
# default mode.
for name, mode_values in colors.variables.items():
for mode, value in mode_values.items():
CheckColorInDefaultMode(name)
if value.var:
CheckColorInDefaultMode(value.var)
if value.rgb_var:
CheckColorInDefaultMode(value.RGBVarToVar())
if value.opacity_var and value.opacity_var not in opacities:
raise ValueError("Opacity '%s' not defined" %
value.opacity_var)
# TODO(calamity): Check for circular references.
# TODO(crbug.com/1053372): Prune unused rgb values.