1 # This file is part of MyPaint.
2 # Copyright (C) 2008 by Martin Renold <martinxyz@gmx.ch>
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.
9 import gtk, gobject, cairo, random
11 from math import floor, ceil, pi, log
12 from numpy import isfinite
13 from warnings import warn
15 from lib import helpers, tiledsurface, pixbufsurface
20 def _make_testbed_model():
21 warn("Creating standalone model for testing", RuntimeWarning, 2)
22 import lib.brush, lib.document
23 brush = lib.brush.BrushInfo()
25 return lib.document.Document(brush)
29 class TiledDrawWidget(gtk.DrawingArea):
31 This widget displays a document (../lib/document*.py).
33 It can show the document translated, rotated or zoomed. It does
34 not respond to user input except for painting. Painting events are
35 passed to the document after applying the inverse transformation.
38 # Register a GType name for Glade, GtkBuilder etc.
39 __gtype_name__ = "TiledDrawWidget"
41 CANNOT_DRAW_CURSOR = gdk.Cursor(gdk.CIRCLE)
43 def __init__(self, app=None, document=None):
44 gtk.DrawingArea.__init__(self)
45 self.connect("expose-event", self.expose_cb)
46 self.connect("enter-notify-event", self.enter_notify_cb)
47 self.connect("leave-notify-event", self.leave_notify_cb)
48 self.connect("size-allocate", self.size_allocate_cb)
49 self.connect("state-changed", self.state_changed_cb)
51 # workaround for https://gna.org/bugs/?14372 ([Windows] crash when moving the pen during startup)
52 def at_application_start(*junk):
53 self.connect("motion-notify-event", self.motion_notify_cb)
54 self.connect("button-press-event", self.button_press_cb)
55 self.connect("button-release-event", self.button_release_cb)
56 gobject.idle_add(at_application_start)
58 self.set_events(gdk.EXPOSURE_MASK
59 | gdk.POINTER_MOTION_MASK
60 | gdk.ENTER_NOTIFY_MASK
61 | gdk.LEAVE_NOTIFY_MASK
62 # Workaround for https://gna.org/bugs/index.php?16253
63 # Mypaint doesn't use proximity-*-event for anything
64 # yet, but this seems to be needed for scrollwheels
65 # etc. to keep working.
66 | gdk.PROXIMITY_OUT_MASK
67 | gdk.PROXIMITY_IN_MASK
68 # for some reason we also need to specify events handled in drawwindow.py:
69 | gdk.BUTTON_PRESS_MASK
70 | gdk.BUTTON_RELEASE_MASK
73 self.set_extension_events (gdk.EXTENSION_EVENTS_ALL)
77 document = _make_testbed_model()
79 self.doc.canvas_observers.append(self.canvas_modified_cb)
80 self.doc.brush.brushinfo.observers.append(self.brush_modified_cb)
82 self.cursor_info = None
84 self.last_event_time = None
85 self.last_event_x = None
86 self.last_event_y = None
87 self.last_event_device = None
88 self.last_event_had_pressure_info = False
89 self.last_painting_pos = None
90 self.device_observers = [] #: Notified during drawing when input devices change
91 self._input_stroke_ended_observers = [] #: Access via gui.document
93 self.visualize_rendering = False
95 self.translation_x = 0.0
96 self.translation_y = 0.0
101 self.has_pointer = False
104 self.current_layer_solo = False
105 self.show_layers_above = True
107 self.overlay_layer = None
109 # gets overwritten for the main window
111 self.zoom_min = 1/5.0
113 #self.scroll_at_edges = False
114 self.pressure_mapping = None
115 self.bad_devices = []
118 # Sensitivity; we draw via a cached snapshot while the widget is
119 # insensitive. tdws are generally only insensitive during loading and
120 # saving, and because we now process the GTK main loop during loading
121 # and saving, we need to avoid drawing partially-loaded files.
123 self.is_sensitive = True # just mirrors gtk.STATE_INSENSITIVE
124 self.snapshot_pixmap = None
126 self.override_cursor = None
128 #def set_scroll_at_edges(self, choice):
129 # self.scroll_at_edges = choice
131 def state_changed_cb(self, widget, oldstate):
132 # Keeps track of the sensitivity state, and regenerates
133 # the snapshot pixbuf on entering it.
134 sensitive = self.get_state() != gtk.STATE_INSENSITIVE
136 self.snapshot_pixmap = None
138 if self.snapshot_pixmap is None:
139 self.snapshot_pixmap = self.get_snapshot()
140 self.is_sensitive = sensitive
142 def enter_notify_cb(self, widget, event):
143 self.has_pointer = True
144 def leave_notify_cb(self, widget, event):
145 self.has_pointer = False
147 def size_allocate_cb(self, widget, allocation):
148 old_alloc = getattr(self, 'stored_allocation', allocation)
149 if old_alloc != allocation:
150 dx = allocation.x - old_alloc.x
151 dy = allocation.y - old_alloc.y
153 self.stored_allocation = allocation
155 def device_used(self, device):
156 """Tell the TDW about a device being used."""
157 if device == self.last_event_device:
159 for func in self.device_observers:
160 func(self.last_event_device, device)
161 self.last_event_device = device
164 # Do not interpolate between motion events from different
165 # devices. If the final pressure value from the previous
166 # device was not 0.0, the motion event of the new device could
167 # cause a visible stroke, even if pressure is 0.0.
168 self.doc.brush.reset()
170 def motion_notify_cb(self, widget, event, button1_pressed=None):
171 if not self.is_sensitive:
174 if self.last_event_time:
175 dtime = (event.time - self.last_event_time)/1000.0
176 dx = event.x - self.last_event_x
177 dy = event.y - self.last_event_y
180 self.last_event_x = event.x
181 self.last_event_y = event.y
182 self.last_event_time = event.time
186 same_device = self.device_used(event.device)
189 self.dragfunc(dx, dy, event.x, event.y)
192 # Refuse drawing if the layer is locked or hidden
193 if self.doc.layer.locked or not self.doc.layer.visible:
195 # TODO: some feedback, maybe
197 cr = self.get_model_coordinates_cairo_context()
198 x, y = cr.device_to_user(event.x, event.y)
200 pressure = event.get_axis(gdk.AXIS_PRESSURE)
202 if pressure is not None and (pressure > 1.0 or pressure < 0.0 or not isfinite(pressure)):
203 if event.device.name not in self.bad_devices:
204 print 'WARNING: device "%s" is reporting bad pressure %+f' % (event.device.name, pressure)
205 self.bad_devices.append(event.device.name)
206 if not isfinite(pressure):
207 # infinity/nan: use button state (instead of clamping in brush.hpp)
208 # https://gna.org/bugs/?14709
212 self.last_event_had_pressure_info = False
213 if button1_pressed is None:
214 button1_pressed = event.state & gdk.BUTTON1_MASK
220 self.last_event_had_pressure_info = True
222 xtilt = event.get_axis(gdk.AXIS_XTILT)
223 ytilt = event.get_axis(gdk.AXIS_YTILT)
224 # Check whether tilt is present. For some tablets without
225 # tilt support GTK reports a tilt axis with value nan, instead
226 # of None. https://gna.org/bugs/?17084
227 if xtilt is None or ytilt is None or not isfinite(xtilt+ytilt):
231 if event.state & gdk.CONTROL_MASK or event.state & gdk.MOD1_MASK:
232 # color picking, do not paint
233 # Don't simply return; this is a workaround for unwanted lines in https://gna.org/bugs/?16169
236 ### CSS experimental - scroll when touching the edge of the screen in fullscreen mode
238 # Disabled for the following reasons:
239 # - causes irritation when doing fast strokes near the edge
240 # - scrolling speed depends on the number of events received (can be a huge difference between tablets/mouse)
241 # - also, mouse button scrolling is usually enough
243 #if self.scroll_at_edges and pressure <= 0.0:
244 # screen_w = gdk.screen_width()
245 # screen_h = gdk.screen_height()
247 # if (event.x <= trigger_area):
249 # if (event.x >= (screen_w-1)-trigger_area):
251 # if (event.y <= trigger_area):
253 # if (event.y >= (screen_h-1)-trigger_area):
256 if self.pressure_mapping:
257 pressure = self.pressure_mapping(pressure)
258 if event.state & gdk.SHIFT_MASK:
262 self.last_painting_pos = x, y
264 # If the device has changed and the last pressure value from the previous device
265 # is not equal to 0.0, this can leave a visible stroke on the layer even if the 'new'
266 # device is not pressed on the tablet and has a pressure axis == 0.0.
267 # Reseting the brush when the device changes fixes this issue, but there may be a
268 # much more elegant solution that only resets the brush on this edge-case.
270 self.doc.brush.reset()
272 # On Windows, GTK timestamps have a resolution around
273 # 15ms, but tablet events arrive every 8ms.
274 # https://gna.org/bugs/index.php?16569
275 # TODO: proper fix in the brush engine, using only smooth,
276 # filtered speed inputs, will make this unneccessary
278 print 'Time is running backwards, dtime=%f' % dtime
280 data = (x, y, pressure, xtilt, ytilt)
282 self.motions.append(data)
285 # replay previous events that had identical timestamp
287 # really old events, don't associate them with the new one
291 step /= len(self.motions)+1
292 for data_old in self.motions:
293 self.doc.stroke_to(step, *data_old)
296 self.doc.stroke_to(dtime, *data)
298 def button_press_cb(self, win, event):
299 if event.type != gdk.BUTTON_PRESS:
300 # ignore the extra double-click event
303 if event.button == 1:
304 # mouse button pressed (while painting without pressure information)
305 if not self.last_event_had_pressure_info:
306 # For the mouse we don't get a motion event for "pressure"
307 # changes, so we simulate it. (Note: we can't use the
308 # event's button state because it carries the old state.)
309 self.motion_notify_cb(win, event, button1_pressed=True)
311 def button_release_cb(self, win, event):
312 # (see comment above in button_press_cb)
313 if event.button == 1 and not self.last_event_had_pressure_info:
314 self.motion_notify_cb(win, event, button1_pressed=False)
315 # Outsiders can access this via gui.document
316 for func in self._input_stroke_ended_observers:
319 def straight_line_from_last_pos(self, is_sequence=False):
320 if not self.last_painting_pos:
322 dst = self.get_cursor_in_model_coordinates()
323 self.doc.straight_line(self.last_painting_pos, dst)
325 self.last_painting_pos = dst
327 def canvas_modified_cb(self, x1, y1, w, h):
331 if w == 0 and h == 0:
332 # full redraw (used when background has changed)
337 cr = self.get_model_coordinates_cairo_context()
339 if self.is_translation_only():
340 x, y = cr.user_to_device(x1, y1)
341 self.queue_draw_area(int(x), int(y), w, h)
343 # create an expose event with the event bbox rotated/zoomed
344 # OPTIMIZE: this is estimated to cause at least twice more rendering work than neccessary
345 # transform 4 bbox corners to screen coordinates
346 corners = [(x1, y1), (x1+w-1, y1), (x1, y1+h-1), (x1+w-1, y1+h-1)]
347 corners = [cr.user_to_device(x, y) for (x, y) in corners]
348 self.queue_draw_area(*helpers.rotated_rectangle_bbox(corners))
350 def expose_cb(self, widget, event):
351 self.update_cursor() # hack to get the initial cursor right
352 if self.snapshot_pixmap:
353 gc = self.get_style().fg_gc[self.get_state()]
355 x,y,w,h = area.x, area.y, area.width, area.height
356 self.window.draw_drawable(gc, self.snapshot_pixmap, x,y, x,y, w,h)
358 self.repaint(event.area)
361 def get_model_coordinates_cairo_context(self, cr=None):
362 # OPTIMIZE: check whether this is a bottleneck during painting (many motion events) - if yes, use cache
364 cr = self.window.cairo_create()
367 # check if scale is almost a power of two
368 scale_log2 = log(scale, 2)
369 scale_log2_rounded = round(scale_log2)
370 if abs(scale_log2-scale_log2_rounded) < 0.01:
371 scale = 2.0**scale_log2_rounded
373 rotation = self.rotation # maybe we should check if rotation is almost a multiple of 90 degrees?
375 cr.translate(self.translation_x, self.translation_y)
377 cr.scale(scale, scale)
379 # Align the translation such that (0,0) maps to an integer
380 # screen pixel, to keep image rendering fast and sharp.
381 x, y = cr.user_to_device(0, 0)
382 x, y = cr.device_to_user(round(x), round(y))
386 m = list(cr.get_matrix())
389 cr.set_matrix(cairo.Matrix(*m))
392 def is_translation_only(self):
393 return self.rotation == 0.0 and self.scale == 1.0 and not self.mirrored
395 def get_cursor_in_model_coordinates(self):
396 x, y, modifiers = self.window.get_pointer()
397 cr = self.get_model_coordinates_cairo_context()
398 return cr.device_to_user(x, y)
400 def get_visible_layers(self):
401 # FIXME: tileddrawwidget should not need to know whether the document has layers
402 layers = self.doc.layers
403 if not self.show_layers_above:
404 layers = self.doc.layers[0:self.doc.layer_idx+1]
405 layers = [l for l in layers if l.visible]
408 def repaint(self, device_bbox=None):
409 cr, surface, sparse, mipmap_level, gdk_clip_region = self.render_prepare(device_bbox)
410 self.render_execute(cr, surface, sparse, mipmap_level, gdk_clip_region)
412 def render_prepare(self, device_bbox):
413 if device_bbox is None:
414 w, h = self.window.get_size()
415 device_bbox = (0, 0, w, h)
416 #print 'device bbox', tuple(device_bbox)
418 gdk_clip_region = self.window.get_clip_region()
419 x, y, w, h = device_bbox
420 sparse = not gdk_clip_region.point_in(x+w/2, y+h/2)
422 cr = self.window.cairo_create()
424 # actually this is only neccessary if we are not answering an expose event
425 cr.rectangle(*device_bbox)
428 # fill it all white, though not required in the most common case
429 if self.visualize_rendering:
431 tmp = random.random()
432 cr.set_source_rgb(tmp, tmp, tmp)
435 # bye bye device coordinates
436 self.get_model_coordinates_cairo_context(cr)
440 if self.app and self.app.preferences['view.high_quality_zoom']:
443 # can cause a very clear slowdown on some hardware
444 # (we probably could avoid this by doing rendering differently)
445 mipmap_level = max(0, int(floor(log(1.0/self.scale,2))))
447 mipmap_level = max(0, int(ceil(log(1/self.scale,2))))
448 # OPTIMIZE: if we would render tile scanlines, we could probably use the better one above...
449 mipmap_level = min(mipmap_level, tiledsurface.MAX_MIPMAP_LEVEL)
450 cr.scale(2**mipmap_level, 2**mipmap_level)
452 translation_only = self.is_translation_only()
454 # calculate the final model bbox with all the clipping above
455 x1, y1, x2, y2 = cr.clip_extents()
456 if not translation_only:
457 # Looks like cairo needs one extra pixel rendered for interpolation at the border.
458 # If we don't do this, we get dark stripe artefacts when panning while zoomed.
463 x1, y1 = int(floor(x1)), int(floor(y1))
464 x2, y2 = int(ceil (x2)), int(ceil (y2))
466 # alpha=True is just to get hardware acceleration, we don't
467 # actually use the alpha channel. Speedup factor 3 for
468 # ATI/Radeon Xorg driver (and hopefully others).
469 # https://bugs.freedesktop.org/show_bug.cgi?id=28670
470 surface = pixbufsurface.Surface(x1, y1, x2-x1+1, y2-y1+1, alpha=True)
472 del x1, y1, x2, y2, w, h
474 return cr, surface, sparse, mipmap_level, gdk_clip_region
476 def render_execute(self, cr, surface, sparse, mipmap_level, gdk_clip_region):
477 translation_only = self.is_translation_only()
478 model_bbox = surface.x, surface.y, surface.w, surface.h
480 #print 'model bbox', model_bbox
482 # not sure if it is a good idea to clip so tightly
483 # has no effect right now because device_bbox is always smaller
484 cr.rectangle(*model_bbox)
487 layers = self.get_visible_layers()
489 if self.visualize_rendering:
490 surface.pixbuf.fill((int(random.random()*0xff)<<16)+0x00000000)
492 tiles = surface.get_tiles()
495 if self.current_layer_solo:
496 background = self.neutral_background_pixbuf
497 layers = [self.doc.layer]
498 # this is for hiding instead
499 #layers.pop(self.doc.layer_idx)
500 if self.overlay_layer:
501 idx = layers.index(self.doc.layer)
502 layers.insert(idx+1, self.overlay_layer)
506 # it is worth checking whether this tile really will be visible
507 # (to speed up the L-shaped expose event during scrolling)
508 # (speedup clearly visible; slowdown measurable when always executing this code)
511 x, y = cr.user_to_device(tx*N, ty*N)
512 bbox = (int(x), int(y), N, N)
514 #corners = [(tx*N, ty*N), ((tx+1)*N-1, ty*N), (tx*N, (ty+1)*N-1), ((tx+1)*N-1, (ty+1)*N-1)]
515 # same problem as above: cairo needs to know one extra pixel for interpolation
516 corners = [(tx*N-1, ty*N-1), ((tx+1)*N, ty*N-1), (tx*N-1, (ty+1)*N), ((tx+1)*N, (ty+1)*N)]
517 corners = [cr.user_to_device(x_, y_) for (x_, y_) in corners]
518 bbox = gdk.Rectangle(*helpers.rotated_rectangle_bbox(corners))
520 if gdk_clip_region.rect_in(bbox) == gdk.OVERLAP_RECTANGLE_OUT:
524 dst = surface.get_tile_memory(tx, ty)
525 self.doc.blit_tile_into(dst, tx, ty, mipmap_level, layers, background)
528 # not sure why, but using gdk directly is notably faster than the same via cairo
529 x, y = cr.user_to_device(surface.x, surface.y)
530 self.window.draw_pixbuf(None, surface.pixbuf, 0, 0, int(x), int(y), dither=gdk.RGB_DITHER_MAX)
532 #print 'Position (screen coordinates):', cr.user_to_device(surface.x, surface.y)
533 cr.set_source_pixbuf(surface.pixbuf, round(surface.x), round(surface.y))
534 pattern = cr.get_source()
536 # We could set interpolation mode here (eg nearest neighbour)
537 #pattern.set_filter(cairo.FILTER_NEAREST) # 1.6s
538 #pattern.set_filter(cairo.FILTER_FAST) # 2.0s
539 #pattern.set_filter(cairo.FILTER_GOOD) # 3.1s
540 #pattern.set_filter(cairo.FILTER_BEST) # 3.1s
541 #pattern.set_filter(cairo.FILTER_BILINEAR) # 3.1s
544 # pixelize at high zoom-in levels
545 pattern.set_filter(cairo.FILTER_NEAREST)
549 if self.doc.frame_enabled:
550 # Draw a semi-transparent black overlay for
551 # all the area outside the "document area"
553 cr.set_source_rgba(0, 0, 0, 0.6)
554 cr.set_operator(cairo.OPERATOR_OVER)
555 mipmap_factor = 2**mipmap_level
556 frame = self.doc.get_frame()
557 cr.rectangle(frame[0]/mipmap_factor, frame[1]/mipmap_factor,
558 frame[2]/mipmap_factor, frame[3]/mipmap_factor)
559 cr.rectangle(*model_bbox)
560 cr.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
564 if self.visualize_rendering:
565 # visualize painted bboxes (blue)
566 cr.set_source_rgba(0, 0, random.random(), 0.4)
569 def scroll(self, dx, dy):
570 self.translation_x -= dx
571 self.translation_y -= dy
573 # This speeds things up nicely when scrolling is already
574 # fast, but produces temporary artefacts and an
575 # annoyingliy non-constant framerate otherwise.
577 # It might be worth it if it was done only once per
578 # redraw, instead of once per motion event. Maybe try to
579 # implement something like "queue_scroll" with priority
581 self.window.scroll(int(-dx), int(-dy))
585 def get_center(self):
586 w, h = self.window.get_size()
589 def rotozoom_with_center(self, function, at_pointer=False):
590 if at_pointer and self.has_pointer and self.last_event_x is not None:
591 cx, cy = self.last_event_x, self.last_event_y
593 w, h = self.window.get_size()
594 cx, cy = self.get_center()
595 cr = self.get_model_coordinates_cairo_context()
596 cx_device, cy_device = cr.device_to_user(cx, cy)
598 self.scale = helpers.clamp(self.scale, self.zoom_min, self.zoom_max)
599 cr = self.get_model_coordinates_cairo_context()
600 cx_new, cy_new = cr.user_to_device(cx_device, cy_device)
601 self.translation_x += cx - cx_new
602 self.translation_y += cy - cy_new
606 def zoom(self, zoom_step):
607 def f(): self.scale *= zoom_step
608 self.rotozoom_with_center(f, at_pointer=True)
610 def set_zoom(self, zoom):
611 def f(): self.scale = zoom
612 self.rotozoom_with_center(f, at_pointer=True)
614 def rotate(self, angle_step):
615 if self.mirrored: angle_step = -angle_step
616 def f(): self.rotation += angle_step
617 self.rotozoom_with_center(f)
619 def set_rotation(self, angle):
620 if self.mirrored: angle = -angle
621 def f(): self.rotation = angle
622 self.rotozoom_with_center(f)
625 def f(): self.mirrored = not self.mirrored
626 self.rotozoom_with_center(f)
628 def set_mirrored(self, mirrored):
629 def f(): self.mirrored = mirrored
630 self.rotozoom_with_center(f)
632 def start_drag(self, dragfunc):
633 self.dragfunc = dragfunc
634 def stop_drag(self, dragfunc):
635 if self.dragfunc == dragfunc:
638 def recenter_document(self):
639 x, y, w, h = self.doc.get_effective_bbox()
640 desired_cx_user = x+w/2
641 desired_cy_user = y+h/2
643 cr = self.get_model_coordinates_cairo_context()
644 w, h = self.window.get_size()
645 cx_user, cy_user = cr.device_to_user(w/2, h/2)
647 self.translation_x += (cx_user - desired_cx_user)*self.scale
648 self.translation_y += (cy_user - desired_cy_user)*self.scale
651 def brush_modified_cb(self, settings):
654 def update_cursor(self):
657 elif self.override_cursor is not None:
658 c = self.override_cursor
659 elif not self.is_sensitive:
661 elif self.doc.layer.locked or not self.doc.layer.visible:
662 c = self.CANNOT_DRAW_CURSOR
664 b = self.doc.brush.brushinfo
665 radius = b.get_effective_radius()*self.scale
666 c = cursor.get_brush_cursor(radius, b.is_eraser(), b.get_base_value('lock_alpha') > 0.9)
667 self.window.set_cursor(c)
669 def set_override_cursor(self, cursor):
670 """Set a cursor which will always be used.
672 Used by the colour picker. The override cursor will be used regardless
673 of the criteria update_cursor() normally uses. Pass None to let it
674 choose normally again.
676 self.override_cursor = cursor
679 def toggle_show_layers_above(self):
680 self.show_layers_above = not self.show_layers_above
684 if __name__ == '__main__':
685 tdw = TiledDrawWidget()
686 tdw.set_size_request(640, 480)
688 win.set_title("tdw test")
689 win.connect("destroy", lambda *a: gtk.main_quit())