buffybox/squeek2lvgl.py
2021-09-12 21:58:51 +02:00

765 lines
No EOL
26 KiB
Python

#!/usr/bin/env python3
# Copyright 2021 Johannes Marbach
#
# This file is part of squeek2lvgl, hereafter referred to as the program.
#
# 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 argparse
import git
import os
import sys
import tempfile
import yaml
###
# Global constants
outfile_c = 'sq2lv_layouts.c'
outfile_h = 'sq2lv_layouts.h'
repository_url = 'https://gitlab.gnome.org/World/Phosh/squeekboard.git'
rel_layouts_dir = 'data/keyboards'
###
# General helpers
##
def die(msg):
"""Print an error message to STDERR and exit with a non-zero code.
msg -- message to output on STDERR
"""
sys.stderr.write(msg if msg.endswith('\n') else msg + '\n')
sys.exit(1)
def warn(msg):
"""Print a warning message to STDERR.
msg -- message to output on STDERR
"""
sys.stderr.write(msg if msg.endswith('\n') else msg + '\n')
def parse_arguments():
""" Parse commandline arguments.
"""
parser = argparse.ArgumentParser(description='Convert squeekboard layouts to LVGL-compatible C code.')
parser.add_argument('--input', dest='input', action='append', required=True, help='squeekboard layout to '
+ 'use as input for generation. Has to be a YAML file path relative to data/keyboards. '
+ 'Can be specified multiple times.')
parser.add_argument('--surround-space-with-arrows', action='store_true', dest='arrows_around_space',
help='insert left / right arrow before / after space key')
parser.add_argument('--generate-scancodes', action='store_true', dest='generate_scancodes', help='also '
+ 'generate scancode tables (only works for US layout currently)')
parser.add_argument('--output', dest='output', type=str, required=True, help='output directory for generated '
+ 'files')
args = parser.parse_args()
if not args.output or not os.path.isdir(args.output):
die('Error: no valid output directory specified')
return args
def clone_squeekboard_repo(destination):
""" Clone the squeekboard git repository.
destination -- directory path to clone to
"""
git.Repo.clone_from(repository_url, destination, depth=1)
def load_yaml(layouts_dir, rel_path):
""" Load a YAML file and return its dictionary representation.
rel_path -- path of the YAML file relative to the layouts root directory
"""
path = os.path.join(layouts_dir, rel_path)
if not os.path.isfile(path):
die(f'could not find input file {path}')
data = None
with open(path, 'r') as stream:
try:
data = yaml.safe_load(stream)
except yaml.YAMLError as exc:
die(f'Could not load YAML file {path}: {exc}')
if not data:
die(f'could not load input file {path}')
if not 'views' in data:
die(f'no "views" element in YAML data loaded from input file {path}')
return data
def write_files(lines_c, lines_h):
"""Write accumulated output to C and header file, respectively.
lines_c -- sequence of lines to write to C file
lines_h -- sequence of lines to write to header
"""
with open(os.path.join(args.output, outfile_c), 'w') as fp:
fp.write('\n'.join(lines_c))
with open(os.path.join(args.output, outfile_h), 'w') as fp:
fp.write('\n'.join(lines_h))
def comma_if_needed(sequence, idx):
"""Return a comma unless idx points to the last element in sequence.
sequence -- a sequence of elements
idx -- an index into the sequence
"""
return ',' if idx < len(sequence) - 1 else ''
###
# SourceFileBuilder
##
class SourceFileBuilder(object):
"""Builder for .c and .h files.
"""
def __init__(self):
"""Constructor.
"""
self.lines = []
self._add_header_comment()
def add_line(self, line=None):
"""Add a single line and return the builder.
line - string representing the line to add (if None, an empty line will be added)
"""
self.lines.append(line if line else '')
return self
def add_lines(self, lines):
"""Add multiple lines and return the builder.
lines - a list of strings representing the lines to add
"""
self.lines += lines
return self
def wrap_in_ifndef(self, macro):
"""Wrap all of the current content in a macro check to prevent double inclusion and
return the builder.
macro -- name of the macro to use
"""
idx = 0
while self.lines[idx][:2] in ['/*', ' *']:
idx += 1
self.lines.insert(idx, '')
self.lines.insert(idx + 1, f'#ifndef {macro}')
self.lines.insert(idx + 2, f'#define {macro}')
self.add_lines(['', f'#endif /* {macro} */'])
def _add_header_comment(self):
"""Add the generator header comment and return the builder.
"""
self.add_lines(['/**', ' * Auto-generated with unsqu33kr', ' **/', ''])
return self
def add_include(self, header):
"""Add an include statement and return the builder.
header -- path to file to be included
"""
self.add_line(f'#include "{header}"')
return self
def add_system_include(self, header):
"""Add a system include statement and return the builder.
header -- path to file to be included
"""
self.add_line(f'#include <{header}>')
return self
def add_section_comment(self, title):
"""Add a comment marking the beginning of a section and return the builder.
title -- title of the section
"""
self.add_lines(['/**', f' * {title}', ' **/'])
return self
def add_subsection_comment(self, title):
"""Add a comment marking the beginning of a subsection and return the builder.
title -- title of the subsection
"""
self.add_line(f'/* {title} */')
return self
def add_array(self, static, type, identifier, values, row_terminator, array_terminator):
"""Add a row-based C array and return the builder.
static -- True if the variable is static
type -- variable type
identifier -- variable identifier
values -- values per row as a list of lists,
row_terminator -- element to append to each row except the last
array_terminator -- element to append to the last row
"""
prefix = 'static ' if static else ''
self.add_line(f'{prefix}{type} {identifier}[] = ' + '{ \\')
for i, values_in_row in enumerate(values):
elements = values_in_row
if i < len(values) - 1 and row_terminator:
elements.append(row_terminator)
if i == len(values) - 1 and array_terminator:
elements.append(array_terminator)
joined = ', '.join([f'{e}' for e in elements])
self.add_line(f' {joined}{comma_if_needed(values, i)} \\')
self.add_line('};')
return self
def add_flat_array(self, static, type, identifier, values, array_terminator):
"""Add a flat C array and return the builder.
static -- True if the variable is static
type -- variable type
identifier -- variable identifier
values -- list of values,
array_terminator -- element to append after the last element
"""
return self.add_array(static, type, identifier, [values], '', array_terminator)
###
# Layout processing
##
layout_name_for_layout_id = {
'terminal/us': 'US English (Terminal)',
'de': 'German',
'es': 'Spanish',
'fr': 'French',
'us': 'US English'
}
def layout_id_to_layout_name(layout_id):
"""Return a descriptive name for a layout.
layout_id -- ID of the layout
"""
return layout_name_for_layout_id[layout_id]
def layout_id_to_c_identifier(layout_id):
"""Return a string for a layout that is suitable to be used in a C identifier.
layout_id -- ID of the layout
"""
return layout_id.lower().replace('/', '_')
layer_name_for_view_id = {
'base': 'Lowercase letters',
'upper': 'Uppercase letters',
'numbers': 'Numbers / symbols',
'eschars': 'Special characters'
}
def view_id_to_layer_name(view_id):
"""Return a descriptive name for a layer based on its view ID.
view_id -- the ID of the view representing the layer
"""
if view_id not in layer_name_for_view_id:
return None
return layer_name_for_view_id[view_id]
layer_identifier_for_view_id = {
'base': 'lower',
'eschars': 'special'
}
def view_id_to_c_identifier(view_id):
"""Return a string for a view that is suitable to be used in a C identifier.
view_id -- ID of the view
"""
return layer_identifier_for_view_id[view_id] if view_id in layer_identifier_for_view_id else view_id
keycap_for_key = {
'\\': '\\\\',
'"': '\\"',
'': 'LV_SYMBOL_UP',
'': 'LV_SYMBOL_DOWN',
'': 'LV_SYMBOL_LEFT',
'': 'LV_SYMBOL_RIGHT',
'BackSpace': 'LV_SYMBOL_BACKSPACE',
'colon': ':',
'period': '.',
'preferences': None,
'Shift_L': {
'base': 'ABC',
'upper': 'abc'
},
'show_actions': None,
'show_eschars': {
'de': 'äöü',
'es': 'áéí',
'fr': 'àéô'
},
'show_letters': 'abc',
'show_numbers': '1#',
'show_numbers_from_symbols': '1#',
'show_symbols': None,
'space': ' ',
'Return': 'LV_SYMBOL_OK',
}
def key_to_keycap(key, view_id, layout_id):
"""Return the keycap for a key
key -- the key
view_id -- ID of the view the key appears on
layout_id -- ID of the layout the view is part of
"""
keycap = keycap_for_key[key] if key in keycap_for_key else key
if isinstance(keycap, dict):
if view_id in keycap:
keycap = keycap[view_id]
elif layout_id in keycap:
keycap = keycap[layout_id]
else:
keycap = None
return keycap
def key_to_attributes(key, data_buttons):
"""Return the LVGL button attributes for a key.
key -- the key in question
data_buttons -- the "buttons" object from the layout's YAML file
"""
if key in ['period', 'colon', '"'] or key not in data_buttons:
return '2'
width = 7 if key == 'space' else 3
return f'LV_KEYBOARD_CTRL_BTN_FLAGS | {width}'
def keycap_to_c_value(keycap):
"""Return the right-hand side C value for a keycap
"""
return keycap if keycap.startswith('LV_') else f'"{keycap}"'
scancodes_for_keycap = {
'0': ['KEY_0'],
'1': ['KEY_1'],
'2': ['KEY_2'],
'3': ['KEY_3'],
'4': ['KEY_4'],
'5': ['KEY_5'],
'6': ['KEY_6'],
'7': ['KEY_7'],
'8': ['KEY_8'],
'9': ['KEY_9'],
'a': ['KEY_A'],
'b': ['KEY_B'],
'c': ['KEY_C'],
'd': ['KEY_D'],
'e': ['KEY_E'],
'f': ['KEY_F'],
'g': ['KEY_G'],
'h': ['KEY_H'],
'i': ['KEY_I'],
'j': ['KEY_J'],
'k': ['KEY_K'],
'l': ['KEY_L'],
'm': ['KEY_M'],
'n': ['KEY_N'],
'o': ['KEY_O'],
'p': ['KEY_P'],
'q': ['KEY_Q'],
'r': ['KEY_R'],
's': ['KEY_S'],
't': ['KEY_T'],
'u': ['KEY_U'],
'v': ['KEY_V'],
'w': ['KEY_W'],
'x': ['KEY_X'],
'y': ['KEY_Y'],
'z': ['KEY_Z'],
'A': ['KEY_LEFTSHIFT', 'KEY_A'],
'B': ['KEY_LEFTSHIFT', 'KEY_B'],
'C': ['KEY_LEFTSHIFT', 'KEY_C'],
'D': ['KEY_LEFTSHIFT', 'KEY_D'],
'E': ['KEY_LEFTSHIFT', 'KEY_E'],
'F': ['KEY_LEFTSHIFT', 'KEY_F'],
'G': ['KEY_LEFTSHIFT', 'KEY_G'],
'H': ['KEY_LEFTSHIFT', 'KEY_H'],
'I': ['KEY_LEFTSHIFT', 'KEY_I'],
'J': ['KEY_LEFTSHIFT', 'KEY_J'],
'K': ['KEY_LEFTSHIFT', 'KEY_K'],
'L': ['KEY_LEFTSHIFT', 'KEY_L'],
'M': ['KEY_LEFTSHIFT', 'KEY_M'],
'N': ['KEY_LEFTSHIFT', 'KEY_N'],
'O': ['KEY_LEFTSHIFT', 'KEY_O'],
'P': ['KEY_LEFTSHIFT', 'KEY_P'],
'Q': ['KEY_LEFTSHIFT', 'KEY_Q'],
'R': ['KEY_LEFTSHIFT', 'KEY_R'],
'S': ['KEY_LEFTSHIFT', 'KEY_S'],
'T': ['KEY_LEFTSHIFT', 'KEY_T'],
'U': ['KEY_LEFTSHIFT', 'KEY_U'],
'V': ['KEY_LEFTSHIFT', 'KEY_V'],
'W': ['KEY_LEFTSHIFT', 'KEY_W'],
'X': ['KEY_LEFTSHIFT', 'KEY_X'],
'Y': ['KEY_LEFTSHIFT', 'KEY_Y'],
'Z': ['KEY_LEFTSHIFT', 'KEY_Z'],
'Alt': ['KEY_LEFTALT'],
'Ctrl': ['KEY_LEFTCTRL'],
'LV_SYMBOL_UP': ['KEY_UP'],
'LV_SYMBOL_DOWN': ['KEY_DOWN'],
'LV_SYMBOL_LEFT': ['KEY_LEFT'],
'LV_SYMBOL_RIGHT': ['KEY_RIGHT'],
'LV_SYMBOL_BACKSPACE': ['KEY_BACKSPACE'],
'LV_SYMBOL_OK': ['KEY_ENTER'],
' ': ['KEY_SPACE'],
'ABC': [],
'abc': [],
'1#': [],
'PgUp': ['KEY_PAGEUP'],
'PgDn': ['KEY_PAGEDOWN'],
'Home': ['KEY_HOME'],
'End': ['KEY_END'],
'*': ['KEY_LEFTSHIFT', 'KEY_8'],
'#': ['KEY_LEFTSHIFT', 'KEY_3'],
'$': ['KEY_LEFTSHIFT', 'KEY_4'],
'/': ['KEY_LEFTSHIFT', 'KEY_QUESTION'],
'&': ['KEY_LEFTSHIFT', 'KEY_7'],
'-': ['KEY_MINUS'],
'_': ['KEY_LEFTSHIFT', 'KEY_MINUS'],
'+': ['KEY_LEFTSHIFT', 'KEY_EQUAL'],
'(': ['KEY_LEFTSHIFT', 'KEY_9'],
')': ['KEY_LEFTSHIFT', 'KEY_0'],
',': ['KEY_COMMA'],
'\\"': ['KEY_LEFTSHIFT', 'KEY_APOSTROPHE'],
'\'': ['KEY_APOSTROPHE'],
':': ['KEY_LEFTSHIFT', 'KEY_SEMICOLON'],
';': ['KEY_SEMICOLON'],
'!': ['KEY_LEFTSHIFT', 'KEY_1'],
'?': ['KEY_QUESTION'],
'.': ['KEY_DOT']
}
def keycap_to_scancodes(keycap):
"""Return the scancodes needed to produce a keycap
keycap -- keycap to produce
"""
if keycap not in scancodes_for_keycap:
die(f'cannot determine scancodes for unknown keycap "{keycap}"')
return scancodes_for_keycap[keycap]
def get_keycaps_attrs_switchers_scancodes(args, layout_id, view_id, data_views, data_buttons):
"""Return keycaps, LVGL button attributes, layer switching key indexes, layer switching key
destinations and scancodes for a view
args -- Commandline arguments
layout_id -- ID of the layout
view_id -- ID of the view
data_views -- the "views" object from the layout's YAML file
data_buttons -- the "buttons" object from the layout's YAML file
"""
keycaps = []
attrs = []
switcher_idxs = []
switcher_dests = []
scancodes = []
idx = 0
for row in data_views[view_id]:
keycaps_in_row = []
attrs_in_row = []
scancodes_in_row = []
keys = row.split()
if args.arrows_around_space:
space_idx = None
try:
space_idx = keys.index('space')
except ValueError:
pass
if space_idx != None:
keys.insert(space_idx, '')
keys.insert(space_idx + 2, '')
for key in keys:
keycap = key_to_keycap(key, view_id, layout_id)
if not keycap:
continue
keycaps_in_row.append(keycap_to_c_value(keycap))
attrs_in_row.append(key_to_attributes(key, data_buttons))
if key in data_buttons and 'action' in data_buttons[key]:
action = data_buttons[key]['action']
dest = None
if 'set_view' in action:
dest = action['set_view']
elif 'locking' in action and 'lock_view' in action['locking'] and 'unlock_view' in action['locking']:
if action['locking']['lock_view'] == view_id:
dest = action['locking']['unlock_view']
elif action['locking']['unlock_view'] == view_id:
dest = action['locking']['lock_view']
if dest:
switcher_idxs.append(idx)
switcher_dests.append(dest)
if args.generate_scancodes:
scancodes_in_row.append(keycap_to_scancodes(keycap))
idx += 1
keycaps.append(keycaps_in_row)
attrs.append(attrs_in_row)
scancodes.append(scancodes_in_row)
return keycaps, attrs, switcher_idxs, switcher_dests, scancodes
def flatten_scancodes(scancodes):
"""Process a nested list of scancodes per key and return a flattened list, a list of starting indexes and a
list of scancode counts.
scancodes -- list (rows) of list (keys) of list (scancodes) of scancodes
"""
flat = []
idxs = []
nums = []
if args.generate_scancodes:
idx = 0
num = 0
for scancodes_in_row in scancodes:
flat_in_row = []
idxs_in_row = []
nums_in_row = []
for codes in scancodes_in_row:
flat_in_row += codes
idxs_in_row.append(idx if len(codes) > 0 else -1)
nums_in_row.append(len(codes))
idx += len(codes)
flat.append(flat_in_row)
idxs.append(idxs_in_row)
nums.append(nums_in_row)
return flat, idxs, nums
###
# Main
##
if __name__ == '__main__':
args = parse_arguments()
c_builder = SourceFileBuilder()
c_builder.add_include(outfile_h)
if args.generate_scancodes:
c_builder.add_system_include('linux/input.h')
c_builder.add_line()
h_builder = SourceFileBuilder()
h_builder.add_include('lvgl/lvgl.h')
h_builder.add_line()
layouts = []
with tempfile.TemporaryDirectory() as tmp:
clone_squeekboard_repo(tmp)
layouts_dir = os.path.join(tmp, rel_layouts_dir)
for file in args.input:
layout_id, _ = os.path.splitext(file)
layout_identifier = layout_id_to_c_identifier(layout_id)
data = load_yaml(layouts_dir, file)
data_views = data['views']
data_buttons = data['buttons'] if 'buttons' in data else {}
c_builder.add_section_comment(f'Layout: {layout_id_to_layout_name(layout_id)} - generated from {layout_id}')
c_builder.add_line()
c_builder.add_line(f'static const char * const name_{layout_identifier} = "{layout_id}";')
c_builder.add_line()
layer_identifiers = []
view_ids = [view_id for view_id in data_views if view_id_to_layer_name(view_id) != None]
for view_id in data_views:
layer_name = view_id_to_layer_name(view_id)
if not layer_name:
warn(f'Ignoring unknown view_id {view_id}')
continue
layer_identifier = f'{view_id_to_c_identifier(view_id)}_{layout_identifier}'
layer_identifiers.append(layer_identifier)
c_builder.add_subsection_comment(f'Layer: {layer_name} - generated from {view_id}')
c_builder.add_line()
keycaps, attrs, switcher_idxs, switcher_dests, scancodes = get_keycaps_attrs_switchers_scancodes(
args, layout_id, view_id, data_views, data_buttons)
for dest in switcher_dests:
if dest not in view_ids:
die(f'Unhandled layer switch destination {dest}')
switcher_dests = [view_ids.index(d) for d in switcher_dests if d in view_ids]
c_builder.add_array(True, 'const char * const', f'keycaps_{layer_identifier}', keycaps, '"\\n"', '""')
c_builder.add_line()
c_builder.add_array(True, 'const lv_btnmatrix_ctrl_t', f'attributes_{layer_identifier}', attrs, '', '')
c_builder.add_line()
c_builder.add_line(f'static const int num_switchers_{layer_identifier} = {len(switcher_idxs)};')
c_builder.add_line()
c_builder.add_flat_array(True, 'const int', f'switcher_idxs_{layer_identifier}', switcher_idxs, '')
c_builder.add_line()
c_builder.add_flat_array(True, 'const int', f'switcher_dests_{layer_identifier}', switcher_dests, '')
c_builder.add_line()
if args.generate_scancodes:
scancodes_flat, scancode_idxs, scancode_nums = flatten_scancodes(scancodes)
c_builder.add_line(f'static const int num_scancodes_{layer_identifier} = {len(scancodes)};')
c_builder.add_line()
c_builder.add_array(True, 'const int', f'scancodes_{layer_identifier}', scancodes_flat, '', '')
c_builder.add_line()
c_builder.add_array(True, 'const int', f'scancode_idxs_{layer_identifier}', scancode_idxs, '', '')
c_builder.add_line()
c_builder.add_array(True, 'const int', f'scancode_nums_{layer_identifier}', scancode_nums, '', '')
c_builder.add_line()
c_builder.add_subsection_comment(f'Layer array')
c_builder.add_line()
c_builder.add_line(f'static const int num_layers_{layout_identifier} = {len(layer_identifiers)};')
c_builder.add_line()
c_builder.add_line(f'static const sq2lv_layer_t layers_{layout_identifier}[] = ' + '{')
for i, identifier in enumerate(layer_identifiers):
c_builder.add_line(' {')
fields = ['keycaps', 'attributes', 'num_switchers', 'switcher_idxs', 'switcher_dests']
if args.generate_scancodes:
fields += ['num_scancodes', 'scancodes', 'scancode_idxs', 'scancode_nums']
for k, field in enumerate(fields):
c_builder.add_line(f' .{field} = {field}_{identifier}{comma_if_needed(fields, k)}')
c_builder.add_line(' }' + comma_if_needed(layer_identifiers, i))
c_builder.add_line('};')
c_builder.add_line()
layouts.append({
'name': layout_id_to_layout_name(layout_id),
'identifier': layout_identifier
})
h_builder.add_line('/* Layout IDs, values can be used as indexes into the sq2lv_layouts array */')
h_builder.add_line('typedef enum {')
for i, layout in enumerate(layouts):
identifier = layout['identifier'].upper()
h_builder.add_line(f' SQ2LV_LAYOUT_{identifier} = {i}{comma_if_needed(layouts, i)}')
h_builder.add_line('} sq2lv_layout_id_t;')
h_builder.add_line()
h_builder.add_line('/* Layer type */')
h_builder.add_line('typedef struct {')
h_builder.add_line(' /* Key caps */')
h_builder.add_line(' const char ** const keycaps;')
h_builder.add_line(' /* Button matrix attributes */')
h_builder.add_line(' const lv_btnmatrix_ctrl_t * const attributes;')
h_builder.add_line(' /* Number of buttons that trigger a layer switch */')
h_builder.add_line(' const int num_switchers;')
h_builder.add_line(' /* Button indexes that trigger a layer switch */')
h_builder.add_line(' const int * const switcher_idxs;')
h_builder.add_line(' /* Indexes of layers to jump to when triggering layer switch buttons */')
h_builder.add_line(' const int * const switcher_dests;')
if args.generate_scancodes:
h_builder.add_line(' /* Total number of scancodes */')
h_builder.add_line(' const int num_scancodes;')
h_builder.add_line(' /* Flat array of scancodes */')
h_builder.add_line(' const int * const scancodes;')
h_builder.add_line(' /* Start index in scancodes array for key cap */')
h_builder.add_line(' const int * const scancode_idxs;')
h_builder.add_line(' /* Number of scancodes for key cap */')
h_builder.add_line(' const int * const scancode_nums;')
h_builder.add_line('} sq2lv_layer_t;')
h_builder.add_line()
h_builder.add_line('/* Layout type */')
h_builder.add_line('typedef struct {')
h_builder.add_line(' /* Total number of layers */')
h_builder.add_line(' const int num_layers;')
h_builder.add_line(' /* Layers array */')
h_builder.add_line(' const sq2lv_layer_t * const layers;')
h_builder.add_line('} sq2lv_layout_t;')
h_builder.add_line()
h_builder.add_line('/* Layouts */')
h_builder.add_line('extern const sq2lv_layout_t sq2lv_layouts[];')
h_builder.add_line('extern const int sq2lv_num_layouts;')
h_builder.add_line()
h_builder.add_line('/* Layout names (suitable for use in lv_dropdown_t */')
h_builder.add_line('extern const char * const sq2lv_layout_names;')
c_builder.add_section_comment('Public interface')
c_builder.add_line()
c_builder.add_line('const sq2lv_layout_t sq2lv_layouts[] = {')
for i, layout in enumerate(layouts):
c_builder.add_line(' /* ' + layout['name'] + ' */')
c_builder.add_line(' {')
c_builder.add_line(' .num_layers = num_layers_' + layout['identifier'] + ',')
c_builder.add_line(' .layers = layers_' + layout['identifier'])
c_builder.add_line(' }' + comma_if_needed(layouts, i))
c_builder.add_line('};')
c_builder.add_line()
c_builder.add_line('const int sq2lv_num_layouts = ' + str(len(layouts)) + ';')
c_builder.add_line()
names = [layout['name'] for layout in layouts]
names = '\n ' + ' "\\n"\n '.join([f'"{name}"' for name in names])
c_builder.add_line(f'const char * const sq2lv_layout_names = {names};')
h_builder.wrap_in_ifndef('SQ2LV_LAYOUTS_H')
c_builder.add_line()
h_builder.add_line()
write_files(c_builder.lines, h_builder.lines)