File: KeyCommon.py

package info (click to toggle)
onboard 1.4.1-5
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, bullseye
  • size: 31,548 kB
  • sloc: python: 29,215; cpp: 5,965; ansic: 5,735; xml: 1,026; sh: 163; makefile: 39
file content (1215 lines) | stat: -rw-r--r-- 38,618 bytes parent folder | download | duplicates (3)
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
# -*- coding: UTF-8 -*-

# Copyright © 2007 Martin Böhme <martin.bohm@kubuntu.org>
# Copyright © 2008-2009 Chris Jones <tortoise@tortuga>
# Copyright © 2010 Francesco Fumanti <francesco.fumanti@gmx.net>
# Copyright © 2009, 2011-2017 marmuta <marmvta@gmail.com>
#
# This file is part of Onboard.
#
# Onboard 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.
#
# Onboard 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 <http://www.gnu.org/licenses/>.

"""
KeyCommon hosts the abstract classes for the various types of Keys.
UI-specific keys should be defined in KeyGtk or KeyKDE files.
"""

from __future__ import division, print_function, unicode_literals

from math import pi
import re

from Onboard.utils import Rect, LABEL_MODIFIERS, Modifiers, \
                          polygon_to_rounded_path

from Onboard.Layout import LayoutItem

### Logging ###
import logging
_logger = logging.getLogger("KeyCommon")
###############

### Config Singleton ###
from Onboard.Config import Config
config = Config()
########################

(
    CHAR_TYPE,
    KEYSYM_TYPE,
    KEYCODE_TYPE,
    MACRO_TYPE,
    SCRIPT_TYPE,
    KEYPRESS_NAME_TYPE,
    BUTTON_TYPE,
    LEGACY_MODIFIER_TYPE,
    WORD_TYPE,
    CORRECTION_TYPE,
) = tuple(range(1, 11))

(
    SINGLE_STROKE_ACTION,  # press on button down, release on up (default)
    DELAYED_STROKE_ACTION, # press+release on button up (MENU)
    DOUBLE_STROKE_ACTION,  # press+release on button down and up, (CAPS, NMLK)
) = tuple(range(3))

actions = {
           "single-stroke"  : SINGLE_STROKE_ACTION,
           "delayed-stroke" : DELAYED_STROKE_ACTION,
           "double-stroke"  : DOUBLE_STROKE_ACTION,
          }

class StickyBehavior:
    """ enum for sticky key behaviors """
    (
        CYCLE,
        DOUBLE_CLICK,
        LATCH_ONLY,
        LOCK_ONLY,
        LATCH_LOCK_NOCYCLE,
        DOUBLE_CLICK_NOCYCLE,
        LATCH_NOCYCLE,
        LOCK_NOCYCLE,
        PUSH_BUTTON,
    ) = tuple(range(9))

    values = {"cycle"              : CYCLE,
              "dblclick"           : DOUBLE_CLICK,
              "latch"              : LATCH_ONLY,
              "lock"               : LOCK_ONLY,
              "latch-lock-nocycle" : LATCH_LOCK_NOCYCLE,
              "dblclick-nocycle"   : DOUBLE_CLICK_NOCYCLE,
              "latch-nocycle"      : LATCH_NOCYCLE,
              "lock-nocycle"       : LOCK_NOCYCLE,
              "push"               : PUSH_BUTTON,
             }

    @staticmethod
    def from_string(str_value):
        """ Raises KeyError """
        return StickyBehavior.values[str_value]

    @staticmethod
    def is_valid(behavior):
        return behavior in StickyBehavior.values.values()

    @staticmethod
    def can_latch(behavior):
        """
        Can sticky key enter latched state?
        Latched keys are automatically released when a
        non-sticky key is pressed.
        """
        return behavior in (StickyBehavior.CYCLE,
                            StickyBehavior.DOUBLE_CLICK,
                            StickyBehavior.LATCH_ONLY,
                            StickyBehavior.LATCH_LOCK_NOCYCLE,
                            StickyBehavior.DOUBLE_CLICK_NOCYCLE,
                            StickyBehavior.LATCH_NOCYCLE)

    @staticmethod
    def can_lock(behavior):
        return StickyBehavior.can_lock_on_single_click(behavior) or \
               StickyBehavior.can_lock_on_double_click(behavior)

    @staticmethod
    def can_lock_on_single_click(behavior):
        """
        Can sticky key enter locked state?
        Locked keys stay active until they are pressed again.
        """
        return behavior in (StickyBehavior.CYCLE,
                            StickyBehavior.LOCK_ONLY,
                            StickyBehavior.LATCH_LOCK_NOCYCLE,
                            StickyBehavior.LOCK_NOCYCLE)

    @staticmethod
    def can_lock_on_double_click(behavior):
        """
        Can sticky key enter locked state on double click?
        Locked keys stay active until they are pressed again.
        """
        return behavior == StickyBehavior.DOUBLE_CLICK or \
               behavior == StickyBehavior.DOUBLE_CLICK_NOCYCLE

    @staticmethod
    def can_cycle(behavior):
        """
        Can sticky key return to normal state?
        Latched keys are still automatically released when a
        non-sticky key is pressed.
        """
        return behavior in (StickyBehavior.CYCLE,
                            StickyBehavior.DOUBLE_CLICK,
                            StickyBehavior.LATCH_ONLY,
                            StickyBehavior.LOCK_ONLY)


class LOD:
    """ enum for level of detail """
    (
        MINIMAL,    # clearly visible reduced detail, fastest
        REDUCED,    # slightly reduced detail
        FULL,       # full detail
    ) = tuple(range(3))

class ImageSlot:
    NORMAL = 0
    ACTIVE = 1

class KeyCommon(LayoutItem):
    """
    library-independent key class. Specific rendering options
    are stored elsewhere.
    """

    # extended id for key specific theme tweaks
    # e.g. theme_id=DELE.numpad (with id=DELE)
    theme_id = None

    # extended id for layout specific tweaks
    # e.g. "hide.wordlist", for hide button in wordlist mode
    svg_id = None

    # optional id of a sublayout used as long-press popup
    popup_id = None

    # Type of action to do when key is pressed.
    action = None

    # Type of key stroke to send
    type = None

    # Data used in sending key strokes.
    code = None

    # Keys that stay stuck when pressed like modifiers.
    sticky = False

    # Behavior if sticky is enabled, see StickyBehavior.
    sticky_behavior = None

    # modifier bit
    modifier = None

    # True when key is being hovered over (not implemented yet)
    prelight = False

    # True when key is being pressed.
    pressed = False

    # True when key stays 'on'
    active = False

    # True when key is sticky and pressed twice.
    locked = False

    # True when Onboard is in scanning mode and key is highlighted
    scanned = False

    # True when action was triggered e.g. key-strokes were sent on press
    activated = False

    # Size to draw the label text in Pango units
    font_size = 1

    # Labels which are displayed by this key
    labels = None  # {modifier_mask : label, ...}

    # label that is currently displayed by this key
    label = ""

    # mod_mask for the currently configured label
    mod_mask = 0

    # smaller label of a currently invisible modifier level
    secondary_label = ""

    # Images displayed by this key (optional)
    image_filenames = None

    # horizontal label alignment
    label_x_align = config.DEFAULT_LABEL_X_ALIGN

    # vertical label alignment
    label_y_align = config.DEFAULT_LABEL_Y_ALIGN

    # label margin (x, y)
    label_margin = config.LABEL_MARGIN

    # tooltip text
    tooltip = None

    # can show label popup
    label_popup = True

###################

    def __init__(self):
        LayoutItem.__init__(self)

    def configure_label(self, mod_mask):
        SHIFT = Modifiers.SHIFT
        labels = self.labels

        if labels is None:
            self.label = self.secondary_label = ""
            return

        # primary label
        label = labels.get(mod_mask)
        if label is None:
            mask = mod_mask & LABEL_MODIFIERS
            label = labels.get(mask)

        # secondary label, usually the label of the shift state
        secondary_label = None
        if not label is None:
            if mod_mask & SHIFT:
                mask = mod_mask & ~SHIFT
            else:
                mask = mod_mask | SHIFT

            secondary_label = labels.get(mask)
            if secondary_label is None:
                mask = mask & LABEL_MODIFIERS
                secondary_label = labels.get(mask)

            # Only keep secondary labels that show different characters
            if not secondary_label is None and \
               secondary_label.upper() == label.upper():
                secondary_label = None

        if label is None:
            # legacy fallback for 0.98 behavior and virtkey until 0.61.0
            if mod_mask & Modifiers.SHIFT:
                if mod_mask & Modifiers.ALTGR and 129 in labels:
                    label = labels[129]
                elif 1 in labels:
                    label = labels[1]
                elif 2 in labels:
                    label = labels[2]

            elif mod_mask & Modifiers.ALTGR and 128 in labels:
                label = labels[128]

            elif mod_mask & Modifiers.CAPS:  # CAPS lock
                if 2 in labels:
                    label = labels[2]
                elif 1 in labels:
                    label = labels[1]

        if label is None:
            label = labels.get(0)

        if label is None:
            label = ""

        self.mod_mask = mod_mask
        self.label = label
        self.secondary_label = secondary_label

        # Don't let erroneous labels shrink their whole size group.
        self.ignore_group = label.startswith("0x")

    def draw_label(self, context = None):
        raise NotImplementedError()

    def set_labels(self, labels):
        self.labels = labels
        self.configure_label(0)

    def get_label(self):
        return self.label

    def get_secondary_label(self):
        return self.secondary_label

    def is_active(self):
        return not self.type is None

    def get_id(self):
        return ""

    def get_svg_id(self):
        return ""

    def set_id(self, id, theme_id = None, svg_id = None):
        self.theme_id, self.id = self.parse_id(id)
        if theme_id:
            self.theme_id = theme_id
        self.svg_id = self.id if not svg_id else svg_id

    @staticmethod
    def parse_id(value):
        """
        The theme id has the form <id>.<arbitrary identifier>, where
        the identifier should be a description of the location of
        the key relative to its surroundings, e.g. 'DELE.next-to-backspace'.
        Don't use layout names or layer ids for the theme id, they lose
        their meaning when layouts are copied or renamed by users.
        """
        theme_id = value
        id = value.split(".")[0]
        return theme_id, id

    @staticmethod
    def split_theme_id(theme_id):
        """
        Simple split in prefix (id) before the dot and suffix after the dot.
        """
        components = theme_id.split(".")
        if len(components) == 1:
            return components[0], ""
        return components[0], components[1]

    @staticmethod
    def build_theme_id(prefix, postfix):
        if postfix:
            return prefix + "." + postfix
        return prefix

    def get_similar_theme_id(self, prefix = None):
        if prefix is None:
            prefix = self.id
        theme_id = prefix
        comps = self.theme_id.split(".")[1:]
        if comps:
            theme_id += "." + comps[0]
        return theme_id

    def is_layer_button(self):
        return self.id.startswith("layer")

    def is_prediction_key(self):
        return self.id.startswith("prediction")

    def is_correction_key(self):
        return self.id.startswith("correction") or \
               self.id in ["expand-corrections"]

    def is_word_suggestion(self):
        return self.is_prediction_key() or self.is_correction_key()

    def is_modifier(self):
        """
        Modifiers are all latchable/lockable non-button keys:
        "LWIN", "RTSH", "LFSH", "RALT", "LALT",
        "RCTL", "LCTL", "CAPS", "NMLK"
        """
        return bool(self.modifier)

    def is_click_type_key(self):
        return self.id in ["singleclick",
                           "secondaryclick",
                           "middleclick",
                           "doubleclick",
                           "dragclick"]
    def is_button(self):
        return self.type == BUTTON_TYPE

    def is_pressed_only(self):
        return self.pressed and not (self.active or \
                                     self.locked or \
                                     self.scanned)

    def is_text_changing(self):
        if not self.is_modifier() and \
               self.type in [KEYCODE_TYPE,
                             KEYSYM_TYPE,
                             CHAR_TYPE,
                             KEYPRESS_NAME_TYPE,
                             MACRO_TYPE,
                             WORD_TYPE,
                             CORRECTION_TYPE]:
            id = self.id
            if not (id.startswith("F") and id[1:].isdigit()) and \
               not id in set(["LEFT", "RGHT", "UP", "DOWN",
                              "HOME", "END", "PGUP", "PGDN",
                              "INS", "ESC", "MENU",
                              "Prnt", "Pause", "Scroll"]):
                return True
        return False

    def is_return(self):
        id = self.id
        return (id == "RTRN" or
                id == "KPEN")

    def is_separator(self):
        id = self.id
        return (id == "SPCE" or
                id == "TAB")

    def is_separator_cancelling(self):
        """ Should this key cancel pending word separators? """
        return (self.is_correction_key() or
                self.is_return() or
                self.id in set(["SPCE", "TAB",
                                # Don't cancel for Backspace. We want to have
                                # it appear to delete the pending separator.
                                # This way it inserts a space, then immediately
                                # deletes it.
                                # "BKSP",
                                "DELE",
                                "LEFT", "RGHT", "UP", "DOWN",
                                "HOME", "END", "PGUP", "PGDN",
                                "INS", "ESC", "MENU",
                                "Prnt", "Pause", "Scroll"]))

    def get_layer_index(self):
        assert(self.is_layer_button())
        return int(self.id[5:])

    def get_popup_layout(self):
        if self.popup_id:
            return self.find_sublayout(self.popup_id)
        return None

    def can_show_label_popup(self):
        return not self.is_modifier() and \
               not self.is_layer_button() and \
               not self.type is None and \
               bool(self.label_popup)


class RectKeyCommon(KeyCommon):
    """ An abstract class for rectangular keyboard buttons """

    # optional path data for keys with arbitrary shapes
    geometry = None

    # size of rounded corners at 100% round_rect_radius
    chamfer_size = None

    # Optional key_style to override the default theme's style.
    style = None

    # Toggles for what gets drawn.
    show_face = True
    show_border = True
    show_label = True
    show_image = True

    # Allow to display active state, i.e. either latched or locked state.
    # Depending on sticky_behavior the button will still become logically
    # active, it just isn't shown. Used for layer0 buttons, mainly. They don't
    # need to stick out, it's usually obvious when the first layer is active.
    show_active = True

    def __init__(self, id, border_rect):
        KeyCommon.__init__(self)
        self.id = id
        self.colors = {}
        self.context.log_rect = border_rect \
                                if not border_rect is None else Rect()

    def get_id(self):
        return self.id

    def get_svg_id(self):
        return self.svg_id

    def get_state(self):
        state = {}
        state["prelight"]  = self.prelight
        state["pressed"]   = self.pressed
        state["active"]    = self.active
        state["locked"]    = self.locked
        state["scanned"]   = self.scanned
        state["sensitive"] = self.sensitive
        return state

    def draw(self, context = None):
        pass

    def align_label(self, label_size, key_size, ltr = True):
        """ returns x- and yoffset of the aligned label """
        label_x_align = self.label_x_align
        label_y_align = self.label_y_align
        if not ltr:  # right to left script?
            label_x_align = 1.0 - label_x_align
        xoffset = label_x_align * (key_size[0] - label_size[0])
        yoffset = label_y_align * (key_size[1] - label_size[1])
        return xoffset, yoffset

    def align_secondary_label(self, label_size, key_size, ltr = True):
        """ returns x- and yoffset of the aligned label """
        label_x_align = 0.97
        label_y_align = 0.0
        if not ltr:  # right to left script?
            label_x_align = 1.0 - label_x_align
        xoffset = label_x_align * (key_size[0] - label_size[0])
        yoffset = label_y_align * (key_size[1] - label_size[1])
        return xoffset, yoffset

    def align_popup_indicator(self, label_size, key_size, ltr = True):
        """ returns x- and yoffset of the aligned label """
        label_x_align = 1.0
        label_y_align = self.label_y_align
        if not ltr:  # right to left script?
            label_x_align = 1.0 - label_x_align
        xoffset = label_x_align * (key_size[0] - label_size[0])
        yoffset = label_y_align * (key_size[1] - label_size[1])
        return xoffset, yoffset

    def get_style(self):
        if not self.style is None:
            return self.style
        return config.theme_settings.key_style

    def get_stroke_width(self):
        return config.theme_settings.key_stroke_width / 100.0

    def get_stroke_gradient(self):
        return config.theme_settings.key_stroke_gradient / 100.0

    def get_light_direction(self):
        return config.theme_settings.key_gradient_direction * pi / 180.0

    def get_fill_color(self):
        return self._get_color("fill")

    def get_stroke_color(self):
        return self._get_color("stroke")

    def get_label_color(self):
        return self._get_color("label")

    def get_secondary_label_color(self):
        return self._get_color("secondary-label")

    def get_dwell_progress_color(self):
        return self._get_color("dwell-progress")

    def get_dwell_progress_canvas_rect(self):
        rect = self.get_label_rect().inflate(0.5)
        return self.context.log_to_canvas_rect(rect)

    def _get_color(self, element):
        color_key = (element, self.prelight, self.pressed,
                              self.active, self.locked,
                              self.sensitive, self.scanned)
        rgba = self.colors.get(color_key)
        if not rgba:
            if self.color_scheme:
                rgba = self.color_scheme.get_key_rgba(self, element)
            elif element == "label":
                rgba = [0.0, 0.0, 0.0, 1.0]
            else:
                rgba = [1.0, 1.0, 1.0, 1.0]
            self.colors[color_key] = rgba
        return rgba

    def get_fullsize_rect(self):
        """ Get bounding box of the key at 100% size in logical coordinates """
        return LayoutItem.get_rect(self)

    def get_canvas_fullsize_rect(self):
        """ Get bounding box of the key at 100% size in canvas coordinates """
        return self.context.log_to_canvas_rect(self.get_fullsize_rect())

    def get_unpressed_rect(self):
        """
        Get bounding box in logical coordinates.
        Just the relatively static unpressed rect withough fake key action.
        """
        rect = self.get_fullsize_rect()
        return self._apply_key_size(rect)

    def get_rect(self):
        """ Get bounding box in logical coordinates """
        return self.get_sized_rect()

    def get_sized_rect(self, horizontal = None):
        rect = self.get_fullsize_rect()

        # fake physical key action
        if self.pressed:
            dx, dy, dw, dh = self.get_pressed_deltas()
            rect.x += dx
            rect.y += dy
            rect.w += dw
            rect.h += dh

        return self._apply_key_size(rect, horizontal)

    @staticmethod
    def _apply_key_size(rect, horizontal = None):
        """ shrink keys to key_size """
        scale = (1.0 - config.theme_settings.key_size / 100.0) * 0.5
        bx = rect.w * scale
        by = rect.h * scale

        if horizontal is None:
            horizontal = rect.h < rect.w

        if horizontal:
            # keys with aspect > 1.0, e.g. space, shift
            bx = by
        else:
            # keys with aspect < 1.0, e.g. click, move, number block + and enter
            by = bx

        return rect.deflate(bx, by)

    def get_pressed_deltas(self):
        """
        dx, dy, dw, dh for fake physical key action of pressed keys.
        Logical coordinate system.
        """
        key_style = self.get_style()
        if key_style == "gradient":
            k = 0.2
        elif key_style == "dish":
            k = 0.45
        else:
            k = 0.0
        return k, 2*k, 0.0, 0.0

    def get_label_rect(self, rect = None):
        """ Label area in logical coordinates """
        if rect is None:
            rect = self.get_rect()
        style = self.get_style()
        if style == "dish":
            stroke_width  = self.get_stroke_width()
            border_x, border_y = config.DISH_KEY_BORDER
            border_x *= stroke_width
            border_y *= stroke_width
            rect = rect.deflate(border_x, border_y)
            rect.y -= config.DISH_KEY_Y_OFFSET * stroke_width
            return rect
        else:
            return rect.deflate(*self.label_margin)

    def get_canvas_label_rect(self):
        log_rect = self.get_label_rect()
        return self.context.log_to_canvas_rect(log_rect)

    def get_border_path(self):
        """ Original path including border in logical coordinates. """
        return self.geometry.get_full_size_path()

    def get_path(self):
        """
        Path of the key geometry in logical coordinates.
        Key size and fake press movement are applied.
        """
        offset_x, offset_y, size_x, size_y = self.get_key_offset_size()
        return self.geometry.get_transformed_path(offset_x, offset_y,
                                                  size_x, size_y)

    def get_canvas_border_path(self):
        path = self.get_border_path()
        return self.context.log_to_canvas_path(path)

    def get_canvas_path(self):
        path = self.get_path()
        return self.context.log_to_canvas_path(path)

    def get_hit_path(self):
        return self.get_canvas_border_path()

    def get_chamfer_size(self, rect = None):
        """ Max size of the rounded corner areas in logical coordinates. """
        if not self.chamfer_size is None:
            return self.chamfer_size
        if not rect:
            if self.geometry:
                rect = self.get_border_path().get_bounds()
            else:
                rect = self.get_rect()
        return min(rect.w, rect.h) * 0.5

    def get_key_offset_size(self, geometry = None):
        size_x = size_y = config.theme_settings.key_size / 100.0
        offset_x = offset_y = 0.0

        if self.pressed:
            offset_x, offset_y, dw, dh = self.get_pressed_deltas()
            if dw != 0.0 or dh != 0.0:
                if geometry is None:
                    geometry = self.geometry
                dw, dh = geometry.scale_log_to_size((dw, dh))
                size_x += dw * 0.5
                size_y += dh * 0.5

        return offset_x, offset_y, size_x, size_y

    def get_canvas_polygons(self, geometry,
                          offset_x, offset_y, size_x, size_y,
                          radius_pct, chamfer_size):
        path = geometry.get_transformed_path(offset_x, offset_y, size_x, size_y)
        canvas_path = self.context.log_to_canvas_path(path)
        polygons = list(canvas_path.iter_polygons())
        polygon_paths = \
            [polygon_to_rounded_path(p, radius_pct, chamfer_size) \
            for p in polygons]
        return polygons, polygon_paths


class InputlineKeyCommon(RectKeyCommon):
    """ An abstract class for InputLine keyboard buttons """

    line = ""
    word_infos = None
    cursor = 0

    def __init__(self, name, border_rect):
        RectKeyCommon.__init__(self, name, border_rect)

    def get_label(self):
        return ""


class KeyGeometry:
    """
    Full description of a key's shape.

    This class generates path variants for a given key_size by path
    interpolation. This allows for key_size dependent shape changes,
    controlled solely by a SVG layout file. See 'Return' key in
    'Full Keyboard' layout for an example.
    """

    path0 = None          # KeyPath at 100% size
    path1 = None          # KepPath at 50% size, optional

    @staticmethod
    def from_paths(paths):
        assert(len(paths) >= 1)

        path0 = paths[0]
        path1 = None
        if len(paths) >= 2:
            path1 = paths[1]

            # Equal number of path segments?
            if len(path0.segments) != len(path1.segments):
                raise ValueError(
                    "paths to interpolate differ in number of segments "
                    "({} vs. {})" \
                        .format(len(path0.segments), len(path1.segments)))

            # Same operations in all path segments?
            for i in range(len(path0.segments)):
                op0, coords0 = path0.segments[i]
                op1, coords1 = path1.segments[i]
                if op0 != op1:
                    raise ValueError(
                        "paths to interpolate have different operations "
                        "at segment {} (op. {} vs. op. {})" \
                            .format(i, op0, op1))

        geometry = KeyGeometry()
        geometry.path0 = path0
        geometry.path1 = path1
        return geometry

    @staticmethod
    def from_rect(rect):
        geometry = KeyGeometry()
        geometry.path0 = KeyPath.from_rect(rect)
        return geometry

    def get_transformed_path(self, offset_x = 0.0, offset_y = 0.0,
                             size_x = 1.0, size_y = 1.0):
        """
        Everything in the logical coordinate system.
        size: 1.0 => path0, 0.5 => path1
        """
        path0 = self.path0
        path1 = self.path1
        if path1:
            pos_x = (1 - size_x) * 2.0
            pos_y = (1 - size_y) * 2.0
            return path0.linint(path1, pos_x, pos_y, offset_x, offset_y)
        else:
            r0 = self.get_full_size_bounds()
            r1 = self.get_half_size_bounds()
            rect = r1.inflate((size_x - 0.5) * (r0.w - r1.w),
                              (size_y - 0.5) * (r0.h - r1.h))
            rect.x += offset_x
            rect.y += offset_y
            return path0.fit_in_rect(rect)

    def get_full_size_path(self):
        return self.path0

    def get_full_size_bounds(self):
        """
        Bounding box at size 1.0.
        """
        return self.path0.get_bounds()

    def get_half_size_bounds(self):
        """
        Bounding box at size 0.5.
        """
        path1 = self.path1
        if path1:
            rect = path1.get_bounds()
        else:
            rect = self.path0.get_bounds()
            if rect.h < rect.w:
                dx = dy = rect.h * 0.25
            else:
                dy = dx = rect.w * 0.25
            rect = rect.deflate(dx, dy)
        return rect

    def scale_log_to_size(self, v):
        """ Scale from logical distances to key size. """
        r0 = self.get_full_size_bounds()
        r1 = self.get_half_size_bounds()
        log_h = (r0.h - r1.h) * 2.0
        log_w = (r0.w - r1.w) * 2.0
        return (v[0] / log_h,
                v[1] / log_w)

    def scale_size_to_log(self, v):
        """ Scale from logical distances to key size. """
        r0 = self.get_full_size_bounds()
        r1 = self.get_half_size_bounds()
        log_h = (r0.h - r1.h) * 2.0
        log_w = (r0.w - r1.w) * 2.0
        return (v[0] * log_h,
                v[1] * log_w)


class KeyPath:
    """
    Cairo-friendly path description for non-rectangular keys.
    Can handle straight line-loops/polygons, but not arcs and splines.
    """
    (
        MOVE_TO,
        LINE_TO,
        CLOSE_PATH,
    ) = range(3)

    _last_abs_pos = (0.0, 0.0)
    _bounds = None           # cached bounding box

    def __init__(self):
        self.segments = []   # normalized list of path segments (all absolute)

    @staticmethod
    def from_svg_path(path_str):
        path = KeyPath()
        path.append_svg_path(path_str)
        return path

    @staticmethod
    def from_rect(rect):
        x0 = rect.x
        y0 = rect.y
        x1 = rect.right()
        y1 = rect.bottom()
        path = KeyPath()
        path.segments = [[KeyPath.MOVE_TO, [x0, y0]],
                         [KeyPath.LINE_TO, [x1, y0, x1, y1, x0, y1]],
                         [KeyPath.CLOSE_PATH, []]]
        path._bounds = rect.copy()
        return path

    _svg_path_pattern = re.compile("([+-]?[0-9.]+)")

    def copy(self):
        result = KeyPath()
        for op, coords in self.segments:
            result.segments.append([op, coords[:]])
        return result

    def append_svg_path(self, path_str):
        """
        Append a SVG path data string to the path.

        Doctests:
        # absolute move_to command
        >>> p = KeyPath.from_svg_path("M 100 200 120 -220")
        >>> print(p.segments)
        [[0, [100.0, 200.0]], [1, [120.0, -220.0]]]

        # relative move_to command
        >>> p = KeyPath.from_svg_path("m 100 200 10 -10")
        >>> print(p.segments)
        [[0, [100.0, 200.0]], [1, [110.0, 190.0]]]

        # relative move_to and close_path segments
        >>> p = KeyPath.from_svg_path("m 100 200 10 -10 z")
        >>> print(p.segments)
        [[0, [100.0, 200.0]], [1, [110.0, 190.0]], [2, []]]

        # spaces and commas and are optional where possible
        >>> p = KeyPath.from_svg_path("m100,200 10-10z")
        >>> print(p.segments)
        [[0, [100.0, 200.0]], [1, [110.0, 190.0]], [2, []]]
        """

        cmd_str = ""
        coords = []
        tokens = self._tokenize_svg_path(path_str)
        for token in tokens:
            try:
                val = float(token)   # raises value error
                coords.append(val)
            except ValueError:
                if token.isalpha():
                    if cmd_str:
                        self.append_command(cmd_str, coords)
                    cmd_str = token
                    coords = []

                elif token == ",":
                    pass

                else:
                    raise ValueError(
                          "unexpected token '{}' in svg path data" \
                          .format(token))

        if cmd_str:
            self.append_command(cmd_str, coords)

    def append_command(self, cmd_str, coords):
        """
        Append a single command and it's coordinate data to the path.

        Doctests:
        # first lowercase move_to position is absolute
        >>> p = KeyPath()
        >>> p.append_command("m", [100, 200])
        >>> print(p.segments)
        [[0, [100, 200]]]

        # move_to segments become line_to segments after the first position
        >>> p = KeyPath()
        >>> p.append_command("M", [100, 200, 110, 190])
        >>> print(p.segments)
        [[0, [100, 200]], [1, [110, 190]]]

        # further lowercase move_to positions are relative, must become absolute
        >>> p = KeyPath()
        >>> p.append_command("m", [100, 200, 10, -10, 10, -10])
        >>> print(p.segments)
        [[0, [100, 200]], [1, [110, 190, 120, 180]]]

        # further lowercase segments must still be become absolute
        >>> p = KeyPath()
        >>> p.append_command("m", [100, 200, 10, -10, 10, -10])
        >>> p.append_command("l", [1, -1, 1, -1])
        >>> print(p.segments)
        [[0, [100, 200]], [1, [110, 190, 120, 180]], [1, [121, 179, 122, 178]]]
        """

        # Convert lowercase segments from relative to absolute coordinates.
        if cmd_str in ("m", "l"):

            # Don't convert the very first coordinate, it is already absolute.
            if self.segments:
                start = 0
                x, y = self._last_abs_pos
            else:
                start = 2
                x, y = coords[0], coords[1]

            for i in range(start, len(coords), 2):
                x += coords[i]
                y += coords[i+1]
                coords[i]   = x
                coords[i+1] = y

        cmd = cmd_str.lower()
        if cmd == "m":
            self.segments.append([self.MOVE_TO, coords[:2]])
            if len(coords) > 2:
                self.segments.append([self.LINE_TO, coords[2:]])

        elif cmd == "l":
            self.segments.append([self.LINE_TO, coords])

        elif cmd == "z":
            self.segments.append([self.CLOSE_PATH, []])

        # remember last absolute position
        if len(coords) >= 2:
            self._last_abs_pos = coords[-2:]

    @staticmethod
    def _tokenize_svg_path(path_str):
        """
        Split SVG path date into command and coordinate tokens.

        Doctests:
        >>> KeyPath._tokenize_svg_path("m 10,20")
        ['m', '10', ',', '20']
        >>> KeyPath._tokenize_svg_path("   m   10  , \\n  20 ")
        ['m', '10', ',', '20']
        >>> KeyPath._tokenize_svg_path("m 10,20 30,40 z")
        ['m', '10', ',', '20', '30', ',', '40', 'z']
        >>> KeyPath._tokenize_svg_path("m10,20 30,40z")
        ['m', '10', ',', '20', '30', ',', '40', 'z']
        >>> KeyPath._tokenize_svg_path("M100.32 100.09 100. -100.")
        ['M', '100.32', '100.09', '100.', '-100.']
        >>> KeyPath._tokenize_svg_path("m123+23 20,-14L200,200")
        ['m', '123', '+23', '20', ',', '-14', 'L', '200', ',', '200']
        >>> KeyPath._tokenize_svg_path("m123+23 20,-14L200,200")
        ['m', '123', '+23', '20', ',', '-14', 'L', '200', ',', '200']
        """
        tokens = [token.strip() \
                  for token in KeyPath._svg_path_pattern.split(path_str)]
        return [token for token in tokens if token]

    def get_bounds(self):
        bounds = self._bounds
        if bounds is None:
            bounds = self._calc_bounds()
            self._bounds = bounds
        return bounds

    def _calc_bounds(self):
        """
        Compute the bounding box of the path.

        Doctests:
        # Simple move_to path, something inkscape would create.
        >>> p = KeyPath.from_svg_path("m 100,200 10,-10 z")
        >>> print(p.get_bounds())
        Rect(x=100.0 y=190.0 w=10.0 h=10.0)
        """

        try:
            xmin = xmax = self.segments[0][1][0]
            ymin = ymax = self.segments[0][1][1]
        except IndexError:
            return Rect()

        for command in self.segments:
            coords = command[1]
            for i in range(0, len(coords), 2):
                x = coords[i]
                y = coords[i+1]
                if xmin > x:
                    xmin = x
                if xmax < x:
                    xmax = x
                if ymin > y:
                    ymin = y
                if ymax < y:
                    ymax = y

        return Rect(xmin, ymin, xmax - xmin, ymax - ymin)

    def inflate(self, dx, dy = None):
        """
        Returns a new path which is larger by dx and dy on all sides.
        """
        rect = self.get_bounds().inflate(dx, dy)
        return self.fit_in_rect(rect)

    def fit_in_rect(self, rect):
        """
        Scales and translates the path so that rect
        becomes its new bounding box.
        """
        result = self.copy()
        bounds = self.get_bounds()
        scalex = rect.w / bounds.w
        scaley = rect.h / bounds.h
        dorgx, dorgy = bounds.get_center()
        dx = rect.x - (dorgx + (bounds.x - dorgx) * scalex)
        dy = rect.y - (dorgy + (bounds.y - dorgy) * scaley)

        for op, coords in result.segments:
            for i in range(0, len(coords), 2):
                coords[i] = dx + dorgx + (coords[i] - dorgx) * scalex
                coords[i+1] = dy + dorgy + (coords[i+1] - dorgy) * scaley

        return result

    def linint(self, path1, pos_x = 1.0, pos_y = 1.0,
               offset_x = 0.0, offset_y = 0.0):
        """
        Interpolate between self and path1.
        Paths must have the same structure (length and operations).
        pos: 0.0 = self, 1.0 = path1.
        """
        result = self.copy()
        segments = result.segments
        segments1 = path1.segments
        for i in range(len(segments)):
            op, coords = segments[i]
            op1, coords1 = segments1[i]
            for j in range(0, len(coords), 2):
                x = coords[j]
                y = coords[j+1]
                x1 = coords1[j]
                y1 = coords1[j+1]
                dx = x1 - x
                dy = y1 - y
                coords[j] = x + pos_x * dx + offset_x
                coords[j+1] = y + pos_y * dy + offset_y

        return result

    def iter_polygons(self):
        """
        Loop through all independent polygons in the path.
        Can't handle splines and arcs, everything has to
        be polygons from here.
        """
        polygon = []

        for op, coords in self.segments:

            if op == self.LINE_TO:
                polygon.extend(coords)

            elif op == self.MOVE_TO:
                polygon = []
                polygon.extend(coords)

            elif op == self.CLOSE_PATH:
                yield polygon

    def is_point_within(self, point):
        for polygon in self.iter_polygons():
            if self.is_point_in_polygon(polygon, point[0], point[1]):
                return True

    @staticmethod
    def is_point_in_polygon(vertices, x, y):
        c = False
        n = len(vertices)

        try:
            x0 = vertices[n - 2]
            y0 = vertices[n - 1]
        except IndexError:
            return False

        for i in range(0, n, 2):
            x1 = vertices[i]
            y1 = vertices[i+1]
            if (y1 <= y and y < y0 or y0 <= y and y < y1) and \
               (x < (x0 - x1) * (y - y1) / (y0 - y1) + x1):
                c = not c
            x0 = x1
            y0 = y1

        return c