tools: add xkbcli-scaffold-new-layout as helper tool
[platform/upstream/libxkbcommon.git] / tools / xkbcli-scaffold-new-layout.py
1 #!/usr/bin/env python3
2 #
3 #  Copyright © 2020 Red Hat, Inc.
4 #
5 #  Permission is hereby granted, free of charge, to any person obtaining a
6 #  copy of this software and associated documentation files (the "Software"),
7 #  to deal in the Software without restriction, including without limitation
8 #  the rights to use, copy, modify, merge, publish, distribute, sublicense,
9 #  and/or sell copies of the Software, and to permit persons to whom the
10 #  Software is furnished to do so, subject to the following conditions:
11 #
12 #  The above copyright notice and this permission notice (including the next
13 #  paragraph) shall be included in all copies or substantial portions of the
14 #  Software.
15 #
16 #  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 #  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 #  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
19 #  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 #  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21 #  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22 #  DEALINGS IN THE SOFTWARE.
23
24
25 # This script creates the necessary scaffolding to create a custom keyboard
26 # layout or option. It does not actually configure anything, it merely creates
27 # the required directory structure and file scaffolding for the key
28 # configurations to be added by the user.
29 #
30
31 import argparse
32 import logging
33 import os
34 import re
35 import sys
36
37 from pathlib import Path
38 from textwrap import dedent
39
40
41 # Default values are set by meson but to make this usable directly within the
42 # git source tree, use some sensible default values and return those where the
43 # meson define hasn't been replaced.
44 def default_value(key):
45     defaults = {
46         'extrapath': ('@XKBEXTRAPATH@', '/etc/xkb'),
47         'rules': ('@DEFAULT_XKB_RULES@', 'evdev'),
48     }
49
50     mesondefault, default = defaults[key]
51     if mesondefault.startswith('@') and mesondefault.endswith('@'):
52         return default
53     else:
54         return mesondefault
55
56
57 logging.basicConfig(level=logging.DEBUG)
58 logger = logging.getLogger('xkbcli')
59 logger.setLevel(logging.INFO)
60
61
62 def create_directory_structure(basedir):
63     basedir.mkdir(exist_ok=True)
64     # Note: we skip geometry
65     for d in ['compat', 'keycodes', 'rules', 'symbols', 'types']:
66         (basedir / d).mkdir(exist_ok=True)
67
68
69 def create_rules_template(base_path, ruleset, layout, option):
70     rules = base_path / 'rules' / ruleset
71     if rules.exists():
72         logger.warning(f'Rules file {rules} already exists, skipping')
73         return
74
75     with open(rules, 'w') as rulesfile:
76         header = dedent(f'''\
77             // generated by xkbcli scaffold-new-layout
78
79             // Note: no rules file entries are required for for a custom layout
80
81             ''')
82         rulesfile.write(header)
83
84         if option:
85             group, section = option
86             option_template = dedent(f'''\
87             // This section maps XKB option "{group}:{section}" to the '{section}' section in the
88             // 'symbols/{group}' file.
89             //
90             ! option = symbols
91               {group}:{section} = +{group}({section})
92
93             ''')
94             rulesfile.write(option_template)
95
96         footer = dedent(f'''\
97             // Include the system '{ruleset}' file
98             ! include %S/{ruleset}
99             ''')
100         rulesfile.write(footer)
101
102
103 def create_symbols_template(basedir, layout_variant, option):
104     if not layout_variant and not option:
105         logger.info('No layout or option given, skipping symbols templates')
106         return
107
108     layout, variant = layout_variant
109     layout_file = Path(basedir) / 'symbols' / layout
110     if layout_file.exists():
111         logger.warning(f'Symbols file {layout_file} already exists, skipping')
112         layout_fd = None
113     else:
114         layout_fd = open(layout_file, 'w')
115         layout_fd.write('// generated by xkbcli scaffold-new-sources\n\n')
116
117     group, section = option
118     options_file = Path(basedir) / 'symbols' / group
119
120     # Cater for a potential "custom(variant)" layout and "custom:foo" option,
121     # i.e. where both layout and options use the same symbols file
122     if options_file == layout_file:
123         options_fd = layout_fd
124     elif options_file.exists():
125         logger.warning(f'File {options_file} already exists, skipping')
126         options_fd = None
127     else:
128         options_fd = open(options_file, 'w')
129         options_fd.write('// generated by xkbcli scaffold\n\n')
130
131     if layout_fd:
132         if variant is None:
133             default = 'default '
134             variant = 'basic'
135             include = ''
136         else:
137             default = ''
138             include = f'include "{layout}(basic)"'
139
140         logger.debug(f'Writing "{layout}({variant})" layout template to {layout_file}')
141         layout_fd.write(dedent(f'''\
142                 {default}partial alphanumeric_keys modifier_keys
143                 xkb_symbols "{variant}" {{
144                     name[Group1]= "{variant} ({layout})";
145
146                     {include}
147
148                     // Example:
149                     // key <CAPS> {{ [ Escape ] }};
150                 }};
151                 '''))
152
153     if options_fd:
154         logger.debug(f'Writing "{section}" options template to {options_file}')
155         options_fd.write(dedent(f'''\
156             partial modifier_keys
157             xkb_symbols "{section}" {{
158                 // Example:
159                 // key <CAPS> {{ [ Escape ] }};
160             }};
161             '''))
162
163     layout_fd.close()
164     options_fd.close()
165
166
167 def create_registry_template(basedir, ruleset, layout_variant, option):
168     xmlpath = Path(basedir) / 'rules' / f'{ruleset}.xml'
169     if xmlpath.exists():
170         logger.warning(f'XML file {xmlpath} already exists, skipping')
171         return
172
173     with open(xmlpath, 'w') as xmlfile:
174         logger.debug(f'Writing XML file {xmlfile}')
175
176         if layout_variant:
177             layout, variant = layout_variant
178             if variant:
179                 variant_template = f'''
180                   <variantList>
181                     <variant>
182                       <configItem>
183                         <name>{variant}</name>
184                         <shortDescription>{variant}</shortDescription>
185                         <description>{layout} ({variant})</description>
186                       </configItem>
187                     </variant>
188                   </variantList>
189                 '''
190             else:
191                 variant_template = ''
192             layout_template = f'''
193               <layoutList>
194                 <layout>
195                   <configItem>
196                     <name>{layout}</name>
197                     <shortDescription>{layout}</shortDescription>
198                     <description>{layout}</description>
199                   </configItem>
200                   {variant_template}
201                 </layout>
202               </layoutList>'''
203         else:
204             layout_template = ''
205
206         if option:
207             group, section = option
208             option_template = f'''
209               <optionList>
210                 <group allowMultipleSelection="true">
211                   <configItem>
212                     <name>{group}</name>
213                     <description>{group} options</description>
214                   </configItem>
215                   <option>
216                     <configItem>
217                       <name>{group}:{section}</name>
218                       <description>{group}:{section} description</description>
219                     </configItem>
220                   </option>
221                 </group>
222               </optionList>
223             '''
224         else:
225             option_template = ''
226
227         template = dedent(f'''\
228             <?xml version="1.0" encoding="UTF-8"?>
229             <!DOCTYPE xkbConfigRegistry SYSTEM "xkb.dtd">
230             <!-- generated by xkbcli scaffold-new-layout -->
231             <xkbConfigRegistry version="1.1">
232             {layout_template}
233             {option_template}
234             </xkbConfigRegistry>''')
235         xmlfile.write(template)
236
237
238 def main():
239     epilog = dedent('''\
240         This tool creates the directory structure and template files for
241         a custom XKB layout and/or option.
242
243         Use the --option and --layout arguments to specify the template names to
244         use. These use default values, unset those with the empty string.
245
246         Examples:
247
248           xkbcli scaffold --layout mylayout --option ''
249           xkbcli scaffold --layout '' --option 'custom:foo'
250           xkbcli scaffold --system --layout 'us(myvariant)' --option 'custom:foo'
251
252         This is a simple tool. If files already exist, the scaffolding skips
253         over that file and the result may not be correct.
254         ''')
255
256     parser = argparse.ArgumentParser(
257         description='Create scaffolding to configure custom keymaps',
258         formatter_class=argparse.RawDescriptionHelpFormatter,
259         epilog=epilog)
260     parser.add_argument('-v', '--verbose', action='store_true', default=False,
261                         help='Enable verbose debugging output')
262     group = parser.add_mutually_exclusive_group()
263     group.add_argument('--system', action='store_true', default=False,
264                        help=f'Create scaffolding in {default_value("extrapath")}')
265     group.add_argument('--user', action='store_true', default=False,
266                        help='Create scaffolding in $XDG_CONFIG_HOME/xkb')
267     parser.add_argument('--rules', type=str, default=default_value("rules"),
268                         help=f'Ruleset name (default: "{default_value("rules")}")')
269     parser.add_argument('--layout', type=str, default='us(myvariant)',
270                         help='Add scaffolding for a new layout or variant (default: "us(myvariant)")')
271     parser.add_argument('--option', type=str, default='custom:myoption',
272                         help='Add scaffolding for a new option (default: "custom:myoption")')
273
274     args = parser.parse_args()
275
276     if args.verbose:
277         logger.setLevel(logging.DEBUG)
278
279     if args.system:
280         basedir = Path(default_value('extrapath'))
281     else:
282         xdgdir = os.getenv('XDG_CONFIG_HOME')
283         if not xdgdir:
284             home = os.getenv('HOME')
285             if not home:
286                 logger.error('Unable to resolve base directory from $XDG_CONFIG_HOME or $HOME')
287                 sys.exit(1)
288             xdgdir = Path(home) / '.config'
289         basedir = Path(xdgdir) / 'xkb'
290
291     if args.option:
292         try:
293             group, section = args.option.split(':')
294             option = (group, section)
295         except ValueError:
296             logger.error('Option must be specified as "group:name"')
297             sys.exit(1)
298     else:
299         option = None
300
301     if args.layout:
302         # match either "us" or "us(intl)" style layouts
303         # [(] should be \( but flake8 complains about that
304         match = re.fullmatch('([a-z]+)([(][a-z]+[)])?', args.layout, flags=re.ASCII)
305         l, v = match.group(1, 2)
306         if v:
307             v = v.strip('()')  # regex above includes ( )
308         layout_variant = l, v
309     else:
310         layout_variant = None
311
312     try:
313         create_directory_structure(basedir)
314         create_rules_template(basedir, args.rules, layout_variant, option)
315         create_symbols_template(basedir, layout_variant, option)
316         create_registry_template(basedir, args.rules, layout_variant, option)
317     except PermissionError as e:
318         logger.critical(e)
319         sys.exit(1)
320
321     print(f'XKB scaffolding for layout "{args.layout}" and option "{args.option}" is now in place.')
322     print(f'Edit the files in {basedir} to create the actual key mapping.')
323
324
325 if __name__ == '__main__':
326     main()