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

Return the menu's button widget.

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

Called when a choice is selected.

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

Called when the menu is closing.

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

Set the selected choice.