bastd.ui.popup

Popup window/menu related functionality.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Popup window/menu related functionality."""
  4
  5from __future__ import annotations
  6
  7import weakref
  8from typing import TYPE_CHECKING
  9
 10import ba
 11import ba.internal
 12
 13if TYPE_CHECKING:
 14    from typing import Any, Sequence, Callable
 15
 16
 17class PopupWindow:
 18    """A transient window that positions and scales itself for visibility.
 19
 20    Category: UI Classes"""
 21
 22    def __init__(
 23        self,
 24        position: tuple[float, float],
 25        size: tuple[float, float],
 26        scale: float = 1.0,
 27        offset: tuple[float, float] = (0, 0),
 28        bg_color: tuple[float, float, float] = (0.35, 0.55, 0.15),
 29        focus_position: tuple[float, float] = (0, 0),
 30        focus_size: tuple[float, float] | None = None,
 31        toolbar_visibility: str = 'menu_minimal_no_back',
 32    ):
 33        # pylint: disable=too-many-locals
 34        if focus_size is None:
 35            focus_size = size
 36
 37        # In vr mode we can't have windows going outside the screen.
 38        if ba.app.vr_mode:
 39            focus_size = size
 40            focus_position = (0, 0)
 41
 42        width = focus_size[0]
 43        height = focus_size[1]
 44
 45        # Ok, we've been given a desired width, height, and scale;
 46        # we now need to ensure that we're all onscreen by scaling down if
 47        # need be and clamping it to the UI bounds.
 48        bounds = ba.app.ui_bounds
 49        edge_buffer = 15
 50        bounds_width = bounds[1] - bounds[0] - edge_buffer * 2
 51        bounds_height = bounds[3] - bounds[2] - edge_buffer * 2
 52
 53        fin_width = width * scale
 54        fin_height = height * scale
 55        if fin_width > bounds_width:
 56            scale /= fin_width / bounds_width
 57            fin_width = width * scale
 58            fin_height = height * scale
 59        if fin_height > bounds_height:
 60            scale /= fin_height / bounds_height
 61            fin_width = width * scale
 62            fin_height = height * scale
 63
 64        x_min = bounds[0] + edge_buffer + fin_width * 0.5
 65        y_min = bounds[2] + edge_buffer + fin_height * 0.5
 66        x_max = bounds[1] - edge_buffer - fin_width * 0.5
 67        y_max = bounds[3] - edge_buffer - fin_height * 0.5
 68
 69        x_fin = min(max(x_min, position[0] + offset[0]), x_max)
 70        y_fin = min(max(y_min, position[1] + offset[1]), y_max)
 71
 72        # ok, we've calced a valid x/y position and a scale based on or
 73        # focus area. ..now calc the difference between the center of our
 74        # focus area and the center of our window to come up with the
 75        # offset we'll need to plug in to the window
 76        x_offs = (
 77            (focus_position[0] + focus_size[0] * 0.5) - (size[0] * 0.5)
 78        ) * scale
 79        y_offs = (
 80            (focus_position[1] + focus_size[1] * 0.5) - (size[1] * 0.5)
 81        ) * scale
 82
 83        self.root_widget = ba.containerwidget(
 84            transition='in_scale',
 85            scale=scale,
 86            toolbar_visibility=toolbar_visibility,
 87            size=size,
 88            parent=ba.internal.get_special_widget('overlay_stack'),
 89            stack_offset=(x_fin - x_offs, y_fin - y_offs),
 90            scale_origin_stack_offset=(position[0], position[1]),
 91            on_outside_click_call=self.on_popup_cancel,
 92            claim_outside_clicks=True,
 93            color=bg_color,
 94            on_cancel_call=self.on_popup_cancel,
 95        )
 96        # complain if we outlive our root widget
 97        ba.uicleanupcheck(self, self.root_widget)
 98
 99    def on_popup_cancel(self) -> None:
100        """Called when the popup is canceled.
101
102        Cancels can occur due to clicking outside the window,
103        hitting escape, etc.
104        """
105
106
107class PopupMenuWindow(PopupWindow):
108    """A menu built using popup-window functionality."""
109
110    def __init__(
111        self,
112        position: tuple[float, float],
113        choices: Sequence[str],
114        current_choice: str,
115        delegate: Any = None,
116        width: float = 230.0,
117        maxwidth: float | None = None,
118        scale: float = 1.0,
119        choices_disabled: Sequence[str] | None = None,
120        choices_display: Sequence[ba.Lstr] | None = None,
121    ):
122        # FIXME: Clean up a bit.
123        # pylint: disable=too-many-branches
124        # pylint: disable=too-many-locals
125        # pylint: disable=too-many-statements
126        if choices_disabled is None:
127            choices_disabled = []
128        if choices_display is None:
129            choices_display = []
130
131        # FIXME: For the moment we base our width on these strings so
132        #  we need to flatten them.
133        choices_display_fin: list[str] = []
134        for choice_display in choices_display:
135            choices_display_fin.append(choice_display.evaluate())
136
137        if maxwidth is None:
138            maxwidth = width * 1.5
139
140        self._transitioning_out = False
141        self._choices = list(choices)
142        self._choices_display = list(choices_display_fin)
143        self._current_choice = current_choice
144        self._choices_disabled = list(choices_disabled)
145        self._done_building = False
146        if not choices:
147            raise TypeError('Must pass at least one choice')
148        self._width = width
149        self._scale = scale
150        if len(choices) > 8:
151            self._height = 280
152            self._use_scroll = True
153        else:
154            self._height = 20 + len(choices) * 33
155            self._use_scroll = False
156        self._delegate = None  # don't want this stuff called just yet..
157
158        # extend width to fit our longest string (or our max-width)
159        for index, choice in enumerate(choices):
160            if len(choices_display_fin) == len(choices):
161                choice_display_name = choices_display_fin[index]
162            else:
163                choice_display_name = choice
164            if self._use_scroll:
165                self._width = max(
166                    self._width,
167                    min(
168                        maxwidth,
169                        ba.internal.get_string_width(
170                            choice_display_name, suppress_warning=True
171                        ),
172                    )
173                    + 75,
174                )
175            else:
176                self._width = max(
177                    self._width,
178                    min(
179                        maxwidth,
180                        ba.internal.get_string_width(
181                            choice_display_name, suppress_warning=True
182                        ),
183                    )
184                    + 60,
185                )
186
187        # init parent class - this will rescale and reposition things as
188        # needed and create our root widget
189        PopupWindow.__init__(
190            self, position, size=(self._width, self._height), scale=self._scale
191        )
192
193        if self._use_scroll:
194            self._scrollwidget = ba.scrollwidget(
195                parent=self.root_widget,
196                position=(20, 20),
197                highlight=False,
198                color=(0.35, 0.55, 0.15),
199                size=(self._width - 40, self._height - 40),
200            )
201            self._columnwidget = ba.columnwidget(
202                parent=self._scrollwidget, border=2, margin=0
203            )
204        else:
205            self._offset_widget = ba.containerwidget(
206                parent=self.root_widget,
207                position=(30, 15),
208                size=(self._width - 40, self._height),
209                background=False,
210            )
211            self._columnwidget = ba.columnwidget(
212                parent=self._offset_widget, border=2, margin=0
213            )
214        for index, choice in enumerate(choices):
215            if len(choices_display_fin) == len(choices):
216                choice_display_name = choices_display_fin[index]
217            else:
218                choice_display_name = choice
219            inactive = choice in self._choices_disabled
220            wdg = ba.textwidget(
221                parent=self._columnwidget,
222                size=(self._width - 40, 28),
223                on_select_call=ba.Call(self._select, index),
224                click_activate=True,
225                color=(0.5, 0.5, 0.5, 0.5)
226                if inactive
227                else (
228                    (0.5, 1, 0.5, 1)
229                    if choice == self._current_choice
230                    else (0.8, 0.8, 0.8, 1.0)
231                ),
232                padding=0,
233                maxwidth=maxwidth,
234                text=choice_display_name,
235                on_activate_call=self._activate,
236                v_align='center',
237                selectable=(not inactive),
238            )
239            if choice == self._current_choice:
240                ba.containerwidget(
241                    edit=self._columnwidget,
242                    selected_child=wdg,
243                    visible_child=wdg,
244                )
245
246        # ok from now on our delegate can be called
247        self._delegate = weakref.ref(delegate)
248        self._done_building = True
249
250    def _select(self, index: int) -> None:
251        if self._done_building:
252            self._current_choice = self._choices[index]
253
254    def _activate(self) -> None:
255        ba.playsound(ba.getsound('swish'))
256        ba.timer(0.05, self._transition_out, timetype=ba.TimeType.REAL)
257        delegate = self._getdelegate()
258        if delegate is not None:
259            # Call this in a timer so it doesn't interfere with us killing
260            # our widgets and whatnot.
261            call = ba.Call(
262                delegate.popup_menu_selected_choice, self, self._current_choice
263            )
264            ba.timer(0, call, timetype=ba.TimeType.REAL)
265
266    def _getdelegate(self) -> Any:
267        return None if self._delegate is None else self._delegate()
268
269    def _transition_out(self) -> None:
270        if not self.root_widget:
271            return
272        if not self._transitioning_out:
273            self._transitioning_out = True
274            delegate = self._getdelegate()
275            if delegate is not None:
276                delegate.popup_menu_closing(self)
277            ba.containerwidget(edit=self.root_widget, transition='out_scale')
278
279    def on_popup_cancel(self) -> None:
280        if not self._transitioning_out:
281            ba.playsound(ba.getsound('swish'))
282        self._transition_out()
283
284
285class PopupMenu:
286    """A complete popup-menu control.
287
288    This creates a button and wrangles its pop-up menu.
289    """
290
291    def __init__(
292        self,
293        parent: ba.Widget,
294        position: tuple[float, float],
295        choices: Sequence[str],
296        current_choice: str | None = None,
297        on_value_change_call: Callable[[str], Any] | None = None,
298        opening_call: Callable[[], Any] | None = None,
299        closing_call: Callable[[], Any] | None = None,
300        width: float = 230.0,
301        maxwidth: float | None = None,
302        scale: float | None = None,
303        choices_disabled: Sequence[str] | None = None,
304        choices_display: Sequence[ba.Lstr] | None = None,
305        button_size: tuple[float, float] = (160.0, 50.0),
306        autoselect: bool = True,
307    ):
308        # pylint: disable=too-many-locals
309        if choices_disabled is None:
310            choices_disabled = []
311        if choices_display is None:
312            choices_display = []
313        uiscale = ba.app.ui.uiscale
314        if scale is None:
315            scale = (
316                2.3
317                if uiscale is ba.UIScale.SMALL
318                else 1.65
319                if uiscale is ba.UIScale.MEDIUM
320                else 1.23
321            )
322        if current_choice not in choices:
323            current_choice = None
324        self._choices = list(choices)
325        if not choices:
326            raise TypeError('no choices given')
327        self._choices_display = list(choices_display)
328        self._choices_disabled = list(choices_disabled)
329        self._width = width
330        self._maxwidth = maxwidth
331        self._scale = scale
332        self._current_choice = (
333            current_choice if current_choice is not None else self._choices[0]
334        )
335        self._position = position
336        self._parent = parent
337        if not choices:
338            raise TypeError('Must pass at least one choice')
339        self._parent = parent
340        self._button_size = button_size
341
342        self._button = ba.buttonwidget(
343            parent=self._parent,
344            position=(self._position[0], self._position[1]),
345            autoselect=autoselect,
346            size=self._button_size,
347            scale=1.0,
348            label='',
349            on_activate_call=lambda: ba.timer(
350                0, self._make_popup, timetype=ba.TimeType.REAL
351            ),
352        )
353        self._on_value_change_call = None  # Don't wanna call for initial set.
354        self._opening_call = opening_call
355        self._autoselect = autoselect
356        self._closing_call = closing_call
357        self.set_choice(self._current_choice)
358        self._on_value_change_call = on_value_change_call
359        self._window_widget: ba.Widget | None = None
360
361        # Complain if we outlive our button.
362        ba.uicleanupcheck(self, self._button)
363
364    def _make_popup(self) -> None:
365        if not self._button:
366            return
367        if self._opening_call:
368            self._opening_call()
369        self._window_widget = PopupMenuWindow(
370            position=self._button.get_screen_space_center(),
371            delegate=self,
372            width=self._width,
373            maxwidth=self._maxwidth,
374            scale=self._scale,
375            choices=self._choices,
376            current_choice=self._current_choice,
377            choices_disabled=self._choices_disabled,
378            choices_display=self._choices_display,
379        ).root_widget
380
381    def get_button(self) -> ba.Widget:
382        """Return the menu's button widget."""
383        return self._button
384
385    def get_window_widget(self) -> ba.Widget | None:
386        """Return the menu's window widget (or None if nonexistent)."""
387        return self._window_widget
388
389    def popup_menu_selected_choice(
390        self, popup_window: PopupWindow, choice: str
391    ) -> None:
392        """Called when a choice is selected."""
393        del popup_window  # Unused here.
394        self.set_choice(choice)
395        if self._on_value_change_call:
396            self._on_value_change_call(choice)
397
398    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
399        """Called when the menu is closing."""
400        del popup_window  # Unused here.
401        if self._button:
402            ba.containerwidget(edit=self._parent, selected_child=self._button)
403        self._window_widget = None
404        if self._closing_call:
405            self._closing_call()
406
407    def set_choice(self, choice: str) -> None:
408        """Set the selected choice."""
409        self._current_choice = choice
410        displayname: str | ba.Lstr
411        if len(self._choices_display) == len(self._choices):
412            displayname = self._choices_display[self._choices.index(choice)]
413        else:
414            displayname = choice
415        if self._button:
416            ba.buttonwidget(edit=self._button, label=displayname)
class PopupMenuWindow(PopupWindow):
108class PopupMenuWindow(PopupWindow):
109    """A menu built using popup-window functionality."""
110
111    def __init__(
112        self,
113        position: tuple[float, float],
114        choices: Sequence[str],
115        current_choice: str,
116        delegate: Any = None,
117        width: float = 230.0,
118        maxwidth: float | None = None,
119        scale: float = 1.0,
120        choices_disabled: Sequence[str] | None = None,
121        choices_display: Sequence[ba.Lstr] | None = None,
122    ):
123        # FIXME: Clean up a bit.
124        # pylint: disable=too-many-branches
125        # pylint: disable=too-many-locals
126        # pylint: disable=too-many-statements
127        if choices_disabled is None:
128            choices_disabled = []
129        if choices_display is None:
130            choices_display = []
131
132        # FIXME: For the moment we base our width on these strings so
133        #  we need to flatten them.
134        choices_display_fin: list[str] = []
135        for choice_display in choices_display:
136            choices_display_fin.append(choice_display.evaluate())
137
138        if maxwidth is None:
139            maxwidth = width * 1.5
140
141        self._transitioning_out = False
142        self._choices = list(choices)
143        self._choices_display = list(choices_display_fin)
144        self._current_choice = current_choice
145        self._choices_disabled = list(choices_disabled)
146        self._done_building = False
147        if not choices:
148            raise TypeError('Must pass at least one choice')
149        self._width = width
150        self._scale = scale
151        if len(choices) > 8:
152            self._height = 280
153            self._use_scroll = True
154        else:
155            self._height = 20 + len(choices) * 33
156            self._use_scroll = False
157        self._delegate = None  # don't want this stuff called just yet..
158
159        # extend width to fit our longest string (or our max-width)
160        for index, choice in enumerate(choices):
161            if len(choices_display_fin) == len(choices):
162                choice_display_name = choices_display_fin[index]
163            else:
164                choice_display_name = choice
165            if self._use_scroll:
166                self._width = max(
167                    self._width,
168                    min(
169                        maxwidth,
170                        ba.internal.get_string_width(
171                            choice_display_name, suppress_warning=True
172                        ),
173                    )
174                    + 75,
175                )
176            else:
177                self._width = max(
178                    self._width,
179                    min(
180                        maxwidth,
181                        ba.internal.get_string_width(
182                            choice_display_name, suppress_warning=True
183                        ),
184                    )
185                    + 60,
186                )
187
188        # init parent class - this will rescale and reposition things as
189        # needed and create our root widget
190        PopupWindow.__init__(
191            self, position, size=(self._width, self._height), scale=self._scale
192        )
193
194        if self._use_scroll:
195            self._scrollwidget = ba.scrollwidget(
196                parent=self.root_widget,
197                position=(20, 20),
198                highlight=False,
199                color=(0.35, 0.55, 0.15),
200                size=(self._width - 40, self._height - 40),
201            )
202            self._columnwidget = ba.columnwidget(
203                parent=self._scrollwidget, border=2, margin=0
204            )
205        else:
206            self._offset_widget = ba.containerwidget(
207                parent=self.root_widget,
208                position=(30, 15),
209                size=(self._width - 40, self._height),
210                background=False,
211            )
212            self._columnwidget = ba.columnwidget(
213                parent=self._offset_widget, border=2, margin=0
214            )
215        for index, choice in enumerate(choices):
216            if len(choices_display_fin) == len(choices):
217                choice_display_name = choices_display_fin[index]
218            else:
219                choice_display_name = choice
220            inactive = choice in self._choices_disabled
221            wdg = ba.textwidget(
222                parent=self._columnwidget,
223                size=(self._width - 40, 28),
224                on_select_call=ba.Call(self._select, index),
225                click_activate=True,
226                color=(0.5, 0.5, 0.5, 0.5)
227                if inactive
228                else (
229                    (0.5, 1, 0.5, 1)
230                    if choice == self._current_choice
231                    else (0.8, 0.8, 0.8, 1.0)
232                ),
233                padding=0,
234                maxwidth=maxwidth,
235                text=choice_display_name,
236                on_activate_call=self._activate,
237                v_align='center',
238                selectable=(not inactive),
239            )
240            if choice == self._current_choice:
241                ba.containerwidget(
242                    edit=self._columnwidget,
243                    selected_child=wdg,
244                    visible_child=wdg,
245                )
246
247        # ok from now on our delegate can be called
248        self._delegate = weakref.ref(delegate)
249        self._done_building = True
250
251    def _select(self, index: int) -> None:
252        if self._done_building:
253            self._current_choice = self._choices[index]
254
255    def _activate(self) -> None:
256        ba.playsound(ba.getsound('swish'))
257        ba.timer(0.05, self._transition_out, timetype=ba.TimeType.REAL)
258        delegate = self._getdelegate()
259        if delegate is not None:
260            # Call this in a timer so it doesn't interfere with us killing
261            # our widgets and whatnot.
262            call = ba.Call(
263                delegate.popup_menu_selected_choice, self, self._current_choice
264            )
265            ba.timer(0, call, timetype=ba.TimeType.REAL)
266
267    def _getdelegate(self) -> Any:
268        return None if self._delegate is None else self._delegate()
269
270    def _transition_out(self) -> None:
271        if not self.root_widget:
272            return
273        if not self._transitioning_out:
274            self._transitioning_out = True
275            delegate = self._getdelegate()
276            if delegate is not None:
277                delegate.popup_menu_closing(self)
278            ba.containerwidget(edit=self.root_widget, transition='out_scale')
279
280    def on_popup_cancel(self) -> None:
281        if not self._transitioning_out:
282            ba.playsound(ba.getsound('swish'))
283        self._transition_out()

A menu built using popup-window functionality.

PopupMenuWindow( position: tuple[float, float], choices: Sequence[str], current_choice: str, delegate: Any = None, width: float = 230.0, maxwidth: float | None = None, scale: float = 1.0, choices_disabled: Optional[Sequence[str]] = None, choices_display: Optional[Sequence[ba._language.Lstr]] = None)
111    def __init__(
112        self,
113        position: tuple[float, float],
114        choices: Sequence[str],
115        current_choice: str,
116        delegate: Any = None,
117        width: float = 230.0,
118        maxwidth: float | None = None,
119        scale: float = 1.0,
120        choices_disabled: Sequence[str] | None = None,
121        choices_display: Sequence[ba.Lstr] | None = None,
122    ):
123        # FIXME: Clean up a bit.
124        # pylint: disable=too-many-branches
125        # pylint: disable=too-many-locals
126        # pylint: disable=too-many-statements
127        if choices_disabled is None:
128            choices_disabled = []
129        if choices_display is None:
130            choices_display = []
131
132        # FIXME: For the moment we base our width on these strings so
133        #  we need to flatten them.
134        choices_display_fin: list[str] = []
135        for choice_display in choices_display:
136            choices_display_fin.append(choice_display.evaluate())
137
138        if maxwidth is None:
139            maxwidth = width * 1.5
140
141        self._transitioning_out = False
142        self._choices = list(choices)
143        self._choices_display = list(choices_display_fin)
144        self._current_choice = current_choice
145        self._choices_disabled = list(choices_disabled)
146        self._done_building = False
147        if not choices:
148            raise TypeError('Must pass at least one choice')
149        self._width = width
150        self._scale = scale
151        if len(choices) > 8:
152            self._height = 280
153            self._use_scroll = True
154        else:
155            self._height = 20 + len(choices) * 33
156            self._use_scroll = False
157        self._delegate = None  # don't want this stuff called just yet..
158
159        # extend width to fit our longest string (or our max-width)
160        for index, choice in enumerate(choices):
161            if len(choices_display_fin) == len(choices):
162                choice_display_name = choices_display_fin[index]
163            else:
164                choice_display_name = choice
165            if self._use_scroll:
166                self._width = max(
167                    self._width,
168                    min(
169                        maxwidth,
170                        ba.internal.get_string_width(
171                            choice_display_name, suppress_warning=True
172                        ),
173                    )
174                    + 75,
175                )
176            else:
177                self._width = max(
178                    self._width,
179                    min(
180                        maxwidth,
181                        ba.internal.get_string_width(
182                            choice_display_name, suppress_warning=True
183                        ),
184                    )
185                    + 60,
186                )
187
188        # init parent class - this will rescale and reposition things as
189        # needed and create our root widget
190        PopupWindow.__init__(
191            self, position, size=(self._width, self._height), scale=self._scale
192        )
193
194        if self._use_scroll:
195            self._scrollwidget = ba.scrollwidget(
196                parent=self.root_widget,
197                position=(20, 20),
198                highlight=False,
199                color=(0.35, 0.55, 0.15),
200                size=(self._width - 40, self._height - 40),
201            )
202            self._columnwidget = ba.columnwidget(
203                parent=self._scrollwidget, border=2, margin=0
204            )
205        else:
206            self._offset_widget = ba.containerwidget(
207                parent=self.root_widget,
208                position=(30, 15),
209                size=(self._width - 40, self._height),
210                background=False,
211            )
212            self._columnwidget = ba.columnwidget(
213                parent=self._offset_widget, border=2, margin=0
214            )
215        for index, choice in enumerate(choices):
216            if len(choices_display_fin) == len(choices):
217                choice_display_name = choices_display_fin[index]
218            else:
219                choice_display_name = choice
220            inactive = choice in self._choices_disabled
221            wdg = ba.textwidget(
222                parent=self._columnwidget,
223                size=(self._width - 40, 28),
224                on_select_call=ba.Call(self._select, index),
225                click_activate=True,
226                color=(0.5, 0.5, 0.5, 0.5)
227                if inactive
228                else (
229                    (0.5, 1, 0.5, 1)
230                    if choice == self._current_choice
231                    else (0.8, 0.8, 0.8, 1.0)
232                ),
233                padding=0,
234                maxwidth=maxwidth,
235                text=choice_display_name,
236                on_activate_call=self._activate,
237                v_align='center',
238                selectable=(not inactive),
239            )
240            if choice == self._current_choice:
241                ba.containerwidget(
242                    edit=self._columnwidget,
243                    selected_child=wdg,
244                    visible_child=wdg,
245                )
246
247        # ok from now on our delegate can be called
248        self._delegate = weakref.ref(delegate)
249        self._done_building = True
def on_popup_cancel(self) -> None:
280    def on_popup_cancel(self) -> None:
281        if not self._transitioning_out:
282            ba.playsound(ba.getsound('swish'))
283        self._transition_out()

Called when the popup is canceled.

Cancels can occur due to clicking outside the window, hitting escape, etc.

class PopupMenu:
286class PopupMenu:
287    """A complete popup-menu control.
288
289    This creates a button and wrangles its pop-up menu.
290    """
291
292    def __init__(
293        self,
294        parent: ba.Widget,
295        position: tuple[float, float],
296        choices: Sequence[str],
297        current_choice: str | None = None,
298        on_value_change_call: Callable[[str], Any] | None = None,
299        opening_call: Callable[[], Any] | None = None,
300        closing_call: Callable[[], Any] | None = None,
301        width: float = 230.0,
302        maxwidth: float | None = None,
303        scale: float | None = None,
304        choices_disabled: Sequence[str] | None = None,
305        choices_display: Sequence[ba.Lstr] | None = None,
306        button_size: tuple[float, float] = (160.0, 50.0),
307        autoselect: bool = True,
308    ):
309        # pylint: disable=too-many-locals
310        if choices_disabled is None:
311            choices_disabled = []
312        if choices_display is None:
313            choices_display = []
314        uiscale = ba.app.ui.uiscale
315        if scale is None:
316            scale = (
317                2.3
318                if uiscale is ba.UIScale.SMALL
319                else 1.65
320                if uiscale is ba.UIScale.MEDIUM
321                else 1.23
322            )
323        if current_choice not in choices:
324            current_choice = None
325        self._choices = list(choices)
326        if not choices:
327            raise TypeError('no choices given')
328        self._choices_display = list(choices_display)
329        self._choices_disabled = list(choices_disabled)
330        self._width = width
331        self._maxwidth = maxwidth
332        self._scale = scale
333        self._current_choice = (
334            current_choice if current_choice is not None else self._choices[0]
335        )
336        self._position = position
337        self._parent = parent
338        if not choices:
339            raise TypeError('Must pass at least one choice')
340        self._parent = parent
341        self._button_size = button_size
342
343        self._button = ba.buttonwidget(
344            parent=self._parent,
345            position=(self._position[0], self._position[1]),
346            autoselect=autoselect,
347            size=self._button_size,
348            scale=1.0,
349            label='',
350            on_activate_call=lambda: ba.timer(
351                0, self._make_popup, timetype=ba.TimeType.REAL
352            ),
353        )
354        self._on_value_change_call = None  # Don't wanna call for initial set.
355        self._opening_call = opening_call
356        self._autoselect = autoselect
357        self._closing_call = closing_call
358        self.set_choice(self._current_choice)
359        self._on_value_change_call = on_value_change_call
360        self._window_widget: ba.Widget | None = None
361
362        # Complain if we outlive our button.
363        ba.uicleanupcheck(self, self._button)
364
365    def _make_popup(self) -> None:
366        if not self._button:
367            return
368        if self._opening_call:
369            self._opening_call()
370        self._window_widget = PopupMenuWindow(
371            position=self._button.get_screen_space_center(),
372            delegate=self,
373            width=self._width,
374            maxwidth=self._maxwidth,
375            scale=self._scale,
376            choices=self._choices,
377            current_choice=self._current_choice,
378            choices_disabled=self._choices_disabled,
379            choices_display=self._choices_display,
380        ).root_widget
381
382    def get_button(self) -> ba.Widget:
383        """Return the menu's button widget."""
384        return self._button
385
386    def get_window_widget(self) -> ba.Widget | None:
387        """Return the menu's window widget (or None if nonexistent)."""
388        return self._window_widget
389
390    def popup_menu_selected_choice(
391        self, popup_window: PopupWindow, choice: str
392    ) -> None:
393        """Called when a choice is selected."""
394        del popup_window  # Unused here.
395        self.set_choice(choice)
396        if self._on_value_change_call:
397            self._on_value_change_call(choice)
398
399    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
400        """Called when the menu is closing."""
401        del popup_window  # Unused here.
402        if self._button:
403            ba.containerwidget(edit=self._parent, selected_child=self._button)
404        self._window_widget = None
405        if self._closing_call:
406            self._closing_call()
407
408    def set_choice(self, choice: str) -> None:
409        """Set the selected choice."""
410        self._current_choice = choice
411        displayname: str | ba.Lstr
412        if len(self._choices_display) == len(self._choices):
413            displayname = self._choices_display[self._choices.index(choice)]
414        else:
415            displayname = choice
416        if self._button:
417            ba.buttonwidget(edit=self._button, label=displayname)

A complete popup-menu control.

This creates a button and wrangles its pop-up menu.

PopupMenu( parent: _ba.Widget, position: tuple[float, float], choices: Sequence[str], current_choice: str | None = None, on_value_change_call: Optional[Callable[[str], Any]] = None, opening_call: Optional[Callable[[], Any]] = None, closing_call: Optional[Callable[[], Any]] = None, width: float = 230.0, maxwidth: float | None = None, scale: float | None = None, choices_disabled: Optional[Sequence[str]] = None, choices_display: Optional[Sequence[ba._language.Lstr]] = None, button_size: tuple[float, float] = (160.0, 50.0), autoselect: bool = True)
292    def __init__(
293        self,
294        parent: ba.Widget,
295        position: tuple[float, float],
296        choices: Sequence[str],
297        current_choice: str | None = None,
298        on_value_change_call: Callable[[str], Any] | None = None,
299        opening_call: Callable[[], Any] | None = None,
300        closing_call: Callable[[], Any] | None = None,
301        width: float = 230.0,
302        maxwidth: float | None = None,
303        scale: float | None = None,
304        choices_disabled: Sequence[str] | None = None,
305        choices_display: Sequence[ba.Lstr] | None = None,
306        button_size: tuple[float, float] = (160.0, 50.0),
307        autoselect: bool = True,
308    ):
309        # pylint: disable=too-many-locals
310        if choices_disabled is None:
311            choices_disabled = []
312        if choices_display is None:
313            choices_display = []
314        uiscale = ba.app.ui.uiscale
315        if scale is None:
316            scale = (
317                2.3
318                if uiscale is ba.UIScale.SMALL
319                else 1.65
320                if uiscale is ba.UIScale.MEDIUM
321                else 1.23
322            )
323        if current_choice not in choices:
324            current_choice = None
325        self._choices = list(choices)
326        if not choices:
327            raise TypeError('no choices given')
328        self._choices_display = list(choices_display)
329        self._choices_disabled = list(choices_disabled)
330        self._width = width
331        self._maxwidth = maxwidth
332        self._scale = scale
333        self._current_choice = (
334            current_choice if current_choice is not None else self._choices[0]
335        )
336        self._position = position
337        self._parent = parent
338        if not choices:
339            raise TypeError('Must pass at least one choice')
340        self._parent = parent
341        self._button_size = button_size
342
343        self._button = ba.buttonwidget(
344            parent=self._parent,
345            position=(self._position[0], self._position[1]),
346            autoselect=autoselect,
347            size=self._button_size,
348            scale=1.0,
349            label='',
350            on_activate_call=lambda: ba.timer(
351                0, self._make_popup, timetype=ba.TimeType.REAL
352            ),
353        )
354        self._on_value_change_call = None  # Don't wanna call for initial set.
355        self._opening_call = opening_call
356        self._autoselect = autoselect
357        self._closing_call = closing_call
358        self.set_choice(self._current_choice)
359        self._on_value_change_call = on_value_change_call
360        self._window_widget: ba.Widget | None = None
361
362        # Complain if we outlive our button.
363        ba.uicleanupcheck(self, self._button)
def get_button(self) -> _ba.Widget:
382    def get_button(self) -> ba.Widget:
383        """Return the menu's button widget."""
384        return self._button

Return the menu's button widget.

def get_window_widget(self) -> _ba.Widget | None:
386    def get_window_widget(self) -> ba.Widget | None:
387        """Return the menu's window widget (or None if nonexistent)."""
388        return self._window_widget

Return the menu's window widget (or None if nonexistent).

def popup_menu_selected_choice(self, popup_window: bastd.ui.popup.PopupWindow, choice: str) -> None:
390    def popup_menu_selected_choice(
391        self, popup_window: PopupWindow, choice: str
392    ) -> None:
393        """Called when a choice is selected."""
394        del popup_window  # Unused here.
395        self.set_choice(choice)
396        if self._on_value_change_call:
397            self._on_value_change_call(choice)

Called when a choice is selected.

def popup_menu_closing(self, popup_window: bastd.ui.popup.PopupWindow) -> None:
399    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
400        """Called when the menu is closing."""
401        del popup_window  # Unused here.
402        if self._button:
403            ba.containerwidget(edit=self._parent, selected_child=self._button)
404        self._window_widget = None
405        if self._closing_call:
406            self._closing_call()

Called when the menu is closing.

def set_choice(self, choice: str) -> None:
408    def set_choice(self, choice: str) -> None:
409        """Set the selected choice."""
410        self._current_choice = choice
411        displayname: str | ba.Lstr
412        if len(self._choices_display) == len(self._choices):
413            displayname = self._choices_display[self._choices.index(choice)]
414        else:
415            displayname = choice
416        if self._button:
417            ba.buttonwidget(edit=self._button, label=displayname)

Set the selected choice.