372 lines
12 KiB
Python
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())
|