Skip to content

Instantly share code, notes, and snippets.

@almarklein
Last active April 10, 2025 07:15
Show Gist options
  • Save almarklein/2715f4ffed13ed5443a79258bb6dfbe9 to your computer and use it in GitHub Desktop.
Save almarklein/2715f4ffed13ed5443a79258bb6dfbe9 to your computer and use it in GitHub Desktop.
rendercanvas glfw playground
import sys
import time
import glfw
from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup
from rendercanvas.asyncio import loop
from rendercanvas._coreutils import SYSTEM_IS_WAYLAND, weakbind, logger
# Make sure that glfw is new enough
glfw_version_info = tuple(int(i) for i in glfw.__version__.split(".")[:2])
if glfw_version_info < (1, 9):
raise ImportError("rendercanvas requires glfw 1.9 or higher.")
# Do checks to prevent pitfalls on hybrid Xorg/Wayland systems
is_wayland = False
if sys.platform.startswith("linux") and SYSTEM_IS_WAYLAND:
if not hasattr(glfw, "get_x11_window"):
# Probably glfw was imported before this module, so we missed our chance
# to set the env var to make glfw use x11.
is_wayland = True
logger.warning("Using GLFW with Wayland, which is experimental.")
# Some glfw functions are not always available
set_window_content_scale_callback = lambda *args: None
set_window_maximize_callback = lambda *args: None
get_window_content_scale = lambda *args: (1, 1)
if hasattr(glfw, "set_window_content_scale_callback"):
set_window_content_scale_callback = glfw.set_window_content_scale_callback
if hasattr(glfw, "set_window_maximize_callback"):
set_window_maximize_callback = glfw.set_window_maximize_callback
if hasattr(glfw, "get_window_content_scale"):
get_window_content_scale = glfw.get_window_content_scale
# Map keys to JS key definitions
# https://www.glfw.org/docs/3.3/group__keys.html
# https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
KEY_MAP = {
glfw.KEY_DOWN: "ArrowDown",
glfw.KEY_UP: "ArrowUp",
glfw.KEY_LEFT: "ArrowLeft",
glfw.KEY_RIGHT: "ArrowRight",
glfw.KEY_BACKSPACE: "Backspace",
glfw.KEY_CAPS_LOCK: "CapsLock",
glfw.KEY_DELETE: "Delete",
glfw.KEY_END: "End",
glfw.KEY_ENTER: "Enter", # aka return
glfw.KEY_ESCAPE: "Escape",
glfw.KEY_F1: "F1",
glfw.KEY_F2: "F2",
glfw.KEY_F3: "F3",
glfw.KEY_F4: "F4",
glfw.KEY_F5: "F5",
glfw.KEY_F6: "F6",
glfw.KEY_F7: "F7",
glfw.KEY_F8: "F8",
glfw.KEY_F9: "F9",
glfw.KEY_F10: "F10",
glfw.KEY_F11: "F11",
glfw.KEY_F12: "F12",
glfw.KEY_HOME: "Home",
glfw.KEY_INSERT: "Insert",
glfw.KEY_LEFT_ALT: "Alt",
glfw.KEY_LEFT_CONTROL: "Control",
glfw.KEY_LEFT_SHIFT: "Shift",
glfw.KEY_LEFT_SUPER: "Meta", # in glfw super means Windows or MacOS-command
glfw.KEY_NUM_LOCK: "NumLock",
glfw.KEY_PAGE_DOWN: "PageDown",
glfw.KEY_PAGE_UP: "Pageup",
glfw.KEY_PAUSE: "Pause",
glfw.KEY_PRINT_SCREEN: "PrintScreen",
glfw.KEY_RIGHT_ALT: "Alt",
glfw.KEY_RIGHT_CONTROL: "Control",
glfw.KEY_RIGHT_SHIFT: "Shift",
glfw.KEY_RIGHT_SUPER: "Meta",
glfw.KEY_SCROLL_LOCK: "ScrollLock",
glfw.KEY_TAB: "Tab",
}
KEY_MAP_MOD = {
glfw.KEY_LEFT_SHIFT: "Shift",
glfw.KEY_RIGHT_SHIFT: "Shift",
glfw.KEY_LEFT_CONTROL: "Control",
glfw.KEY_RIGHT_CONTROL: "Control",
glfw.KEY_LEFT_ALT: "Alt",
glfw.KEY_RIGHT_ALT: "Alt",
glfw.KEY_LEFT_SUPER: "Meta",
glfw.KEY_RIGHT_SUPER: "Meta",
}
def get_glfw_present_methods(window):
if sys.platform.startswith("win"):
return {
"screen": {
"platform": "windows",
"window": int(glfw.get_win32_window(window)),
}
}
elif sys.platform.startswith("darwin"):
return {
"screen": {
"platform": "cocoa",
"window": int(glfw.get_cocoa_window(window)),
}
}
elif sys.platform.startswith("linux"):
if is_wayland:
return {
"screen": {
"platform": "wayland",
"window": int(glfw.get_wayland_window(window)),
"display": int(glfw.get_wayland_display()),
}
}
else:
return {
"screen": {
"platform": "x11",
"window": int(glfw.get_x11_window(window)),
"display": int(glfw.get_x11_display()),
}
}
else:
raise RuntimeError(f"Cannot get GLFW surface info on {sys.platform}.")
def get_physical_size(window):
psize = glfw.get_framebuffer_size(window)
return int(psize[0]), int(psize[1])
def enable_glfw():
glfw.init() # this also resets all window hints
glfw._rc_alive = True
class GlfwCanvasGroup(BaseCanvasGroup):
glfw = glfw # make sure we can access the glfw module in the __del__
def __del__(self):
# Because this object is used as a class attribute (on the canvas), this
# __del__ method gets called later than a function registed to atexit.
# This is important when used in combination with wgpu, where the release of the surface
# should happen before the termination of glfw. On some systems this can otherwiser
# result in a segfault, see https://github.com/pygfx/pygfx/issues/642
try:
self.glfw._rc_alive = False
self.glfw.terminate()
except Exception:
pass
try:
super().__del__()
except Exception:
pass # object has no __del__
class GlfwRenderCanvas(BaseRenderCanvas):
"""A glfw window providing a render canvas."""
# See https://www.glfw.org/docs/latest/group__window.html
_rc_canvas_group = GlfwCanvasGroup(loop)
def __init__(self, *args, present_method=None, **kwargs):
enable_glfw()
super().__init__(*args, **kwargs)
if present_method == "bitmap":
logger.warning(
"Ignoring present_method 'bitmap'; glfw can only render to screen"
)
# Set window hints
glfw.window_hint(glfw.CLIENT_API, glfw.NO_API)
glfw.window_hint(glfw.RESIZABLE, True)
glfw.window_hint(glfw.VISIBLE, False) # start hidden
# Create the window (the initial size may not be in logical pixels)
self._window = glfw.create_window(640, 480, "", None, None)
# Other internal variables
self._changing_pixel_ratio = False
self._is_minimized = False
self._is_in_poll_events = False
# Size vars
self._physical_size = 0, 0
self._logical_size = 0, 0
self._pixel_ratio = 1
# Register callbacks. We may get notified too often, but that's
# ok, they'll result in a single draw.
glfw.set_framebuffer_size_callback(self._window, weakbind(self._on_size_change))
glfw.set_window_close_callback(self._window, weakbind(self._on_want_close))
glfw.set_window_refresh_callback(self._window, weakbind(self._on_window_dirty))
glfw.set_window_focus_callback(self._window, weakbind(self._on_window_dirty))
set_window_content_scale_callback(
self._window, weakbind(self._on_pixelratio_change)
)
set_window_maximize_callback(self._window, weakbind(self._on_window_dirty))
glfw.set_window_iconify_callback(self._window, weakbind(self._on_iconify))
# User input
self._key_modifiers = ()
self._pointer_buttons = ()
self._pointer_pos = 0, 0
self._double_click_state = {"clicks": 0}
glfw.set_mouse_button_callback(self._window, weakbind(self._on_mouse_button))
glfw.set_cursor_pos_callback(self._window, weakbind(self._on_cursor_pos))
glfw.set_scroll_callback(self._window, weakbind(self._on_scroll))
glfw.set_key_callback(self._window, weakbind(self._on_key))
glfw.set_char_callback(self._window, weakbind(self._on_char))
# Initialize the size
self._pixel_ratio = -1
self._screen_size_is_logical = False
# Set size, title, etc.
self._final_canvas_init()
# Now show the window
glfw.show_window(self._window)
def _draw_now(self):
if self._check_size():
print("Detected resize event between event and draw")
self._draw_frame_and_present()
def _on_window_dirty(self, *args):
self.request_draw()
def _on_iconify(self, window, iconified):
self._is_minimized = bool(iconified)
if not self._is_minimized:
self._rc_request_draw()
def _check_size(self):
if self._window is None:
return
pixel_ratio = get_window_content_scale(self._window)[0]
psize = get_physical_size(self._window)
# Early exit if no change
if psize == self._physical_size and pixel_ratio == self._pixel_ratio:
return
self._pixel_ratio = pixel_ratio
self._physical_size = psize
self._logical_size = psize[0] / pixel_ratio, psize[1] / pixel_ratio
ev = {
"event_type": "resize",
"width": self._logical_size[0],
"height": self._logical_size[1],
"pixel_ratio": self._pixel_ratio,
}
self.submit_event(ev)
return True
def _on_want_close(self, *args):
# Called when the user attempts to close the window, for example by clicking the close widget in the title bar.
# We could prevent closing the window here. But we don't :)
pass # Prevent closing: glfw.set_window_should_close(self._window, 0)
def _maybe_close(self):
if self._window is not None:
if glfw.window_should_close(self._window):
self.close()
def _set_logical_size(self, new_logical_size):
if self._window is None:
return
# There is unclarity about the window size in "screen pixels".
# It appears that on Windows and X11 its the same as the
# framebuffer size, and on macOS it's logical pixels.
# See https://github.com/glfw/glfw/issues/845
# Here, we simply do a quick test so we can compensate.
# The current screen size and physical size, and its ratio
pixel_ratio = get_window_content_scale(self._window)[0]
ssize = glfw.get_window_size(self._window)
psize = glfw.get_framebuffer_size(self._window)
# Apply
if is_wayland:
# Not sure why, but on Wayland things work differently
screen_ratio = ssize[0] / new_logical_size[0]
glfw.set_window_size(
self._window,
int(new_logical_size[0] / screen_ratio),
int(new_logical_size[1] / screen_ratio),
)
else:
screen_ratio = ssize[0] / psize[0]
glfw.set_window_size(
self._window,
int(new_logical_size[0] * pixel_ratio * screen_ratio),
int(new_logical_size[1] * pixel_ratio * screen_ratio),
)
self._screen_size_is_logical = screen_ratio != 1
# If this causes the widget size to change, then _on_size_change will
# be called, but we may want force redetermining the size.
if pixel_ratio != self._pixel_ratio:
self._check_size()
# %% Methods to implement RenderCanvas
def _rc_gui_poll(self):
glfw.post_empty_event() # Awake the event loop, if it's in wait-mode
try:
self._is_in_poll_events = True
glfw.poll_events() # Note: this blocks when the window is being resized
finally:
self._is_in_poll_events = False
self._maybe_close()
def _rc_get_present_methods(self):
return get_glfw_present_methods(self._window)
def _rc_request_draw(self):
if not self._is_minimized:
loop = self._rc_canvas_group.get_loop()
loop.call_soon(self._draw_now)
def _rc_force_draw(self):
self._draw_now()
def _rc_present_bitmap(self, **kwargs):
raise NotImplementedError()
# AFAIK glfw does not have a builtin way to blit an image. It also does
# not really need one, since it's the most reliable backend to
# render to the screen.
def _rc_get_physical_size(self):
self._check_size()
return self._physical_size
def _rc_get_logical_size(self):
return self._logical_size
def _rc_get_pixel_ratio(self):
return self._pixel_ratio
def _rc_set_logical_size(self, width, height):
if width < 0 or height < 0:
raise ValueError("Window width and height must not be negative")
self._set_logical_size((float(width), float(height)))
def _rc_close(self):
if self._window is not None:
glfw.destroy_window(self._window) # not just glfw.hide_window
self._window = None
self.submit_event({"event_type": "close"})
# If this is the last canvas to close, the loop will stop, and glfw will not be polled anymore.
# But on some systems glfw needs a bit of time to properly close the window.
if not self._rc_canvas_group.get_canvases():
poll_glfw_briefly(0.05)
def _rc_get_closed(self):
return self._window is None
def _rc_set_title(self, title):
if self._window is not None:
glfw.set_window_title(self._window, title)
# %% Turn glfw events into rendercanvas events
def _on_pixelratio_change(self, *args):
if self._changing_pixel_ratio:
return
self._changing_pixel_ratio = True # prevent recursion (on Wayland)
try:
self._set_logical_size(self._logical_size)
finally:
self._changing_pixel_ratio = False
self.request_draw()
def _on_size_change(self, *args):
self._check_size()
self.request_draw()
# During a resize, the glfw.poll_events() function blocks, so
# our event-loop is on pause. However, glfw still sends resize
# events, and we can use these to draw, to get a smoother
# experience. Note that if the user holds the mouse still while
# resizing, there are no draws. Also note that any animations
# that rely on the event-loop are paused (only animations
# updated in the draw callback are alive).
# if self._is_in_poll_events and not self._is_minimized:
# self._draw_now()
def _on_mouse_button(self, window, but, action, mods):
# Map button being changed, which we use to update self._pointer_buttons.
button_map = {
glfw.MOUSE_BUTTON_1: 1, # == MOUSE_BUTTON_LEFT
glfw.MOUSE_BUTTON_2: 2, # == MOUSE_BUTTON_RIGHT
glfw.MOUSE_BUTTON_3: 3, # == MOUSE_BUTTON_MIDDLE
glfw.MOUSE_BUTTON_4: 4,
glfw.MOUSE_BUTTON_5: 5,
glfw.MOUSE_BUTTON_6: 6,
glfw.MOUSE_BUTTON_7: 7,
glfw.MOUSE_BUTTON_8: 8,
}
button = button_map.get(but, 0)
if action == glfw.PRESS:
event_type = "pointer_down"
buttons = set(self._pointer_buttons)
buttons.add(button)
self._pointer_buttons = tuple(sorted(buttons))
elif action == glfw.RELEASE:
event_type = "pointer_up"
buttons = set(self._pointer_buttons)
buttons.discard(button)
self._pointer_buttons = tuple(sorted(buttons))
else:
return
ev = {
"event_type": event_type,
"x": self._pointer_pos[0],
"y": self._pointer_pos[1],
"button": button,
"buttons": tuple(self._pointer_buttons),
"modifiers": tuple(self._key_modifiers),
"ntouches": 0, # glfw does not have touch support
"touches": {},
}
# Emit the current event
self.submit_event(ev)
# Maybe emit a double-click event
self._follow_double_click(action, button)
def _follow_double_click(self, action, button):
# If a sequence of down-up-down-up is made in nearly the same
# spot, and within a short time, we emit the double-click event.
x, y = self._pointer_pos[0], self._pointer_pos[1]
state = self._double_click_state
timeout = 0.25
distance = 5
# Clear the state if it does no longer match
if state["clicks"] > 0:
d = ((x - state["x"]) ** 2 + (y - state["y"]) ** 2) ** 0.5
if (
d > distance
or time.perf_counter() - state["time"] > timeout
or button != state["button"]
):
self._double_click_state = {"clicks": 0}
clicks = self._double_click_state["clicks"]
# Check and update order. Emit event if we make it to the final step
if clicks == 0 and action == glfw.PRESS:
self._double_click_state = {
"clicks": 1,
"button": button,
"time": time.perf_counter(),
"x": x,
"y": y,
}
elif clicks == 1 and action == glfw.RELEASE:
self._double_click_state["clicks"] = 2
elif clicks == 2 and action == glfw.PRESS:
self._double_click_state["clicks"] = 3
elif clicks == 3 and action == glfw.RELEASE:
self._double_click_state = {"clicks": 0}
ev = {
"event_type": "double_click",
"x": self._pointer_pos[0],
"y": self._pointer_pos[1],
"button": button,
"buttons": tuple(self._pointer_buttons),
"modifiers": tuple(self._key_modifiers),
"ntouches": 0, # glfw does not have touch support
"touches": {},
}
self.submit_event(ev)
def _on_cursor_pos(self, window, x, y):
# Store pointer position in logical coordinates
if self._screen_size_is_logical:
self._pointer_pos = x, y
else:
self._pointer_pos = x / self._pixel_ratio, y / self._pixel_ratio
ev = {
"event_type": "pointer_move",
"x": self._pointer_pos[0],
"y": self._pointer_pos[1],
"button": 0,
"buttons": tuple(self._pointer_buttons),
"modifiers": tuple(self._key_modifiers),
"ntouches": 0, # glfw does not have touch support
"touches": {},
}
self.submit_event(ev)
def _on_scroll(self, window, dx, dy):
# wheel is 1 or -1 in glfw, in jupyter_rfb this is ~100
ev = {
"event_type": "wheel",
"dx": 100.0 * dx,
"dy": -100.0 * dy,
"x": self._pointer_pos[0],
"y": self._pointer_pos[1],
"buttons": tuple(self._pointer_buttons),
"modifiers": tuple(self._key_modifiers),
}
self.submit_event(ev)
def _on_key(self, window, key, scancode, action, mods):
modifier = KEY_MAP_MOD.get(key, None)
if action == glfw.PRESS:
event_type = "key_down"
if modifier:
modifiers = set(self._key_modifiers)
modifiers.add(modifier)
self._key_modifiers = tuple(sorted(modifiers))
elif action == glfw.RELEASE:
event_type = "key_up"
if modifier:
modifiers = set(self._key_modifiers)
modifiers.discard(modifier)
self._key_modifiers = tuple(sorted(modifiers))
else: # glfw.REPEAT
return
# Note that if the user holds shift while pressing "5", will result in "5",
# and not in the "%" that you'd expect on a US keyboard. Glfw wants us to
# use set_char_callback for text input, but then we'd only get an event for
# key presses (down followed by up). So we accept that GLFW is less complete
# in this respect.
if key in KEY_MAP:
keyname = KEY_MAP[key]
else:
try:
keyname = chr(key)
except ValueError:
return # Probably a special key that we don't have in our KEY_MAP
if "Shift" not in self._key_modifiers:
keyname = keyname.lower()
ev = {
"event_type": event_type,
"key": keyname,
"modifiers": tuple(self._key_modifiers),
}
self.submit_event(ev)
def _on_char(self, window, char):
# Undocumented char event to make imgui work, see https://github.com/pygfx/wgpu-py/issues/530
ev = {
"event_type": "char",
"char_str": chr(char),
"modifiers": tuple(self._key_modifiers),
}
self.submit_event(ev)
def poll_glfw_briefly(poll_time=0.1):
"""Briefly poll glfw for a set amount of time.
Intended to work around the bug that destroyed windows sometimes hang
around if the mainloop exits: https://github.com/glfw/glfw/issues/1766
I found that 10ms is enough, but make it 100ms just in case. You should
only run this right after your mainloop stops.
"""
if not glfw._rc_alive:
return
end_time = time.perf_counter() + poll_time
while time.perf_counter() < end_time:
glfw.wait_events_timeout(end_time - time.perf_counter())
# Make available under a name that is the same for all backends
loop = loop # default loop is AsyncioLoop
RenderCanvas = GlfwRenderCanvas
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment