BreezeStyleSheets/configure.py

372 lines
12 KiB
Python

'''
configure
=========
Configure icons, stylesheets, and resource files.
'''
__version__ = '0.1.0'
import argparse
import glob
import json
import os
import re
import shutil
import subprocess
import sys
home = os.path.dirname(os.path.realpath(__file__))
dist = os.path.join(home, 'dist')
qrc_dist = os.path.join(dist, 'qrc')
pyqt6_dist = os.path.join(dist, 'pyqt6')
template_dir = os.path.join(home, 'template')
theme_dir = os.path.join(home, 'theme')
extension_dir = os.path.join(home, 'extension')
def parse_args(argv=None):
'''Parse the command-line options.'''
parser = argparse.ArgumentParser(description='Styles to configure for a Qt application.')
parser.add_argument(
'-v',
'--version',
action='version',
version=f'%(prog)s {__version__}'
)
parser.add_argument(
'--styles',
help='comma-separate list of styles to configure. pass `all` to build all themes',
default='light,dark',
)
parser.add_argument(
'--extensions',
help='comma-separate list of styles to configure. pass `all` to build all themes',
default='',
)
parser.add_argument(
'--resource',
help='output resource file name',
default='breeze.qrc',
)
parser.add_argument(
'--no-qrc',
help='do not build QRC resources.',
action='store_true'
)
parser.add_argument(
'--pyqt6',
help='build PyQt6 resources.',
action='store_true'
)
parser.add_argument(
'--clean',
help='clean dist directory prior to configuring themes.',
action='store_true'
)
parser.add_argument(
'--pyrcc5',
help='name of the pyrcc5 executable. Overridden by the `PYRCC5` envvar.',
default='pyrcc5',
)
parser.add_argument(
'--compiled-resource',
help='output compiled python resource file.',
)
args = parser.parse_args(argv)
parse_styles(args)
parse_extensions(args)
return args
def load_json(path):
'''Read a JSON file with limited comments support.'''
# Note: we need comments for maintainability, so we
# can annotate what works and the rationale, but
# we don't want to prevent code from working without
# a complex parser, so we do something very simple:
# only remove lines starting with '//'.
with open(path) as file:
lines = file.read().splitlines()
lines = [i for i in lines if not i.strip().startswith('//')]
return json.loads('\n'.join(lines))
def read_template_dir(directory):
'''Read the template data from a directory'''
# Make the stylesheet template optional.
stylesheet = ''
stylesheet_path = f'{directory}/stylesheet.qss.in'
if os.path.exists(stylesheet_path):
stylesheet = open(f'{directory}/stylesheet.qss.in').read()
data = {
'stylesheet': stylesheet,
'icons': [],
}
if os.path.exists(f'{directory}/icons.json'):
icon_data = load_json(f'{directory}/icons.json')
else:
icon_data = {}
for file in glob.glob(f'{directory}/*.svg.in'):
svg = open(file).read()
name = os.path.splitext(os.path.splitext(os.path.basename(file))[0])[0]
if name in icon_data:
replacements = icon_data[name]
else:
# Need to find all the values inside the image.
keys = re.findall(r'\^[0-9a-zA-Z_-]+\^', svg)
replacements = [i[1:-1] for i in keys]
data['icons'].append({
'name': name,
'svg': svg,
'replacements': replacements,
})
return data
def split_csv(string):
'''Split a list of values provided as comma-separated values.'''
values = string.split(',')
return [i for i in values if i]
def parse_styles(args):
'''Parse a list of valid styles.'''
values = split_csv(args.styles)
if 'all' in values:
files = glob.glob(f'{theme_dir}/*json')
values = [os.path.splitext(os.path.basename(i))[0] for i in files]
args.styles = values
def parse_extensions(args):
'''Parse a list of valid extensions.'''
values = split_csv(args.extensions)
if 'all' in values:
values = []
for dirname in os.listdir(extension_dir):
ext = f'{extension_dir}/{dirname}'
ext_files = ('stylesheet.qss.in', 'icons.json')
paths = [f'{ext}/{i}' for i in ext_files]
if os.path.isdir(ext) and any(os.path.exists(i) for i in paths):
values.append(dirname)
args.extensions = values
def parse_hexcolor(color):
'''Parse a hexadecimal color.'''
# Have a hex color: can be 6 or 8 (non-standard) items.
color = color[1:]
if len(color) not in (6, 8):
raise NotImplementedError
red = int(color[:2], 16)
green = int(color[2:4], 16)
blue = int(color[4:6], 16)
alpha = 1.0
if len(color) == 8:
alpha = int(color[6:8], 16) / 100
return (red, green, blue, alpha)
def parse_rgba(color):
'''Parse an RGBA color.'''
# Match our rgba character. Note that this is
# First split the rgba components to get the inner stuff.
# Both rgb() and rgba() can have or omit an alpha layer.
rgba = re.match(r'^\s*rgba?\s*\((.*)\)\s*$', color).group(1)
split = re.split(r'(?:\s*,\s*)|\s+', rgba)
if len(split) not in (3, 4):
raise NotImplementedError
red = int(split[0])
green = int(split[1])
blue = int(split[2])
alpha = 1.0
if len(split) == 4:
alpha = float(split[3])
return (red, green, blue, alpha)
def parse_color(color):
'''Parse a color into the RGBA components.'''
if color.startswith('#'):
return parse_hexcolor(color)
elif color.startswith('rgb'):
return parse_rgba(color)
raise NotImplementedError
def icon_basename(icon, extension):
'''Get the basename for an icon.'''
if extension == 'default':
return icon
return f'{icon}_{extension}'
def replace_by_name(contents, theme, colors=None):
'''Replace values by color name.'''
# The placeholders have a syntax like `^foreground^`.
# To simplify the replacement process, you can specify
# a limited subset of colors, rather than use all of them.
if colors is None:
colors = theme.keys()
for key in colors:
color = theme[key]
contents = contents.replace(f'^{key}^', color)
return contents
def replace_by_index(contents, theme, colors):
'''Replace values by color name.'''
# The placeholders have a syntax like `^0^`, where
# the is a list of valid colors and the index of
# the color is the replacement key.
# This is useful since we can want multiple colors
# for the same icon (such as hovered arrows).
for index, key in enumerate(colors):
sub = f'^{index}^'
# Need special handle values with opacities. Standard
# SVG currently does not support `rgba` syntax, with an
# opacity, but it does provide `fill-opacity` and `stroke-opacity`.
# Therefore, if the replacement specifies `opacity` or `hex`,
# parse the color, get the correct value, and use only that
# for the replacement.
if key.endswith(':hex'):
color = theme[key[:-len(':hex')]]
rgb = [f"{i:02x}" for i in parse_color(color)[:3]]
value = f'#{"".join(rgb)}'
elif key.endswith(':opacity'):
color = theme[key[:-len(':opacity')]]
value = str(parse_color(color)[3])
else:
value = theme[key]
contents = contents.replace(sub, value)
return contents
def configure_icons(config, style, qt_dist):
'''Configure icons for a given style.'''
theme = config['themes'][style]
for template in config['templates']:
for icon in template['icons']:
replacements = icon['replacements']
name = icon['name']
if isinstance(replacements, dict):
# Then we have the following format:
# The key is the substate of the icon, such
# as default, hover, pressed, etc, and the value
# is an ordered list of replacements.
for ext, colors in replacements.items():
contents = replace_by_index(icon['svg'], theme, colors)
filename = f'{qt_dist}/{style}/{icon_basename(name, ext)}.svg'
with open(filename, 'w') as file:
file.write(contents)
else:
# Then we just have a list of replacements for the
# icon, using standard colors. For example,
# replacement values might be `^foreground^`.
assert isinstance(replacements, list)
contents = replace_by_name(icon['svg'], theme, replacements)
filename = f'{qt_dist}/{style}/{name}.svg'
with open(filename, 'w') as file:
file.write(contents)
def configure_stylesheet(config, style, qt_dist, style_prefix):
'''Configure the stylesheet for a given style.'''
contents = '\n'.join([i['stylesheet'] for i in config['templates']])
contents = replace_by_name(contents, config['themes'][style])
contents = contents.replace('^style^', style_prefix)
with open(f'{qt_dist}/{style}/stylesheet.qss', 'w') as file:
file.write(contents)
def configure_style(config, style):
'''Configure the icons and stylesheet for a given style.'''
def configure_qt(qt_dist, style_prefix):
os.makedirs(f'{qt_dist}/{style}', exist_ok=True)
# Need to pass the qt_dist dir.
# also need to set the name scheming: qrc or pyqt6
configure_icons(config, style, qt_dist)
configure_stylesheet(config, style, qt_dist, style_prefix)
# Need to replace the URL paths for loading icons/
# assets. In C++ Qt and PyQt5, this uses the resource
# system, AKA, `url(:/dark/path/to/resource)`. In PyQt6, the
# resource system has been replaced to use native
# Python packaging, so we define a user-friendly name
# based on the theme name, so `url(dark:path/to/resource)`.
if not config['no_qrc']:
configure_qt(qrc_dist, f':/{style}/')
if config['pyqt6']:
configure_qt(pyqt6_dist, f'{style}:')
def write_qrc(config):
'''Simple QRC writer.'''
resources = []
for style in config['themes'].keys():
files = os.listdir(f'{qrc_dist}/{style}')
resources += [f'{style}/{i}' for i in files]
with open(f'{qrc_dist}/{config["resource"]}', 'w') as file:
print('<RCC>', file=file)
print(' <qresource>', file=file)
for resource in sorted(resources):
print(f' <file>{resource}</file>', file=file)
print(' </qresource>', file=file)
print('</RCC>', file=file)
def configure(args):
'''Configure all styles and write the files to a QRC file.'''
if args.clean:
shutil.rmtree(dist, ignore_errors=True)
# Need to convert our styles accordingly.
config = {
'themes': {},
'templates': [],
'no_qrc': args.no_qrc,
'pyqt6': args.pyqt6,
'resource': args.resource
}
config['templates'].append(read_template_dir(template_dir))
for style in args.styles:
config['themes'][style] = load_json(f'{theme_dir}/{style}.json')
for extension in args.extensions:
config['templates'].append(read_template_dir(f'{extension_dir}/{extension}'))
for style in config['themes'].keys():
configure_style(config, style)
# Create and compile our resource files.
# resource files aren't used in PyQt6: no rcc6 anyway.
if not args.no_qrc:
write_qrc(config)
if not args.no_qrc and args.compiled_resource is not None:
pyrcc5 = os.environ.get('PYRCC5', args.pyrcc5)
command = [
pyrcc5,
f'{qrc_dist}/{args.resource}',
'-o',
f'{home}/{args.compiled_resource}'
]
subprocess.check_call(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
shell=False,
)
def main(argv=None):
'''Configuration entry point'''
configure(parse_args(argv))
if __name__ == '__main__':
sys.exit(main())