1# This file is part of MyPaint. 2# Copyright (C) 2015 by Andrew Chadwick <a.t.chadwick@gmail.com> 3# 4# This program is free software; you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation; either version 2 of the License, or 7# (at your option) any later version. 8 9 10## Imports 11 12from __future__ import division, print_function 13 14import math 15import collections 16import weakref 17from logging import getLogger 18 19from gettext import gettext as _ 20from lib.gibindings import Gdk 21from lib.gibindings import GLib 22import numpy as np 23 24import gui.mode 25import gui.overlays 26import gui.style 27import gui.drawutils 28import lib.helpers 29import gui.cursor 30import lib.observable 31import gui.mvp 32from lib.pycompat import xrange 33 34 35## Module constants 36 37logger = getLogger(__name__) 38 39 40## Class defs 41 42 43class _Phase: 44 """Enumeration of the states that an InkingMode can be in""" 45 CAPTURE = 0 46 ADJUST = 1 47 48 49_NODE_FIELDS = ("x", "y", "pressure", "xtilt", "ytilt", "time", "viewzoom", "viewrotation", "barrel_rotation") 50 51 52class _Node (collections.namedtuple("_Node", _NODE_FIELDS)): 53 """Recorded control point, as a namedtuple. 54 55 Node tuples have the following 6 fields, in order 56 57 * x, y: model coords, float 58 * pressure: float in [0.0, 1.0] 59 * xtilt, ytilt: float in [-1.0, 1.0] 60 * time: absolute seconds, float 61 * viewzoom: current zoom level [0.0, 64] 62 * viewrotation: current view rotation [-180.0, 180.0] 63 * barrel_rotation: float in [0.0, 1.0] 64 """ 65 66 67class _EditZone: 68 """Enumeration of what the pointer is on in the ADJUST phase""" 69 EMPTY_CANVAS = 0 #: Nothing, empty space 70 CONTROL_NODE = 1 #: Any control node; see target_node_index 71 REJECT_BUTTON = 2 #: On-canvas button that abandons the current line 72 ACCEPT_BUTTON = 3 #: On-canvas button that commits the current line 73 74 75class InkingMode (gui.mode.ScrollableModeMixin, 76 gui.mode.BrushworkModeMixin, 77 gui.mode.DragMode): 78 79 ## Metadata properties 80 81 ACTION_NAME = "InkingMode" 82 pointer_behavior = gui.mode.Behavior.PAINT_FREEHAND 83 scroll_behavior = gui.mode.Behavior.CHANGE_VIEW 84 permitted_switch_actions = ( 85 set(gui.mode.BUTTON_BINDING_ACTIONS).union([ 86 'RotateViewMode', 87 'ZoomViewMode', 88 'PanViewMode', 89 ]) 90 ) 91 92 ## Metadata methods 93 94 @classmethod 95 def get_name(cls): 96 return _(u"Inking") 97 98 def get_usage(self): 99 return _(u"Draw, and then adjust smooth lines") 100 101 @property 102 def inactive_cursor(self): 103 return None 104 105 @property 106 def active_cursor(self): 107 if self.phase == _Phase.ADJUST: 108 if self.zone == _EditZone.CONTROL_NODE: 109 return self._crosshair_cursor 110 elif self.zone != _EditZone.EMPTY_CANVAS: # assume button 111 return self._arrow_cursor 112 return None 113 114 ## Class config vars 115 116 # Input node capture settings: 117 MAX_INTERNODE_DISTANCE_MIDDLE = 30 # display pixels 118 MAX_INTERNODE_DISTANCE_ENDS = 10 # display pixels 119 MAX_INTERNODE_TIME = 1 / 100.0 # seconds 120 121 # Captured input nodes are then interpolated with a spline. 122 # The code tries to make nice smooth input for the brush engine, 123 # but avoids generating too much work. 124 INTERPOLATION_MAX_SLICE_TIME = 1 / 200.0 # seconds 125 INTERPOLATION_MAX_SLICE_DISTANCE = 20 # model pixels 126 INTERPOLATION_MAX_SLICES = MAX_INTERNODE_DISTANCE_MIDDLE * 5 127 # In other words, limit to a set number of interpolation slices 128 # per display pixel at the time of stroke capture. 129 130 # Node value adjustment settings 131 MIN_INTERNODE_TIME = 1 / 200.0 # seconds (used to manage adjusting) 132 133 ## Other class vars 134 135 _OPTIONS_PRESENTER = None #: Options presenter singleton 136 137 ## Initialization & lifecycle methods 138 139 def __init__(self, **kwargs): 140 super(InkingMode, self).__init__(**kwargs) 141 self.phase = _Phase.CAPTURE 142 self.zone = _EditZone.EMPTY_CANVAS 143 self.current_node_index = None #: Node active in the options ui 144 self.target_node_index = None #: Node that's prelit 145 self._overlays = {} # keyed by tdw 146 self._reset_nodes() 147 self._reset_capture_data() 148 self._reset_adjust_data() 149 self._task_queue = collections.deque() # (cb, args, kwargs) 150 self._task_queue_runner_id = None 151 self._click_info = None # (button, zone) 152 self._current_override_cursor = None 153 # Button pressed while drawing 154 # Not every device sends button presses, but evdev ones 155 # do, and this is used as a workaround for an evdev bug: 156 # https://github.com/mypaint/mypaint/issues/223 157 self._button_down = None 158 self._last_good_raw_pressure = 0.0 159 self._last_good_raw_xtilt = 0.0 160 self._last_good_raw_ytilt = 0.0 161 self._last_good_raw_viewzoom = 0.0 162 self._last_good_raw_viewrotation = 0.0 163 self._last_good_raw_barrel_rotation = 0.0 164 165 def _reset_nodes(self): 166 self.nodes = [] # nodes that met the distance+time criteria 167 168 def _reset_capture_data(self): 169 self._last_event_node = None # node for the last event 170 self._last_node_evdata = None # (xdisp, ydisp, tmilli) for nodes[-1] 171 172 def _reset_adjust_data(self): 173 self.zone = _EditZone.EMPTY_CANVAS 174 self.current_node_index = None 175 self.target_node_index = None 176 self._dragged_node_start_pos = None 177 178 def _ensure_overlay_for_tdw(self, tdw): 179 overlay = self._overlays.get(tdw) 180 if not overlay: 181 overlay = Overlay(self, tdw) 182 tdw.display_overlays.append(overlay) 183 self._overlays[tdw] = overlay 184 return overlay 185 186 def _is_active(self): 187 for mode in self.doc.modes: 188 if mode is self: 189 return True 190 return False 191 192 def _discard_overlays(self): 193 for tdw, overlay in self._overlays.items(): 194 tdw.display_overlays.remove(overlay) 195 tdw.queue_draw() 196 self._overlays.clear() 197 198 def enter(self, doc, **kwds): 199 """Enters the mode: called by `ModeStack.push()` etc.""" 200 super(InkingMode, self).enter(doc, **kwds) 201 if not self._is_active(): 202 self._discard_overlays() 203 self._ensure_overlay_for_tdw(self.doc.tdw) 204 self._arrow_cursor = self.doc.app.cursors.get_action_cursor( 205 self.ACTION_NAME, 206 gui.cursor.Name.ARROW, 207 ) 208 self._crosshair_cursor = self.doc.app.cursors.get_action_cursor( 209 self.ACTION_NAME, 210 gui.cursor.Name.CROSSHAIR_OPEN_PRECISE, 211 ) 212 213 def leave(self, **kwds): 214 """Leaves the mode: called by `ModeStack.pop()` etc.""" 215 if not self._is_active(): 216 self._discard_overlays() 217 self._stop_task_queue_runner(complete=True) 218 super(InkingMode, self).leave(**kwds) # supercall will commit 219 220 def checkpoint(self, flush=True, **kwargs): 221 """Sync pending changes from (and to) the model 222 223 If called with flush==False, this is an override which just 224 redraws the pending stroke with the current brush settings and 225 color. This is the behavior our testers expect: 226 https://github.com/mypaint/mypaint/issues/226 227 228 When this mode is left for another mode (see `leave()`), the 229 pending brushwork is committed properly. 230 231 """ 232 if flush: 233 # Commit the pending work normally 234 self._start_new_capture_phase(rollback=False) 235 super(InkingMode, self).checkpoint(flush=flush, **kwargs) 236 else: 237 # Queue a re-rendering with any new brush data 238 # No supercall 239 self._stop_task_queue_runner(complete=False) 240 self._queue_draw_buttons() 241 self._queue_redraw_all_nodes() 242 self._queue_redraw_curve() 243 244 def _start_new_capture_phase(self, rollback=False): 245 """Let the user capture a new ink stroke""" 246 if rollback: 247 self._stop_task_queue_runner(complete=False) 248 self.brushwork_rollback_all() 249 else: 250 self._stop_task_queue_runner(complete=True) 251 self.brushwork_commit_all() 252 self.options_presenter.target = (self, None) 253 self._queue_draw_buttons() 254 self._queue_redraw_all_nodes() 255 self._reset_nodes() 256 self._reset_capture_data() 257 self._reset_adjust_data() 258 self.phase = _Phase.CAPTURE 259 260 ## Raw event handling (prelight & zone selection in adjust phase) 261 262 def button_press_cb(self, tdw, event): 263 self._ensure_overlay_for_tdw(tdw) 264 current_layer = tdw.doc._layers.current 265 if not (tdw.is_sensitive and current_layer.get_paintable()): 266 return False 267 self._update_zone_and_target(tdw, event.x, event.y) 268 self._update_current_node_index() 269 if self.phase == _Phase.ADJUST: 270 if self.zone in (_EditZone.REJECT_BUTTON, 271 _EditZone.ACCEPT_BUTTON): 272 button = event.button 273 if button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: 274 self._click_info = (button, self.zone) 275 return False 276 # FALLTHRU: *do* allow drags to start with other buttons 277 elif self.zone == _EditZone.EMPTY_CANVAS: 278 self._start_new_capture_phase(rollback=False) 279 assert self.phase == _Phase.CAPTURE 280 # FALLTHRU: *do* start a drag 281 elif self.phase == _Phase.CAPTURE: 282 # XXX Not sure what to do here. 283 # XXX Click to append nodes? 284 # XXX but how to stop that and enter the adjust phase? 285 # XXX Click to add a 1st & 2nd (=last) node only? 286 # XXX but needs to allow a drag after the 1st one's placed. 287 pass 288 else: 289 raise NotImplementedError("Unrecognized zone %r", self.zone) 290 # Update workaround state for evdev dropouts 291 self._button_down = event.button 292 self._last_good_raw_pressure = 0.0 293 self._last_good_raw_xtilt = 0.0 294 self._last_good_raw_ytilt = 0.0 295 self._last_good_raw_viewzoom = 0.0 296 self._last_good_raw_viewrotation = 0.0 297 self._last_good_raw_barrel_rotation = 0.0 298 # Supercall: start drags etc 299 return super(InkingMode, self).button_press_cb(tdw, event) 300 301 def button_release_cb(self, tdw, event): 302 self._ensure_overlay_for_tdw(tdw) 303 current_layer = tdw.doc._layers.current 304 if not (tdw.is_sensitive and current_layer.get_paintable()): 305 return False 306 if self.phase == _Phase.ADJUST: 307 if self._click_info: 308 button0, zone0 = self._click_info 309 if event.button == button0: 310 if self.zone == zone0: 311 if zone0 == _EditZone.REJECT_BUTTON: 312 self._start_new_capture_phase(rollback=True) 313 assert self.phase == _Phase.CAPTURE 314 elif zone0 == _EditZone.ACCEPT_BUTTON: 315 self._start_new_capture_phase(rollback=False) 316 assert self.phase == _Phase.CAPTURE 317 self._click_info = None 318 self._update_zone_and_target(tdw, event.x, event.y) 319 self._update_current_node_index() 320 return False 321 # (otherwise fall through and end any current drag) 322 elif self.phase == _Phase.CAPTURE: 323 # XXX Not sure what to do here: see above 324 # Update options_presenter when capture phase end 325 self.options_presenter.target = (self, None) 326 else: 327 raise NotImplementedError("Unrecognized zone %r", self.zone) 328 # Update workaround state for evdev dropouts 329 self._button_down = None 330 self._last_good_raw_pressure = 0.0 331 self._last_good_raw_xtilt = 0.0 332 self._last_good_raw_ytilt = 0.0 333 self._last_good_raw_viewzoom = 0.0 334 self._last_good_raw_viewrotation = 0.0 335 self._last_good_raw_barrel_rotation = 0.0 336 # Supercall: stop current drag 337 return super(InkingMode, self).button_release_cb(tdw, event) 338 339 def motion_notify_cb(self, tdw, event): 340 self._ensure_overlay_for_tdw(tdw) 341 current_layer = tdw.doc._layers.current 342 if not (tdw.is_sensitive and current_layer.get_paintable()): 343 return False 344 self._update_zone_and_target(tdw, event.x, event.y) 345 return super(InkingMode, self).motion_notify_cb(tdw, event) 346 347 def _update_current_node_index(self): 348 """Updates current_node_index from target_node_index & redraw""" 349 new_index = self.target_node_index 350 old_index = self.current_node_index 351 if new_index == old_index: 352 return 353 self.current_node_index = new_index 354 self.current_node_changed(new_index) 355 self.options_presenter.target = (self, new_index) 356 for i in (old_index, new_index): 357 if i is not None: 358 self._queue_draw_node(i) 359 360 @lib.observable.event 361 def current_node_changed(self, index): 362 """Event: current_node_index was changed""" 363 364 def _update_zone_and_target(self, tdw, x, y): 365 """Update the zone and target node under a cursor position""" 366 self._ensure_overlay_for_tdw(tdw) 367 new_zone = _EditZone.EMPTY_CANVAS 368 if self.phase == _Phase.ADJUST and not self.in_drag: 369 new_target_node_index = None 370 # Test buttons for hits 371 overlay = self._ensure_overlay_for_tdw(tdw) 372 hit_dist = gui.style.FLOATING_BUTTON_RADIUS 373 button_info = [ 374 (_EditZone.ACCEPT_BUTTON, overlay.accept_button_pos), 375 (_EditZone.REJECT_BUTTON, overlay.reject_button_pos), 376 ] 377 for btn_zone, btn_pos in button_info: 378 if btn_pos is None: 379 continue 380 btn_x, btn_y = btn_pos 381 d = math.hypot(btn_x - x, btn_y - y) 382 if d <= hit_dist: 383 new_target_node_index = None 384 new_zone = btn_zone 385 break 386 # Test nodes for a hit, in reverse draw order 387 if new_zone == _EditZone.EMPTY_CANVAS: 388 hit_dist = gui.style.DRAGGABLE_POINT_HANDLE_SIZE + 12 389 new_target_node_index = None 390 for i, node in reversed(list(enumerate(self.nodes))): 391 node_x, node_y = tdw.model_to_display(node.x, node.y) 392 d = math.hypot(node_x - x, node_y - y) 393 if d > hit_dist: 394 continue 395 new_target_node_index = i 396 new_zone = _EditZone.CONTROL_NODE 397 break 398 # Update the prelit node, and draw changes to it 399 if new_target_node_index != self.target_node_index: 400 if self.target_node_index is not None: 401 self._queue_draw_node(self.target_node_index) 402 self.target_node_index = new_target_node_index 403 if self.target_node_index is not None: 404 self._queue_draw_node(self.target_node_index) 405 # Update the zone, and assume any change implies a button state 406 # change as well (for now...) 407 if self.zone != new_zone: 408 self.zone = new_zone 409 self._ensure_overlay_for_tdw(tdw) 410 self._queue_draw_buttons() 411 # Update the "real" inactive cursor too: 412 if not self.in_drag: 413 cursor = None 414 if self.phase == _Phase.ADJUST: 415 if self.zone == _EditZone.CONTROL_NODE: 416 cursor = self._crosshair_cursor 417 elif self.zone != _EditZone.EMPTY_CANVAS: # assume button 418 cursor = self._arrow_cursor 419 if cursor is not self._current_override_cursor: 420 tdw.set_override_cursor(cursor) 421 self._current_override_cursor = cursor 422 423 ## Redraws 424 425 def _queue_draw_buttons(self): 426 """Redraws the accept/reject buttons on all known view TDWs""" 427 for tdw, overlay in self._overlays.items(): 428 overlay.update_button_positions() 429 positions = ( 430 overlay.reject_button_pos, 431 overlay.accept_button_pos, 432 ) 433 for pos in positions: 434 if pos is None: 435 continue 436 r = gui.style.FLOATING_BUTTON_ICON_SIZE 437 r += max( 438 gui.style.DROP_SHADOW_X_OFFSET, 439 gui.style.DROP_SHADOW_Y_OFFSET, 440 ) 441 r += gui.style.DROP_SHADOW_BLUR 442 x, y = pos 443 tdw.queue_draw_area(x - r, y - r, (2 * r) + 1, (2 * r) + 1) 444 445 def _queue_draw_node(self, i): 446 """Redraws a specific control node on all known view TDWs""" 447 for tdw in self._overlays: 448 node = self.nodes[i] 449 x, y = tdw.model_to_display(node.x, node.y) 450 x = math.floor(x) 451 y = math.floor(y) 452 size = math.ceil(gui.style.DRAGGABLE_POINT_HANDLE_SIZE * 2) 453 tdw.queue_draw_area( 454 x - size, y - size, 455 (size * 2) + 1, (size * 2) + 1, 456 ) 457 458 def _queue_redraw_all_nodes(self): 459 """Redraws all nodes on all known view TDWs""" 460 for i in xrange(len(self.nodes)): 461 self._queue_draw_node(i) 462 463 def _queue_redraw_curve(self): 464 """Redraws the entire curve on all known view TDWs""" 465 self._stop_task_queue_runner(complete=False) 466 for tdw in self._overlays: 467 model = tdw.doc 468 if len(self.nodes) < 2: 469 continue 470 self._queue_task(self.brushwork_rollback, model) 471 self._queue_task( 472 self.brushwork_begin, model, 473 description=_("Inking"), 474 abrupt=True, 475 ) 476 interp_state = {"t_abs": self.nodes[0].time} 477 for p_1, p0, p1, p2 in gui.drawutils.spline_iter(self.nodes): 478 self._queue_task( 479 self._draw_curve_segment, 480 model, 481 p_1, p0, p1, p2, 482 state=interp_state 483 ) 484 self._start_task_queue_runner() 485 486 def _draw_curve_segment(self, model, p_1, p0, p1, p2, state): 487 """Draw the curve segment between the middle two points""" 488 last_t_abs = state["t_abs"] 489 dtime_p0_p1_real = p1[-1] - p0[-1] 490 steps_t = dtime_p0_p1_real / self.INTERPOLATION_MAX_SLICE_TIME 491 dist_p1_p2 = math.hypot(p1[0] - p2[0], p1[1] - p2[1]) 492 steps_d = dist_p1_p2 / self.INTERPOLATION_MAX_SLICE_DISTANCE 493 steps = math.ceil(min(self.INTERPOLATION_MAX_SLICES, 494 max(2, steps_t, steps_d))) 495 for i in xrange(int(steps) + 1): 496 t = i / steps 497 point = gui.drawutils.spline_4p(t, p_1, p0, p1, p2) 498 x, y, pressure, xtilt, ytilt, t_abs, viewzoom, viewrotation, barrel_rotation = point 499 pressure = lib.helpers.clamp(pressure, 0.0, 1.0) 500 xtilt = lib.helpers.clamp(xtilt, -1.0, 1.0) 501 ytilt = lib.helpers.clamp(ytilt, -1.0, 1.0) 502 t_abs = max(last_t_abs, t_abs) 503 dtime = t_abs - last_t_abs 504 viewzoom = self.doc.tdw.scale 505 viewrotation = self.doc.tdw.rotation 506 barrel_rotation = 0.0 507 self.stroke_to( 508 model, dtime, x, y, pressure, xtilt, ytilt, viewzoom, viewrotation, barrel_rotation, 509 auto_split=False, 510 ) 511 last_t_abs = t_abs 512 state["t_abs"] = last_t_abs 513 514 def _queue_task(self, callback, *args, **kwargs): 515 """Append a task to be done later in an idle cycle""" 516 self._task_queue.append((callback, args, kwargs)) 517 518 def _start_task_queue_runner(self): 519 """Begin processing the task queue, if not already going""" 520 if self._task_queue_runner_id is not None: 521 return 522 idler_id = GLib.idle_add(self._task_queue_runner_cb) 523 self._task_queue_runner_id = idler_id 524 525 def _stop_task_queue_runner(self, complete=True): 526 """Halts processing of the task queue, and clears it""" 527 if self._task_queue_runner_id is None: 528 return 529 if complete: 530 for (callback, args, kwargs) in self._task_queue: 531 callback(*args, **kwargs) 532 self._task_queue.clear() 533 GLib.source_remove(self._task_queue_runner_id) 534 self._task_queue_runner_id = None 535 536 def _task_queue_runner_cb(self): 537 """Idle runner callback for the task queue""" 538 try: 539 callback, args, kwargs = self._task_queue.popleft() 540 except IndexError: # queue empty 541 self._task_queue_runner_id = None 542 return False 543 else: 544 callback(*args, **kwargs) 545 return True 546 547 ## Drag handling (both capture and adjust phases) 548 549 def drag_start_cb(self, tdw, event): 550 self._ensure_overlay_for_tdw(tdw) 551 if self.phase == _Phase.CAPTURE: 552 self._reset_nodes() 553 self._reset_capture_data() 554 self._reset_adjust_data() 555 node = self._get_event_data(tdw, event) 556 self.nodes.append(node) 557 self._queue_draw_node(0) 558 self._last_node_evdata = (event.x, event.y, event.time) 559 self._last_event_node = node 560 elif self.phase == _Phase.ADJUST: 561 if self.target_node_index is not None: 562 node = self.nodes[self.target_node_index] 563 self._dragged_node_start_pos = (node.x, node.y) 564 else: 565 raise NotImplementedError("Unknown phase %r" % self.phase) 566 567 def drag_update_cb(self, tdw, event, dx, dy): 568 self._ensure_overlay_for_tdw(tdw) 569 if self.phase == _Phase.CAPTURE: 570 node = self._get_event_data(tdw, event) 571 evdata = (event.x, event.y, event.time) 572 if not self._last_node_evdata: # e.g. after an undo while dragging 573 append_node = True 574 elif evdata == self._last_node_evdata: 575 logger.debug( 576 "Capture: ignored successive events " 577 "with identical position and time: %r", 578 evdata, 579 ) 580 append_node = False 581 else: 582 dx = event.x - self._last_node_evdata[0] 583 dy = event.y - self._last_node_evdata[1] 584 dist = math.hypot(dy, dx) 585 dt = event.time - self._last_node_evdata[2] 586 max_dist = self.MAX_INTERNODE_DISTANCE_MIDDLE 587 if len(self.nodes) < 2: 588 max_dist = self.MAX_INTERNODE_DISTANCE_ENDS 589 append_node = ( 590 dist > max_dist and 591 dt > self.MAX_INTERNODE_TIME 592 ) 593 if append_node: 594 self.nodes.append(node) 595 self._queue_draw_node(len(self.nodes) - 1) 596 self._queue_redraw_curve() 597 self._last_node_evdata = evdata 598 self._last_event_node = node 599 elif self.phase == _Phase.ADJUST: 600 if self._dragged_node_start_pos: 601 x0, y0 = self._dragged_node_start_pos 602 disp_x, disp_y = tdw.model_to_display(x0, y0) 603 disp_x += event.x - self.start_x 604 disp_y += event.y - self.start_y 605 x, y = tdw.display_to_model(disp_x, disp_y) 606 self.update_node(self.target_node_index, x=x, y=y) 607 else: 608 raise NotImplementedError("Unknown phase %r" % self.phase) 609 610 def drag_stop_cb(self, tdw): 611 self._ensure_overlay_for_tdw(tdw) 612 if self.phase == _Phase.CAPTURE: 613 if not self.nodes: 614 return 615 node = self._last_event_node 616 # TODO: maybe rewrite the last node here so it's the right 617 # TODO: distance from the end? 618 if self.nodes[-1] is not node: 619 self.nodes.append(node) 620 self._reset_capture_data() 621 self._reset_adjust_data() 622 if len(self.nodes) > 1: 623 self.phase = _Phase.ADJUST 624 self._queue_redraw_all_nodes() 625 self._queue_redraw_curve() 626 self._queue_draw_buttons() 627 else: 628 self._reset_nodes() 629 tdw.queue_draw() 630 elif self.phase == _Phase.ADJUST: 631 self._dragged_node_start_pos = None 632 self._queue_redraw_curve() 633 self._queue_draw_buttons() 634 else: 635 raise NotImplementedError("Unknown phase %r" % self.phase) 636 637 ## Interrogating events 638 639 def _get_event_data(self, tdw, event): 640 x, y = tdw.display_to_model(event.x, event.y) 641 xtilt, ytilt = self._get_event_tilt(tdw, event) 642 return _Node( 643 x=x, y=y, 644 pressure=self._get_event_pressure(event), 645 xtilt=xtilt, ytilt=ytilt, 646 time=(event.time / 1000.0), 647 viewzoom = self.doc.tdw.scale, 648 viewrotation = self.doc.tdw.rotation, 649 barrel_rotation = 0.0, 650 ) 651 652 def _get_event_pressure(self, event): 653 # FIXME: CODE DUPLICATION: copied from freehand.py 654 pressure = event.get_axis(Gdk.AxisUse.PRESSURE) 655 if pressure is not None: 656 if not np.isfinite(pressure): 657 pressure = None 658 else: 659 pressure = lib.helpers.clamp(pressure, 0.0, 1.0) 660 661 if pressure is None: 662 pressure = 0.0 663 if event.state & Gdk.ModifierType.BUTTON1_MASK: 664 pressure = 0.5 665 666 # Workaround for buggy evdev behaviour. 667 # Events sometimes get a zero raw pressure reading when the 668 # pressure reading has not changed. This results in broken 669 # lines. As a workaround, forbid zero pressures if there is a 670 # button pressed down, and substitute the last-known good value. 671 # Detail: https://github.com/mypaint/mypaint/issues/223 672 if self._button_down is not None: 673 if pressure == 0.0: 674 pressure = self._last_good_raw_pressure 675 elif pressure is not None and np.isfinite(pressure): 676 self._last_good_raw_pressure = pressure 677 return pressure 678 679 680 def _get_event_tilt(self, tdw, event): 681 # FIXME: CODE DUPLICATION: copied from freehand.py 682 xtilt = event.get_axis(Gdk.AxisUse.XTILT) 683 ytilt = event.get_axis(Gdk.AxisUse.YTILT) 684 if xtilt is None or ytilt is None or not np.isfinite(xtilt + ytilt): 685 return (0.0, 0.0) 686 687 # Switching from a non-tilt device to a device which reports 688 # tilt can cause GDK to return out-of-range tilt values, on X11. 689 xtilt = lib.helpers.clamp(xtilt, -1.0, 1.0) 690 ytilt = lib.helpers.clamp(ytilt, -1.0, 1.0) 691 692 # Evdev workaround. X and Y tilts suffer from the same 693 # problem as pressure for fancier devices. 694 if self._button_down is not None: 695 if xtilt == 0.0: 696 xtilt = self._last_good_raw_xtilt 697 else: 698 self._last_good_raw_xtilt = xtilt 699 if ytilt == 0.0: 700 ytilt = self._last_good_raw_ytilt 701 else: 702 self._last_good_raw_ytilt = ytilt 703 704 if tdw.mirrored: 705 xtilt *= -1.0 706 707 return (xtilt, ytilt) 708 709 ## Node editing 710 711 @property 712 def options_presenter(self): 713 """MVP presenter object for the node editor panel""" 714 cls = self.__class__ 715 if cls._OPTIONS_PRESENTER is None: 716 cls._OPTIONS_PRESENTER = OptionsUI() 717 return cls._OPTIONS_PRESENTER 718 719 def get_options_widget(self): 720 """Get the (class singleton) options widget""" 721 return self.options_presenter.widget 722 723 def update_node(self, i, **kwargs): 724 """Updates properties of a node, and redraws it""" 725 changing_pos = bool({"x", "y"}.intersection(kwargs)) 726 oldnode = self.nodes[i] 727 if changing_pos: 728 self._queue_draw_node(i) 729 self.nodes[i] = oldnode._replace(**kwargs) 730 # FIXME: The curve redraw is a bit flickery. 731 # Perhaps dragging to adjust should only draw an 732 # armature during the drag, leaving the redraw to 733 # the stop handler. 734 self._queue_redraw_curve() 735 if changing_pos: 736 self._queue_draw_node(i) 737 738 def get_node_dtime(self, i): 739 if not (0 < i < len(self.nodes)): 740 return 0.0 741 n0 = self.nodes[i - 1] 742 n1 = self.nodes[i] 743 dtime = n1.time - n0.time 744 dtime = max(dtime, self.MIN_INTERNODE_TIME) 745 return dtime 746 747 def set_node_dtime(self, i, dtime): 748 dtime = max(dtime, self.MIN_INTERNODE_TIME) 749 nodes = self.nodes 750 if not (0 < i < len(nodes)): 751 return 752 old_dtime = nodes[i].time - nodes[i - 1].time 753 for j in range(i, len(nodes)): 754 n = nodes[j] 755 new_time = n.time + dtime - old_dtime 756 self.update_node(j, time=new_time) 757 758 def can_delete_node(self, i): 759 if i is None: 760 return False 761 return 0 < i < len(self.nodes) - 1 762 763 def delete_node(self, i): 764 """Delete a node, and issue redraws & updates""" 765 assert self.can_delete_node(i), "Can't delete endpoints" 766 # Redraw old locations of things while the node still exists 767 self._queue_draw_buttons() 768 self._queue_draw_node(i) 769 # Remove the node 770 self.nodes.pop(i) 771 # Limit the current node 772 new_cn = self.current_node_index 773 if (new_cn is not None) and new_cn >= len(self.nodes): 774 new_cn = len(self.nodes) - 2 775 self.current_node_index = new_cn 776 self.current_node_changed(new_cn) 777 # Options panel update 778 self.options_presenter.target = (self, new_cn) 779 # Issue redraws for the changed on-canvas elements 780 self._queue_redraw_curve() 781 self._queue_redraw_all_nodes() 782 self._queue_draw_buttons() 783 784 def delete_current_node(self): 785 if self.can_delete_node(self.current_node_index): 786 self.delete_node(self.current_node_index) 787 788 # FIXME: Quick hack,to avoid indexerror(very rare case) 789 self.target_node_index = None 790 791 def can_insert_node(self, i): 792 if i is None: 793 return False 794 return 0 <= i < (len(self.nodes) - 1) 795 796 def insert_node(self, i): 797 """Insert a node, and issue redraws & updates""" 798 assert self.can_insert_node(i), "Can't insert back of the endpoint" 799 # Redraw old locations of things while the node still exists 800 self._queue_draw_buttons() 801 self._queue_draw_node(i) 802 # Create the new e 803 cn = self.nodes[i] 804 nn = self.nodes[i + 1] 805 806 newnode = _Node( 807 x=(cn.x + nn.x) / 2.0, y=(cn.y + nn.y) / 2.0, 808 pressure=(cn.pressure + nn.pressure) / 2.0, 809 xtilt=(cn.xtilt + nn.xtilt) / 2.0, 810 ytilt=(cn.ytilt + nn.ytilt) / 2.0, 811 time=(cn.time + nn.time) / 2.0, 812 viewzoom=(cn.viewzoom + nn.viewzoom) / 2.0, 813 viewrotation=(cn.viewrotation + nn.viewrotation) / 2.0, 814 barrel_rotation=(cn.barrel_rotation + nn.barrel_rotation) / 2.0, 815 ) 816 self.nodes.insert(i + 1, newnode) 817 818 # Issue redraws for the changed on-canvas elements 819 self._queue_redraw_curve() 820 self._queue_redraw_all_nodes() 821 self._queue_draw_buttons() 822 823 def insert_current_node(self): 824 if self.can_insert_node(self.current_node_index): 825 self.insert_node(self.current_node_index) 826 827 def _simplify_nodes(self, tolerance): 828 """Internal method of simplify nodes. 829 830 Algorithm: Reumann-Witkam. 831 832 """ 833 i = 0 834 oldcnt = len(self.nodes) 835 while i < len(self.nodes) - 2: 836 try: 837 vsx = self.nodes[i + 1].x - self.nodes[i].x 838 vsy = self.nodes[i + 1].y - self.nodes[i].y 839 ss = math.sqrt((vsx * vsx) + (vsy * vsy)) 840 nsx = vsx / ss 841 nsy = vsy / ss 842 while (i + 2) < len(self.nodes): 843 vex = self.nodes[i + 2].x - self.nodes[i].x 844 vey = self.nodes[i + 2].y - self.nodes[i].y 845 es = math.sqrt((vex * vex) + (vey * vey)) 846 px = nsx * es 847 py = nsy * es 848 dp = (px * (vex / es) + py * (vey / es)) / es 849 hx = (vex * dp) - px 850 hy = (vey * dp) - py 851 852 if math.sqrt((hx * hx) + (hy * hy)) < tolerance: 853 self.nodes.pop(i + 1) 854 else: 855 break 856 857 except ValueError: 858 pass 859 except ZeroDivisionError: 860 pass 861 finally: 862 i += 1 863 864 return oldcnt - len(self.nodes) 865 866 def _cull_nodes(self): 867 """Internal method of cull nodes.""" 868 curcnt = len(self.nodes) 869 lastnode = self.nodes[-1] 870 self.nodes = self.nodes[:-1:2] 871 self.nodes.append(lastnode) 872 return curcnt - len(self.nodes) 873 874 def _nodes_deletion_operation(self, func, args): 875 """Internal method for delete-related operation of multiple nodes.""" 876 # To ensure redraw entire overlay,avoiding glitches. 877 self._queue_redraw_curve() 878 self._queue_redraw_all_nodes() 879 self._queue_draw_buttons() 880 881 if func(*args) > 0: 882 883 new_cn = self.current_node_index 884 if (new_cn is not None) and new_cn >= len(self.nodes): 885 new_cn = len(self.nodes) - 2 886 self.current_node_index = new_cn 887 self.current_node_changed(new_cn) 888 self.options_presenter.target = (self, new_cn) 889 890 # FIXME: Quick hack,to avoid indexerror 891 self.target_node_index = None 892 893 # Issue redraws for the changed on-canvas elements 894 self._queue_redraw_curve() 895 self._queue_redraw_all_nodes() 896 self._queue_draw_buttons() 897 898 def simplify_nodes(self): 899 """User interface method of simplify nodes.""" 900 # For now, parameter is fixed value. 901 # tolerance is 8, in model coords. 902 self._nodes_deletion_operation(self._simplify_nodes, (8,)) 903 904 def cull_nodes(self): 905 """User interface method of cull nodes.""" 906 self._nodes_deletion_operation(self._cull_nodes, ()) 907 908 909class Overlay (gui.overlays.Overlay): 910 """Overlay for an InkingMode's adjustable points""" 911 912 def __init__(self, inkmode, tdw): 913 super(Overlay, self).__init__() 914 self._inkmode = weakref.proxy(inkmode) 915 self._tdw = weakref.proxy(tdw) 916 self._button_pixbuf_cache = {} 917 self.accept_button_pos = None 918 self.reject_button_pos = None 919 920 def update_button_positions(self): 921 """Recalculates the positions of the mode's buttons.""" 922 nodes = self._inkmode.nodes 923 num_nodes = len(nodes) 924 if num_nodes == 0: 925 self.reject_button_pos = None 926 self.accept_button_pos = None 927 return 928 929 button_radius = gui.style.FLOATING_BUTTON_RADIUS 930 margin = 1.5 * button_radius 931 alloc = self._tdw.get_allocation() 932 view_x0, view_y0 = alloc.x, alloc.y 933 view_x1, view_y1 = (view_x0 + alloc.width), (view_y0 + alloc.height) 934 935 # Force-directed layout: "wandering nodes" for the buttons' 936 # eventual positions, moving around a constellation of "fixed" 937 # points corresponding to the nodes the user manipulates. 938 fixed = [] 939 940 for i, node in enumerate(nodes): 941 x, y = self._tdw.model_to_display(node.x, node.y) 942 fixed.append(_LayoutNode(x, y)) 943 944 # The reject and accept buttons are connected to different nodes 945 # in the stroke by virtual springs. 946 stroke_end_i = len(fixed) - 1 947 stroke_start_i = 0 948 stroke_last_quarter_i = int(stroke_end_i * 3.0 // 4.0) 949 assert stroke_last_quarter_i < stroke_end_i 950 reject_anchor_i = stroke_start_i 951 accept_anchor_i = stroke_end_i 952 953 # Classify the stroke direction as a unit vector 954 stroke_tail = ( 955 fixed[stroke_end_i].x - fixed[stroke_last_quarter_i].x, 956 fixed[stroke_end_i].y - fixed[stroke_last_quarter_i].y, 957 ) 958 stroke_tail_len = math.hypot(*stroke_tail) 959 if stroke_tail_len <= 0: 960 stroke_tail = (0., 1.) 961 else: 962 stroke_tail = tuple(c / stroke_tail_len for c in stroke_tail) 963 964 # Initial positions. 965 accept_button = _LayoutNode( 966 fixed[accept_anchor_i].x + stroke_tail[0] * margin, 967 fixed[accept_anchor_i].y + stroke_tail[1] * margin, 968 ) 969 reject_button = _LayoutNode( 970 fixed[reject_anchor_i].x - stroke_tail[0] * margin, 971 fixed[reject_anchor_i].y - stroke_tail[1] * margin, 972 ) 973 974 # Constraint boxes. They mustn't share corners. 975 # Natural hand strokes are often downwards, 976 # so let the reject button to go above the accept button. 977 reject_button_bbox = ( 978 view_x0 + margin, view_x1 - margin, 979 view_y0 + margin, view_y1 - (2.666 * margin), 980 ) 981 accept_button_bbox = ( 982 view_x0 + margin, view_x1 - margin, 983 view_y0 + (2.666 * margin), view_y1 - margin, 984 ) 985 986 # Force-update constants 987 k_repel = -25.0 988 k_attract = 0.05 989 990 # Let the buttons bounce around until they've settled. 991 for iter_i in xrange(100): 992 accept_button \ 993 .add_forces_inverse_square(fixed, k=k_repel) \ 994 .add_forces_inverse_square([reject_button], k=k_repel) \ 995 .add_forces_linear([fixed[accept_anchor_i]], k=k_attract) 996 reject_button \ 997 .add_forces_inverse_square(fixed, k=k_repel) \ 998 .add_forces_inverse_square([accept_button], k=k_repel) \ 999 .add_forces_linear([fixed[reject_anchor_i]], k=k_attract) 1000 reject_button \ 1001 .update_position() \ 1002 .constrain_position(*reject_button_bbox) 1003 accept_button \ 1004 .update_position() \ 1005 .constrain_position(*accept_button_bbox) 1006 settled = [(p.speed < 0.5) for p in [accept_button, reject_button]] 1007 if all(settled): 1008 break 1009 self.accept_button_pos = accept_button.x, accept_button.y 1010 self.reject_button_pos = reject_button.x, reject_button.y 1011 1012 def _get_button_pixbuf(self, name): 1013 """Loads the pixbuf corresponding to a button name (cached)""" 1014 cache = self._button_pixbuf_cache 1015 pixbuf = cache.get(name) 1016 if not pixbuf: 1017 pixbuf = gui.drawutils.load_symbolic_icon( 1018 icon_name=name, 1019 size=gui.style.FLOATING_BUTTON_ICON_SIZE, 1020 fg=(0, 0, 0, 1), 1021 ) 1022 cache[name] = pixbuf 1023 return pixbuf 1024 1025 def _get_onscreen_nodes(self): 1026 """Iterates across only the on-screen nodes.""" 1027 mode = self._inkmode 1028 radius = gui.style.DRAGGABLE_POINT_HANDLE_SIZE 1029 alloc = self._tdw.get_allocation() 1030 for i, node in enumerate(mode.nodes): 1031 x, y = self._tdw.model_to_display(node.x, node.y) 1032 node_on_screen = ( 1033 x > alloc.x - (radius * 2) and 1034 y > alloc.y - (radius * 2) and 1035 x < alloc.x + alloc.width + (radius * 2) and 1036 y < alloc.y + alloc.height + (radius * 2) 1037 ) 1038 if node_on_screen: 1039 yield (i, node, x, y) 1040 1041 def paint(self, cr): 1042 """Draw adjustable nodes to the screen""" 1043 # Control nodes 1044 mode = self._inkmode 1045 radius = gui.style.DRAGGABLE_POINT_HANDLE_SIZE 1046 for i, node, x, y in self._get_onscreen_nodes(): 1047 color = gui.style.EDITABLE_ITEM_COLOR 1048 if mode.phase == _Phase.ADJUST: 1049 if i == mode.current_node_index: 1050 color = gui.style.ACTIVE_ITEM_COLOR 1051 elif i == mode.target_node_index: 1052 color = gui.style.PRELIT_ITEM_COLOR 1053 gui.drawutils.render_round_floating_color_chip( 1054 cr=cr, x=x, y=y, 1055 color=color, 1056 radius=radius, 1057 ) 1058 # Buttons 1059 if mode.phase == _Phase.ADJUST and not mode.in_drag: 1060 self.update_button_positions() 1061 radius = gui.style.FLOATING_BUTTON_RADIUS 1062 button_info = [ 1063 ( 1064 "mypaint-ok-symbolic", 1065 self.accept_button_pos, 1066 _EditZone.ACCEPT_BUTTON, 1067 ), 1068 ( 1069 "mypaint-trash-symbolic", 1070 self.reject_button_pos, 1071 _EditZone.REJECT_BUTTON, 1072 ), 1073 ] 1074 for icon_name, pos, zone in button_info: 1075 if pos is None: 1076 continue 1077 x, y = pos 1078 if mode.zone == zone: 1079 color = gui.style.ACTIVE_ITEM_COLOR 1080 else: 1081 color = gui.style.EDITABLE_ITEM_COLOR 1082 icon_pixbuf = self._get_button_pixbuf(icon_name) 1083 gui.drawutils.render_round_floating_button( 1084 cr=cr, x=x, y=y, 1085 color=color, 1086 pixbuf=icon_pixbuf, 1087 radius=radius, 1088 ) 1089 1090 1091class _LayoutNode (object): 1092 """Vertex/point for the button layout algorithm.""" 1093 1094 def __init__(self, x, y, force=(0., 0.), velocity=(0., 0.)): 1095 self.x = float(x) 1096 self.y = float(y) 1097 self.force = tuple(float(c) for c in force[:2]) 1098 self.velocity = tuple(float(c) for c in velocity[:2]) 1099 1100 def __repr__(self): 1101 return "_LayoutNode(x=%r, y=%r, force=%r, velocity=%r)" % ( 1102 self.x, self.y, self.force, self.velocity, 1103 ) 1104 1105 @property 1106 def pos(self): 1107 return (self.x, self.y) 1108 1109 @property 1110 def speed(self): 1111 return math.hypot(*self.velocity) 1112 1113 def add_forces_inverse_square(self, others, k=20.0): 1114 """Adds inverse-square components to the effective force. 1115 1116 :param [_LayoutNode] others: _LayoutNodes affecting this one 1117 :param float k: scaling factor 1118 :returns: self 1119 1120 The forces applied are proportional to k, and inversely 1121 proportional to the square of the distances. Examples: 1122 gravity, electrostatic repulsion. 1123 1124 With the default arguments, the added force components are 1125 attractive. Use negative k to simulate repulsive forces. 1126 1127 """ 1128 fx, fy = self.force 1129 for other in others: 1130 if other is self: 1131 continue 1132 rsquared = (self.x - other.x) ** 2 + (self.y - other.y) ** 2 1133 if rsquared == 0: 1134 continue 1135 else: 1136 fx += k * (other.x - self.x) / rsquared 1137 fy += k * (other.y - self.y) / rsquared 1138 self.force = (fx, fy) 1139 return self 1140 1141 def add_forces_linear(self, others, k=0.05): 1142 """Adds linear components to the total effective force. 1143 1144 :param [_LayoutNode] others: _LayoutNodes affecting this one 1145 :param float k: scaling factor 1146 :returns: self 1147 1148 The forces applied are proportional to k, and to the distance. 1149 Example: springs. 1150 1151 With the default arguments, the added force components are 1152 attractive. Use negative k to simulate repulsive forces. 1153 1154 """ 1155 fx, fy = self.force 1156 for other in others: 1157 if other is self: 1158 continue 1159 fx += k * (other.x - self.x) 1160 fy += k * (other.y - self.y) 1161 self.force = (fx, fy) 1162 return self 1163 1164 def update_position(self, damping=0.85): 1165 """Updates velocity & position from total force, then resets it. 1166 1167 :param float damping: Damping factor for velocity/speed. 1168 :returns: self 1169 1170 Calling this method should be done just once per iteration, 1171 after all the force components have been added in. The effective 1172 force is reset to zero after calling this method. 1173 1174 """ 1175 fx, fy = self.force 1176 self.force = (0., 0.) 1177 vx, vy = self.velocity 1178 vx = (vx + fx) * damping 1179 vy = (vy + fy) * damping 1180 self.velocity = (vx, vy) 1181 self.x += vx 1182 self.y += vy 1183 return self 1184 1185 def constrain_position(self, x0, x1, y0, y1): 1186 vx, vy = self.velocity 1187 if self.x < x0: 1188 self.x = x0 1189 vx = 0 1190 elif self.x > x1: 1191 self.x = x1 1192 vx = 0 1193 if self.y < y0: 1194 self.y = y0 1195 vy = 0 1196 elif self.y > y1: 1197 self.y = y1 1198 vy = 0 1199 self.velocity = (vx, vy) 1200 return self 1201 1202 1203class OptionsUI (gui.mvp.BuiltUIPresenter, object): 1204 """Presents UI for directly editing point values etc.""" 1205 1206 def __init__(self): 1207 super(OptionsUI, self).__init__() 1208 self._target = (None, None) 1209 1210 def init_view(self): 1211 self.view.point_values_grid.set_sensitive(False) 1212 self.view.insert_point_button.set_sensitive(False) 1213 self.view.delete_point_button.set_sensitive(False) 1214 self.view.simplify_points_button.set_sensitive(False) 1215 self.view.cull_points_button.set_sensitive(False) 1216 1217 @property 1218 def widget(self): 1219 return self.view.options_grid 1220 1221 @property 1222 def target(self): 1223 """The active mode and its current node index 1224 1225 :returns: a pair of the form (inkmode, node_idx) 1226 :rtype: tuple 1227 1228 Updating this pair via the property also updates the options UI 1229 view, shortly afterwards. The target mode must be an InkingTool 1230 instance. 1231 1232 """ 1233 mode_ref, node_idx = self._target 1234 mode = None 1235 if mode_ref is not None: 1236 mode = mode_ref() 1237 return (mode, node_idx) 1238 1239 @target.setter 1240 def target(self, targ): 1241 inkmode, cn_idx = targ 1242 inkmode_ref = None 1243 if inkmode: 1244 inkmode_ref = weakref.ref(inkmode) 1245 self._target = (inkmode_ref, cn_idx) 1246 1247 GLib.idle_add(self._update_ui_for_current_target) 1248 1249 @gui.mvp.view_updater(default=False) 1250 def _update_ui_for_current_target(self): 1251 (inkmode, cn_idx) = self.target 1252 if (cn_idx is not None) and (0 <= cn_idx < len(inkmode.nodes)): 1253 cn = inkmode.nodes[cn_idx] 1254 self.view.pressure_adj.set_value(cn.pressure) 1255 self.view.xtilt_adj.set_value(cn.xtilt) 1256 self.view.ytilt_adj.set_value(cn.ytilt) 1257 if cn_idx > 0: 1258 sensitive = True 1259 dtime = inkmode.get_node_dtime(cn_idx) 1260 else: 1261 sensitive = False 1262 dtime = 0.0 1263 for w in (self.view.dtime_scale, self.view.dtime_label): 1264 w.set_sensitive(sensitive) 1265 self.view.dtime_adj.set_value(dtime) 1266 self.view.point_values_grid.set_sensitive(True) 1267 else: 1268 self.view.point_values_grid.set_sensitive(False) 1269 button_sensitivities = [ 1270 (self.view.insert_point_button, inkmode.can_insert_node(cn_idx)), 1271 (self.view.delete_point_button, inkmode.can_delete_node(cn_idx)), 1272 (self.view.simplify_points_button, (len(inkmode.nodes) > 3)), 1273 (self.view.cull_points_button, (len(inkmode.nodes) > 2)), 1274 ] 1275 for button, sens in button_sensitivities: 1276 button.set_sensitive(sens) 1277 return False 1278 1279 @gui.mvp.model_updater 1280 def _pressure_adj_value_changed_cb(self, adj): 1281 inkmode, node_idx = self.target 1282 inkmode.update_node(node_idx, pressure=float(adj.get_value())) 1283 1284 @gui.mvp.model_updater 1285 def _dtime_adj_value_changed_cb(self, adj): 1286 inkmode, node_idx = self.target 1287 inkmode.set_node_dtime(node_idx, adj.get_value()) 1288 1289 @gui.mvp.model_updater 1290 def _xtilt_adj_value_changed_cb(self, adj): 1291 value = float(adj.get_value()) 1292 inkmode, node_idx = self.target 1293 inkmode.update_node(node_idx, xtilt=value) 1294 1295 @gui.mvp.model_updater 1296 def _ytilt_adj_value_changed_cb(self, adj): 1297 value = float(adj.get_value()) 1298 inkmode, node_idx = self.target 1299 inkmode.update_node(node_idx, ytilt=value) 1300 1301 @gui.mvp.model_updater 1302 def _insert_point_button_clicked_cb(self, button): 1303 inkmode, node_idx = self.target 1304 if inkmode.can_insert_node(node_idx): 1305 inkmode.insert_node(node_idx) 1306 1307 @gui.mvp.model_updater 1308 def _delete_point_button_clicked_cb(self, button): 1309 inkmode, node_idx = self.target 1310 if inkmode.can_delete_node(node_idx): 1311 inkmode.delete_node(node_idx) 1312 1313 @gui.mvp.model_updater 1314 def _simplify_points_button_clicked_cb(self, button): 1315 inkmode, node_idx = self.target 1316 if len(inkmode.nodes) > 3: 1317 inkmode.simplify_nodes() 1318 1319 @gui.mvp.model_updater 1320 def _cull_points_button_clicked_cb(self, button): 1321 inkmode, node_idx = self.target 1322 if len(inkmode.nodes) > 2: 1323 inkmode.cull_nodes() 1324