# -*- coding: UTF-8 -*- # Copyright © 2007 Martin Böhme # Copyright © 2008-2009 Chris Jones # Copyright © 2010 Francesco Fumanti # Copyright © 2009, 2011-2017 marmuta # # 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 . """ 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 ., 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