Skip to content

Commit 55fa9c7

Browse files
committed
gui: enhance UIScrollArea with scroll speed and inversion options
1 parent 62b3e82 commit 55fa9c7

8 files changed

Lines changed: 710 additions & 408 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
1616
- Fixed an issue where pixel scaling for high-dpi displays did not work correctly in web browsers via Pyodide. See [#2846](https://github.com/pythonarcade/arcade/pull/2846)
1717
- Fixed issues with update/draw rate handling that changes with Pyglet 3, rates are now handled properly between desktop and browser. See [#2845](https://github.com/pythonarcade/arcade/pull/2845)
1818
- Fixed caret behavior not responding appropriately when activating an input field. See [#2850](https://github.com/pythonarcade/arcade/pull/2850)
19+
- GUI: Fixed `UILabel.update_font` always reporting a font change when the requested font was a fallback tuple (e.g. `UIFlatButton`'s default styles), which caused a full UI re-render every frame. This made widget-heavy UIs, such as the `exp_scroll_area` example, run at a few FPS.
1920
## 4.0.0.dev4
2021

2122
### New Features

arcade/gui/experimental/scroll_area.py

Lines changed: 118 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import warnings
34
from collections.abc import Iterable
45
from typing import TypeVar
56

@@ -9,6 +10,7 @@
910
from arcade import XYWH
1011
from arcade.gui.events import (
1112
UIEvent,
13+
UIKeyPressEvent,
1214
UIMouseDragEvent,
1315
UIMouseEvent,
1416
UIMouseMovementEvent,
@@ -175,7 +177,26 @@ def do_render(self, surface: Surface):
175177
class UIScrollArea(UILayout):
176178
"""A widget that can scroll its children.
177179
178-
This widget is highly experimental and only provides a proof of concept.
180+
Children are laid out on an internal canvas, which resizes itself to fit
181+
all children (at least the size of the scroll area itself). The visible
182+
part of the canvas is controlled by :py:attr:`scroll_x` and
183+
:py:attr:`scroll_y` (both range from ``0`` at the start of the content
184+
to a negative value at the end).
185+
186+
For a typical scrollable list, add a container with ``size_hint=(1, 0)``
187+
(fill the width of the scroll area, grow to the natural content height)
188+
like ``UIBoxLayout``.
189+
190+
Scrolling is supported via mouse wheel, :class:`UIScrollBar` and, while
191+
the mouse hovers the widget, the keyboard
192+
(arrow keys, PageUp/PageDown, Home/End).
193+
194+
.. note::
195+
Content containing labels may finish sizing only during the first
196+
layout pass and render on the following frame. Single-frame capture
197+
pipelines (e.g. CI screenshots) should draw two frames.
198+
199+
This widget is experimental, the API might change.
179200
180201
Args:
181202
x: x position of the widget
@@ -186,18 +207,20 @@ class UIScrollArea(UILayout):
186207
size_hint: size hint of the widget
187208
size_hint_min: minimum size hint of the widget
188209
size_hint_max: maximum size hint of the widget
189-
canvas_size: size of the canvas, which is scrollable
210+
canvas_size: deprecated, the canvas is sized automatically to fit
211+
all children
190212
overscroll_x: allow over scrolling in x direction (scroll past the end)
191213
overscroll_y: allow over scrolling in y direction (scroll past the end)
214+
scroll_speed: speed of scrolling in pixels per scroll event,
215+
defaults to :py:attr:`scroll_speed`
216+
invert_scroll: invert the scroll direction,
217+
defaults to :py:attr:`invert_scroll`
192218
**kwargs: passed to UIWidget
193219
"""
194220

195221
scroll_x = Property[float](default=0.0)
196222
scroll_y = Property[float](default=0.0)
197223

198-
scroll_speed = 1.8
199-
invert_scroll = False
200-
201224
def __init__(
202225
self,
203226
*,
@@ -209,11 +232,37 @@ def __init__(
209232
size_hint=None,
210233
size_hint_min=None,
211234
size_hint_max=None,
212-
canvas_size=(300, 300),
235+
canvas_size=None,
213236
overscroll_x=False,
214237
overscroll_y=False,
238+
scroll_speed: float = 15.0,
239+
invert_scroll: bool = False,
215240
**kwargs,
216241
):
242+
self.default_anchor_x = "left"
243+
self.default_anchor_y = "bottom"
244+
self.overscroll_x = overscroll_x
245+
self.overscroll_y = overscroll_y
246+
self.scroll_speed = scroll_speed
247+
self.invert_scroll = invert_scroll
248+
self._hovering = False
249+
250+
if canvas_size is not None:
251+
warnings.warn(
252+
"canvas_size is deprecated, the canvas is sized automatically to fit all children",
253+
DeprecationWarning,
254+
stacklevel=2,
255+
)
256+
else:
257+
canvas_size = (max(int(width), 1), max(int(height), 1))
258+
259+
# The canvas has to match the window's pixel ratio,
260+
# otherwise content renders at reduced resolution on hi-DPI displays.
261+
self.surface = Surface(
262+
size=canvas_size,
263+
pixel_ratio=arcade.get_window().get_pixel_ratio(),
264+
)
265+
217266
super().__init__(
218267
x=x,
219268
y=y,
@@ -225,14 +274,6 @@ def __init__(
225274
size_hint_max=size_hint_max,
226275
**kwargs,
227276
)
228-
self.default_anchor_x = "left"
229-
self.default_anchor_y = "bottom"
230-
self.overscroll_x = overscroll_x
231-
self.overscroll_y = overscroll_y
232-
233-
self.surface = Surface(
234-
size=canvas_size,
235-
)
236277

237278
bind(self, "scroll_x", UIScrollArea.trigger_full_render)
238279
bind(self, "scroll_y", UIScrollArea.trigger_full_render)
@@ -285,16 +326,20 @@ def do_layout(self):
285326
if new_rect != child.rect:
286327
child.rect = new_rect
287328

288-
total_min_x = round(total_min_x)
289-
total_min_y = round(total_min_y)
329+
# the canvas covers at least the visible area, so children which do not
330+
# fill the scroll area never leave the viewport partially uncovered
331+
# and scroll ranges never become negative
332+
total_min_x = max(round(total_min_x), round(self.content_width), 1)
333+
total_min_y = max(round(total_min_y), round(self.content_height), 1)
290334

291335
# resize surface to fit all children
292336
if self.surface.size != (total_min_x, total_min_y):
293337
self.surface.resize(
294338
size=(total_min_x, total_min_y), pixel_ratio=self.surface.pixel_ratio
295339
)
296-
self.scroll_x = 0
297-
self.scroll_y = 0
340+
# preserve the scroll position when content changes,
341+
# only clamp it into the new scroll range
342+
self._clamp_scroll()
298343

299344
def _do_render(self, surface: Surface, force=False) -> bool:
300345
if not self.visible:
@@ -336,8 +381,58 @@ def _get_scroll_offset(self):
336381

337382
return self.scroll_x, -normal_pos_y - self.scroll_y
338383

384+
def _clamp_scroll(self):
385+
"""Clamp the scroll position into the valid scroll range.
386+
387+
Axes with overscroll enabled are not clamped.
388+
"""
389+
if not self.overscroll_x:
390+
# clip scroll_x between 0 and -(self.surface.width - self.width)
391+
scroll_range = int(self.content_width - self.surface.width)
392+
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
393+
self.scroll_x = max(scroll_range, min(0.0, self.scroll_x))
394+
395+
if not self.overscroll_y:
396+
# clip scroll_y between 0 and -(self.surface.height - self.height)
397+
scroll_range = int(self.content_height - self.surface.height)
398+
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
399+
self.scroll_y = max(scroll_range, min(0.0, self.scroll_y))
400+
401+
def _scroll_by_key(self, symbol: int) -> bool:
402+
"""Scroll on arrow keys, PageUp/PageDown, Home and End.
403+
404+
Returns True if the key was handled.
405+
"""
406+
v_scrollable = self.surface.height > self.content_height
407+
h_scrollable = self.surface.width > self.content_width
408+
409+
if symbol == arcade.key.UP and v_scrollable:
410+
self.scroll_y += self.scroll_speed
411+
elif symbol == arcade.key.DOWN and v_scrollable:
412+
self.scroll_y -= self.scroll_speed
413+
elif symbol == arcade.key.LEFT and h_scrollable:
414+
self.scroll_x += self.scroll_speed
415+
elif symbol == arcade.key.RIGHT and h_scrollable:
416+
self.scroll_x -= self.scroll_speed
417+
elif symbol == arcade.key.PAGEUP and v_scrollable:
418+
self.scroll_y += self.content_height
419+
elif symbol == arcade.key.PAGEDOWN and v_scrollable:
420+
self.scroll_y -= self.content_height
421+
elif symbol == arcade.key.HOME and v_scrollable:
422+
self.scroll_y = 0.0
423+
elif symbol == arcade.key.END and v_scrollable:
424+
self.scroll_y = float(min(0, int(self.content_height - self.surface.height)))
425+
else:
426+
return False
427+
428+
self._clamp_scroll()
429+
return True
430+
339431
def on_event(self, event: UIEvent) -> bool | None:
340432
"""Handle scrolling of the widget."""
433+
if isinstance(event, UIMouseMovementEvent):
434+
self._hovering = self.rect.point_in_rect(event.pos)
435+
341436
if isinstance(event, UIMouseDragEvent) and not self.rect.point_in_rect(event.pos):
342437
return EVENT_UNHANDLED
343438

@@ -347,23 +442,13 @@ def on_event(self, event: UIEvent) -> bool | None:
347442
self.scroll_x -= -event.scroll_x * self.scroll_speed * invert
348443
self.scroll_y -= event.scroll_y * self.scroll_speed * invert
349444

350-
# clip scrolling to canvas size
351-
if not self.overscroll_x:
352-
# clip scroll_x between 0 and -(self.surface.width - self.width)
353-
scroll_range = int(self.content_width - self.surface.width)
354-
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
355-
self.scroll_x = min(0, self.scroll_x)
356-
self.scroll_x = max(self.scroll_x, scroll_range)
357-
358-
if not self.overscroll_y:
359-
# clip scroll_y between 0 and -(self.surface.height - self.height)
360-
scroll_range = int(self.content_height - self.surface.height)
361-
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
362-
self.scroll_y = min(0, self.scroll_y)
363-
self.scroll_y = max(self.scroll_y, scroll_range)
364-
445+
self._clamp_scroll()
365446
return True
366447

448+
if isinstance(event, UIKeyPressEvent) and self._hovering:
449+
if self._scroll_by_key(event.symbol):
450+
return True
451+
367452
child_event = event
368453
if isinstance(event, UIMouseEvent):
369454
if self.rect.point_in_rect(event.pos):

arcade/gui/surface.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,17 @@ def __init__(
5050
*self.ctx.BLEND_DEFAULT,
5151
*self.ctx.BLEND_ADDITIVE,
5252
)
53-
#: Blend mode for when we're drawing the surface
53+
#: Blend mode for when we're drawing the surface.
54+
#: Content rendered into the surface over transparent black ends up
55+
#: with premultiplied color channels, so the composite has to use
56+
#: premultiplied-alpha blending. Straight alpha would multiply the
57+
#: color by alpha a second time (dark fringes on anti-aliased edges)
58+
#: and erode the destination alpha under semi-transparent texels.
5459
self.blend_func_render = (
55-
*self.ctx.BLEND_DEFAULT,
56-
*self.ctx.BLEND_DEFAULT,
60+
self.ctx.ONE,
61+
self.ctx.ONE_MINUS_SRC_ALPHA,
62+
self.ctx.ONE,
63+
self.ctx.ONE_MINUS_SRC_ALPHA,
5764
)
5865

5966
# 5 floats per vertex (pos 3f, tex 2f) with 4 vertices
@@ -249,7 +256,14 @@ def draw(
249256
self.texture.filter = self.ctx.LINEAR, self.ctx.LINEAR
250257

251258
self.texture.use(0)
252-
self._geometry.render(self._program)
259+
# Blending must be enforced here: pyglet toggles GL_BLEND directly
260+
# (e.g. text layouts disable it after drawing), bypassing arcade's
261+
# context flag cache. ctx.enabled() always issues the real enable
262+
# call, so it resyncs the driver state; without it the composite can
263+
# silently run in replace mode and overwrite the destination with
264+
# the surface's transparent texels.
265+
with self.ctx.enabled(self.ctx.BLEND):
266+
self._geometry.render(self._program)
253267

254268
# Restore blend function
255269
self.ctx.blend_func = blend_func

arcade/gui/widgets/dropdown.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,10 @@ def __init__(
4242
self._scroll_area = UIScrollArea(
4343
width=100,
4444
height=100,
45-
canvas_size=(100, 100),
4645
size_hint=(1, 1),
46+
scroll_speed=scroll_speed,
47+
invert_scroll=invert_scroll,
4748
)
48-
self._scroll_area.invert_scroll = invert_scroll
49-
self._scroll_area.scroll_speed = scroll_speed
5049
self._scroll_area.add(self._options_layout)
5150

5251
super().add(self._scroll_area)
@@ -294,8 +293,7 @@ def do_layout(self):
294293
overlay_w = self.width + scroll_bar_w
295294

296295
overlay.rect = (
297-
overlay.rect
298-
.resize(overlay_w, visible_h)
296+
overlay.rect.resize(overlay_w, visible_h)
299297
.align_top(self.bottom - 2)
300298
.align_left(self._default_button.left)
301299
)

tests/unit/gui/test_surface.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
import arcade
34
from arcade import LBWH, load_texture
45
from arcade.gui import Surface, NinePatchTexture
56

@@ -36,3 +37,50 @@ def test_limit_surface(window):
3637

3738
surface.limit(None)
3839
assert surface._cam.viewport == LBWH(0, 0, 100, 100)
40+
41+
42+
@pytest.mark.backendgl
43+
def test_draw_enforces_blending(window):
44+
"""Surface.draw() has to enforce GL blending.
45+
46+
pyglet toggles GL_BLEND directly (e.g. text layouts disable it after
47+
drawing), bypassing arcade's context flag cache. Without a forced enable
48+
the composite runs in replace mode: the surface's transparent texels
49+
overwrite the destination, punching an alpha hole through the UI.
50+
"""
51+
from pyglet.graphics.api import gl
52+
53+
parent = Surface(size=(50, 50))
54+
child = Surface(size=(50, 50)) # stays fully transparent
55+
56+
with parent.activate():
57+
parent.clear((255, 255, 255, 255))
58+
59+
# simulate pyglet disabling blending behind arcade's state cache
60+
window.ctx.enable(window.ctx.BLEND)
61+
gl.glDisable(gl.GL_BLEND)
62+
63+
child.draw()
64+
65+
center = parent.to_image().getpixel((25, 25))
66+
assert center == (255, 255, 255, 255)
67+
68+
69+
def test_draw_composites_premultiplied(window):
70+
"""Content rendered into a surface over transparent black is
71+
premultiplied; a straight-alpha composite would darken the content and
72+
erode the destination alpha."""
73+
parent = Surface(size=(50, 50))
74+
child = Surface(size=(50, 50))
75+
76+
with child.activate():
77+
# 50% red over transparent black -> premultiplied (128, 0, 0, 128)
78+
arcade.draw_rect_filled(LBWH(0, 0, 50, 50), (255, 0, 0, 128))
79+
80+
with parent.activate():
81+
parent.clear((255, 255, 255, 255))
82+
child.draw()
83+
84+
r, g, b, a = parent.to_image().getpixel((25, 25))
85+
# 50% red over opaque white keeps full alpha
86+
assert (r, g, b, a) == pytest.approx((255, 127, 127, 255), abs=2)

tests/unit/gui/test_uilabel.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,23 @@ def test_size_hint_min_adapts_to_smaller_font(window):
162162
assert label.size_hint_min[1] < shm_h
163163

164164

165+
def test_update_font_with_unchanged_font_does_not_trigger_render(window):
166+
"""The label resolves a font fallback tuple to a concrete loaded font.
167+
168+
Re-applying the same requested tuple (button styles do this on every
169+
render) must not be reported as a change, otherwise every render
170+
triggers a full render of the whole UI, which repeats itself every frame.
171+
"""
172+
# first entry does not exist, so the label falls back to a later font
173+
font_names = ("NonExistentFont", "arial", "calibri")
174+
label = UILabel(text="Example", font_name=font_names, text_color=Color(255, 255, 255))
175+
label._requires_render = False
176+
177+
label.update_font(font_name=font_names, font_size=12, font_color=Color(255, 255, 255))
178+
179+
assert label._requires_render is False
180+
181+
165182
def test_multiline_enabled_size_hint_min_adapts_to_new_text(window):
166183
"""Tests multiline with auto size. It should adapt to new text.
167184

0 commit comments

Comments
 (0)