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

Return the menu's button widget.

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

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

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

Called when a choice is selected.

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

Called when the menu is closing.

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

Set the selected choice.