I've got the following hierarchy:
ScrolledWindow
ViewPort
I've implemented a Zoom tool which basically scales a GdkPixbuf that I draw in my DrawingArea. Originally, the image is 1280x1040. When moving the scrolls, the Draw callback function takes about 0.005s to draw the GdkPixbuf - it looks really smooth.
However, when applying a zoom level of 300%, it takes up to 0.03s, making it look way less smooth. The portion of the DrawingArea that is visible remains always the same. It looks like if the drawing operation took into account the area that is not visible.
I've set up the following code so you guys can run it. The zoom ratio is already at 300%.
# -*- encoding: utf-8 -*-
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf
import cairo
import time
class MyWindow(Gtk.Window):
def __init__(self):
Gtk.Window.__init__(self, title="DrawingTool")
self.set_default_size(800, 600)
# The Zoom ratio
self.ratio = 3.
# The DrawingImage Brush
self.brush = Brush()
# Image
filename = "image.jpg"
self.original_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
self.displayed_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
self.scale_image()
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
# Zoom buttons
self.button_zoom_in = Gtk.Button(label="Zoom-In")
self.button_zoom_out = Gtk.Button(label="Zoom-Out")
# |ScrolledWindow
# |-> Viewport
# |--> DrawingArea
scrolledwindow = Gtk.ScrolledWindow()
viewport = Gtk.Viewport()
self.drawing_area = Gtk.DrawingArea()
self.drawing_area.set_size_request(
self.displayed_pixbuf.get_width(), self.displayed_pixbuf.get_height())
self.drawing_area.set_events(Gdk.EventMask.ALL_EVENTS_MASK)
# Pack
viewport.add(self.drawing_area)
scrolledwindow.add(viewport)
box.pack_start(self.button_zoom_in, False, True, 0)
box.pack_start(self.button_zoom_out, False, True, 0)
box.pack_start(scrolledwindow, True, True, 0)
self.add(box)
# Connect
self.connect("destroy", Gtk.main_quit)
self.button_zoom_in.connect("clicked", self.on_button_zoom_in_clicked)
self.button_zoom_out.connect("clicked", self.on_button_zoom_out_clicked)
self.drawing_area.connect("enter-notify-event", self.on_drawing_area_mouse_enter)
self.drawing_area.connect("leave-notify-event", self.on_drawing_area_mouse_leave)
self.drawing_area.connect("motion-notify-event", self.on_drawing_area_mouse_motion)
self.drawing_area.connect("draw", self.on_drawing_area_draw)
self.drawing_area.connect("button-press-event", self.on_drawing_area_button_press_event)
self.drawing_area.connect("button-release-event", self.on_drawing_area_button_release_event)
self.show_all()
def on_button_zoom_in_clicked(self, widget):
self.ratio += 0.1
self.scale_image()
self.drawing_area.queue_draw()
def on_button_zoom_out_clicked(self, widget):
self.ratio -= 0.1
self.scale_image()
self.drawing_area.queue_draw()
def scale_image(self):
self.displayed_pixbuf = self.original_pixbuf.scale_simple(self.original_pixbuf.get_width() * self.ratio,
self.original_pixbuf.get_height() * self.ratio, 2)
def on_drawing_area_draw(self, drawable, cairo_context):
start = time.time()
# DrawingArea size depends on Pixbuf size
self.drawing_area.get_window().resize(self.displayed_pixbuf.get_width(),
self.displayed_pixbuf .get_height())
self.drawing_area.set_size_request(self.displayed_pixbuf.get_width(),
self.displayed_pixbuf.get_height())
# Draw image
Gdk.cairo_set_source_pixbuf(cairo_context, self.displayed_pixbuf, 0, 0)
cairo_context.paint()
# Draw lines
self.brush._draw(cairo_context)
end = time.time()
print(f"Runtime of the program is {end - start}")
def on_drawing_area_mouse_enter(self, widget, event):
print("In - DrawingArea")
def on_drawing_area_mouse_leave(self, widget, event):
print("Out - DrawingArea")
def on_drawing_area_mouse_motion(self, widget, event):
(x, y) = int(event.x), int(event.y)
# Should not happen but just in case.
if not ( (x >= 0 and x < self.displayed_pixbuf.get_width()) and
(y >= 0 and y < self.displayed_pixbuf.get_height()) ):
return True
# If user is holding the left mouse button
if event.state & Gdk.EventMask.BUTTON_PRESS_MASK:
self.brush._add_point((x, y))
self.drawing_area.queue_draw()
def on_drawing_area_button_press_event(self, widget, event):
self.brush._add_point((int(event.x), int(event.y)))
def on_drawing_area_button_release_event(self, widget, event):
self.brush._line_ended()
# ## ## ## ## ## ## ## ## ## ## ## ## # ## ## ## ## ## ## ## ## ## ## ## ## #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
# #
# Brush : #
# #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##
class Brush(object):
default_rgba_color = (0, 0, 0, 1)
def __init__(self, width=None, rgba_color=None):
if rgba_color is None:
rgba_color = self.default_rgba_color
if width is None:
width = 3
self.__width = width
self.__rgba_color = rgba_color
self.__stroke = []
self.__current_line = []
def _line_ended(self):
self.__stroke.append(self.__current_line.copy())
self.__current_line = []
def _add_point(self, point):
self.__current_line.append(point)
def _draw(self, cairo_context):
cairo_context.set_source_rgba(*self.__rgba_color)
cairo_context.set_line_width(self.__width)
cairo_context.set_line_cap(cairo.LINE_CAP_ROUND)
cairo_context.new_path()
for line in self.__stroke:
for x, y in line:
cairo_context.line_to(x, y)
cairo_context.new_sub_path()
for x, y in self.__current_line:
cairo_context.line_to(x, y)
cairo_context.stroke()
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
# ~ Getters & Setters ~ #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
def _get_width(self):
return self.__width
def _set_width(self, width):
self.__width = width
def _get_rgba_color(self):
return self.__rgba_color
def _set_rgba_color(self, rgba_color):
self.__rgba_color = rgba_color
def _get_stroke(self):
return self.__stroke
def _get_current_line(self):
return self.__current_line
MyWindow()
Gtk.main()
So, is this a normal unavoidable thing?
EDIT
This is the full code of the solution implemented.
# -*- encoding: utf-8 -*-
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf
import cairo
import time
class MyWindow(Gtk.Window):
def __init__(self):
Gtk.Window.__init__(self, title="DrawingTool")
self.set_default_size(800, 600)
# The Zoom ratio
self.ratio = 3.
# The DrawingImage Brush
self.brush = Brush()
# Image
filename = "image.jpg"
self.original_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
self.displayed_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
self.scale_image()
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
# Zoom buttons
self.button_zoom_in = Gtk.Button(label="Zoom-In")
self.button_zoom_out = Gtk.Button(label="Zoom-Out")
# |ScrolledWindow
# |-> Viewport
# |--> DrawingArea
scrolledwindow = Gtk.ScrolledWindow()
self.viewport = Gtk.Viewport()
self.drawing_area = Gtk.DrawingArea()
self.drawing_area.set_size_request(
self.displayed_pixbuf.get_width(), self.displayed_pixbuf.get_height())
self.drawing_area.set_events(Gdk.EventMask.ALL_EVENTS_MASK)
# Pack
self.viewport.add(self.drawing_area)
scrolledwindow.add(self.viewport)
box.pack_start(self.button_zoom_in, False, True, 0)
box.pack_start(self.button_zoom_out, False, True, 0)
box.pack_start(scrolledwindow, True, True, 0)
self.add(box)
# Connect
self.connect("destroy", Gtk.main_quit)
self.button_zoom_in.connect("clicked", self.on_button_zoom_in_clicked)
self.button_zoom_out.connect("clicked", self.on_button_zoom_out_clicked)
self.drawing_area.connect("enter-notify-event", self.on_drawing_area_mouse_enter)
self.drawing_area.connect("leave-notify-event", self.on_drawing_area_mouse_leave)
self.drawing_area.connect("motion-notify-event", self.on_drawing_area_mouse_motion)
self.drawing_area.connect("draw", self.on_drawing_area_draw)
self.drawing_area.connect("button-press-event", self.on_drawing_area_button_press_event)
self.drawing_area.connect("button-release-event", self.on_drawing_area_button_release_event)
scrolledwindow.get_hscrollbar().connect("value-changed", self.on_scrolledwindow_horizontal_scrollbar_value_changed)
scrolledwindow.get_vscrollbar().connect("value-changed", self.on_scrolledwindow_vertical_scrollbar_value_changed)
self.show_all()
def on_button_zoom_in_clicked(self, widget):
self.ratio += 0.1
self.scale_image()
self.drawing_area.queue_draw()
def on_button_zoom_out_clicked(self, widget):
self.ratio -= 0.1
self.scale_image()
self.drawing_area.queue_draw()
def scale_image(self):
self.displayed_pixbuf = self.original_pixbuf.scale_simple(self.original_pixbuf.get_width() * self.ratio,
self.original_pixbuf.get_height() * self.ratio, 2)
def on_scrolledwindow_horizontal_scrollbar_value_changed(self, scrollbar):
self.drawing_area.queue_draw()
def on_scrolledwindow_vertical_scrollbar_value_changed(self, scrollbar):
self.drawing_area.queue_draw()
def on_drawing_area_draw(self, drawable, cairo_context):
start = time.time()
# DrawingArea size depends on Pixbuf size
self.drawing_area.get_window().resize(self.displayed_pixbuf.get_width(),
self.displayed_pixbuf .get_height())
self.drawing_area.set_size_request(self.displayed_pixbuf.get_width(),
self.displayed_pixbuf.get_height())
# (x, y) offsets
pixbuf_x = int(self.viewport.get_hadjustment().get_value())
pixbuf_y = int(self.viewport.get_vadjustment().get_value())
# Width and height of the image's clip
width = cairo_context.get_target().get_width()
height = cairo_context.get_target().get_height()
if pixbuf_x + width > self.displayed_pixbuf.get_width():
width = self.displayed_pixbuf.get_width() - pixbuf_x
if pixbuf_y + height > self.displayed_pixbuf.get_height():
height = self.displayed_pixbuf.get_height() - pixbuf_y
if width > 0 and height > 0:
# Create the area of the image that will be displayed in the right position
image = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, width, height)
self.displayed_pixbuf.copy_area(pixbuf_x, pixbuf_y, width, height, image, 0, 0)
# Draw created area of the Sample's Pixbuf
Gdk.cairo_set_source_pixbuf(cairo_context, image, pixbuf_x, pixbuf_y)
cairo_context.paint()
# Draw brush strokes
self.brush._draw(cairo_context)
end = time.time()
print(f"Runtime of the program is {end - start}")
def on_drawing_area_mouse_enter(self, widget, event):
print("In - DrawingArea")
def on_drawing_area_mouse_leave(self, widget, event):
print("Out - DrawingArea")
def on_drawing_area_mouse_motion(self, widget, event):
(x, y) = int(event.x), int(event.y)
# Should not happen but just in case.
if not ( (x >= 0 and x < self.displayed_pixbuf.get_width()) and
(y >= 0 and y < self.displayed_pixbuf.get_height()) ):
return True
# If user is holding the left mouse button
if event.state & Gdk.EventMask.BUTTON_PRESS_MASK:
self.brush._add_point((x, y))
self.drawing_area.queue_draw()
def on_drawing_area_button_press_event(self, widget, event):
self.brush._add_point((int(event.x), int(event.y)))
def on_drawing_area_button_release_event(self, widget, event):
self.brush._line_ended()
# ## ## ## ## ## ## ## ## ## ## ## ## # ## ## ## ## ## ## ## ## ## ## ## ## #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
# #
# Brush : #
# #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##
class Brush(object):
default_rgba_color = (0, 0, 0, 1)
def __init__(self, width=None, rgba_color=None):
if rgba_color is None:
rgba_color = self.default_rgba_color
if width is None:
width = 3
self.__width = width
self.__rgba_color = rgba_color
self.__stroke = []
self.__current_line = []
def _line_ended(self):
self.__stroke.append(self.__current_line.copy())
self.__current_line = []
def _add_point(self, point):
self.__current_line.append(point)
def _draw(self, cairo_context):
cairo_context.set_source_rgba(*self.__rgba_color)
cairo_context.set_line_width(self.__width)
cairo_context.set_line_cap(cairo.LINE_CAP_ROUND)
cairo_context.new_path()
for line in self.__stroke:
for x, y in line:
cairo_context.line_to(x, y)
cairo_context.new_sub_path()
for x, y in self.__current_line:
cairo_context.line_to(x, y)
cairo_context.stroke()
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
# ~ Getters & Setters ~ #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
def _get_width(self):
return self.__width
def _set_width(self, width):
self.__width = width
def _get_rgba_color(self):
return self.__rgba_color
def _set_rgba_color(self, rgba_color):
self.__rgba_color = rgba_color
def _get_stroke(self):
return self.__stroke
def _get_current_line(self):
return self.__current_line
MyWindow()
Gtk.main()
I've figured it out how to solve this efficiency issue. What I've done is, instead of drawing the whole image, I now draw the specific area of the image that needs to be redrawn.
I'll explain each line below the code:
''' Draw method. '''
def _draw(self, cairo_context, pixbuf):
# Set drawing area size
self.__drawing_area.get_window().resize(pixbuf.get_width(), pixbuf.get_height())
self.__drawing_area.set_size_request(pixbuf.get_width(), pixbuf.get_height())
# (x, y) offsets
pixbuf_x = int(self.__viewport.get_hadjustment().get_value())
pixbuf_y = int(self.__viewport.get_vadjustment().get_value())
# Width and height of the image's clip
width = cairo_context.get_target().get_width()
height = cairo_context.get_target().get_height()
if pixbuf_x + width > pixbuf.get_width():
width = pixbuf.get_width() - pixbuf_x
if pixbuf_y + height > pixbuf.get_height():
height = pixbuf.get_height() - pixbuf_y
if width > 0 and height > 0:
# Create the area of the image that will be displayed in the right position
image = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, width, height)
pixbuf.copy_area(pixbuf_x, pixbuf_y, width, height, image, 0, 0)
# Draw created area of the Sample's Pixbuf
Gdk.cairo_set_source_pixbuf(cairo_context, image, pixbuf_x, pixbuf_y)
cairo_context.paint()
# Draw brush strokes
self.__brush._draw(cairo_context)
These lines set the Gtk.DrawingArea size. When my Pixbuf size is larger than the Gtk.DrawingArea visible area, the scrollbars of the Gtk.ScrolledWindow know this and allow you to move along the image. The first line is needed for when the Pixbuf that you are drawing is smaller than the Gtk.DrawingArea visible area, so for instance when you connect mouse signals to the Gtk.DrawingArea, these are only emited when the mouse is over the image.
# Set drawing area size
self.__drawing_area.get_window().resize(pixbuf.get_width(), pixbuf.get_height())
self.__drawing_area.set_size_request(pixbuf.get_width(), pixbuf.get_height())
The x and y offsets are the left and top pixels that you need to:
i) Tell cairo where to draw in the Gtk.DrawingArea
ii) Clip your image
I'm using the Gtk.Viewport but you can use also Gtk.ScrolledWindow.get_hadjustment().get_value()
and Gtk.ScrolledWindow.get_vadjustment().get_value()
, for example.
# (x, y) offsets
pixbuf_x = int(self.__viewport.get_hadjustment().get_value())
pixbuf_y = int(self.__viewport.get_vadjustment().get_value())
In the following lines I just calculate the width and height that I need in order to clip the image. This is done based on the size of the Gtk.DrawingArea visible area and your image size. With cairo_context.get_target().get_width()
you are basically getting the width of the Gtk.DrawingArea visible area, and viceversa with the height.
# Width and height of the image's clip
width = cairo_context.get_target().get_width()
height = cairo_context.get_target().get_height()
if pixbuf_x + width > pixbuf.get_width():
width = pixbuf.get_width() - pixbuf_x
if pixbuf_y + height > pixbuf.get_height():
height = pixbuf.get_height() - pixbuf_y
Finally you just need to clip your original image and draw it in the right position of the Gtk.DrawingArea. The if-then-else is just a workaround I've made to overcome a problem when zooming out on the right-down edge because the values that the Gtk components return to get the offsets seems not to be updated when they need to.
EDIT
I've forgot to mention that you also need to redraw the image when the scrollbars move. Otherwise garbage will be rendered. See the last 2 connect
methods in the full code in the edited part of the original question.