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

Return the menu's button widget.

def get_window_widget(self) -> _bauiv1.Widget | None:
391    def get_window_widget(self) -> bui.Widget | None:
392        """Return the menu's window widget (or None if nonexistent)."""
393        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:
395    def popup_menu_selected_choice(
396        self, popup_window: PopupWindow, choice: str
397    ) -> None:
398        """Called when a choice is selected."""
399        del popup_window  # Unused here.
400        self.set_choice(choice)
401        if self._on_value_change_call:
402            self._on_value_change_call(choice)

Called when a choice is selected.

def popup_menu_closing(self, popup_window: PopupWindow) -> None:
404    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
405        """Called when the menu is closing."""
406        del popup_window  # Unused here.
407        if self._button:
408            bui.containerwidget(edit=self._parent, selected_child=self._button)
409        self._window_widget = None
410        if self._closing_call:
411            self._closing_call()

Called when the menu is closing.

def set_choice(self, choice: str) -> None:
413    def set_choice(self, choice: str) -> None:
414        """Set the selected choice."""
415        self._current_choice = choice
416        displayname: str | bui.Lstr
417        if len(self._choices_display) == len(self._choices):
418            displayname = self._choices_display[self._choices.index(choice)]
419        else:
420            displayname = choice
421        if self._button:
422            bui.buttonwidget(edit=self._button, label=displayname)

Set the selected choice.