def redraw(self, color_change=False): tdw = self.doc.tdw if color_change: tdw.queue_draw() else: prev_trash = self._prev_disable_button_rectangle new_trash = self._disable_button_rectangle if prev_trash: tdw.queue_draw_area(*prev_trash) if new_trash: tdw.queue_draw_area(*new_trash) old_corners = tuple(pairwise(self._prev_display_corners)) new_corners = tuple(pairwise(self._display_corners)) prev = self._prev_rectangles new = self._new_rectangles for i, (r1, r2) in enumerate(zip(prev, new)): if r1 == r2: continue # Skip if unchanged if r1 and r2: r1.expand_to_include_rect(r2) tdw.queue_draw_area(*r1) elif r1 or r2: r = r1 or r2 corners = new_corners if r == r1 else old_corners if corners: (cx0, cy0), (cx1, cy1) = corners[i] r.expand_to_include_point(cx0, cy0) r.expand_to_include_point(cx1, cy1) tdw.queue_draw_area(*r) else: tdw.queue_draw() return
def get_color_at_position(self, x, y, ignore_mask=False): """Converts an `x`, `y` position to a color. Ordinarily, this implmentation uses any active mask to limit the colors which can be clicked on. Set `ignore_mask` to disable this added behaviour. """ sup = HueSaturationWheelMixin if ignore_mask or not self.mask_toggle.get_active(): return sup.get_color_at_position(self, x, y) voids = self.get_mask_voids() if not voids: return sup.get_color_at_position(self, x, y) isects = [] for vi, void in enumerate(voids): # If we're inside a void, use the unchanged value if geom.point_in_convex_poly((x, y), void): return sup.get_color_at_position(self, x, y) # If outside, find the nearest point on the nearest void's edge for p1, p2 in geom.pairwise(void): isect = geom.nearest_point_in_segment(p1, p2, (x, y)) if isect is not None: d = math.sqrt((isect[0] - x)**2 + (isect[1] - y)**2) isects.append((d, isect)) # Above doesn't include segment ends, so add those d = math.sqrt((p1[0] - x)**2 + (p1[1] - y)**2) isects.append((d, p1)) # Determine the closest point. if isects: isects.sort() x, y = isects[0][1] return sup.get_color_at_position(self, x, y)
def get_color_at_position(self, x, y, ignore_mask=False): """Converts an `x`, `y` position to a color. Ordinarily, this implmentation uses any active mask to limit the colors which can be clicked on. Set `ignore_mask` to disable this added behaviour. """ sup = HueSaturationWheelMixin if ignore_mask or not self.mask_toggle.get_active(): return sup.get_color_at_position(self, x, y) voids = self.get_mask_voids() if not voids: return sup.get_color_at_position(self, x, y) isects = [] for vi, void in enumerate(voids): # If we're inside a void, use the unchanged value if geom.point_in_convex_poly((x, y), void): return sup.get_color_at_position(self, x, y) # If outside, find the nearest point on the nearest void's edge for p1, p2 in geom.pairwise(void): isect = geom.nearest_point_in_segment(p1, p2, (x, y)) if isect is not None: d = math.sqrt((isect[0]-x)**2 + (isect[1]-y)**2) isects.append((d, isect)) # Above doesn't include segment ends, so add those d = math.sqrt((p1[0]-x)**2 + (p1[1]-y)**2) isects.append((d, p1)) # Determine the closest point. if isects: isects.sort() x, y = isects[0][1] return sup.get_color_at_position(self, x, y)
def __dist_to_nearest_shape(self, x, y): # Distance from `x`, `y` to the nearest edge or vertex of any shape. dists = [] for hull in self.get_mask_voids(): # cx, cy = geom.poly_centroid(hull) for p1, p2 in geom.pairwise(hull): np = geom.nearest_point_in_segment(p1, p2, (x, y)) if np is not None: nx, ny = np d = math.sqrt((x - nx)**2 + (y - ny)**2) dists.append(d) # Segment end too d = math.sqrt((p1[0] - x)**2 + (p1[1] - y)**2) dists.append(d) if not dists: return None dists.sort() return dists[0]
def __dist_to_nearest_shape(self, x, y): # Distance from `x`, `y` to the nearest edge or vertex of any shape. dists = [] for hull in self.get_mask_voids(): # cx, cy = geom.poly_centroid(hull) for p1, p2 in geom.pairwise(hull): nearest_point = geom.nearest_point_in_segment(p1, p2, (x, y)) if nearest_point is not None: nx, ny = nearest_point d = math.sqrt((x-nx)**2 + (y-ny)**2) dists.append(d) # Segment end too d = math.sqrt((p1[0]-x)**2 + (p1[1]-y)**2) dists.append(d) if not dists: return None dists.sort() return dists[0]
def __update_active_objects(self, x, y): # Decides what a click or a drag at (x, y) would do, and updates the # mouse cursor and draw state to match. assert self.__drag_func is None self.__active_shape = None self.__active_ctrlpoint = None self.__tmp_new_ctrlpoint = None self.queue_draw() # yes, always # Possible mask void manipulations mask = self.get_mask() for mask_idx in xrange(len(mask)): colors = mask[mask_idx] if len(colors) < 3: continue # If the pointer is near an existing control point, clicking and # dragging will move it. void = [] for col_idx in xrange(len(colors)): col = colors[col_idx] px, py = self.get_pos_for_color(col) dp = math.sqrt((x - px)**2 + (y - py)**2) if dp <= self.__ctrlpoint_grab_radius: mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__active_ctrlpoint = col_idx self.__set_cursor(None) return void.append((px, py)) # If within a certain distance of an edge, dragging will create and # then move a new control point. void = geom.convex_hull(void) for p1, p2 in geom.pairwise(void): isect = geom.nearest_point_in_segment(p1, p2, (x, y)) if isect is not None: ix, iy = isect di = math.sqrt((ix - x)**2 + (iy - y)**2) if di <= self.__ctrlpoint_grab_radius: newcol = self.get_color_at_position(ix, iy) self.__tmp_new_ctrlpoint = newcol mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__set_cursor(None) return # If the mouse is within a mask void, then dragging would move that # shape around within the mask. if geom.point_in_convex_poly((x, y), void): mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__set_cursor(None) return # Away from shapes, clicks and drags manipulate the entire mask: adding # cutout voids to it, or rotating the whole mask around its central # axis. alloc = self.get_allocation() cx, cy = self.get_center(alloc=alloc) radius = self.get_radius(alloc=alloc) dx, dy = x - cx, y - cy r = math.sqrt(dx**2 + dy**2) if r < radius * (1.0 - self.min_shape_size): if len(mask) < self.__max_num_shapes: d = self.__dist_to_nearest_shape(x, y) minsize = radius * self.min_shape_size if d is None or d > minsize: # Clicking will result in a new void self.__set_cursor(self.__add_cursor) else: # Click-drag to rotate the entire mask self.__set_cursor(self.__rotate_cursor)
def __update_active_objects(self, x, y): # Decides what a click or a drag at (x, y) would do, and updates the # mouse cursor and draw state to match. assert self.__drag_func is None self.__active_shape = None self.__active_ctrlpoint = None self.__tmp_new_ctrlpoint = None self.queue_draw() # yes, always # Possible mask void manipulations mask = self.get_mask() for mask_idx in xrange(len(mask)): colors = mask[mask_idx] if len(colors) < 3: continue # If the pointer is near an existing control point, clicking and # dragging will move it. void = [] for col_idx in xrange(len(colors)): col = colors[col_idx] px, py = self.get_pos_for_color(col) dp = math.sqrt((x-px)**2 + (y-py)**2) if dp <= self.__ctrlpoint_grab_radius: mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__active_ctrlpoint = col_idx self.__set_cursor(None) return void.append((px, py)) # If within a certain distance of an edge, dragging will create and # then move a new control point. void = geom.convex_hull(void) for p1, p2 in geom.pairwise(void): isect = geom.nearest_point_in_segment(p1, p2, (x, y)) if isect is not None: ix, iy = isect di = math.sqrt((ix-x)**2 + (iy-y)**2) if di <= self.__ctrlpoint_grab_radius: newcol = self.get_color_at_position(ix, iy) self.__tmp_new_ctrlpoint = newcol mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__set_cursor(None) return # If the mouse is within a mask void, then dragging would move that # shape around within the mask. if geom.point_in_convex_poly((x, y), void): mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__set_cursor(None) return # Away from shapes, clicks and drags manipulate the entire mask: adding # cutout voids to it, or rotating the whole mask around its central # axis. alloc = self.get_allocation() cx, cy = self.get_center(alloc=alloc) radius = self.get_radius(alloc=alloc) dx, dy = x-cx, y-cy r = math.sqrt(dx**2 + dy**2) if r < radius*(1.0-self.min_shape_size): if len(mask) < self.__max_num_shapes: d = self.__dist_to_nearest_shape(x, y) minsize = radius * self.min_shape_size if d is None or d > minsize: # Clicking will result in a new void self.__set_cursor(self.__add_cursor) else: # Click-drag to rotate the entire mask self.__set_cursor(self.__rotate_cursor)
def _recalculate_coordinates(self, redraw, *args): """Calculates geometric data that does not need updating every time""" # Skip calculations when the frame is not enabled (this is important # because otherwise all of this would be recalculated on moving, # scaling and rotating the canvas. if not (self.doc.model.frame_enabled or redraw): return tdw = self.doc.tdw # Canvas rectangle - regular and offset self._canvas_rect = Rect.new_from_gdk_rectangle(tdw.get_allocation()) self._canvas_rect_offset = self._canvas_rect.expanded( self.OUTLINE_WIDTH * 4) # Frame corners in model coordinates x, y, w, h = tuple(self.doc.model.get_frame()) corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] # Pixel-aligned frame corners in display space d_corners = [tdw.model_to_display(mx, my) for mx, my in corners] pxoffs = 0.5 if (self.OUTLINE_WIDTH % 2) else 0.0 self._prev_display_corners = self._display_corners self._display_corners = tuple( (int(x) + pxoffs, int(y) + pxoffs) for x, y in d_corners) # Position of the button for disabling/deleting the frame # Placed near the center of the frame, clamped to the viewport, # with an offset so it does not cover visually small frames # (when the frame _is_ small, or when zoomed out). xs, ys = zip(*d_corners) r = gui.style.FLOATING_BUTTON_RADIUS tx, ty = self._canvas_rect.expanded(-2 * r).clamped_point( sum(xs) / 4.0, sum(ys) / 4.0) self._trash_btn_pos = tx, ty r += 6 # margin for drop shadows self._prev_disable_button_rectangle = self._disable_button_rectangle self._disable_button_rectangle = (tx - r, ty - r, r * 2, r * 2) # Corners self._zone_corners = [] radius = gui.style.DRAGGABLE_POINT_HANDLE_SIZE canvas_limit = self._canvas_rect.expanded(radius) for i, (cx, cy) in enumerate(d_corners): if canvas_limit.contains_pixel(cx, cy): self._zone_corners.append((cx, cy, self._ZONE_EDGES[i])) # Intersecting frame lines & calculation of rectangles l_type = LineType.SEGMENT cx, cy, cw, ch = self._canvas_rect canvas_corners = ((cx, cy), (cx + cw, cy), (cx + cw, cy + ch), (cx, cy + ch)) intersections = [ intersection_of_vector_and_poly(canvas_corners, p1, p2, l_type) for p1, p2 in pairwise(d_corners) ] self._prev_rectangles = self._new_rectangles self._new_rectangles = [(), (), (), ()] if intersections != [None, None, None, None]: self._new_rectangles = [] m = radius + 6 # margin for handle drop shadows for intersection in intersections: if not intersection: self._new_rectangles.append(()) continue (x0, y0), (x1, y1) = intersection w = abs(x1 - x0) + 2 * m h = abs(y1 - y0) + 2 * m x = min(x0, x1) - m y = min(y0, y1) - m self._new_rectangles.append(Rect(x, y, w, h)) if redraw: self.redraw()
class FrameOverlay(Overlay): """Overlay showing the frame, and edit boxes if in FrameEditMode This is a display-space overlay, since the edit boxes need to be drawn with pixel precision at a consistent weight regardless of zoom. Only the main TDW is supported.""" OUTLINE_WIDTH = 1 # Which edges belong to which corner, CW from top left _ZONE_EDGES = [sum(p) for p in pairwise(_SIDES)] def __init__(self, doc): """Initialize overlay""" Overlay.__init__(self) self.doc = doc self.app = doc.app self._trash_icon_pixbuf = None # Cached data used for painting and to minimize redraw areas self._canvas_rect = None self._canvas_rect_offset = None self._display_corners = [] self._prev_display_corners = [] self._trash_btn_pos = None # Stores per-edge invalidation rectangles, # indexed canonically: top, right, bottom, left self._prev_rectangles = [(), (), (), ()] self._new_rectangles = [(), (), (), ()] self._prev_disable_button_rectangle = None self._disable_button_rectangle = None # Calculate initial data - recalculate on frame changes # and view changes. self._recalculate_coordinates(True) self.app.doc.model.frame_updated += self._frame_updated_cb self.doc.tdw.transformation_updated += self._transformation_updated_cb def _frame_updated_cb(self, *args): self._recalculate_coordinates(True, *args) def _transformation_updated_cb(self, *args): # The redraw is already triggered at this point self._recalculate_coordinates(False, *args) def _recalculate_coordinates(self, redraw, *args): """Calculates geometric data that does not need updating every time""" # Skip calculations when the frame is not enabled (this is important # because otherwise all of this would be recalculated on moving, # scaling and rotating the canvas. if not (self.doc.model.frame_enabled or redraw): return tdw = self.doc.tdw # Canvas rectangle - regular and offset self._canvas_rect = Rect.new_from_gdk_rectangle(tdw.get_allocation()) self._canvas_rect_offset = self._canvas_rect.expanded( self.OUTLINE_WIDTH * 4) # Frame corners in model coordinates x, y, w, h = tuple(self.doc.model.get_frame()) corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] # Pixel-aligned frame corners in display space d_corners = [tdw.model_to_display(mx, my) for mx, my in corners] pxoffs = 0.5 if (self.OUTLINE_WIDTH % 2) else 0.0 self._prev_display_corners = self._display_corners self._display_corners = tuple( (int(x) + pxoffs, int(y) + pxoffs) for x, y in d_corners) # Position of the button for disabling/deleting the frame # Placed near the center of the frame, clamped to the viewport, # with an offset so it does not cover visually small frames # (when the frame _is_ small, or when zoomed out). xs, ys = zip(*d_corners) r = gui.style.FLOATING_BUTTON_RADIUS tx, ty = self._canvas_rect.expanded(-2 * r).clamped_point( sum(xs) / 4.0, sum(ys) / 4.0) self._trash_btn_pos = tx, ty r += 6 # margin for drop shadows self._prev_disable_button_rectangle = self._disable_button_rectangle self._disable_button_rectangle = (tx - r, ty - r, r * 2, r * 2) # Corners self._zone_corners = [] radius = gui.style.DRAGGABLE_POINT_HANDLE_SIZE canvas_limit = self._canvas_rect.expanded(radius) for i, (cx, cy) in enumerate(d_corners): if canvas_limit.contains_pixel(cx, cy): self._zone_corners.append((cx, cy, self._ZONE_EDGES[i])) # Intersecting frame lines & calculation of rectangles l_type = LineType.SEGMENT cx, cy, cw, ch = self._canvas_rect canvas_corners = ((cx, cy), (cx + cw, cy), (cx + cw, cy + ch), (cx, cy + ch)) intersections = [ intersection_of_vector_and_poly(canvas_corners, p1, p2, l_type) for p1, p2 in pairwise(d_corners) ] self._prev_rectangles = self._new_rectangles self._new_rectangles = [(), (), (), ()] if intersections != [None, None, None, None]: self._new_rectangles = [] m = radius + 6 # margin for handle drop shadows for intersection in intersections: if not intersection: self._new_rectangles.append(()) continue (x0, y0), (x1, y1) = intersection w = abs(x1 - x0) + 2 * m h = abs(y1 - y0) + 2 * m x = min(x0, x1) - m y = min(y0, y1) - m self._new_rectangles.append(Rect(x, y, w, h)) if redraw: self.redraw() def redraw(self, color_change=False): tdw = self.doc.tdw if color_change: tdw.queue_draw() else: prev_trash = self._prev_disable_button_rectangle new_trash = self._disable_button_rectangle if prev_trash: tdw.queue_draw_area(*prev_trash) if new_trash: tdw.queue_draw_area(*new_trash) old_corners = tuple(pairwise(self._prev_display_corners)) new_corners = tuple(pairwise(self._display_corners)) prev = self._prev_rectangles new = self._new_rectangles for i, (r1, r2) in enumerate(zip(prev, new)): if r1 == r2: continue # Skip if unchanged if r1 and r2: r1.expand_to_include_rect(r2) tdw.queue_draw_area(*r1) elif r1 or r2: r = r1 or r2 corners = new_corners if r == r1 else old_corners if corners: (cx0, cy0), (cx1, cy1) = corners[i] r.expand_to_include_point(cx0, cy0) r.expand_to_include_point(cx1, cy1) tdw.queue_draw_area(*r) else: tdw.queue_draw() return def paint(self, cr): """Paints the frame, and the edit boxes if appropriate""" if not self.doc.model.frame_enabled: return # Frame mask: outer closed rectangle just outside the viewport cr.rectangle(*self._canvas_rect_offset) # Frame mask: inner closed rectangle p1, p2, p3, p4 = self._display_corners cr.move_to(*p1) cr.line_to(*p2) cr.line_to(*p3) cr.line_to(*p4) cr.close_path() # Fill the frame mask. We may need the shape again. frame_rgba = self.app.preferences["frame.color_rgba"] frame_rgba = [lib.helpers.clamp(c, 0, 1) for c in frame_rgba] cr.set_source_rgba(*frame_rgba) cr.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) cr.fill_preserve() # If the doc controller is not in a frame-editing mode, no edit # controls will be drawn. The frame mask drawn above normally # has some alpha, and may be unclear as a result. To make it # clearer, double-strike the edges. editmode = None for m in self.doc.modes: if isinstance(m, FrameEditMode): editmode = m break if not editmode: cr.set_line_width(self.OUTLINE_WIDTH) cr.stroke() return # Editable frame: shadows for the frame edge lines cr.set_line_cap(cairo.LINE_CAP_ROUND) zonelines = [ (_EditZone.TOP, p1, p2), (_EditZone.RIGHT, p2, p3), (_EditZone.BOTTOM, p3, p4), (_EditZone.LEFT, p4, p1), ] cr.set_line_width(gui.style.DRAGGABLE_EDGE_WIDTH) for zone, p, q in zonelines: cr.move_to(*p) cr.line_to(*q) gui.drawutils.render_drop_shadow(cr, z=1) cr.new_path() for zone, p, q in zonelines: cr.move_to(*p) cr.line_to(*q) if editmode._zone and (editmode._zone == zone): rgb = gui.style.ACTIVE_ITEM_COLOR.get_rgb() else: rgb = gui.style.EDITABLE_ITEM_COLOR.get_rgb() cr.set_source_rgb(*rgb) cr.stroke() # Editable corners: drag handles (with hover) for x, y, zonemask in self._zone_corners: if editmode._zone and (editmode._zone == zonemask): col = gui.style.ACTIVE_ITEM_COLOR else: col = gui.style.EDITABLE_ITEM_COLOR gui.drawutils.render_round_floating_color_chip( cr=cr, x=x, y=y, color=col, radius=gui.style.DRAGGABLE_POINT_HANDLE_SIZE, ) # Frame remove button position, frame center, constrained to viewport if editmode._zone == _EditZone.REMOVE_FRAME: button_color = gui.style.ACTIVE_ITEM_COLOR else: button_color = gui.style.EDITABLE_ITEM_COLOR bx, by = self._trash_btn_pos gui.drawutils.render_round_floating_button( cr=cr, x=bx, y=by, color=button_color, radius=gui.style.FLOATING_BUTTON_RADIUS, pixbuf=self._trash_icon(), ) editmode.remove_button_pos = (bx, by) def _trash_icon(self): """Return trash icon, using cached instance if it exists""" if not self._trash_icon_pixbuf: self._trash_icon_pixbuf = gui.drawutils.load_symbolic_icon( icon_name="mypaint-trash-symbolic", size=gui.style.FLOATING_BUTTON_ICON_SIZE, fg=(0, 0, 0, 1), ) return self._trash_icon_pixbuf