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

Called when the popup is canceled.

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

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

A complete popup-menu control.

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

PopupMenu( parent: _bauiv1.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[babase.Lstr]] = None, button_size: tuple[float, float] = (160.0, 50.0), autoselect: bool = True)
305    def __init__(
306        self,
307        parent: bui.Widget,
308        position: tuple[float, float],
309        choices: Sequence[str],
310        *,
311        current_choice: str | None = None,
312        on_value_change_call: Callable[[str], Any] | None = None,
313        opening_call: Callable[[], Any] | None = None,
314        closing_call: Callable[[], Any] | None = None,
315        width: float = 230.0,
316        maxwidth: float | None = None,
317        scale: float | None = None,
318        choices_disabled: Sequence[str] | None = None,
319        choices_display: Sequence[bui.Lstr] | None = None,
320        button_size: tuple[float, float] = (160.0, 50.0),
321        autoselect: bool = True,
322    ):
323        # pylint: disable=too-many-locals
324        if choices_disabled is None:
325            choices_disabled = []
326        if choices_display is None:
327            choices_display = []
328        assert bui.app.classic is not None
329        uiscale = bui.app.ui_v1.uiscale
330        if scale is None:
331            scale = (
332                2.3
333                if uiscale is bui.UIScale.SMALL
334                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
335            )
336        if current_choice not in choices:
337            current_choice = None
338        self._choices = list(choices)
339        if not choices:
340            raise TypeError('no choices given')
341        self._choices_display = list(choices_display)
342        self._choices_disabled = list(choices_disabled)
343        self._width = width
344        self._maxwidth = maxwidth
345        self._scale = scale
346        self._current_choice = (
347            current_choice if current_choice is not None else self._choices[0]
348        )
349        self._position = position
350        self._parent = parent
351        if not choices:
352            raise TypeError('Must pass at least one choice')
353        self._parent = parent
354        self._button_size = button_size
355
356        self._button = bui.buttonwidget(
357            parent=self._parent,
358            position=(self._position[0], self._position[1]),
359            autoselect=autoselect,
360            size=self._button_size,
361            scale=1.0,
362            label='',
363            on_activate_call=lambda: bui.apptimer(0, self._make_popup),
364        )
365        self._on_value_change_call = None  # Don't wanna call for initial set.
366        self._opening_call = opening_call
367        self._autoselect = autoselect
368        self._closing_call = closing_call
369        self.set_choice(self._current_choice)
370        self._on_value_change_call = on_value_change_call
371        self._window_widget: bui.Widget | None = None
372
373        # Complain if we outlive our button.
374        bui.uicleanupcheck(self, self._button)
def get_button(self) -> _bauiv1.Widget:
393    def get_button(self) -> bui.Widget:
394        """Return the menu's button widget."""
395        return self._button

Return the menu's button widget.

def get_window_widget(self) -> _bauiv1.Widget | None:
397    def get_window_widget(self) -> bui.Widget | None:
398        """Return the menu's window widget (or None if nonexistent)."""
399        return self._window_widget

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

def popup_menu_selected_choice(self, popup_window: PopupWindow, choice: str) -> None:
401    def popup_menu_selected_choice(
402        self, popup_window: PopupWindow, choice: str
403    ) -> None:
404        """Called when a choice is selected."""
405        del popup_window  # Unused here.
406        self.set_choice(choice)
407        if self._on_value_change_call:
408            self._on_value_change_call(choice)

Called when a choice is selected.

def popup_menu_closing(self, popup_window: PopupWindow) -> None:
410    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
411        """Called when the menu is closing."""
412        del popup_window  # Unused here.
413        if self._button:
414            bui.containerwidget(edit=self._parent, selected_child=self._button)
415        self._window_widget = None
416        if self._closing_call:
417            self._closing_call()

Called when the menu is closing.

def set_choice(self, choice: str) -> None:
419    def set_choice(self, choice: str) -> None:
420        """Set the selected choice."""
421        self._current_choice = choice
422        displayname: str | bui.Lstr
423        if len(self._choices_display) == len(self._choices):
424            displayname = self._choices_display[self._choices.index(choice)]
425        else:
426            displayname = choice
427        if self._button:
428            bui.buttonwidget(edit=self._button, label=displayname)

Set the selected choice.