Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- 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)
- 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)
- Fixed caret behavior not responding appropriately when activating an input field. See [#2850](https://github.com/pythonarcade/arcade/pull/2850)
- 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.
## 4.0.0.dev4

### New Features
Expand Down
23 changes: 12 additions & 11 deletions arcade/draw/rect.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def draw_texture_rect(
# Clamp alpha to 0-255
alpha_normalized = max(0, min(255, alpha)) / 255.0

blend_state = ctx.is_enabled(ctx.BLEND)
if blend:
ctx.enable(ctx.BLEND)
else:
Expand Down Expand Up @@ -76,7 +77,9 @@ def draw_texture_rect(

geometry.render(program, mode=gl.TRIANGLE_STRIP, vertices=4)

if blend:
if blend_state:
ctx.enable(ctx.BLEND)
else:
ctx.disable(ctx.BLEND)


Expand Down Expand Up @@ -387,17 +390,15 @@ def draw_rect_filled(rect: Rect, color: RGBOrA255, tilt_angle: float = 0) -> Non
# Validate & normalize to a pass the shader an RGBA float uniform
color_normalized = Color.from_iterable(color).normalized

ctx.enable(ctx.BLEND)

# Pass data to the shader
program["color"] = color_normalized
program["shape"] = rect.width, rect.height, tilt_angle
buffer.orphan()
buffer.write(data=array.array("f", (rect.x, rect.y)))

geometry.render(program, instances=1)
# contextmanager will restore state which existed before
with ctx.enabled(ctx.BLEND):
# Pass data to the shader
program["color"] = color_normalized
program["shape"] = rect.width, rect.height, tilt_angle
buffer.orphan()
buffer.write(data=array.array("f", (rect.x, rect.y)))

ctx.disable(ctx.BLEND)
geometry.render(program, instances=1)


# These might be "oddly specific" and also needs docstrings. Disabling or 3.0.0
Expand Down
77 changes: 77 additions & 0 deletions arcade/examples/gui/transitions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Example showing how to use the TransitionChain and TransitionAttr classes.

If Arcade and Python are properly installed, you can run this example with:
python -m arcade.examples.gui.transitions
"""

import arcade
from arcade.anim import Easing
from arcade.gui import UIManager, TransitionChain, TransitionAttr, TransitionAttrIncr
from arcade.gui.transition import TransitionAttrSet
from arcade.gui.widgets.buttons import UIFlatButton


class AutoSizeButton(UIFlatButton):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.ui_label.fit_content()

def prepare_layout(self):
# update size hint min to fit children
min_w = max(map(lambda c: c.width, self.children))
min_h = max(map(lambda c: c.height, self.children))
self.size_hint_min = (min_w, min_h)


class DemoWindow(arcade.Window):
def __init__(self):
super().__init__(800, 600, "UI Mockup", resizable=True)
arcade.set_background_color(arcade.color.DARK_BLUE_GRAY)

# Init UIManager
self.manager = UIManager()
self.manager.enable()

button = self.manager.add(AutoSizeButton(text="Click me I can move!"))
button.center_on_screen()

@button.event
def on_click(event):
# button.disabled = True

start_x, start_y = button.center
chain = TransitionChain()

chain.add(TransitionAttrSet(attribute="disabled", value=True, duration=0))

chain.add(TransitionAttrIncr(attribute="center_x", increment=100, duration=1.0))
chain.add(
TransitionAttrIncr(
attribute="center_y", increment=100, duration=1, ease_function=Easing.LINEAR
)
)

# Go back
chain.add(
TransitionAttr(
attribute="center_x", end=start_x, duration=1, ease_function=Easing.LINEAR
)
)
chain.add(
TransitionAttr(
attribute="center_y", end=start_y, duration=1, ease_function=Easing.LINEAR
)
)
chain.add(TransitionAttrSet(attribute="disabled", value=False, duration=0))

button.add_transition(chain)

def on_draw(self):
self.clear()
self.manager.draw()


if __name__ == "__main__":
arcade.resources.load_kenney_fonts()
DemoWindow().run()
20 changes: 20 additions & 0 deletions arcade/gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
from arcade.gui.widgets import UILayout
from arcade.gui.widgets import UISpace
from arcade.gui.view import UIView
from arcade.gui.transition import (
TransitionBase,
EventTransitionBase,
TransitionAttr,
TransitionAttrIncr,
TransitionChain,
TransitionParallel,
TransitionDelay,
TransitionAttrSet,
)
from arcade.gui.widgets.dropdown import UIDropdown
from arcade.gui.widgets import UISpriteWidget
from arcade.gui.widgets import UIInteractiveSpriteWidget
Expand Down Expand Up @@ -99,6 +109,16 @@
"UIWidget",
"Surface",
"NinePatchTexture",
# Transitions
"EaseFunctions",
"TransitionBase",
"EventTransitionBase",
"TransitionAttr",
"TransitionAttrIncr",
"TransitionAttrSet",
"TransitionChain",
"TransitionParallel",
"TransitionDelay",
# Property classes
"ListProperty",
"DictProperty",
Expand Down
151 changes: 118 additions & 33 deletions arcade/gui/experimental/scroll_area.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import warnings
from collections.abc import Iterable
from typing import TypeVar

Expand All @@ -9,6 +10,7 @@
from arcade import XYWH
from arcade.gui.events import (
UIEvent,
UIKeyPressEvent,
UIMouseDragEvent,
UIMouseEvent,
UIMouseMovementEvent,
Expand Down Expand Up @@ -175,7 +177,26 @@ def do_render(self, surface: Surface):
class UIScrollArea(UILayout):
"""A widget that can scroll its children.

This widget is highly experimental and only provides a proof of concept.
Children are laid out on an internal canvas, which resizes itself to fit
all children (at least the size of the scroll area itself). The visible
part of the canvas is controlled by :py:attr:`scroll_x` and
:py:attr:`scroll_y` (both range from ``0`` at the start of the content
to a negative value at the end).

For a typical scrollable list, add a container with ``size_hint=(1, 0)``
(fill the width of the scroll area, grow to the natural content height)
like ``UIBoxLayout``.

Scrolling is supported via mouse wheel, :class:`UIScrollBar` and, while
the mouse hovers the widget, the keyboard
(arrow keys, PageUp/PageDown, Home/End).

.. note::
Content containing labels may finish sizing only during the first
layout pass and render on the following frame. Single-frame capture
pipelines (e.g. CI screenshots) should draw two frames.

This widget is experimental, the API might change.

Args:
x: x position of the widget
Expand All @@ -186,18 +207,20 @@ class UIScrollArea(UILayout):
size_hint: size hint of the widget
size_hint_min: minimum size hint of the widget
size_hint_max: maximum size hint of the widget
canvas_size: size of the canvas, which is scrollable
canvas_size: deprecated, the canvas is sized automatically to fit
all children
overscroll_x: allow over scrolling in x direction (scroll past the end)
overscroll_y: allow over scrolling in y direction (scroll past the end)
scroll_speed: speed of scrolling in pixels per scroll event,
defaults to :py:attr:`scroll_speed`
invert_scroll: invert the scroll direction,
defaults to :py:attr:`invert_scroll`
**kwargs: passed to UIWidget
"""

scroll_x = Property[float](default=0.0)
scroll_y = Property[float](default=0.0)

scroll_speed = 1.8
invert_scroll = False

def __init__(
self,
*,
Expand All @@ -209,11 +232,37 @@ def __init__(
size_hint=None,
size_hint_min=None,
size_hint_max=None,
canvas_size=(300, 300),
canvas_size=None,
overscroll_x=False,
overscroll_y=False,
scroll_speed: float = 15.0,
invert_scroll: bool = False,
**kwargs,
):
self.default_anchor_x = "left"
self.default_anchor_y = "bottom"
self.overscroll_x = overscroll_x
self.overscroll_y = overscroll_y
self.scroll_speed = scroll_speed
self.invert_scroll = invert_scroll
self._hovering = False

if canvas_size is not None:
warnings.warn(
"canvas_size is deprecated, the canvas is sized automatically to fit all children",
DeprecationWarning,
stacklevel=2,
)
else:
canvas_size = (max(int(width), 1), max(int(height), 1))

# The canvas has to match the window's pixel ratio,
# otherwise content renders at reduced resolution on hi-DPI displays.
self.surface = Surface(
size=canvas_size,
pixel_ratio=arcade.get_window().get_pixel_ratio(),
)

super().__init__(
x=x,
y=y,
Expand All @@ -225,14 +274,6 @@ def __init__(
size_hint_max=size_hint_max,
**kwargs,
)
self.default_anchor_x = "left"
self.default_anchor_y = "bottom"
self.overscroll_x = overscroll_x
self.overscroll_y = overscroll_y

self.surface = Surface(
size=canvas_size,
)

bind(self, "scroll_x", UIScrollArea.trigger_full_render)
bind(self, "scroll_y", UIScrollArea.trigger_full_render)
Expand Down Expand Up @@ -285,16 +326,20 @@ def do_layout(self):
if new_rect != child.rect:
child.rect = new_rect

total_min_x = round(total_min_x)
total_min_y = round(total_min_y)
# the canvas covers at least the visible area, so children which do not
# fill the scroll area never leave the viewport partially uncovered
# and scroll ranges never become negative
total_min_x = max(round(total_min_x), round(self.content_width), 1)
total_min_y = max(round(total_min_y), round(self.content_height), 1)

# resize surface to fit all children
if self.surface.size != (total_min_x, total_min_y):
self.surface.resize(
size=(total_min_x, total_min_y), pixel_ratio=self.surface.pixel_ratio
)
self.scroll_x = 0
self.scroll_y = 0
# preserve the scroll position when content changes,
# only clamp it into the new scroll range
self._clamp_scroll()

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

return self.scroll_x, -normal_pos_y - self.scroll_y

def _clamp_scroll(self):
"""Clamp the scroll position into the valid scroll range.

Axes with overscroll enabled are not clamped.
"""
if not self.overscroll_x:
# clip scroll_x between 0 and -(self.surface.width - self.width)
scroll_range = int(self.content_width - self.surface.width)
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
self.scroll_x = max(scroll_range, min(0.0, self.scroll_x))

if not self.overscroll_y:
# clip scroll_y between 0 and -(self.surface.height - self.height)
scroll_range = int(self.content_height - self.surface.height)
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
self.scroll_y = max(scroll_range, min(0.0, self.scroll_y))

def _scroll_by_key(self, symbol: int) -> bool:
"""Scroll on arrow keys, PageUp/PageDown, Home and End.

Returns True if the key was handled.
"""
v_scrollable = self.surface.height > self.content_height
h_scrollable = self.surface.width > self.content_width

if symbol == arcade.key.UP and v_scrollable:
self.scroll_y += self.scroll_speed
elif symbol == arcade.key.DOWN and v_scrollable:
self.scroll_y -= self.scroll_speed
elif symbol == arcade.key.LEFT and h_scrollable:
self.scroll_x += self.scroll_speed
elif symbol == arcade.key.RIGHT and h_scrollable:
self.scroll_x -= self.scroll_speed
elif symbol == arcade.key.PAGEUP and v_scrollable:
self.scroll_y += self.content_height
elif symbol == arcade.key.PAGEDOWN and v_scrollable:
self.scroll_y -= self.content_height
elif symbol == arcade.key.HOME and v_scrollable:
self.scroll_y = 0.0
elif symbol == arcade.key.END and v_scrollable:
self.scroll_y = float(min(0, int(self.content_height - self.surface.height)))
else:
return False

self._clamp_scroll()
return True

def on_event(self, event: UIEvent) -> bool | None:
"""Handle scrolling of the widget."""
if isinstance(event, UIMouseMovementEvent):
self._hovering = self.rect.point_in_rect(event.pos)

if isinstance(event, UIMouseDragEvent) and not self.rect.point_in_rect(event.pos):
return EVENT_UNHANDLED

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

# clip scrolling to canvas size
if not self.overscroll_x:
# clip scroll_x between 0 and -(self.surface.width - self.width)
scroll_range = int(self.content_width - self.surface.width)
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
self.scroll_x = min(0, self.scroll_x)
self.scroll_x = max(self.scroll_x, scroll_range)

if not self.overscroll_y:
# clip scroll_y between 0 and -(self.surface.height - self.height)
scroll_range = int(self.content_height - self.surface.height)
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
self.scroll_y = min(0, self.scroll_y)
self.scroll_y = max(self.scroll_y, scroll_range)

self._clamp_scroll()
return True

if isinstance(event, UIKeyPressEvent) and self._hovering:
if self._scroll_by_key(event.symbol):
return True

child_event = event
if isinstance(event, UIMouseEvent):
if self.rect.point_in_rect(event.pos):
Expand Down
Loading
Loading