Files @ 41ee6a32070a
Branch filter:

Location: majic-scripts/utils/mapping_generator.py

branko
[cheatsheet_viewer.sh] Added support for automatically activating overlay mode based on current window title.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright (C) 2025 Branko Majic
#
# This program 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.
#
# This program 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
# <https://www.gnu.org/licenses/>.


import shutil
import math
import os
import subprocess

import click
import yaml

from defusedxml import ElementTree


def display_mappables_as_text(mappables):
    """
    Display mappables in text format.

    :param mappables: List of mappable IDs in the template.
    :type mappable: list[str]
    """

    # Show mappables in multiple columns, while enforcing a minimum column height (element count). For example, if
    # there are only 5 mappables, show them in a single column instead of splitting them up into multiple ones.
    terminal_size = shutil.get_terminal_size((80, 20))
    column_spacing = 4
    row_threshold = math.floor(terminal_size.lines * 0.5)
    column_width = max([len(m) for m in mappables])
    columns = math.floor((terminal_size.columns) / (column_width + column_spacing))
    page_threshold = row_threshold * columns

    # Expand the list with blank strings in order to have exact number of 'cells' when printing to
    # screeen. Simplifies the code dealing with printing a bit and avoid invalid index access.
    if len(mappables) <= row_threshold:
        rows = len(mappables)
    elif len(mappables) <= page_threshold:
        rows = row_threshold
        mappables.extend([''] * (page_threshold - len(mappables)))
    else:
        rows = math.ceil(len(mappables) / columns)
        mappables.extend([''] * (columns * rows - len(mappables)))

    click.echo('Mappables:\n')

    for i in range(rows):
        click.echo((" " * column_spacing).join([f'{m:<{column_width}}' for m in mappables[i::rows]]))


def display_mappables_as_yaml(mappables):
    """
    Display mappables in YAML format.

    :param mappables: List of mappable IDs in the template.
    :type mappable: list[str]
    """

    document = {
        'mappables': {}
    }

    for mappable in mappables:
        document['mappables'][mappable] = ''

    click.echo(yaml.dump(document, width=4096))


def validate_output_height(ctx, param, value):
    """
    Validates the output height value, ensuring it is within the correct range.

    :param ctx: Click context.
    :type ctx: click.Context

    :param param: Click option that is being validated.
    :type param: click.Option

    :param value: Option value.
    :type value: any

    :raises click.BadParameter: If the passed-in value is out of bounds.

    :returns: Validated value.
    :rtype: int
    """

    if value < 0 or value > 2147483647:
        raise click.BadParameter('must be a non-negative integer between 0 and 2147483647')

    return value


# Allow use of short option for showing help.
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])


@click.group(context_settings=CONTEXT_SETTINGS)
def cli():
    """
    Generate input mapping cheatsheets for input devices using specially-crafted scalable vector graphics (SVG)
    templates.

    To prepare a template, create an SVG using Inkscape, and assign custom IDs to text elements that should be managed
    by the mapping generator.

    These elements can be populated by applying user-provided parameters against the template. Parameters are provided
    in the form of YAML file. The YAML file should have the 'mappables' key at the top level of the document, which then
    contains key-to-value mappings for custom text element IDs.

    The easiest way to generate the initial (blank) parameters file is by using the 'info' command alongside the output
    format option.
    """
    pass


@cli.command()
@click.argument('template', type=click.File('r'))
@click.option('--search', '-s', default='', help='only show matching mappings')
@click.option('--output-format', '-f', type=click.Choice(['text', 'yaml']), default='text', help='output format')
def info(template, search, output_format):
    """
    Shows information about the passed-in SVG template. This currently includes:

      - List of mappable elements.
    """

    template_tree = ElementTree.fromstring(template.read())
    namespaces = {
        'svg': 'http://www.w3.org/2000/svg',
    }

    # Inkscape default ID starts with string 'text' unless user assigns custom ID.
    mappable_elements = [
        e for e in template_tree.findall('.//svg:text', namespaces) if not e.get('id').startswith('text')
    ]
    matched_mappables = sorted([e.get('id') for e in mappable_elements if search in e.get('id')])

    if output_format == 'yaml':
        click.echo('---\n')

    if not mappable_elements:
        error = 'No mappables found in the specified template. Please check that you have specified correct file.'
    elif not matched_mappables:
        error = 'Search term did not match any mappable in the specified template.'
    else:
        error = None

    if error and output_format == 'yaml':
        error = yaml.dump({'error': error}, width=4096)

    if error:
        click.echo(error)

    elif output_format == 'text':
        display_mappables_as_text(matched_mappables)

    elif output_format == 'yaml':
        display_mappables_as_yaml(matched_mappables)


@cli.command()
@click.argument('template', type=click.File('r'))
@click.argument('parameters', type=click.File('r'))
@click.argument('output', type=click.Path(dir_okay=False, writable=True, allow_dash=True))
@click.option('--force-overwrite', '-f', is_flag=True, help='force overwriting output file')
@click.option('--output-height', '-H', default=0, callback=validate_output_height, help='output image height for png')
def apply(template, parameters, output, force_overwrite, output_height):
    """
    Applies user-provided PARAMETERS against the TEMPLATE, and places the result into OUTPUT file. The output file
    can be either a filename or '-' for stdout.

    Output format is based on passed-in file extension. Currently supported file extensions/formats are: svg. When
    output is '-' (stdout), the output format is always svg.
    """

    template_tree = ElementTree.fromstring(template.read())
    namespaces = {
        'svg': 'http://www.w3.org/2000/svg',
    }

    # Inkscape default ID starts with string 'text' unless user assigns custom ID.
    mappable_elements = [
        e for e in template_tree.findall('.//svg:text', namespaces) if not e.get('id').startswith('text')
    ]

    parameters = yaml.safe_load(parameters.read())

    for element in mappable_elements:
        element_id = element.get('id')
        replacement = parameters['mappables'].get(element_id, '')
        element.find('svg:tspan', namespaces).text = replacement

    if os.path.exists(output) and not force_overwrite:
        raise click.FileError(output, 'file already exists')

    if os.path.isdir(output):
        raise click.FileError(output, 'output must be a file path')

    if output.endswith('.svg') or output == '-':
        with click.open_file(output, 'wb') as output_file:
            output_file.write(ElementTree.tostring(template_tree))
    elif output.endswith('.png'):
        command = ['inkscape', '--pipe', '--export-filename', output]
        command += ['--export-height', str(output_height)]
        subprocess.run(command, input=ElementTree.tostring(template_tree), check=True)
    else:
        raise click.BadParameter(
            f'unsupported extension/output format ({ os.path.splitext(output)[-1] or "none specified" })',
            param=output, param_hint='output')


if __name__ == '__main__':
    cli()