# -*- 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


