Skip to content

Commit 737d9fe

Browse files
authored
Refactor resizing logic and avoid mismatch between last resize event and draw (#120)
1 parent cced105 commit 737d9fe

File tree

8 files changed

+131
-167
lines changed

8 files changed

+131
-167
lines changed

rendercanvas/_scheduler.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,7 @@ async def __scheduler_task(self):
140140

141141
last_tick_time = time.perf_counter()
142142

143-
# Process events, handlers may request a draw
144-
if (canvas := self.get_canvas()) is None:
145-
break
146-
canvas._process_events()
147-
del canvas
148-
149-
# Determine what to do next ...
143+
# Determine whether to draw or not yet
150144

151145
do_draw = False
152146

@@ -166,28 +160,36 @@ async def __scheduler_task(self):
166160
and time.perf_counter() - last_draw_time > 1 / self._min_fps
167161
):
168162
do_draw = True
169-
170163
elif self._mode == "manual":
171164
pass
172165
else:
173166
raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'")
174167

175-
# If we don't want to draw, we move to the next iter
176-
if not do_draw:
177-
continue
178-
179-
self._events.emit({"event_type": "before_draw"})
180-
181-
# Ask the canvas to draw
168+
# Get canvas object or stop the loop
182169
if (canvas := self.get_canvas()) is None:
183170
break
184-
canvas._rc_request_draw()
185-
del canvas
186171

187-
# Wait for the draw to happen
188-
self._async_draw_event = Event()
189-
await self._async_draw_event.wait()
190-
last_draw_time = time.perf_counter()
172+
# Process events now.
173+
# Note that we don't want to emit events *during* the draw, because event
174+
# callbacks do stuff, and that stuff may include changing the canvas size,
175+
# or affect layout in a UI application, all which are not recommended during
176+
# the main draw-event (a.k.a. animation frame), and may even lead to errors.
177+
# The one exception is resize events, which we do emit during a draw, if the
178+
# size has changed since the last time that events were processed.
179+
canvas._process_events()
180+
181+
if not do_draw:
182+
# If we don't want to draw, move to the next iter
183+
del canvas
184+
continue
185+
else:
186+
# Otherwise, request a draw ...
187+
canvas._rc_request_draw()
188+
del canvas
189+
# ... and wait for the draw to happen
190+
self._async_draw_event = Event()
191+
await self._async_draw_event.wait()
192+
last_draw_time = time.perf_counter()
191193

192194
# Note that when the canvas is closed, we may detect it here and break from the loop.
193195
# But the task may also be waiting for a draw to happen, or something else. In that case

rendercanvas/base.py

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def select_loop(cls, loop: BaseLoop) -> None:
122122
def __init__(
123123
self,
124124
*args,
125-
size: Tuple[int, int] = (640, 480),
125+
size: Tuple[float, float] = (640, 480),
126126
title: str = "$backend",
127127
update_mode: UpdateModeEnum = "ondemand",
128128
min_fps: float = 0.0,
@@ -151,6 +151,8 @@ def __init__(
151151
if (self._rc_canvas_group and self._rc_canvas_group.get_loop())
152152
else "no-loop",
153153
}
154+
self._set_size_info((0, 0), 1.0) # Init self.__size_info
155+
self.__size_info["need_event"] = False
154156

155157
# Events and scheduler
156158
self._events = EventEmitter()
@@ -177,8 +179,8 @@ def __init__(
177179
def _final_canvas_init(self):
178180
"""Must be called by the subclasses at the end of their ``__init__``.
179181
180-
This sets the canvas size and title, which must happen *after* the widget itself
181-
is initialized. Doing this automatically can be done with a metaclass, but let's keep it simple.
182+
This sets the canvas logical size and title, which must happen *after* the widget itself
183+
is initialized. (Doing this automatically can be done with a metaclass, but let's keep it simple.)
182184
"""
183185
# Pop kwargs
184186
try:
@@ -211,7 +213,7 @@ def __del__(self):
211213

212214
def get_physical_size(self) -> Tuple[int, int]:
213215
"""Get the physical size of the canvas in integer pixels."""
214-
return self._rc_get_physical_size()
216+
return self.__size_info["physical_size"]
215217

216218
def get_context(self, context_type: str) -> object:
217219
"""Get a context object that can be used to render to this canvas.
@@ -288,6 +290,27 @@ def get_context(self, context_type: str) -> object:
288290

289291
# %% Events
290292

293+
def _set_size_info(self, physical_size: Tuple[int, int], pixel_ratio: float):
294+
"""Must be called by subclasses when their size changes.
295+
296+
Backends must *not* submit a "resize" event; the base class takes care of that, because
297+
it requires some more attention than the other events.
298+
299+
The subclass must call this when the actual viewport has changed. So not in ``_rc_set_logical_size()``,
300+
but e.g. when the underlying GUI layer fires a resize event, and maybe on init.
301+
"""
302+
w, h = physical_size
303+
304+
psize = int(w), int(h)
305+
pixel_ratio = float(pixel_ratio)
306+
lsize = psize[0] / pixel_ratio, psize[1] / pixel_ratio
307+
self.__size_info = {
308+
"physical_size": psize,
309+
"logical_size": lsize,
310+
"pixel_ratio": pixel_ratio,
311+
"need_event": True,
312+
}
313+
291314
def add_event_handler(
292315
self, *args: EventTypeEnum | EventHandlerFunction, order: float = 0
293316
) -> Callable:
@@ -308,6 +331,22 @@ def submit_event(self, event: dict) -> None:
308331

309332
# %% Scheduling and drawing
310333

334+
def __maybe_emit_resize_event(self):
335+
if self.__size_info["need_event"]:
336+
self.__size_info["need_event"] = False
337+
lsize = self.__size_info["logical_size"]
338+
self._events.emit(
339+
{
340+
"event_type": "resize",
341+
"width": lsize[0],
342+
"height": lsize[1],
343+
"pixel_ratio": self.__size_info["pixel_ratio"],
344+
# Would be nice to have more details. But as it is now, PyGfx errors if we add fields it does not know, so let's do later.
345+
# "logical_size": self.__size_info["logical_size"],
346+
# "physical_size": self.__size_info["physical_size"],
347+
}
348+
)
349+
311350
def _process_events(self):
312351
"""Process events and animations.
313352
@@ -321,6 +360,9 @@ def _process_events(self):
321360
# Get events from the GUI into our event mechanism.
322361
self._rc_gui_poll()
323362

363+
# If the canvas changed size, send event
364+
self.__maybe_emit_resize_event()
365+
324366
# Flush our events, so downstream code can update stuff.
325367
# Maybe that downstream code request a new draw.
326368
self._events.flush()
@@ -426,9 +468,16 @@ def _draw_frame_and_present(self):
426468
if self._rc_get_closed():
427469
return
428470

429-
# Process special events
430-
# Note that we must not process normal events here, since these can do stuff
431-
# with the canvas (resize/close/etc) and most GUI systems don't like that.
471+
# Note: could check whether the known physical size is > 0.
472+
# But we also consider it the responsiblity of the backend to not
473+
# draw if the size is zero. GUI toolkits like Qt do this correctly.
474+
# I might get back on this once we also draw outside of the draw-event ...
475+
476+
# Make sure that the user-code is up-to-date with the current size before it draws.
477+
self.__maybe_emit_resize_event()
478+
479+
# Emit before-draw
480+
self._events.emit({"event_type": "before_draw"})
432481

433482
# Notify the scheduler
434483
if self.__scheduler is not None:
@@ -471,7 +520,7 @@ def get_logical_size(self) -> Tuple[float, float]:
471520
The logical size can be smaller than the physical size, e.g. on HiDPI
472521
monitors or when the user's system has the display-scale set to e.g. 125%.
473522
"""
474-
return self._rc_get_logical_size()
523+
return self.__size_info["logical_size"]
475524

476525
def get_pixel_ratio(self) -> float:
477526
"""Get the float ratio between logical and physical pixels.
@@ -482,7 +531,7 @@ def get_pixel_ratio(self) -> float:
482531
pixel ratio >= 2.0. On MacOS (with a Retina screen) the pixel ratio is
483532
always 2.0.
484533
"""
485-
return self._rc_get_pixel_ratio()
534+
return self.__size_info["pixel_ratio"]
486535

487536
def close(self) -> None:
488537
"""Close the canvas."""
@@ -611,18 +660,6 @@ def _rc_present_bitmap(self, *, data, format, **kwargs):
611660
"""
612661
raise NotImplementedError()
613662

614-
def _rc_get_physical_size(self) -> Tuple[int, int]:
615-
"""Get the physical size (with, height) in integer pixels."""
616-
raise NotImplementedError()
617-
618-
def _rc_get_logical_size(self) -> Tuple[float, float]:
619-
"""Get the logical size (with, height) in float pixels."""
620-
raise NotImplementedError()
621-
622-
def _rc_get_pixel_ratio(self) -> float:
623-
"""Get ratio between physical and logical size."""
624-
raise NotImplementedError()
625-
626663
def _rc_set_logical_size(self, width: float, height: float):
627664
"""Set the logical size. May be ignired when it makes no sense.
628665

rendercanvas/glfw.py

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def __init__(self, *args, present_method=None, **kwargs):
246246
self._screen_size_is_logical = False
247247

248248
# Set size, title, etc.
249+
self._determine_size()
249250
self._final_canvas_init()
250251

251252
# Now show the window
@@ -270,17 +271,8 @@ def _determine_size(self):
270271
pixel_ratio = get_window_content_scale(self._window)[0]
271272
psize = get_physical_size(self._window)
272273

273-
self._pixel_ratio = pixel_ratio
274-
self._physical_size = psize
275-
self._logical_size = psize[0] / pixel_ratio, psize[1] / pixel_ratio
276-
277-
ev = {
278-
"event_type": "resize",
279-
"width": self._logical_size[0],
280-
"height": self._logical_size[1],
281-
"pixel_ratio": self._pixel_ratio,
282-
}
283-
self.submit_event(ev)
274+
self._pixel_ratio = pixel_ratio # store
275+
self._set_size_info(psize, pixel_ratio)
284276

285277
def _on_want_close(self, *args):
286278
# Called when the user attempts to close the window, for example by clicking the close widget in the title bar.
@@ -313,12 +305,7 @@ def _set_logical_size(self, new_logical_size):
313305
int(new_logical_size[0] * pixel_ratio * screen_ratio),
314306
int(new_logical_size[1] * pixel_ratio * screen_ratio),
315307
)
316-
317308
self._screen_size_is_logical = screen_ratio != 1
318-
# If this causes the widget size to change, then _on_size_change will
319-
# be called, but we may want force redetermining the size.
320-
if pixel_ratio != self._pixel_ratio:
321-
self._determine_size()
322309

323310
# %% Methods to implement RenderCanvas
324311

@@ -348,15 +335,6 @@ def _rc_present_bitmap(self, **kwargs):
348335
# not really need one, since it's the most reliable backend to
349336
# render to the screen.
350337

351-
def _rc_get_physical_size(self):
352-
return self._physical_size
353-
354-
def _rc_get_logical_size(self):
355-
return self._logical_size
356-
357-
def _rc_get_pixel_ratio(self):
358-
return self._pixel_ratio
359-
360338
def _rc_set_logical_size(self, width, height):
361339
if width < 0 or height < 0:
362340
raise ValueError("Window width and height must not be negative")
@@ -406,7 +384,8 @@ def _on_pixelratio_change(self, *args):
406384
return
407385
self._changing_pixel_ratio = True # prevent recursion (on Wayland)
408386
try:
409-
self._set_logical_size(self._logical_size)
387+
self._set_logical_size(self.get_logical_size())
388+
self._determine_size()
410389
finally:
411390
self._changing_pixel_ratio = False
412391
self.request_draw()

rendercanvas/jupyter.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ def __init__(self, *args, **kwargs):
2929

3030
# Internal variables
3131
self._last_image = None
32-
self._pixel_ratio = 1
33-
self._logical_size = 0, 0
3432
self._is_closed = False
3533
self._draw_request_time = 0
3634
self._rendercanvas_event_types = set(EventType)
@@ -82,19 +80,7 @@ def _rc_present_bitmap(self, *, data, format, **kwargs):
8280
assert format == "rgba-u8"
8381
self._last_image = np.frombuffer(data, np.uint8).reshape(data.shape)
8482

85-
def _rc_get_physical_size(self):
86-
return int(self._logical_size[0] * self._pixel_ratio), int(
87-
self._logical_size[1] * self._pixel_ratio
88-
)
89-
90-
def _rc_get_logical_size(self):
91-
return self._logical_size
92-
93-
def _rc_get_pixel_ratio(self):
94-
return self._pixel_ratio
95-
9683
def _rc_set_logical_size(self, width, height):
97-
self._logical_size = width, height
9884
self.css_width = f"{width}px"
9985
self.css_height = f"{height}px"
10086

@@ -117,10 +103,17 @@ def handle_event(self, event):
117103
if event_type == "close":
118104
self._is_closed = True
119105
elif event_type == "resize":
120-
self._pixel_ratio = event["pixel_ratio"]
121-
self._logical_size = event["width"], event["height"]
122-
123-
# Only submit events that rendercanvas known. Otherwise, if new events are added
106+
logical_size = event["width"], event["height"]
107+
pixel_ratio = event["pixel_ratio"]
108+
physical_size = (
109+
int(logical_size[0] * pixel_ratio),
110+
int(logical_size[1] * pixel_ratio),
111+
)
112+
self._set_size_info(physical_size, pixel_ratio)
113+
self.request_draw()
114+
return
115+
116+
# Only submit events that rendercanvas knows. Otherwise, if new events are added
124117
# to jupyter_rfb that rendercanvas does not (yet) know, rendercanvas will complain.
125118
if event_type in self._rendercanvas_event_types:
126119
self.submit_event(event)

rendercanvas/offscreen.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class OffscreenRenderCanvas(BaseRenderCanvas):
2929

3030
def __init__(self, *args, pixel_ratio=1.0, format="rgba-u8", **kwargs):
3131
super().__init__(*args, **kwargs)
32-
self._pixel_ratio = pixel_ratio
32+
self._pixel_ratio = float(pixel_ratio)
3333
self._closed = False
3434
self._last_image = None
3535

@@ -70,19 +70,13 @@ def _rc_force_draw(self):
7070
def _rc_present_bitmap(self, *, data, format, **kwargs):
7171
self._last_image = data
7272

73-
def _rc_get_physical_size(self):
74-
return int(self._logical_size[0] * self._pixel_ratio), int(
75-
self._logical_size[1] * self._pixel_ratio
76-
)
77-
78-
def _rc_get_logical_size(self):
79-
return self._logical_size
80-
81-
def _rc_get_pixel_ratio(self):
82-
return self._pixel_ratio
83-
8473
def _rc_set_logical_size(self, width, height):
85-
self._logical_size = width, height
74+
logical_size = float(width), float(height)
75+
physical_size = (
76+
int(logical_size[0] * self._pixel_ratio),
77+
int(logical_size[1] * self._pixel_ratio),
78+
)
79+
self._set_size_info(physical_size, self._pixel_ratio)
8680

8781
def _rc_close(self):
8882
self._closed = True

0 commit comments

Comments
 (0)