diff --git a/gen_sinhala_phonetic_layout.py b/gen_sinhala_phonetic_layout.py new file mode 100755 index 0000000..44cc39f --- /dev/null +++ b/gen_sinhala_phonetic_layout.py @@ -0,0 +1,524 @@ +#! /bin/env python3 + +""" +Script to generate a layout based on an existing. + +Tuned to create Sinhala phonetic layout based on qwerty (US), but may be adoped +for other scripts. Look at dicts before the LayoutBuilder code. + +Usage: + python3 gen_sinhala_phonetic_layout [-h|--help] [-v|--verbose] [-o|--output] + +By default with no args will write to corresponding file in `srcs/layouts/`. + +Script uses central symbol (in direction "c") to identify a key, which may not +be appropriate for base (reference) layouts other, than qwerty (US). + +Warning will be printed to stderr if new symbol overrides some symbol of the +reference layout in directions other, than "c". + +Exception will be rised on other +conflicts e. g. when trying to move a symbol into occupied position. + + - Made with latn_qwerty_us.xml from commit `6b1551d` + - Made with Python 3.13 + - Requires Python >= 3.11 +""" + +import argparse +import logging + +from enum import StrEnum +from pathlib import Path +from xml.etree import ElementTree + + +class Placement(StrEnum): + C = 'c' + NW = 'nw' + N = 'n' + NE = 'ne' + E = 'e' + SE = 'se' + S = 's' + SW = 'sw' + W = 'w' + + +# Based on XKB Sinhala (phonetic) +KEYS_MAP: dict[str, tuple[str, str, str, str]] = { + # Row 1 ########################################### + 'q': ('ඍ', 'ඎ', '\u0DD8', '\u0DF2'), + 'w': ('ඇ', 'ඈ', '\u0DD0', '\u0DD1'), + 'e': ('එ', 'ඒ', '\u0DD9', '\u0DDA'), + 'r': ('ර', '', '', ''), # In XKB virama is on layer 2 + 't': ('ත', 'ථ', 'ට', 'ඨ'), + 'y': ('ය', '', '', ''), # In XKB virama is on layer 2 + 'u': ('උ', 'ඌ', '\u0DD4', '\u0DD6'), + 'i': ('ඉ', 'ඊ', '\u0DD2', '\u0DD3'), + 'o': ('ඔ', 'ඕ', '\u0DDC', '\u0DDD'), + 'p': ('ප', 'ඵ', '', ''), + # Row 2 ########################################### + 'a': ('අ', 'ආ', '\u0DCA', '\u0DCF'), + 's': ('ස', 'ශ', 'ෂ', ''), + 'd': ('ද', 'ධ', 'ඩ', 'ඪ'), + 'f': ('ෆ', '\u0D93', '', '\u0DDB'), # In XKB aiyanna placed otherwise + 'g': ('ග', 'ඝ', 'ඟ', ''), + 'h': ('හ', '\u0D83', '\u0DDE', 'ඖ'), + 'j': ('ජ', 'ඣ', 'ඦ', ''), + 'k': ('ක', 'ඛ', 'ඦ', 'ඐ'), + 'l': ('ල', 'ළ', '\u0DDF', '\u0DF3'), + # Row 3 ########################################### + 'z': ('ඤ', 'ඥ', '', ''), # In XKB contains bar, broken bar + 'x': ('ඳ', 'ඬ', '', ''), + 'c': ('ච', 'ඡ', '', ''), + 'v': ('ව', '', '', ''), + 'b': ('බ', 'භ', '', ''), + 'n': ('න', 'ණ', '\u0D82', 'ඞ'), + 'm': ('ම', 'ඹ', '', ''), +} + +# How to place four levels of Key. +# Syntax: LEVEL: PLACEMENT | 'FROM_LEVEL+MODIFIER' +# The last means symbol on level FROM_LEVEL with modifier key MODIFIER gives +# key on level LEVEL +# +LEVELS_MAP = { + 0: Placement.C, + 1: Placement.NE, + 2: '0+shift', + 3: '1+shift', +} + +# Additional modify keys combinations. +# Syntax: +# MODKEY: { A: B } +# +MODMAP_EXTRA: dict[str, dict[str, str]] = { + 'shift': { + # Astrological numbers + '1': '෧', + '2': '෨', + '3': '෩', + '4': '෪', + '5': '෫', + '6': '෬', + '7': '෭', + '8': '෮', + '9': '෯', + '0': '෦', + # Kunddaliya + '.': '෴', + # Extra broken bar intead z key in XKB + '\u007C': '\u00A6', + # Special whitespaces + 'zwj': 'zwnj', + }, + 'fn': { + # Sinhala archaic digits + 'ඍ': '𑇡', # 1 + 'ඇ': '𑇢', # 2 + 'එ': '𑇣', # 3 + 'ර': '𑇤', # 4 + 'ත': '𑇥', # 5 + 'ය': '𑇦', # 6 + 'උ': '𑇧', # 7 + 'ඉ': '𑇨', # 8 + 'ඔ': '𑇩', # 9 + 'ප': '𑇪', # 10 + 'අ': '𑇫', # 20 + 'ස': '𑇬', # 30 + 'ද': '𑇭', # 40 + 'ෆ': '𑇮', # 50 + 'ග': '𑇯', # 60 + 'හ': '𑇰', # 70 + 'ජ': '𑇱', # 80 + 'ක': '𑇲', # 90 + 'ල': '𑇳', # 100 + 'ළ': '𑇴', # 1000 + # Sinhala candrabindu for Sanskrit + 'ණ': '\u0D81', + }, +} + +# Table to move additional characters in reference layout. +# Format is (CENTRAL_CHAR, PLACEMENT): (CENTRAL_CHAR, PLACEMENT). E. g. to move +# char from key with central character "q", direction "se" to key with central +# character "w", direction "sw", add line: +# ('q', Placement.SE): ('w', Placement.SW), +# +# To delete a char, use None as destination placement. E.g.: +# ('q', Placment.SE): ('q', None) +# +# Moving of main char in central placement is not supported. +# +TRANSITIONS_MAP: dict[tuple[str, Placement], tuple[str, Placement | None]] = { + ('q', Placement.SE): ('q', Placement.SW), # loc esc + ('q', Placement.NE): ('q', Placement.SE), # 1 + + ('w', Placement.NE): ('w', Placement.SE), # 2 + + ('e', Placement.SE): ('r', Placement.NW), # loc € + ('e', Placement.NE): ('e', Placement.SE), # 3 + + ('r', Placement.NE): ('r', Placement.SE), # 4 + ('t', Placement.NE): ('t', Placement.SE), # 5 + ('y', Placement.NE): ('y', Placement.SE), # 6 + ('u', Placement.NE): ('u', Placement.SE), # 7 + ('i', Placement.NE): ('i', Placement.SE), # 8 + + ('o', Placement.SE): ('p', Placement.SW), # ) + ('o', Placement.NE): ('o', Placement.SE), # 9 + + ('p', Placement.NE): ('p', Placement.SE), # 0 + + ('a', Placement.NE): ('a', Placement.NW), # ` + ('a', Placement.NW): ('a', Placement.SW), # loc tab + + ('s', Placement.NE): ('s', Placement.NW), # loc § + + ('g', Placement.SW): ('g', Placement.NW), # _ + ('g', Placement.NE): ('g', Placement.SW), # - + + ('h', Placement.SW): ('h', Placement.NW), # + + ('h', Placement.NE): ('h', Placement.SW), # = + + ('l', Placement.NE): ('l', Placement.NW), # | + + ('x', Placement.NE): ('x', Placement.NW), # loc † + ('c', Placement.NE): ('c', Placement.NW), # < + ('b', Placement.NE): ('b', Placement.NW), # ? + ('n', Placement.NE): ('n', Placement.NW), # : + ('m', Placement.NE): ('m', Placement.NW), # " +} + +# Add additional characters to arbitrary places. +# Syntax is CHAR: POSITION, where POSITION is a pari as in TRANSITIONS_MAP. +# +CHARS_EXTRA = { + # In XKB ZWJ is on `/` key, and ZWNJ is on spacebar + 'zwj': ('m', Placement.SE), +} + + +# List of char unicode numbers and inclusive ranges of numbers to encode as XML +# numeric character references. +# Good for combining signs to not mess with quotes. +# Characters in line of the keyboard tag will not be escaped. +# +ESCAPE_LIST: list[int | tuple[int, int]] = [ + # Sinhalese diacritics + (0xD81, 0xD83), + (0xDCA, 0xDDF), +] + +# Default filename. Output path can be overrided with `-o` flag also. +LAYOUT_FILENAME = 'sinhala_phonetic.xml' + +# Will be placed after XML declaration. Need to have proper tags. +COMMENT = ''' + +''' + +BASE_DIR = Path(__file__).parent +REFERENCE_LAYOUT_FILE = BASE_DIR / 'srcs/layouts/latn_qwerty_us.xml' + +LOGGER = logging.getLogger(__name__) +KeysMapType = list[list[ElementTree.Element]] + + +class LayoutGenError(RuntimeError): + ... + + +def xml_elem_to_str(element: ElementTree.Element) -> str: + return ElementTree.tostring( + element, + xml_declaration=False, + encoding='unicode').strip() + + +def keys_map_to_str(keys_map: KeysMapType) -> str: + """ Make laout rows map printable for debug purposes """ + result = '[\n' + for row in keys_map: + result += ' ' * 4 + for key in row: + result += str(key.attrib) + ', ' + result += '\n' + result += ']' + return result + + +def is_in_escape_list(char: str | int) -> bool: + if isinstance(char, str): + char = ord(char) + for item in ESCAPE_LIST: + if isinstance(item, tuple) and char >= item[0] and char <= item[1]: + return True + elif isinstance(item, int): + if char == item: + return True + else: + TypeError(f'Unexpected item {item} of ESCAPE_LIST') + return False + + +def xml_encode_char(ch: str | int) -> str: + if isinstance(ch, str): + ch = ord(ch) + hex_val = hex(ch).split('x')[-1] + return f'&#x{hex_val.upper().zfill(4)};' + + +class LayoutBuilder: + XML_DECLARATION = "" + + def __init__( + self, + name: str = '', + script: str = '', + numpad_script: str = '', + comment: str = '', + ) -> None: + """ + :param comment: MUST be a valid XML comment wrapped in + """ + attrs = {} + if name: + attrs['name'] = name + if script: + attrs['script'] = script + if numpad_script: + attrs['numpad_script'] = numpad_script + self._comment = None + if comment: + self._comment = comment.strip() or None + self._xml_keyboard = ElementTree.Element('keyboard', attrib=attrs) + self._modmap = ElementTree.Element('modmap') + + @staticmethod + def _parse_reference_layout() -> list[ElementTree.Element]: + return ElementTree.parse(REFERENCE_LAYOUT_FILE).findall('row') + + @staticmethod + def _move_untransited_to_new_map( + ref_map: KeysMapType, + new_map: KeysMapType + ) -> None: + coordinates = [ + (row_num, key_num) + for row_num in range(len(ref_map)) + for key_num in range(len(ref_map[row_num])) + ] + + for row_num, key_num in coordinates: + old_key = ref_map[row_num][key_num] + new_key = new_map[row_num][key_num] + for k, val in old_key.attrib.items(): + if (transited := new_key.attrib.get(k)) is not None: + msg = ( + f'Transition of {transited} to' + f' {new_key.get(Placement.C)}:{k} conflictls with' + f' existing value "{val}"') + raise LayoutGenError(msg) + new_key.set(k, val) + + @staticmethod + def _add_extra_chars_to_ref_map( + coord_map: dict[str, tuple[int, int]], + new_map: KeysMapType + ) -> None: + for char, (to_key_name, to_plc) in CHARS_EXTRA.items(): + if not (to_coord := coord_map.get(to_key_name)): + msg = f'Trying to add "{char}" to missing key "{to_key_name}"' + raise LayoutGenError(msg) + row_num, key_num = to_coord + key = new_map[row_num][key_num] + if (existing := key.get(to_plc)) is not None: + msg = f'Trying to add char to <{to_key_name}:{to_plc}>, but already contains "{existing}"' + raise LayoutGenError(msg) + key.set(to_plc, char) + LOGGER.info( + 'Added "%s" to <%s:%s>', + char, to_key_name, to_plc) + + @classmethod + def _apply_transitions(cls, ref_map: list) -> list: + coord_map: dict[str, tuple[int, int]] = {} + + coordinates = [ + (row_num, key_num) + for row_num in range(len(ref_map)) + for key_num in range(len(ref_map[row_num])) + ] + + for row_num, key_num in coordinates: + row = ref_map[row_num] + key = row[key_num] + key_name = key.get(Placement.C) + if key_name in coord_map: + msg = f'Duplicated value "{key_name}" in central position' + raise LayoutGenError(msg) + coord_map[key_name] = (row_num, key_num) + + # Make new map with empty keys + result_map = [[ElementTree.Element('key') for key in row] for row in ref_map] + + # Place by transitions map on new places + for (from_key_name, from_plc), (to_key_name, to_plc) in TRANSITIONS_MAP.items(): + if Placement.C in (from_plc, to_plc): + raise NotImplementedError('Transition from or to placment "c"') + if not (from_coord := coord_map.get(from_key_name)): + raise LayoutGenError(f'Transition from missing key {from_key_name}') + if not (to_coord := coord_map.get(to_key_name)): + raise LayoutGenError(f'Transition to missing key {to_key_name}') + from_key = ref_map[from_coord[0]][from_coord[1]] + to_key = result_map[to_coord[0]][to_coord[1]] + try: + val = from_key.attrib.pop(from_plc) + except KeyError: + msg = f'No value in key {from_key_name}, placement {from_plc} to move' + raise LayoutGenError(msg) + if to_plc is not None: + if to_key.get(to_plc): + msg = f'Second transition to key {to_key_name}, placement {to_plc}' + raise LayoutGenError(msg) + to_key.set(to_plc, val) + LOGGER.info( + 'Moved "%s" from <%s:%s> to <%s:%s>', + val, from_key_name, from_plc, to_key_name, to_plc) + else: + LOGGER.info( + 'Deleted "%s" from <%s:%s>', + val, from_key_name, from_plc) + + # Fill new map with other values + cls._move_untransited_to_new_map(ref_map, new_map=result_map) + + # Add additional characters + cls._add_extra_chars_to_ref_map(coord_map, new_map=result_map) + + return result_map + + @staticmethod + def _resolve_placement( + key: ElementTree.Element, + placement: Placement, + new_char: str + ) -> None: + if placement != Placement.C: + central_char = key.get(Placement.C) + existing = key.get(placement) + if existing: + LOGGER.warning( + 'Placement %s of key %s already occupied with %s', + placement, central_char, existing) + key.set(placement, new_char) + + def _process_key(self, key: ElementTree.Element) -> ElementTree.Element: + central_char = key.get(Placement.C) + if not central_char: + return key + new_key_entry = KEYS_MAP.get(central_char) + if new_key_entry is None: + return key + + for level, placement_spec in LEVELS_MAP.items(): + if not (new_char := new_key_entry[level]): + continue + if '+' in placement_spec: + pair = placement_spec.split('+') + from_level, modkey = int(pair[0]), pair[1] + key_a = new_key_entry[from_level] + key_b = new_char + if key_a is None: + raise LayoutGenError(f'Tried to modife {key_a} to {key_b}') + ElementTree.SubElement(self._modmap, modkey, a=key_a, b=key_b) + else: + placement = Placement(placement_spec) + self._resolve_placement(key, placement=placement, new_char=new_char) + return key + + @staticmethod + def _make_extra_modmap(modmap: ElementTree.Element) -> ElementTree.Element: + for modkey, ab_map in MODMAP_EXTRA.items(): + for a, b in ab_map.items(): + LOGGER.info('Adding modmap %s "%s" -> "%s"', modkey, a, b) + ElementTree.SubElement(modmap, modkey, a=a, b=b) + return modmap + + @staticmethod + def _post_escape(data: str) -> str: + buf = '' + lines = data.splitlines(keepends=True) + for line in lines: + # Skip keyboard tag line to keep attributes + if ' None: + raw_ref_rows = self._parse_reference_layout() + ref_rows = self._apply_transitions(raw_ref_rows) + for row in ref_rows: + new_row = ElementTree.SubElement(self._xml_keyboard, 'row') + for key in row: + LOGGER.debug( + 'Processing reference entry %s', + xml_elem_to_str(key)) + new_row.append(self._process_key(key)) + self._modmap = self._make_extra_modmap(self._modmap) + self._xml_keyboard.append(self._modmap) + + def get_xml(self) -> str: + ElementTree.indent(self._xml_keyboard) + body_raw = xml_elem_to_str(self._xml_keyboard) + body = self._post_escape(body_raw) + + result = self.XML_DECLARATION + '\n' + if self._comment: + result += self._comment + '\n' + result += body + '\n' + + return result + + +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog='gen_sinhala_phonetic_layout', + description='Generate XKB-based Sinhala layout',) + parser.add_argument( + '-o', + '--output', + default=BASE_DIR / f'srcs/layouts/{LAYOUT_FILENAME}', + help='File to write result, `-` for stdout') + parser.add_argument( + '-v', + '--verbose', + help='More verbose logging', + action='store_true') + return parser.parse_args() + + +if __name__ == '__main__': + args = get_args() + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.WARNING, + format='%(levelname)s: %(message)s') + builder = LayoutBuilder(name='සිංහල', script='sinhala', comment=COMMENT) + builder.build() + content = builder.get_xml() + if args.output == '-': + print(content) + else: + with open(args.output, 'w') as file: + file.write(content) diff --git a/res/values/layouts.xml b/res/values/layouts.xml index e2bec86..417b497 100644 --- a/res/values/layouts.xml +++ b/res/values/layouts.xml @@ -80,6 +80,7 @@ latn_qwertz_sq latn_workman_us shaw_imperial_en + sinhala_phonetic tamil_default urdu_phonetic_ur custom @@ -163,6 +164,7 @@ QWERTZ (Albanian) WORKMAN (US) Shaw Imperial + සිංහල தமிழ் Urdu Phonetic @string/pref_layout_e_custom @@ -246,6 +248,7 @@ @xml/latn_qwertz_sq @xml/latn_workman_us @xml/shaw_imperial_en + @xml/sinhala_phonetic @xml/tamil_default @xml/urdu_phonetic_ur -1 diff --git a/res/xml/method.xml b/res/xml/method.xml index 8d738b9..8b2599f 100644 --- a/res/xml/method.xml +++ b/res/xml/method.xml @@ -51,6 +51,7 @@ + diff --git a/srcs/juloo.keyboard2/KeyValue.java b/srcs/juloo.keyboard2/KeyValue.java index ecfdd94..ee7b415 100644 --- a/srcs/juloo.keyboard2/KeyValue.java +++ b/srcs/juloo.keyboard2/KeyValue.java @@ -769,6 +769,38 @@ public final class KeyValue implements Comparable case "௲": case "௳": return makeStringKey(name, FLAG_SMALLER_FONT); + /* Sinhala letters to reduced size */ + case "අ": case "ආ": case "ඇ": case "ඈ": case "ඉ": + case "ඊ": case "උ": case "ඌ": case "ඍ": case "ඎ": + case "ඏ": case "ඐ": case "එ": case "ඒ": case "ඓ": + case "ඔ": case "ඕ": case "ඖ": case "ක": case "ඛ": + case "ග": case "ඝ": case "ඞ": case "ඟ": case "ච": + case "ඡ": case "ජ": case "ඣ": case "ඤ": case "ඥ": + case "ඦ": case "ට": case "ඨ": case "ඩ": case "ඪ": + case "ණ": case "ඬ": case "ත": case "ථ": case "ද": + case "ධ": case "න": case "ඳ": case "ප": case "ඵ": + case "බ": case "භ": case "ම": case "ඹ": case "ය": + case "ර": case "ල": case "ව": case "ශ": case "ෂ": + case "ස": case "හ": case "ළ": case "ෆ": + /* Astrological numbers */ + case "෦": case "෧": case "෨": case "෩": case "෪": + case "෫": case "෬": case "෭": case "෮": case "෯": + case "ෲ": case "ෳ": + /* Diacritics */ + case "\u0d81": case "\u0d82": case "\u0d83": case "\u0dca": + case "\u0dcf": case "\u0dd0": case "\u0dd1": case "\u0dd2": + case "\u0dd3": case "\u0dd4": case "\u0dd6": case "\u0dd8": + case "\u0dd9": case "\u0dda": case "\u0ddb": case "\u0ddc": + case "\u0ddd": case "\u0dde": case "\u0ddf": + /* Archaic digits */ + case "𑇡": case "𑇢": case "𑇣": case "𑇤": case "𑇥": + case "𑇦": case "𑇧": case "𑇨": case "𑇩": case "𑇪": + case "𑇫": case "𑇬": case "𑇭": case "𑇮": case "𑇯": + case "𑇰": case "𑇱": case "𑇲": case "𑇳": case "𑇴": + /* Exta */ + case "෴": case "₨": // Rupee is not exclusively Sinhala sign + return makeStringKey(name, FLAG_SMALLER_FONT); + /* Internal keys */ case "selection_mode": return makeInternalModifier(Modifier.SELECTION_MODE); diff --git a/srcs/layouts/sinhala_phonetic.xml b/srcs/layouts/sinhala_phonetic.xml new file mode 100644 index 0000000..d9ae23f --- /dev/null +++ b/srcs/layouts/sinhala_phonetic.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +