Files
@ 41ee6a32070a
Branch filter:
Location: majic-scripts/utils/mapping_generator.py
41ee6a32070a
7.9 KiB
text/x-python
[cheatsheet_viewer.sh] Added support for automatically activating overlay mode based on current window title.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | #!/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()
|