summaryrefslogtreecommitdiff
path: root/lulua/render.py
diff options
context:
space:
mode:
authorLars-Dominik Braun <lars@6xq.net>2019-09-17 18:31:24 +0200
committerLars-Dominik Braun <lars@6xq.net>2019-09-17 18:31:24 +0200
commit969d1d393e75a229523c234203059fb570d28ed1 (patch)
tree5e11a08af59309b8245b3c3d062de639a7d30350 /lulua/render.py
downloadlulua-969d1d393e75a229523c234203059fb570d28ed1.tar.gz
lulua-969d1d393e75a229523c234203059fb570d28ed1.tar.bz2
lulua-969d1d393e75a229523c234203059fb570d28ed1.zip
Initial import
Diffstat (limited to 'lulua/render.py')
-rw-r--r--lulua/render.py353
1 files changed, 353 insertions, 0 deletions
diff --git a/lulua/render.py b/lulua/render.py
new file mode 100644
index 0000000..cbe553b
--- /dev/null
+++ b/lulua/render.py
@@ -0,0 +1,353 @@
+# Copyright (c) 2019 lulua contributors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import argparse, sys, unicodedata, logging
+from collections import namedtuple, defaultdict
+from operator import attrgetter
+from datetime import datetime
+
+import svgwrite
+from svgwrite import em
+import yaml
+
+from .layout import LITTLE, RING, MIDDLE, INDEX, THUMB, GenericLayout, defaultLayouts
+from .writer import Writer
+from .keyboard import defaultKeyboards
+from .util import first
+
+RendererSettings = namedtuple ('RendererSetting', ['buttonMargin', 'middleGap', 'buttonWidth', 'rounded', 'shadowOffset'])
+
+class Renderer:
+ """ Keyboard to SVG renderer """
+
+ __slots__ = ('keyboard', 'layout', 'settings', 'cursor', 'writer')
+
+ defaultSettings = RendererSettings (
+ buttonMargin=0.2,
+ middleGap=0.1,
+ buttonWidth=2,
+ rounded=0.1,
+ shadowOffset=0.05,
+ )
+
+ def __init__ (self, keyboard, layout=None, writer=None, settings=None):
+ self.keyboard = keyboard
+ self.layout = layout
+ self.writer = writer
+ self.settings = settings or self.defaultSettings
+
+ self.cursor = [0, 0]
+
+ def render (self):
+ maxWidth = 0
+ maxHeight = 0
+
+ settings = self.settings
+ self.cursor = [0, 0]
+
+ # compute row widths so we can apply margin correction, balancing
+ # out their widths
+ rowWidth = []
+ for l, r in self.keyboard:
+ w = 0
+ for btn in l:
+ w += self.buttonWidth (btn) + settings.buttonMargin
+ w += settings.middleGap
+ for btn in r:
+ w += self.buttonWidth (btn) + settings.buttonMargin
+ w -= settings.buttonMargin
+ rowWidth.append (w)
+ logging.info (f'row width {rowWidth}')
+
+ g = svgwrite.container.Group ()
+
+ for l, r in self.keyboard:
+ for btn in l:
+ b, width = self._addButton (btn)
+ g.add (b)
+ self.cursor[0] += width + settings.buttonMargin
+ self.cursor[0] += settings.middleGap
+ for btn in r:
+ b, width = self._addButton (btn)
+ g.add (b)
+ self.cursor[0] += width + settings.buttonMargin
+ self.cursor[1] += settings.buttonWidth + settings.buttonMargin
+ maxWidth = max (self.cursor[0], maxWidth)
+ self.cursor[0] = 0
+ maxHeight = self.cursor[1]
+
+ return g, (maxWidth, maxHeight)
+
+ def buttonWidth (self, btn):
+ return btn.width * self.settings.buttonWidth
+
+ def _addButton (self, btn):
+ def toDisplayText (text):
+ if text is None:
+ return text
+ if len (text) == 1 and unicodedata.combining (text) != 0:
+ # add circle if combining
+ return '\u25cc' + text
+ invMap = {
+ '\t': '⭾',
+ '\n': '↳',
+ ' ': '\u2423',
+ '\u200e': '[LRM]', # left to right mark
+ '\u061c': '[ALM]', # arabic letter mark
+ '\u202c': '[PDF]', # pop directional formatting
+ "\u2066": '[LRI]', # left-to-right isolate (lri)
+ "\u2067": '[RLI]', # right-to-left isolate (rli)
+ "\u2069": '[PDI]', # pop directional isolate (pdi)
+ }
+ return invMap.get (text, text)
+
+ xoff, yoff = self.cursor
+ settings = self.settings
+ width = self.buttonWidth (btn)
+
+ hand, finger = self.writer.getHandFinger (btn)
+
+ gclass = ['button', f'finger-{finger.name.lower ()}', f'hand-{hand.name.lower ()}']
+
+ g = svgwrite.container.Group ()
+
+ # map modifier keys to arrows
+ mod = frozenset ([btn])
+ isModifier = self.layout.isModifier (mod)
+ if isModifier:
+ layerToArrow = {1: '⭡', 2: '⭧', 3: '⭨'}
+ i, layer = self.layout.modifierToLayer (mod)
+ buttonText = [layerToArrow[i]]
+ gclass.append ('modifier')
+ else:
+ buttonText = list (map (toDisplayText, self.layout.getButtonText (btn)))
+
+ # background rect
+ if any (buttonText):
+ b = svgwrite.shapes.Rect (
+ insert=((xoff+settings.shadowOffset)*em, (yoff+settings.shadowOffset)*em),
+ size=(width*em, settings.buttonWidth*em),
+ rx=settings.rounded*em,
+ ry=settings.rounded*em,
+ class_='shadow')
+ g.add (b)
+ else:
+ gclass.append ('unused')
+ b = svgwrite.shapes.Rect (
+ insert=(xoff*em, yoff*em),
+ size=(width*em, settings.buttonWidth*em),
+ rx=settings.rounded*em,
+ ry=settings.rounded*em,
+ class_='cap')
+ g.add (b)
+
+ g.attribs['class'] = ' '.join (gclass)
+
+ # button marker
+ if btn.isMarked:
+ start = (xoff+width*0.3, yoff+settings.buttonWidth*0.9)
+ end = (xoff+width*0.7, yoff+settings.buttonWidth*0.9)
+ # its shadow
+ l = svgwrite.shapes.Line (
+ map (lambda x: (x+settings.shadowOffset)*em, start),
+ map (lambda x: (x+settings.shadowOffset)*em, end),
+ stroke_width=0.07*em,
+ class_='marker-shadow')
+ g.add (l)
+ # the marker itself
+ l = svgwrite.shapes.Line (
+ map (em, start),
+ map (em, end),
+ stroke_width=0.07*em,
+ class_='marker')
+ g.add (l)
+
+ # clock-wise from bottom-left to bottom-right
+ textParam = [
+ (-0.5, 0.6, 'layer-1'),
+ (-0.5, -1/3, 'layer-2'),
+ (0.5, -1/3, 'layer-3'),
+ (0.5, 2/3, 'layer-4'),
+ ]
+ for text, (txoff, tyoff, style) in zip (buttonText, textParam):
+ if text is None:
+ continue
+ # actual text must be inside tspan, so we can apply smaller font size
+ # without affecting element position
+ t = svgwrite.text.Text ('',
+ insert=((xoff+width/2+txoff)*em, (yoff+settings.buttonWidth/2+tyoff)*em),
+ text_anchor='middle',
+ class_='label')
+ if text.startswith ('[') and text.endswith (']'):
+ t.add (svgwrite.text.TSpan (text[1:-1],
+ class_='controlchar',
+ direction='ltr'))
+ g.add (svgwrite.shapes.Rect (
+ insert=((xoff+width/2+txoff-0.4)*em, (yoff+settings.buttonWidth/2+tyoff-0.4)*em),
+ size=(0.8*em, 0.5*em),
+ stroke_width=0.05*em,
+ stroke_dasharray='5,3',
+ class_='controllabel'))
+ else:
+ t.add (svgwrite.text.TSpan (text, class_=style, direction='rtl'))
+ g.add (t)
+
+ return g, width
+
+def unique (l, key):
+ return dict ((key (v), v) for v in l).values ()
+
+def render ():
+ parser = argparse.ArgumentParser(description='Render keyboard into output format.')
+ parser.add_argument('-l', '--layout', metavar='LAYOUT', help='Keyboard layout name')
+ parser.add_argument('-k', '--keyboard', metavar='KEYBOARD',
+ default='ibmpc105', help='Physical keyboard name')
+ parser.add_argument('format', metavar='FORMAT', choices={'svg', 'xmodmap'}, help='Output format')
+ parser.add_argument('output', metavar='FILE', help='Output file')
+
+ logging.basicConfig (level=logging.INFO)
+ args = parser.parse_args()
+
+ keyboard = defaultKeyboards[args.keyboard]
+ layout = defaultLayouts[args.layout].specialize (keyboard)
+ writer = Writer (layout)
+
+ if args.format == 'svg':
+ style = """
+ svg {
+ font-family: "IBM Plex Arabic";
+ font-size: 25pt;
+ }
+ .button.unused {
+ opacity: 0.6;
+ }
+ .button .label .layer-1 {
+ }
+ .button.modifier .label .layer-1 {
+ font-size: 80%;
+ }
+ .button .label .layer-2, .button .label .layer-3, .button .label .layer-4 {
+ font-size: 80%;
+ font-weight: 200;
+ }
+ .button .label .controlchar {
+ font-size: 40%; font-family: sans-serif;
+ }
+ .button .cap {
+ fill: #eee8d5;
+ }
+ .button.finger-little .shadow {
+ fill: #dc322f; /* red */
+ }
+ .button.finger-ring .shadow {
+ fill: #268bd2; /* blue */
+ }
+ .button.finger-middle .shadow {
+ fill: #d33682; /* magenta */
+ }
+ .button.finger-index .shadow {
+ fill: #6c71c4; /* violet */
+ }
+ .button.finger-thumb .shadow {
+ fill: #2aa198; /* cyan */
+ }
+ .button .label {
+ fill: #657b83;
+ }
+ .button .controllabel {
+ stroke: #657b83;
+ fill: none;
+ }
+ .button .marker-shadow {
+ stroke: #93a1a1;
+ }
+ .button .marker {
+ stroke: #fdf6e3;
+ }
+ """
+ r = Renderer (keyboard, layout=layout, writer=writer)
+ rendered, (w, h) = r.render ()
+ d = svgwrite.Drawing(args.output, size=(w*em, h*em), profile='full')
+ d.defs.add (d.style (style))
+ d.add (rendered)
+ d.save()
+ elif args.format == 'xmodmap':
+ with open (args.output, 'w') as fd:
+ # inspired by https://neo-layout.org/neo_de.xmodmap
+ fd.write ('\n'.join ([
+ '!! auto-generated xmodmap',
+ f'!! layout: {layout.name}',
+ f'!! generated: {datetime.utcnow ()}',
+ '',
+ 'clear Lock',
+ 'clear Mod2',
+ 'clear Mod3',
+ 'clear Mod5',
+ '',
+ ]))
+
+ keycodeMap = defaultdict (list)
+ # XXX: this is an ugly quirk to get layer 4 working
+ # layers: 1, 2, 3, 5, 4, None, 6, 7
+ for i in (0, 1, 2, 4, 3, 99999, 5, 6):
+ if i >= len (layout.layers):
+ for btn in unique (keyboard.keys (), key=attrgetter ('xorgKeycode')):
+ keycodeMap[btn].append ('NoSymbol')
+ continue
+ l = layout.layers[i]
+ # space button shares the same keycode and must be removed
+ for btn in unique (keyboard.keys (), key=attrgetter ('xorgKeycode')):
+ if not layout.isModifier (frozenset ([btn])):
+ text = l.layout.get (btn)
+ if not text:
+ if btn.name == 'Br_bs' and i == 0:
+ text = 'BackSpace'
+ else:
+ text = 'NoSymbol'
+ else:
+ # some keys cannot be represented by unicode
+ # characters and must be mapped
+ specialMap = {
+ '\t': 'Tab',
+ '\n': 'Return',
+ ' ': 'space',
+ }
+ text = specialMap.get (text, f'U{ord (text):04X}')
+ keycodeMap[btn].append (text)
+ # XXX layer modmap functionality is fixed for now
+ layerMap = [
+ [],
+ ['Shift_L', 'Shift_Lock'],
+ ['ISO_Group_Shift', 'ISO_Group_Shift', 'ISO_First_Group', 'NoSymbol'],
+ ['ISO_Level3_Shift', 'ISO_Level3_Shift', 'ISO_Group_Shift', 'ISO_Group_Shift', 'ISO_Level3_Lock', 'NoSymbol'],
+ ]
+ for i, l in enumerate (layout.layers):
+ for m in l.modifier:
+ assert len (m) <= 1, ('multi-key modifier not supported', m)
+ if not m:
+ continue
+ btn = first (m)
+ keycodeMap[btn] = layerMap[i]
+
+ for btn, v in keycodeMap.items ():
+ v = '\t'.join (v)
+ fd.write (f'!! {btn.name}\nkeycode {btn.xorgKeycode} = {v}\n')
+ fd.write ('\n'.join (['add Mod3 = ISO_First_Group', 'add Mod5 = ISO_Level3_Shift', '']))
+