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