''' 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''' data = { 'stylesheet': open(f'{directory}/stylesheet.qss.in').read(), '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'{home}/theme/*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: files = glob.glob(f'{home}/extension/*/*stylesheet.qss.in') values = [os.path.basename(os.path.dirname(i)) for i in files] 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('', file=file) print(' ', file=file) for resource in sorted(resources): print(f' {resource}', file=file) print(' ', file=file) print('', 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())