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            )
207            self._columnwidget = bui.columnwidget(
208                parent=self._scrollwidget, border=2, margin=0
209            )
210        else:
211            self._offset_widget = bui.containerwidget(
212                parent=self.root_widget,
213                position=(12, 12),
214                size=(self._width - 40, self._height),
215                background=False,
216            )
217            self._columnwidget = bui.columnwidget(
218                parent=self._offset_widget, border=2, margin=0
219            )
220        for index, choice in enumerate(choices):
221            if len(choices_display_fin) == len(choices):
222                choice_display_name = choices_display_fin[index]
223            else:
224                choice_display_name = choice
225            inactive = choice in self._choices_disabled
226            wdg = bui.textwidget(
227                parent=self._columnwidget,
228                size=(self._width - 40, 28),
229                on_select_call=bui.Call(self._select, index),
230                click_activate=True,
231                color=(
232                    (0.5, 0.5, 0.5, 0.5)
233                    if inactive
234                    else (
235                        (0.5, 1, 0.5, 1)
236                        if choice == self._current_choice
237                        else (0.8, 0.8, 0.8, 1.0)
238                    )
239                ),
240                padding=0,
241                maxwidth=maxwidth,
242                text=choice_display_name,
243                on_activate_call=self._activate,
244                v_align='center',
245                selectable=(not inactive),
246                glow_type='uniform',
247            )
248            if choice == self._current_choice:
249                bui.containerwidget(
250                    edit=self._columnwidget,
251                    selected_child=wdg,
252                    visible_child=wdg,
253                )
254
255        # ok from now on our delegate can be called
256        self._delegate = weakref.ref(delegate)
257        self._done_building = True
258
259    def _select(self, index: int) -> None:
260        if self._done_building:
261            self._current_choice = self._choices[index]
262
263    def _activate(self) -> None:
264        bui.getsound('swish').play()
265        bui.apptimer(0.05, self._transition_out)
266        delegate = self._getdelegate()
267        if delegate is not None:
268            # Call this in a timer so it doesn't interfere with us killing
269            # our widgets and whatnot.
270            call = bui.Call(
271                delegate.popup_menu_selected_choice, self, self._current_choice
272            )
273            bui.apptimer(0, call)
274
275    def _getdelegate(self) -> Any:
276        return None if self._delegate is None else self._delegate()
277
278    def _transition_out(self) -> None:
279        if not self.root_widget:
280            return
281        if not self._transitioning_out:
282            self._transitioning_out = True
283            delegate = self._getdelegate()
284            if delegate is not None:
285                delegate.popup_menu_closing(self)
286            bui.containerwidget(edit=self.root_widget, transition='out_scale')
287
288    @override
289    def on_popup_cancel(self) -> None:
290        if not self._transitioning_out:
291            bui.getsound('swish').play()
292        self._transition_out()
293
294
295class PopupMenu:
296    """A complete popup-menu control.
297
298    This creates a button and wrangles its pop-up menu.
299    """
300
301    def __init__(
302        self,
303        parent: bui.Widget,
304        position: tuple[float, float],
305        choices: Sequence[str],
306        *,
307        current_choice: str | None = None,
308        on_value_change_call: Callable[[str], Any] | None = None,
309        opening_call: Callable[[], Any] | None = None,
310        closing_call: Callable[[], Any] | None = None,
311        width: float = 230.0,
312        maxwidth: float | None = None,
313        scale: float | None = None,
314        choices_disabled: Sequence[str] | None = None,
315        choices_display: Sequence[bui.Lstr] | None = None,
316        button_size: tuple[float, float] = (160.0, 50.0),
317        autoselect: bool = True,
318    ):
319        # pylint: disable=too-many-locals
320        if choices_disabled is None:
321            choices_disabled = []
322        if choices_display is None:
323            choices_display = []
324        assert bui.app.classic is not None
325        uiscale = bui.app.ui_v1.uiscale
326        if scale is None:
327            scale = (
328                2.3
329                if uiscale is bui.UIScale.SMALL
330                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
331            )
332        if current_choice not in choices:
333            current_choice = None
334        self._choices = list(choices)
335        if not choices:
336            raise TypeError('no choices given')
337        self._choices_display = list(choices_display)
338        self._choices_disabled = list(choices_disabled)
339        self._width = width
340        self._maxwidth = maxwidth
341        self._scale = scale
342        self._current_choice = (
343            current_choice if current_choice is not None else self._choices[0]
344        )
345        self._position = position
346        self._parent = parent
347        if not choices:
348            raise TypeError('Must pass at least one choice')
349        self._parent = parent
350        self._button_size = button_size
351
352        self._button = bui.buttonwidget(
353            parent=self._parent,
354            position=(self._position[0], self._position[1]),
355            autoselect=autoselect,
356            size=self._button_size,
357            scale=1.0,
358            label='',
359            on_activate_call=lambda: bui.apptimer(0, self._make_popup),
360        )
361        self._on_value_change_call = None  # Don't wanna call for initial set.
362        self._opening_call = opening_call
363        self._autoselect = autoselect
364        self._closing_call = closing_call
365        self.set_choice(self._current_choice)
366        self._on_value_change_call = on_value_change_call
367        self._window_widget: bui.Widget | None = None
368
369        # Complain if we outlive our button.
370        bui.uicleanupcheck(self, self._button)
371
372    def _make_popup(self) -> None:
373        if not self._button:
374            return
375        if self._opening_call:
376            self._opening_call()
377        self._window_widget = PopupMenuWindow(
378            position=self._button.get_screen_space_center(),
379            delegate=self,
380            width=self._width,
381            maxwidth=self._maxwidth,
382            scale=self._scale,
383            choices=self._choices,
384            current_choice=self._current_choice,
385            choices_disabled=self._choices_disabled,
386            choices_display=self._choices_display,
387        ).root_widget
388
389    def get_button(self) -> bui.Widget:
390        """Return the menu's button widget."""
391        return self._button
392
393    def get_window_widget(self) -> bui.Widget | None:
394        """Return the menu's window widget (or None if nonexistent)."""
395        return self._window_widget
396
397    def popup_menu_selected_choice(
398        self, popup_window: PopupWindow, choice: str
399    ) -> None:
400        """Called when a choice is selected."""
401        del popup_window  # Unused here.
402        self.set_choice(choice)
403        if self._on_value_change_call:
404            self._on_value_change_call(choice)
405
406    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
407        """Called when the menu is closing."""
408        del popup_window  # Unused here.
409        if self._button:
410            bui.containerwidget(edit=self._parent, selected_child=self._button)
411        self._window_widget = None
412        if self._closing_call:
413            self._closing_call()
414
415    def set_choice(self, choice: str) -> None:
416        """Set the selected choice."""
417        self._current_choice = choice
418        displayname: str | bui.Lstr
419        if len(self._choices_display) == len(self._choices):
420            displayname = self._choices_display[self._choices.index(choice)]
421        else:
422            displayname = choice
423        if self._button:
424            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            )
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()

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            )
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
@override
def on_popup_cancel(self) -> None:
289    @override
290    def on_popup_cancel(self) -> None:
291        if not self._transitioning_out:
292            bui.getsound('swish').play()
293        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:
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)

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)
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)
def get_button(self) -> _bauiv1.Widget:
390    def get_button(self) -> bui.Widget:
391        """Return the menu's button widget."""
392        return self._button

Return the menu's button widget.

def get_window_widget(self) -> _bauiv1.Widget | None:
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

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

def popup_menu_selected_choice(self, popup_window: PopupWindow, choice: str) -> None:
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)

Called when a choice is selected.

def popup_menu_closing(self, popup_window: PopupWindow) -> None:
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()

Called when the menu is closing.

def set_choice(self, choice: str) -> None:
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)

Set the selected choice.