From fbdc0a347f27277ecdda402675d4e93187469e2d Mon Sep 17 00:00:00 2001 From: Lcbx Date: Tue, 28 Jul 2026 23:10:06 +0200 Subject: [PATCH 1/2] support for tkinter backend. tested on windows 11 --- examples/tk_app.py | 154 +++++++++++++ rendercanvas/auto.py | 4 + rendercanvas/tk.py | 473 +++++++++++++++++++++++++++++++++++++++ tests/test_backend_tk.py | 69 ++++++ tests/test_backends.py | 12 + 5 files changed, 712 insertions(+) create mode 100644 examples/tk_app.py create mode 100644 rendercanvas/tk.py create mode 100644 tests/test_backend_tk.py diff --git a/examples/tk_app.py b/examples/tk_app.py new file mode 100644 index 0000000..dfa3178 --- /dev/null +++ b/examples/tk_app.py @@ -0,0 +1,154 @@ +""" +tk app +---------- + +An example demonstrating how to embed wgpu cube in a Tkinter widget / App. +""" + +import time +import tkinter as tk +from tkinter import ttk + +import numpy as np +import wgpu + +from rendercanvas.tk import RenderWidget +from rendercanvas.utils import cube + + +ROTATION_SPEEDS = { + "stopped": (0.0, 0.0, 0.0), + "x": (1.0, 0.0, 0.0), + "y": (0.0, 1.0, 0.0), + "z": (0.0, 0.0, 1.0), + "xy": (1.0, 0.7, 0.0), + "xyz": (1.0, 0.7, 0.4), +} + +_ROTATION_PLANES = { + 0: (1, 2), # X axis + 1: (2, 0), # Y axis + 2: (0, 1), # Z axis +} + + +def rotation_matrix(axis, angle): + """Return a 4x4 rotation matrix for axis 0, 1, or 2.""" + c, s = np.cos(angle), np.sin(angle) + i, j = _ROTATION_PLANES[axis] + matrix = np.eye(4, dtype=np.float32) + matrix[i, i] = matrix[j, j] = c + matrix[i, j], matrix[j, i] = -s, s + return matrix + + +def setup_cube(canvas, mode, speed): + """Create the cube renderer and return its draw and reset functions.""" + adapter = wgpu.gpu.request_adapter_sync(power_preference="high-performance") + device = adapter.request_device_sync() + + pipeline_layout, uniform_buffer, bind_groups = cube.create_pipeline_layout(device) + pipeline = device.create_render_pipeline( + **cube.get_render_pipeline_kwargs(canvas, device, pipeline_layout, None) + ) + + vertex_buffer = device.create_buffer_with_data( + data=cube.vertex_data, + usage=wgpu.BufferUsage.VERTEX, + ) + index_buffer = device.create_buffer_with_data( + data=cube.index_data, + usage=wgpu.BufferUsage.INDEX, + ) + + context = canvas.get_context("wgpu") + uniform_data = np.zeros((), dtype=cube.uniform_dtype) + scale = np.diag([0.6, 0.6, 0.6, 1.0]).astype(np.float32) + angles = np.array([-0.3, 0.0, 0.0], dtype=np.float32) + last_time = time.perf_counter() + + def reset(): + nonlocal last_time + angles[:] = (-0.3, 0.0, 0.0) + last_time = time.perf_counter() + + def draw(): + nonlocal last_time + + now = time.perf_counter() + dt = min(now - last_time, 0.1) + last_time = now + angles[:] += np.asarray(ROTATION_SPEEDS[mode.get()]) * speed.get() * dt + + transform = scale + for axis, angle in enumerate(angles): + transform = rotation_matrix(axis, angle) @ transform + uniform_data["transform"] = transform + device.queue.write_buffer(uniform_buffer, 0, uniform_data) + + encoder = device.create_command_encoder() + render_pass = encoder.begin_render_pass( + color_attachments=[ + { + "view": context.get_current_texture().create_view(), + "resolve_target": None, + "clear_value": (0.05, 0.05, 0.05, 1.0), + "load_op": wgpu.LoadOp.clear, + "store_op": wgpu.StoreOp.store, + } + ] + ) + render_pass.set_pipeline(pipeline) + render_pass.set_vertex_buffer(0, vertex_buffer) + render_pass.set_index_buffer(index_buffer, wgpu.IndexFormat.uint32) + for index, bind_group in enumerate(bind_groups): + render_pass.set_bind_group(index, bind_group) + render_pass.draw_indexed(cube.index_data.size, 1, 0, 0, 0) + render_pass.end() + device.queue.submit([encoder.finish()]) + + return draw, reset + + +root = tk.Tk() +root.title("rendercanvas + Tkinter widgets") +root.geometry("1000x650") + +mode = tk.StringVar(value="xy") +speed = tk.DoubleVar(value=1.0) + +controls = ttk.Frame(root, padding=12) +controls.pack(side="left", fill="y") + + +ttk.Label(controls, text="Rotation", font=("TkDefaultFont", 10, "bold")).pack( + anchor="w", pady=(0, 6) +) +for label, value in ( + ("Stopped", "stopped"), + ("X axis", "x"), + ("Y axis", "y"), + ("Z axis", "z"), + ("X + Y", "xy"), + ("X + Y + Z", "xyz"), +): + ttk.Radiobutton(controls, text=label, variable=mode, value=value).pack( + anchor="w" + ) + +ttk.Separator(controls).pack(fill="x", pady=12) +ttk.Label(controls, text="Speed").pack(anchor="w") +ttk.Scale(controls, from_=0.0, to=3.0, variable=speed, orient="horizontal").pack( + fill="x" +) + +canvas = RenderWidget(root, update_mode="continuous", width=800, height=600) +canvas.pack(side="right", fill="both", expand=True) + +draw, reset_rotation = setup_cube(canvas, mode, speed) +ttk.Button(controls, text="Reset orientation", command=reset_rotation).pack( + fill="x", pady=(12, 0) +) + +canvas.request_draw(draw) +root.mainloop() \ No newline at end of file diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py index 225d74e..d42194b 100644 --- a/rendercanvas/auto.py +++ b/rendercanvas/auto.py @@ -160,6 +160,9 @@ def backends_by_imported_modules(): if has_qt_app: yield "qt", "Qt app is running" + if "tkinter" in sys.modules: + yield "tk", "tkinter is imported" + # If there is an asyncio loop, we can nicely run glfw, if glfw is available. if has_asyncio_loop: try: @@ -191,6 +194,7 @@ def backends_by_trying_in_order(): "PySide2": "qt", "PyQt5": "qt", "wx": "wx", + "tkinter" : "tk", } for libname, backend_name in lib_to_backend.items(): diff --git a/rendercanvas/tk.py b/rendercanvas/tk.py new file mode 100644 index 0000000..f96bf11 --- /dev/null +++ b/rendercanvas/tk.py @@ -0,0 +1,473 @@ +"""Tkinter backend for rendercanvas. + +Direct GPU presentation is supported on Windows and should be with X11/XWayland. +Other platforms fall back to bitmap presentation. +Async integration is limited. + +Tkinter does not support gamepads, touch, stylus pressure, gestures, raw mouse input, or pointer lock, +but you could use glfw and update state directly as a workaround. + +""" + +__all__ = [ + "RenderCanvas", + "RenderWidget", + "TkLoop", + "TkRenderCanvas", + "TkRenderWidget", + "loop", +] + +import queue +import sys +import tkinter as tk + +from .base import BaseCanvasGroup, BaseLoop, BaseRenderCanvas, WrapperRenderCanvas +from .core.coreutils import get_alt_x11_display + + +# mouse buttons +# 1 = left for Tk & rendercanvas +# 2 = middle for Tk, right for rendercanvas +# 3 = right for Tk, middle for rendercanvas +_MOUSE_BUTTON_MAP = {1: 1, 2: 3, 3: 2} + +_KEY_MAP = { + "Down": "ArrowDown", + "Up": "ArrowUp", + "Left": "ArrowLeft", + "Right": "ArrowRight", + "BackSpace": "Backspace", + "Caps_Lock": "CapsLock", + "Delete": "Delete", + "End": "End", + "Return": "Enter", + "Escape": "Escape", + "Home": "Home", + "Insert": "Insert", + "Num_Lock": "NumLock", + "Prior": "PageUp", + "Next": "PageDown", + "Pause": "Pause", + "Scroll_Lock": "ScrollLock", + "Tab": "Tab", + "Shift_L": "Shift", + "Shift_R": "Shift", + "Control_L": "Control", + "Control_R": "Control", + "Alt_L": "Alt", + "Alt_R": "Alt", + "Meta_L": "Meta", + "Meta_R": "Meta", + "Super_L": "Meta", + "Super_R": "Meta", + # F1, F2, etc.. correspond 1 to 1 +} + +_CURSOR_MAP = { + "default": "", + "text": "xterm", + "crosshair": "crosshair", + "pointer": "hand2", + "ew-resize": "sb_h_double_arrow", + "ns-resize": "sb_v_double_arrow", + "nesw-resize": "size_ne_sw", + "nwse-resize": "size_nw_se", + "not-allowed": "X_cursor", + "none": "none", +} + + +def _modifiers(state: int) -> tuple[str, ...]: + modifiers = [] + if state & 0x0001: + modifiers.append("Shift") + if state & 0x0004: + modifiers.append("Control") + if state & (0x0008 | 0x20000): + modifiers.append("Alt") + if state & (0x0040 | 0x200000): + modifiers.append("Meta") + return tuple(modifiers) + + +def _char_from_event(event) -> str | None: + if event.keysym == "Return": + return "\n" + if event.keysym == "Tab": + return "\t" + if event.char and event.char.isprintable(): + return event.char + return None + + +class TkLoop(BaseLoop): + def __init__(self): + super().__init__() + self._root = None + self._thread_callbacks = queue.SimpleQueue() + self._polling_callbacks = False + self._running = False + + def _rc_init(self): + root = tk._default_root + if root is None: + root = tk.Tk() + root.withdraw() + if root is not self._root: + self._root = root + self._polling_callbacks = False + + if not self._polling_callbacks: + self._polling_callbacks = True + root.after(10, self._drain_thread_callbacks) + + def _rc_run(self): + self._running = True + try: + self._root.mainloop() + finally: + self._running = False + + async def _rc_run_async(self): + raise NotImplementedError() + + def _rc_stop(self): + if self._running and self._root is not None: + try: + self._root.quit() + except tk.TclError: + pass + + def _rc_add_task(self, async_func, name): + return super()._rc_add_task(async_func, name) + + def _rc_call_later(self, delay, callback): + if self._root is None: + return + try: + self._root.after(max(0, round(delay * 1000)), callback) + except tk.TclError: + pass + + def _rc_call_soon_threadsafe(self, callback): + self._thread_callbacks.put(callback) + + def _drain_thread_callbacks(self): + try: + while True: + try: + callback = self._thread_callbacks.get_nowait() + except queue.Empty: + break + callback() + finally: + if self._root is not None: + try: + self._root.after(10, self._drain_thread_callbacks) + except tk.TclError: + self._polling_callbacks = False + + +loop = TkLoop() + + +class TkCanvasGroup(BaseCanvasGroup): + pass + + +class TkRenderWidget(BaseRenderCanvas, tk.Canvas): + """A render canvas that can be embedded in a Tkinter application.""" + + _rc_canvas_group = TkCanvasGroup(loop) + + def __init__(self, master=None, **kwargs): + kwargs.setdefault("highlightthickness", 0) + kwargs.setdefault("takefocus", True) + super().__init__(master, **kwargs) + + self._is_closed = False + self._paint_pending = False + self._buttons = set() + self._pointer_pos = (0, 0) + self._photo = None + self._image_item = None + + self.bind("", self._on_resize) + self.bind("", lambda event: self._set_visible(True)) + self.bind("", lambda event: self._set_visible(False)) + self.bind("", self._on_destroy) + self.bind("", lambda event: self.submit_event({"event_type": "pointer_enter"})) + self.bind("", lambda event: self.submit_event({"event_type": "pointer_leave"})) + self.bind("", self._on_pointer_move) + self.bind("", self._on_wheel) + self.bind("", self._on_wheel) + self.bind("", self._on_wheel) + self.bind("", self._on_key_down) + self.bind("", self._on_key_up) + self.bind("", self._on_focus_out) + + for button in _MOUSE_BUTTON_MAP: + self.bind(f"", self._on_pointer_down) + self.bind(f"", self._on_pointer_up) + self.bind(f"", self._on_double_click) + + self._final_canvas_init() + + def _rc_gui_poll(self): + try: + self.update_idletasks() + if not isinstance(self._rc_canvas_group.get_loop(), TkLoop): + self.update() + except tk.TclError: + pass + + def _rc_get_present_info(self, present_methods): + for method in present_methods: + if method == "screen": + surface_ids = self._get_surface_ids() + if surface_ids is not None: + return {"method": "screen", **surface_ids} + elif method == "bitmap": + return {"method": "bitmap", "formats": ["rgba-u8"]} + return None + + def _get_surface_ids(self): + try: + self.update_idletasks() + window = int(self.winfo_id()) + except tk.TclError: + return None + + if sys.platform == "win32": + return {"window": window} + + if sys.platform.startswith("linux"): + if self.tk.call("tk", "windowingsystem") == "x11": + display = get_alt_x11_display() + if display: + return { + "platform": "x11", + "window": window, + "display": int(display), + } + + return None + + def _rc_request_draw(self): + self._time_to_draw() + + def _rc_request_paint(self): + if self._paint_pending or self._is_closed: + return + self._paint_pending = True + try: + self.after_idle(self._paint) + except tk.TclError: + self._paint_pending = False + + def _paint(self): + self._paint_pending = False + if not self._is_closed: + self._time_to_paint() + + def _rc_force_paint(self): + self._time_to_paint() + try: + self.update_idletasks() + except tk.TclError: + pass + + def _rc_present_bitmap(self, *, data, format, **kwargs): + if format != "rgba-u8": + raise ValueError(f"Unsupported bitmap format {format!r}") + + try: + rgba = memoryview(data) + except TypeError as err: + raise ValueError("Expected an HxWx4 uint8 bitmap") from err + + if rgba.ndim != 3 or rgba.shape[2] != 4 or rgba.itemsize != 1: + raise ValueError("Expected an HxWx4 uint8 bitmap") + + height, width = rgba.shape[:2] + flat = rgba.cast("B") if rgba.c_contiguous else memoryview(rgba.tobytes()) + + rgb = bytearray(width * height * 3) + rgb[0::3] = flat[0::4] + rgb[1::3] = flat[1::4] + rgb[2::3] = flat[2::4] + + ppm = f"P6\n{width} {height}\n255\n".encode() + rgb + photo = tk.PhotoImage(master=self, data=ppm, format="PPM") + + self._photo = photo + + if self._image_item is None: + self._image_item = self.create_image( 0, 0, anchor="nw", image=photo ) + else: + self.itemconfigure(self._image_item, image=photo) + + def _rc_set_logical_size(self, width, height): + width, height = max(1, round(width)), max(1, round(height)) + if getattr(self.master, "_is_tk_rendercanvas", False): + self.master.geometry(f"{width}x{height}") + else: + self.configure(width=width, height=height) + self._size_info.set_physical_size(width, height, 1.0) + + def _rc_close(self): + if self._is_closed: + return + widget = self.master if getattr(self.master, "_is_tk_rendercanvas", False) else self + try: + widget.destroy() + except tk.TclError: + self._is_closed = True + + def _rc_get_closed(self): + return self._is_closed + + def _rc_set_title(self, title): + if getattr(self.master, "_is_tk_rendercanvas", False): + self.master.title(title) + + def _rc_set_cursor(self, cursor): + try: + self.configure(cursor=_CURSOR_MAP[cursor]) + except tk.TclError: + self.configure(cursor="") + + def _on_resize(self, event): + self._size_info.set_physical_size(max(0, event.width), max(0, event.height), 1.0) + self.request_draw() + + def _on_destroy(self, event): + if event.widget is not self or self._is_closed: + return + self._is_closed = True + self._photo = None + self.submit_event({"event_type": "close"}) + current_loop = self._rc_canvas_group.get_loop() + if current_loop is not None and not current_loop.get_canvases(): + current_loop.stop(force=True) + + def _pointer_event(self, event_type, event, *, touches=True): + self._pointer_pos = event.x, event.y + button = _MOUSE_BUTTON_MAP.get(event.num, 0) + result = { + "event_type": event_type, + "x": event.x, + "y": event.y, + "button": button, + "buttons": tuple(sorted(self._buttons)), + "modifiers": _modifiers(event.state), + } + if touches: + result.update(ntouches=0, touches={}) + self.submit_event(result) + + def _on_pointer_down(self, event): + self.focus_set() + self._buttons.add(_MOUSE_BUTTON_MAP[event.num]) + self._pointer_event("pointer_down", event) + + def _on_pointer_up(self, event): + self._buttons.discard(_MOUSE_BUTTON_MAP[event.num]) + self._pointer_event("pointer_up", event) + + def _on_pointer_move(self, event): + self._pointer_event("pointer_move", event) + + def _on_double_click(self, event): + self._buttons.add(_MOUSE_BUTTON_MAP[event.num]) + self._pointer_event("double_click", event, touches=False) + + def _on_focus_out(self, event): + while self._buttons: + button = self._buttons.pop() + self.submit_event( + { + "event_type": "pointer_up", + "x": self._pointer_pos[0], + "y": self._pointer_pos[1], + "button": button, + "buttons": tuple(sorted(self._buttons)), + "modifiers": (), + "ntouches": 0, + "touches": {}, + } + ) + + def _on_wheel(self, event): + self._pointer_pos = event.x, event.y + + """ + On Windows, provides event.delta, usually in multiples of 120. + On macOS, event.delta is typically already expressed in smaller scroll units. + On Linux/X11, ordinary wheel movement often arrives as mouse buttons: + Button 4 = scroll up, Button 5 = scroll down + The sign is reversed because Tk and rendercanvas use opposite conventions. + """ + if event.num in (4, 5): + delta = -100 if event.num == 4 else 100 + else: + unit = 120 if sys.platform == "win32" else 1 + delta = -100 * event.delta / unit + + # horizontal scroll if shift modifier is pressed + horizontal = bool(event.state & 0x0001) + self.submit_event( + { + "event_type": "wheel", + "dx": delta if horizontal else 0, + "dy": 0 if horizontal else delta, + "x": event.x, + "y": event.y, + "modifiers": _modifiers(event.state), + } + ) + + def _key_event(self, event_type, event): + key = _KEY_MAP.get(event.keysym, event.char or event.keysym) + print(key) + self.submit_event( + { + "event_type": event_type, + "key": key, + "modifiers": _modifiers(event.state), + } + ) + + def _on_key_down(self, event): + self._key_event("key_down", event) + char = _char_from_event(event) + if char is not None: + self.submit_event( + { + "event_type": "char", + "data": char, + "char_str": char, + "modifiers": None, + } + ) + + def _on_key_up(self, event): + self._key_event("key_up", event) + + +class TkRenderCanvas(WrapperRenderCanvas, tk.Toplevel): + """A standalone Tkinter window containing a :class:`TkRenderWidget`.""" + + def __init__(self, master=None, **kwargs): + loop._rc_init() + super().__init__(master or loop._root) + self._is_tk_rendercanvas = True + self._subwidget = TkRenderWidget(self, **kwargs) + self._subwidget.pack(fill="both", expand=True) + self.protocol("WM_DELETE_WINDOW", self._subwidget.close) + self._final_canvas_init() + + +RenderWidget = TkRenderWidget +RenderCanvas = TkRenderCanvas diff --git a/tests/test_backend_tk.py b/tests/test_backend_tk.py new file mode 100644 index 0000000..3145cc0 --- /dev/null +++ b/tests/test_backend_tk.py @@ -0,0 +1,69 @@ +"""Tests specific to Tkinter. + +Only runs when explicitly targeted, because running multiple GUI frameworks in +the same process is unreliable. +""" + +import sys +import tkinter as tk + +import pytest +from testutils import run_tests +from testutils_backends import BACKEND_TEST_FUNCS + +if not (__name__ == "__main__" or any(__name__ in arg for arg in sys.argv)): + pytest.skip(f"Skipping backend specific tests {__name__}", allow_module_level=True) + +from rendercanvas.base import BaseRenderCanvas, WrapperRenderCanvas +from rendercanvas.tk import RenderCanvas, RenderWidget +from rendercanvas.tk import TkRenderCanvas, TkRenderWidget + + +def test_is_canvas_classes(): + assert TkRenderCanvas is RenderCanvas + assert TkRenderWidget is RenderWidget + + assert issubclass(RenderWidget, BaseRenderCanvas) + assert issubclass(RenderCanvas, BaseRenderCanvas) + assert issubclass(RenderCanvas, WrapperRenderCanvas) + + assert issubclass(RenderWidget, tk.Canvas) + assert issubclass(RenderCanvas, tk.Toplevel) + +def test_present_bitmap(): + root = tk.Tk() + root.withdraw() + + try: + canvas = RenderWidget(root, width=2, height=2) + + rgba = bytearray( + [ + 255, 0, 0, 255, + 0, 255, 0, 128, + 0, 0, 255, 64, + 255, 255, 0, 0, + ] + ) + bitmap = memoryview(rgba).cast("B", (2, 2, 4)) + + canvas._rc_present_bitmap(data=bitmap, format="rgba-u8") + + assert canvas._photo.width() == 2 + assert canvas._photo.height() == 2 + assert canvas._photo.get(0, 0) == (255, 0, 0) + assert canvas._photo.get(1, 0) == (0, 255, 0) + assert canvas._photo.get(0, 1) == (0, 0, 255) + assert canvas._photo.get(1, 1) == (255, 255, 0) + assert canvas._image_item is not None + finally: + root.destroy() + +@pytest.mark.parametrize("backend", ["tk"]) +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_generic(func, backend): + func(backend) + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_backends.py b/tests/test_backends.py index 76efd1e..7d37780 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -332,6 +332,18 @@ def test_wx_module(): assert loop_class.name == "WxLoop" +def test_tk_module(): + m = Module("tk") + + canvas_class = m.get_canvas_class() + m.check_canvas(canvas_class) + assert canvas_class.name == "TkRenderWidget" + + loop_class = m.get_loop_class() + m.check_loop(loop_class) + assert loop_class.name == "TkLoop" + + def test_terminal_module(): m = Module("terminal") From a8bcedd3af81e0abe42902dc759c817c4f7054a8 Mon Sep 17 00:00:00 2001 From: Lcbx Date: Wed, 29 Jul 2026 00:45:02 +0200 Subject: [PATCH 2/2] improve tk example(s) --- examples/tk_app.py | 3 ++- examples/tk_minimal.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 examples/tk_minimal.py diff --git a/examples/tk_app.py b/examples/tk_app.py index dfa3178..972e343 100644 --- a/examples/tk_app.py +++ b/examples/tk_app.py @@ -109,10 +109,11 @@ def draw(): return draw, reset +dimensions = (1000,650) root = tk.Tk() root.title("rendercanvas + Tkinter widgets") -root.geometry("1000x650") +root.geometry("{0}x{1}".format(*dimensions)) mode = tk.StringVar(value="xy") speed = tk.DoubleVar(value=1.0) diff --git a/examples/tk_minimal.py b/examples/tk_minimal.py new file mode 100644 index 0000000..9d0af4f --- /dev/null +++ b/examples/tk_minimal.py @@ -0,0 +1,48 @@ +""" +tk minimal +---------- + +A minimal example of a Tkinter widget on top of a wgpu scene. +""" + +import tkinter as tk +from tkinter import ttk + +from rendercanvas.tk import RenderWidget +from rendercanvas.utils.cube import setup_drawing_sync + + +root = tk.Tk() +root.geometry("640x480") + +container = ttk.Frame(root) +container.pack(fill="both", expand=True) + +# the canvas we draw into +canvas = RenderWidget(container, update_mode="continuous") +canvas.place(x=0, y=0, relwidth=1, relheight=1) + +# a frame around a button, in the middle of the panel +controls = ttk.Frame(container, padding=8) +controls.place(relx=0.5, rely=0.5, anchor="center") +label = ttk.Label(controls, text="I feel empty...") +label.pack() + +clicks = 0 +def on_click(): + global clicks + clicks += 1 + label.configure(text=f"Clicked {clicks} times") + +button = ttk.Button(controls, text="Click me", command=on_click) +button.pack(pady=5) + +# ensure the controls (= button) are drawn after the RenderWidget +# this is not technically needed they were added to the container in the correct draw order +root.update_idletasks() +controls.lift() + +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(draw_frame) + +root.mainloop() \ No newline at end of file