Source code for arcade.gui.widgets.dropdown

from copy import deepcopy

from pyglet.event import EVENT_HANDLED

import arcade
from arcade import uicolor
from arcade.gui import UIEvent, UIMousePressEvent
from arcade.gui.events import UIControllerButtonPressEvent, UIOnChangeEvent, UIOnClickEvent
from arcade.gui.experimental import UIScrollArea
from arcade.gui.experimental.focus import UIFocusMixin
from arcade.gui.experimental.scroll_area import UIScrollBar
from arcade.gui.ui_manager import UIManager
from arcade.gui.widgets import UILayout, UIWidget
from arcade.gui.widgets.buttons import UIFlatButton
from arcade.gui.widgets.layout import UIBoxLayout


class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout):
    """Represents the dropdown options overlay with scroll support.

    Contains a UIScrollArea with the option buttons and a UIScrollBar
    for navigating when options exceed the maximum height.
    """

    SCROLL_BAR_WIDTH = 15

    def __init__(
        self,
        max_height: float = 200,
        invert_scroll: bool = False,
        scroll_speed: float = 15.0,
        show_scroll_bar: bool = False,
    ):
        # Horizontal layout: [scroll_area | scroll_bar]
        # size_hint=None prevents UIManager from overriding the rect
        # that UIDropdown.do_layout explicitly sets.
        super().__init__(vertical=False, align="top", size_hint=None)
        self._max_height = max_height
        self._show_scroll_bar = show_scroll_bar

        self._options_layout = UIBoxLayout(size_hint=(1, 0))
        self._scroll_area = UIScrollArea(
            width=100,
            height=100,
            canvas_size=(100, 100),
            size_hint=(1, 1),
        )
        self._scroll_area.invert_scroll = invert_scroll
        self._scroll_area.scroll_speed = scroll_speed
        self._scroll_area.add(self._options_layout)

        super().add(self._scroll_area)

        if show_scroll_bar:
            self._scroll_bar = UIScrollBar(self._scroll_area, vertical=True)
            self._scroll_bar.size_hint = (None, 1)
            self._scroll_bar.rect = self._scroll_bar.rect.resize(width=self.SCROLL_BAR_WIDTH)
            super().add(self._scroll_bar)

    def add_option(self, widget: UIWidget) -> UIWidget:
        """Add an option widget to the options layout."""
        return self._options_layout.add(widget)

    def clear_options(self):
        """Clear all options and reset scroll position."""
        self._options_layout.clear()
        self._scroll_area.scroll_y = 0

    def show(self, manager: UIManager | UIScrollArea):
        manager.add(self, layer=UIManager.OVERLAY_LAYER)

    def hide(self):
        """Hide the overlay."""
        self.set_focus(None)
        if self.parent:
            self.parent.remove(self)

    def on_event(self, event: UIEvent) -> bool | None:
        if isinstance(event, UIMousePressEvent):
            # Click outside of dropdown options
            if not self.rect.point_in_rect((event.x, event.y)):
                self.hide()
                return EVENT_HANDLED

        if isinstance(event, UIControllerButtonPressEvent):
            # TODO find a better and more generic way to handle controller events for this
            if event.button == "b":
                self.hide()
                return EVENT_HANDLED

        return super().on_event(event)


[docs] class UIDropdown(UILayout): """A dropdown layout. When clicked displays a list of options provided. Triggers an event when an option is clicked, the event can be read by .. code:: py dropdown = Dropdown() @dropdown.event() def on_change(event: UIOnChangeEvent): print(event.old_value, event.new_value) Args: x: x coordinate of bottom left y: y coordinate of bottom left width: Width of each of the option. height: Height of each of the option. default: The default value shown. options: The options displayed when the layout is clicked. max_height: Maximum height of the dropdown menu before scrolling is enabled. invert_scroll: Invert the scroll direction of the dropdown menu. scroll_speed: Speed of scrolling in the dropdown menu. show_scroll_bar: Show a scroll bar in the dropdown menu. primary_style: The style of the primary button. dropdown_style: The style of the buttons in the dropdown. active_style: The style of the dropdown button, which represents the active option. """ DIVIDER = None DEFAULT_BUTTON_STYLE = { "normal": UIFlatButton.UIStyle( font_color=uicolor.GREEN_NEPHRITIS, ), "hover": UIFlatButton.UIStyle( font_color=uicolor.WHITE, bg=uicolor.DARK_BLUE_WET_ASPHALT, border=uicolor.GRAY_CONCRETE, ), "press": UIFlatButton.UIStyle( font_color=uicolor.DARK_BLUE_MIDNIGHT_BLUE, bg=uicolor.WHITE_CLOUDS, border=uicolor.GRAY_CONCRETE, ), "disabled": UIFlatButton.UIStyle( font_color=uicolor.WHITE_SILVER, bg=uicolor.GRAY_ASBESTOS, ), } DEFAULT_DROPDOWN_STYLE = { "normal": UIFlatButton.UIStyle(), "hover": UIFlatButton.UIStyle( font_color=uicolor.WHITE, bg=uicolor.DARK_BLUE_WET_ASPHALT, border=uicolor.GRAY_CONCRETE, ), "press": UIFlatButton.UIStyle( font_color=uicolor.DARK_BLUE_MIDNIGHT_BLUE, bg=uicolor.WHITE_CLOUDS, border=uicolor.GRAY_CONCRETE, ), "disabled": UIFlatButton.UIStyle( font_color=uicolor.WHITE_SILVER, bg=uicolor.GRAY_ASBESTOS, ), } def __init__( self, *, x: float = 0, y: float = 0, width: float = 150, height: float = 30, default: str | None = None, options: list[str | None] | None = None, max_height: float = 200, invert_scroll: bool = False, scroll_speed: float = 15.0, show_scroll_bar: bool = False, primary_style=None, dropdown_style=None, active_style=None, **kwargs, ): if primary_style is None: primary_style = self.DEFAULT_BUTTON_STYLE if dropdown_style is None: dropdown_style = self.DEFAULT_DROPDOWN_STYLE if active_style is None: active_style = self.DEFAULT_BUTTON_STYLE # TODO handle if default value not in options or options empty if options is None: options = [] self._options = options self._value = default super().__init__(x=x, y=y, width=width, height=height, **kwargs) self._default_style = deepcopy(primary_style) self._dropdown_style = deepcopy(dropdown_style) self._active_style = deepcopy(active_style) # Setup button showing value self._default_button = UIFlatButton( text=self._value or "", width=self.width, height=self.height, style=self._default_style ) self._default_button.on_click = self._on_button_click # type: ignore self._overlay = _UIDropdownOverlay( max_height=max_height, invert_scroll=invert_scroll, scroll_speed=scroll_speed, show_scroll_bar=show_scroll_bar, ) self._update_options() # add children after super class setup self.add(self._default_button) self.register_event_type("on_change") @property def value(self) -> str | None: """Current selected option.""" return self._value @value.setter def value(self, value: str | None): """Change the current selected option to a new option.""" old_value = self._value self._value = value self._default_button.text = self._value or "" self._update_options() self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, value)) self.trigger_render() def _update_options(self): # generate options self._overlay.clear_options() for option in self._options: if option is None: # None = UIDropdown.DIVIDER, required by pyright self._overlay.add_option( UIWidget(width=self.width, height=2).with_background(color=arcade.color.GRAY) ) continue else: button = self._overlay.add_option( UIFlatButton( text=option, width=self.width, height=self.height, style=self._active_style if self.value == option else self._dropdown_style, ) ) button.on_click = self._on_option_click self._overlay.detect_focusable_widgets() def _show_overlay(self): # traverse parents until UIManager or UIScrollArea is found parent = self.parent while parent is not None: if isinstance(parent, UIManager): break if isinstance(parent, UIScrollArea): break parent = parent.parent if parent is None: raise Exception("UIDropdown could not find a valid parent for the overlay.") self._overlay.show(parent) def _on_button_click(self, _: UIOnClickEvent): self._show_overlay() def _on_option_click(self, event: UIOnClickEvent): source: UIFlatButton = event.source self._overlay.hide() self.value = source.text
[docs] def do_layout(self): """Position the overlay, this is not a common thing to do in do_layout, but is required for the dropdown.""" self._default_button.rect = self.rect # Calculate total options height total_h = 0 for option in self._options: total_h += 2 if option is None else self.height # Cap at max_height overlay = self._overlay visible_h = min(total_h, overlay._max_height) if total_h > 0 else self.height scroll_bar_w = _UIDropdownOverlay.SCROLL_BAR_WIDTH if overlay._show_scroll_bar else 0 overlay_w = self.width + scroll_bar_w overlay.rect = ( overlay.rect .resize(overlay_w, visible_h) .align_top(self.bottom - 2) .align_left(self._default_button.left) )
[docs] def on_change(self, event: UIOnChangeEvent): """To be implemented by the user, triggered when the current selected value is changed to a different option. """ pass