fix attempt for touchpads without pressure data
[mypaint:mypaint.git] / gui / tileddrawwidget.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2008 by Martin Renold <martinxyz@gmx.ch>
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 import gtk, gobject, cairo, random
10 gdk = gtk.gdk
11 from math import floor, ceil, pi, log
12
13 from lib import helpers, tiledsurface, pixbufsurface
14 import cursor
15
16 class TiledDrawWidget(gtk.DrawingArea):
17     """
18     This widget displays a document (../lib/document*.py).
19     
20     It can show the document translated, rotated or zoomed. It does
21     not respond to user input except for painting. Painting events are
22     passed to the document after applying the inverse transformation.
23     """
24
25     def __init__(self, document):
26         gtk.DrawingArea.__init__(self)
27         self.connect("expose-event", self.expose_cb)
28         self.connect("enter-notify-event", self.enter_notify_cb)
29         self.connect("leave-notify-event", self.leave_notify_cb)
30         self.connect("size-allocate", self.size_allocate_cb)
31
32         # workaround for https://gna.org/bugs/?14372 ([Windows] crash when moving the pen during startup)
33         def at_application_start(*trash):
34             self.connect("motion-notify-event", self.motion_notify_cb)
35             self.connect("button-press-event", self.button_press_cb)
36             self.connect("button-release-event", self.button_release_cb)
37         gobject.idle_add(at_application_start)
38
39         self.set_events(gdk.EXPOSURE_MASK
40                         | gdk.POINTER_MOTION_MASK
41                         | gdk.ENTER_NOTIFY_MASK
42                         | gdk.LEAVE_NOTIFY_MASK
43                         # for some reason we also need to specify events handled in drawwindow.py:
44                         | gdk.BUTTON_PRESS_MASK
45                         | gdk.BUTTON_RELEASE_MASK
46                         )
47
48         self.set_extension_events (gdk.EXTENSION_EVENTS_ALL)
49
50         self.doc = document
51         self.doc.canvas_observers.append(self.canvas_modified_cb)
52         self.doc.brush.settings_observers.append(self.brush_modified_cb)
53
54         self.cursor_info = None
55
56         self.last_event_time = None
57         self.last_event_x = None
58         self.last_event_y = None
59         self.last_event_device = None
60         self.last_event_had_pressure_info = False
61         self.last_painting_pos = None
62         self.device_observers = []
63
64         self.visualize_rendering = False
65
66         self.translation_x = 0.0
67         self.translation_y = 0.0
68         self.scale = 1.0
69         self.rotation = 0.0
70         self.mirrored = False
71         # only used when forcing translation_x/y aligned to full pixels
72         self.translation_subpixel_x = 0.0
73         self.translation_subpixel_y = 0.0
74
75         self.has_pointer = False
76         self.dragfunc = None
77
78         self.current_layer_solo = False
79         self.show_layers_above = True
80
81         self.overlay_layer = None
82
83         # gets overwritten for the main window
84         self.zoom_max = 5.0
85         self.zoom_min = 1/5.0
86         
87         #self.scroll_at_edges = False
88         self.pressure_mapping = None
89         self.bad_devices = []
90
91     #def set_scroll_at_edges(self, choice):
92     #    self.scroll_at_edges = choice
93       
94     def enter_notify_cb(self, widget, event):
95         self.has_pointer = True
96     def leave_notify_cb(self, widget, event):
97         self.has_pointer = False
98
99     def size_allocate_cb(self, widget, allocation):
100         new_size = tuple(allocation)[2:4]
101         old_size = getattr(self, 'current_size', new_size)
102         self.current_size = new_size
103         if new_size != old_size:
104             # recenter
105             dx = old_size[0] - new_size[0]
106             dy = old_size[1] - new_size[1]
107             self.scroll(dx/2, dy/2)
108
109     def motion_notify_cb(self, widget, event, button1_pressed=None):
110         if self.last_event_time:
111             dtime = (event.time - self.last_event_time)/1000.0
112             dx = event.x - self.last_event_x
113             dy = event.y - self.last_event_y
114         else:
115             dtime = None
116         if event.device != self.last_event_device:
117             for f in self.device_observers:
118                 f(self.last_event_device, event.device)
119         self.last_event_x = event.x
120         self.last_event_y = event.y
121         self.last_event_time = event.time
122         self.last_event_device = event.device
123         if dtime is None:
124             return
125
126         if self.dragfunc:
127             self.dragfunc(dx, dy)
128             return
129
130         cr = self.get_model_coordinates_cairo_context()
131         x, y = cr.device_to_user(event.x, event.y)
132         
133         pressure = event.get_axis(gdk.AXIS_PRESSURE)
134
135         if pressure is not None and (pressure > 1.0 or pressure < 0.0):
136             if event.device.name not in self.bad_devices:
137                 print 'WARNING: device "%s" is reporting bad pressure %+f' % (event.device.name, pressure)
138                 self.bad_devices.append(event.device.name)
139             if pressure > 1000.0 or pressure < -1000.0:
140                 # infinity: use button state (instead of clamping in brush.hpp)
141                 # https://gna.org/bugs/?14709
142                 pressure = None
143
144         if pressure is None:
145             self.last_event_had_pressure_info = False
146             if button1_pressed is None:
147                 button1_pressed = event.state & gdk.BUTTON1_MASK
148             if button1_pressed:
149                 pressure = 0.5
150             else:
151                 pressure = 0.0
152         else:
153             self.last_event_had_pressure_info = True
154         
155         if event.state & gdk.CONTROL_MASK:
156             # color picking, do not paint
157             # Don't simply return; this is a workaround for unwanted lines in https://gna.org/bugs/?16169
158             pressure = 0.0
159             
160         ### CSS experimental - scroll when touching the edge of the screen in fullscreen mode
161         #
162         # Disabled for the following reasons:
163         # - causes irritation when doing fast strokes near the edge
164         # - scrolling speed depends on the number of events received (can be a huge difference between tablets/mouse)
165         # - also, mouse button scrolling is usually enough
166         #
167         #if self.scroll_at_edges and pressure <= 0.0:
168         #  screen_w = gdk.screen_width()
169         #  screen_h = gdk.screen_height()
170         #  trigger_area = 10
171         #  if (event.x <= trigger_area):
172         #    self.scroll(-10,0)
173         #  if (event.x >= (screen_w-1)-trigger_area):
174         #    self.scroll(10,0)
175         #  if (event.y <= trigger_area):
176         #    self.scroll(0,-10)
177         #  if (event.y >= (screen_h-1)-trigger_area):
178         #    self.scroll(0,10)
179
180         if self.pressure_mapping:
181             pressure = self.pressure_mapping(pressure)
182         if event.state & gdk.SHIFT_MASK:
183             pressure = 0.0
184
185         if pressure:
186             self.last_painting_pos = x, y
187
188         self.doc.stroke_to(dtime, x, y, pressure)
189
190     def button_press_cb(self, win, event):
191         if event.type != gdk.BUTTON_PRESS:
192             # ignore the extra double-click event
193             return
194
195         # straight line
196         if (event.state & gdk.SHIFT_MASK) and self.last_painting_pos:
197             dst = self.get_cursor_in_model_coordinates()
198             self.doc.straight_line(self.last_painting_pos, dst)
199
200         # mouse button pressed (while painting without pressure information)
201         if event.button == 1 and not self.last_event_had_pressure_info:
202             # For the mouse we don't get a motion event for "pressure"
203             # changes, so we simulate it. (Note: we can't use the
204             # event's button state because it carries the old state.)
205             self.motion_notify_cb(win, event, button1_pressed=True)
206
207     def button_release_cb(self, win, event):
208         # (see comment above in button_press_cb)
209         if event.button == 1 and not self.last_event_had_pressure_info:
210             self.motion_notify_cb(win, event, button1_pressed=False)
211
212     def canvas_modified_cb(self, x1, y1, w, h):
213         if not self.window:
214             return
215         
216         if w == 0 and h == 0:
217             # full redraw (used when background has changed)
218             #print 'full redraw'
219             self.queue_draw()
220             return
221
222         cr = self.get_model_coordinates_cairo_context()
223
224         if self.is_translation_only():
225             x, y = cr.user_to_device(x1, y1)
226             self.queue_draw_area(int(x), int(y), w, h)
227         else:
228             # create an expose event with the event bbox rotated/zoomed
229             # OPTIMIZE: this is estimated to cause at least twice more rendering work than neccessary
230             # transform 4 bbox corners to screen coordinates
231             corners = [(x1, y1), (x1+w-1, y1), (x1, y1+h-1), (x1+w-1, y1+h-1)]
232             corners = [cr.user_to_device(x, y) for (x, y) in corners]
233             self.queue_draw_area(*helpers.rotated_rectangle_bbox(corners))
234
235     def expose_cb(self, widget, event):
236         self.update_cursor() # hack to get the initial cursor right
237         #print 'expose', tuple(event.area)
238         self.repaint(event.area)
239         return True
240
241     def get_model_coordinates_cairo_context(self, cr=None):
242         if cr is None:
243             cr = self.window.cairo_create()
244         cr.translate(self.translation_x, self.translation_y)
245         cr.rotate(self.rotation)
246         cr.scale(self.scale, self.scale)
247         if self.mirrored:
248             m = list(cr.get_matrix())
249             m[0] = -m[0]
250             m[2] = -m[2]
251             cr.set_matrix(cairo.Matrix(*m))
252         return cr
253
254     def is_translation_only(self):
255         return self.rotation == 0.0 and self.scale == 1.0 and not self.mirrored
256
257     def get_cursor_in_model_coordinates(self):
258         x, y, modifiers = self.window.get_pointer()
259         cr = self.get_model_coordinates_cairo_context()
260         return cr.device_to_user(x, y)
261
262     def get_visible_layers(self):
263         # FIXME: tileddrawwidget should not need to know whether the document has layers
264         layers = self.doc.layers
265         if not self.show_layers_above:
266             layers = self.doc.layers[0:self.doc.layer_idx+1]
267         layers = [l for l in layers if l.visible]
268         return layers
269
270     def repaint(self, device_bbox=None):
271         if device_bbox is None:
272             w, h = self.window.get_size()
273             device_bbox = (0, 0, w, h)
274         #print 'device bbox', tuple(device_bbox)
275
276         gdk_clip_region = self.window.get_clip_region()
277         x, y, w, h = device_bbox
278         sparse = not gdk_clip_region.point_in(x+w/2, y+h/2)
279
280         cr = self.window.cairo_create()
281
282         # actually this is only neccessary if we are not answering an expose event
283         cr.rectangle(*device_bbox)
284         cr.clip()
285
286         # fill it all white, though not required in the most common case
287         if self.visualize_rendering:
288             # grey
289             tmp = random.random()
290             cr.set_source_rgb(tmp, tmp, tmp)
291             cr.paint()
292
293         # bye bye device coordinates
294         self.get_model_coordinates_cairo_context(cr)
295
296         # choose best mipmap
297         mipmap_level = max(0, int(ceil(log(1/self.scale,2))))
298         #mipmap_level = max(0, int(floor(log(1.0/self.scale,2)))) # slightly better quality but clearly slower
299         mipmap_level = min(mipmap_level, tiledsurface.MAX_MIPMAP_LEVEL)
300         cr.scale(2**mipmap_level, 2**mipmap_level)
301
302         translation_only = self.is_translation_only()
303
304         # calculate the final model bbox with all the clipping above
305         x1, y1, x2, y2 = cr.clip_extents()
306         if not translation_only:
307             # Looks like cairo needs one extra pixel rendered for interpolation at the border.
308             # If we don't do this, we get dark stripe artefacts when panning while zoomed.
309             x1 -= 1
310             y1 -= 1
311             x2 += 1
312             y2 += 1
313         x1, y1 = int(floor(x1)), int(floor(y1))
314         x2, y2 = int(ceil (x2)), int(ceil (y2))
315
316         # alpha=True is just to get hardware acceleration, we don't
317         # actually use the alpha channel. Speedup factor 3 for
318         # ATI/Radeon Xorg driver (and hopefully others).
319         # TODO: measure effect on pure software rendering
320         surface = pixbufsurface.Surface(x1, y1, x2-x1+1, y2-y1+1, alpha=True)
321
322         del x1, y1, x2, y2, w, h
323
324         model_bbox = surface.x, surface.y, surface.w, surface.h
325         #print 'model bbox', model_bbox
326
327         # not sure if it is a good idea to clip so tightly
328         # has no effect right now because device_bbox is always smaller
329         cr.rectangle(*model_bbox)
330         cr.clip()
331
332         layers = self.get_visible_layers()
333
334         if self.visualize_rendering:
335             surface.pixbuf.fill((int(random.random()*0xff)<<16)+0x00000000)
336
337         tiles = surface.get_tiles()
338
339         background = None
340         if self.current_layer_solo:
341             background = self.neutral_background_pixbuf
342             layers = [self.doc.layer]
343             # this is for hiding instead
344             #layers.pop(self.doc.layer_idx)
345         if self.overlay_layer:
346             idx = layers.index(self.doc.layer)
347             layers.insert(idx+1, self.overlay_layer)
348
349         for tx, ty in tiles:
350             if sparse:
351                 # it is worth checking whether this tile really will be visible
352                 # (to speed up the L-shaped expose event during scrolling)
353                 # (speedup clearly visible; slowdown measurable when always executing this code)
354                 N = tiledsurface.N
355                 if translation_only:
356                     x, y = cr.user_to_device(tx*N, ty*N)
357                     bbox = (int(x), int(y), N, N)
358                 else:
359                     #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)]
360                     # same problem as above: cairo needs to know one extra pixel for interpolation
361                     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)]
362                     corners = [cr.user_to_device(x_, y_) for (x_, y_) in corners]
363                     bbox = gdk.Rectangle(*helpers.rotated_rectangle_bbox(corners))
364
365                 if gdk_clip_region.rect_in(bbox) == gdk.OVERLAP_RECTANGLE_OUT:
366                     continue
367
368
369             dst = surface.get_tile_memory(tx, ty)
370             self.doc.blit_tile_into(dst, tx, ty, mipmap_level, layers, background)
371
372         if translation_only:
373             # not sure why, but using gdk directly is notably faster than the same via cairo
374             x, y = cr.user_to_device(surface.x, surface.y)
375             self.window.draw_pixbuf(None, surface.pixbuf, 0, 0, int(x), int(y), dither=gdk.RGB_DITHER_MAX)
376         else:
377             cr.set_source_pixbuf(surface.pixbuf, surface.x, surface.y)
378             pattern = cr.get_source()
379             # Required for ATI drivers, to avoid a slower-than-software fallback
380             # https://gna.org/bugs/?16122 and https://bugs.freedesktop.org/show_bug.cgi?id=28670
381             # However, when using an alpha channel in the source image
382             # (as we do now) performance is much better without this.
383             #pattern.set_extend(cairo.EXTEND_PAD)
384
385             # We could set interpolation mode here (eg nearest neighbour)
386             #pattern.set_filter(cairo.FILTER_NEAREST)  # 1.6s
387             #pattern.set_filter(cairo.FILTER_FAST)     # 2.0s
388             #pattern.set_filter(cairo.FILTER_GOOD)     # 3.1s
389             #pattern.set_filter(cairo.FILTER_BEST)     # 3.1s
390             #pattern.set_filter(cairo.FILTER_BILINEAR) # 3.1s
391
392             if self.scale > 1.5:
393                 # pixelize at high zoom-in levels
394                 pattern.set_filter(cairo.FILTER_NEAREST)
395
396             cr.paint()
397
398         if self.visualize_rendering:
399             # visualize painted bboxes (blue)
400             cr.set_source_rgba(0, 0, random.random(), 0.4)
401             cr.paint()
402
403     def align_translation(self):
404         """
405         Align translation to integer pixel coordinates. Keep track of
406         the remainder to allow incremental sub-pixel scrolling.
407         """
408         if self.is_translation_only():
409             tx_real = self.translation_x + self.translation_subpixel_x
410             ty_real = self.translation_y + self.translation_subpixel_y
411             self.translation_x = round(tx_real)
412             self.translation_y = round(ty_real)
413             self.translation_subpixel_x = tx_real - self.translation_x
414             self.translation_subpixel_y = ty_real - self.translation_y
415         else:
416             # forget about the subpixel translation (which was never executed)
417             self.translation_subpixel_x = 0.0
418             self.translation_subpixel_y = 0.0
419
420     def scroll(self, dx, dy):
421         self.translation_x -= dx
422         self.translation_y -= dy
423         self.align_translation()
424         if False:
425             # This speeds things up nicely when scrolling is already
426             # fast, but produces temporary artefacts and an
427             # annoyingliy non-constant framerate otherwise.
428             #
429             # It might be worth it if it was done only once per
430             # redraw, instead of once per motion event. Maybe try to
431             # implement something like "queue_scroll" with priority
432             # similar to redraw?
433             self.window.scroll(int(-dx), int(-dy))
434         else:
435             self.queue_draw()
436
437     def rotozoom_with_center(self, function, at_pointer=False):
438         if at_pointer and self.has_pointer and self.last_event_x is not None:
439             cx, cy = self.last_event_x, self.last_event_y
440         else:
441             w, h = self.window.get_size()
442             cx, cy = w/2.0, h/2.0
443         cr = self.get_model_coordinates_cairo_context()
444         cx_device, cy_device = cr.device_to_user(cx, cy)
445         function()
446         self.scale = helpers.clamp(self.scale, self.zoom_min, self.zoom_max)
447         cr = self.get_model_coordinates_cairo_context()
448         cx_new, cy_new = cr.user_to_device(cx_device, cy_device)
449         self.translation_x += cx - cx_new
450         self.translation_y += cy - cy_new
451
452         # this is for fast scrolling with only tanslation
453         self.rotation = self.rotation % (2*pi)
454         if self.is_translation_only():
455             self.translation_x = int(self.translation_x)
456             self.translation_y = int(self.translation_y)
457         self.align_translation()
458
459         self.queue_draw()
460
461     def zoom(self, zoom_step):
462         def f(): self.scale *= zoom_step
463         self.rotozoom_with_center(f, at_pointer=True)
464
465     def set_zoom(self, zoom):
466         def f(): self.scale = zoom
467         self.rotozoom_with_center(f, at_pointer=True)
468
469     def rotate(self, angle_step):
470         def f(): self.rotation += angle_step
471         self.rotozoom_with_center(f)
472
473     def set_rotation(self, angle):
474         def f(): self.rotation = angle
475         self.rotozoom_with_center(f)
476
477     def mirror(self):
478         def f(): self.mirrored = not self.mirrored
479         self.rotozoom_with_center(f)
480
481     def set_mirrored(self, mirrored):
482         def f(): self.mirrored = mirrored
483         self.rotozoom_with_center(f)
484
485     def start_drag(self, dragfunc):
486         self.dragfunc = dragfunc
487     def stop_drag(self, dragfunc):
488         if self.dragfunc == dragfunc:
489             self.dragfunc = None
490
491     def recenter_document(self):
492         x, y, w, h = self.doc.get_bbox()
493         desired_cx_user = x+w/2
494         desired_cy_user = y+h/2
495
496         cr = self.get_model_coordinates_cairo_context()
497         w, h = self.window.get_size()
498         cx_user, cy_user = cr.device_to_user(w/2, h/2)
499
500         self.translation_x += (cx_user - desired_cx_user)*self.scale
501         self.translation_y += (cy_user - desired_cy_user)*self.scale
502         self.queue_draw()
503
504     def brush_modified_cb(self):
505         self.update_cursor()
506
507     def update_cursor(self):
508         if not self.window: return
509
510         b = self.doc.brush
511         radius = b.get_actual_radius()*self.scale
512         c = cursor.get_brush_cursor(radius, b.is_eraser())
513         self.window.set_cursor(c)
514
515     def toggle_show_layers_above(self):
516         self.show_layers_above = not self.show_layers_above
517         self.queue_draw()
518