Search code examples
python-3.xmultithreadinggtk3pygtkpycairo

SOLVED: I get an error when trying to use threading with GTK & Pycairo (to draw a window and signal it from another thread)


SOLUTION

  • Remove the channel and associated code
  • Add a new update function inside the window class which takes the new shapes as a parameter
  • modify the initialisation of the class
  • call the update function

Modifications for the solution

Apologies, but the diff markdown doesn't seem to be displaying properly, hopefully you should still get an idea of how the solution works

Window class

class Window(Gtk.Window):
-    __gsignals__ = {
-        'update_signal': (GObject.SIGNAL_RUN_FIRST, None,
-                      ())
-    }
-
-    def do_update_signal(self):
-        print("UPDATE SIGNAL CALLED")
-        self.shapes = self.shapes_channel.read()
-        print("Num new shapes:", len(self.shapes))
-        self.show_all()

in the class method init_ui

         self.connect("delete-event", Gtk.main_quit)
+        self.show_all()
+        a = self.darea.get_allocation()
+        print (a.x, a.y, a.width, a.height)
+        self.img = cairo.ImageSurface(cairo.Format.RGB24, a.width, a.height)

a new class method update_shapes

+    def update_shapes(self, shapes):
+        self.shapes = shapes
+        cr = cairo.Context(self.img)
+        self.draw_background(cr)
+        for shape in self.shapes:
+            shape.draw(cr)
+        self.darea.queue_draw()
+        return True

Main code

- shapes_channel = Channel()
  iter_num = 0
- def optimize(chan, prob, signaller):
+ def optimize(prob, signaller):
      def print_iter_num(xk):
          global iter_num
          iter_num += 1
          prob.update_positions(xk)
          prob.update_grads(jacobian(xk))
          new_shapes = convert_grid(prob.grid, building_size=1.0/GRID_SIZE)
-         chan.write(new_shapes)
-         signaller.emit("update_signal")
+         GLib.idle_add(signaller.update_shapes, new_shapes)
          print("Iteration", iter_num, "complete...")
      try:
          sol = minimize(objective, x0, bounds = all_bounds, constraints=constraints, options={'maxiter': MAX_ITER, 'disp': True}, callback=print_iter_num, jac=jacobian)
          prob.update_positions(sol.x)
      except Exception as e:
          print("ran into an error", e)

- window = new_window(shapes_channel=shapes_channel)
+ window = new_window()
- x = threading.Thread(target=optimize, args=(shapes_channel, optim_problem, window))
+ x = threading.Thread(target=optimize, args=(optim_problem, window))
  x.start()
  window.run()

QUESTION

Window class

import cairo
import gi
import math
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject

class Line():
    def __init__(self, start, end, thickness, colour):
        self.start = start
        self.end = end
        self.thickness = thickness
        self.colour = colour

    def draw(self, cr):
        cr.move_to(*self.start)
        cr.line_to(*self.end)
        cr.set_source_rgba(*self.colour)
        cr.set_line_width(self.thickness)
        cr.stroke()

class Polygon():
    def __init__(self, points, line_colour, line_thickness, fill_colour=None):
        self.points = points # points should be an iterable of points
        self.line_colour = line_colour
        self.line_thickness = line_thickness
        self.fill_colour = fill_colour

    def draw(self, cr):
        cr.move_to(*self.points[0])
        for point in self.points[1:]:
            cr.line_to(*point)
        cr.close_path()
        cr.set_source_rgba(*self.line_colour)
        cr.set_line_width(self.line_thickness)
        cr.stroke()
        if self.fill_colour is not None:
            cr.move_to(*self.points[0])
            for point in self.points[1:]:
                cr.line_to(*point)
            cr.close_path()
            cr.set_source_rgba(*self.fill_colour)
            cr.fill()

class Window(Gtk.Window):
    __gsignals__ = {
        'update_signal': (GObject.SIGNAL_RUN_FIRST, None,
                      ())
    }

    def do_update_signal(self):
        print("UPDATE SIGNAL CALLED")
        self.shapes = self.shapes_channel.read()
        print("Num new shapes:", len(self.shapes))
        self.show_all()

    def __init__(self, shapes_channel, window_size, background_colour=(1, 1, 1, 1), title="GTK window"):
        super(Window, self).__init__()
        self.width = window_size[0]
        self.height = window_size[1]
        self.background_colour = background_colour
        self.title = title
        self.shapes = []
        self.shapes_channel = shapes_channel
        self.init_ui()

    def init_ui(self):    
        darea = Gtk.DrawingArea()
        darea.connect("draw", self.on_draw)
        self.add(darea)
        self.set_title(self.title)
        self.resize(self.width, self.height)
        self.set_position(Gtk.WindowPosition.CENTER)
        self.connect("delete-event", Gtk.main_quit)

    def draw_background(self, cr: cairo.Context):
        cr.scale(self.width, self.height)
        cr.rectangle(0, 0, 1, 1)  # Rectangle(x0, y0, x1, y1)
        cr.set_source_rgba(*self.background_colour)
        cr.fill()

    def on_draw(self, wid, cr: cairo.Context):
        self.draw_background(cr)
        for shape in self.shapes:
            shape.draw(cr)

    def run(self):
        Gtk.main()

def new_window(shapes_channel,
         window_size=(1000, 1000),
         background_colour=(1,1,1,1),
         title="3yp"):
    return Window(shapes_channel,
                    window_size=window_size,
                    background_colour=background_colour,
                    title=title)

I'm trying to run a window that can draw the shapes I've defined (Lines and Polygons).

It worked fine before when I supplied it a list of shapes and ran it at the end of my application

However, I am trying to add interactivity and have it redraw a list of shapes when the update_signal gets called and a list of new shapes get passed along the shapes_channel that is part of the constructor.

Main Code

Here is the relevant bits from my main code:

shapes_channel = Channel()
iter_num = 0

def optimize(chan, prob, signaller):
    def print_iter_num(xk):
        global iter_num
        iter_num += 1
        prob.update_positions(xk)
        prob.update_grads(jacobian(xk))
        new_shapes = convert_grid(prob.grid, building_size=1.0/GRID_SIZE)
        chan.write(new_shapes)
        signaller.emit("update_signal")
        print("Iteration", iter_num, "complete...")
    try:
        sol = minimize(objective, x0, bounds = all_bounds, constraints=constraints, options={'maxiter': MAX_ITER, 'disp': True}, callback=print_iter_num, jac=jacobian)
        prob.update_positions(sol.x)
    except Exception as e:
        print("ran into an error", e)

window = new_window(shapes_channel=shapes_channel)
x = threading.Thread(target=optimize, args=(shapes_channel, optim_problem, window))
x.start()
window.run()

As you can see:

  1. A Channel() object is created, named shapes_channel
  2. A new window is created, with the shapes_channel passed into the constructor via the intermediate function new_window.
  3. This window is passed to the other thread so that the other thread can emit the relevant signal ("update_signal")
  4. The other thread is run
  5. The window is run in the main thread I get the following console output:
UPDATE SIGNAL CALLED
Num new shapes: 31
Gdk-Message: 01:27:14.090: main.py: Fatal IO error 0 (Success) on X server :0.

From the console output, we can infer that the signal is called successfully, and the new shapes are passed to the window and stored correctly, but it fails on the line self.show_all().

This is an object that was working fine previously, and producing graphical output, and I can only think of 2 possible things that may have changed from the objects perspective:

  1. The Channel object works as intended, but perhaps the mere presence of an object that is shared across threads throws the whole thing into disarray
  2. Even though it's on the main thread, it doesn't like that there are other threads.

I would really appreciate some guidance on this maddening occurrence.


Solution

  • About your assumptions:

    1. It is unclear if your channel object is possible to safely access from two threads.
    2. The signal handler is executed in the thread that emits the signal.

    My guess would be that it is the fact that you emit the signal from another thread that causes the issue.

    You can solve this by using GLib.idle_add(your_update_func). Instead of calling your_update_func directly, a request is added to the Gtk main loop, which executes it when there are no more events to process, preventing any threading issues.

    Read more here: https://wiki.gnome.org/Projects/PyGObject/Threading