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

Return the menu's button widget.

def get_window_widget(self) -> _bauiv1.Widget | None:
384    def get_window_widget(self) -> bui.Widget | None:
385        """Return the menu's window widget (or None if nonexistent)."""
386        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:
388    def popup_menu_selected_choice(
389        self, popup_window: PopupWindow, choice: str
390    ) -> None:
391        """Called when a choice is selected."""
392        del popup_window  # Unused here.
393        self.set_choice(choice)
394        if self._on_value_change_call:
395            self._on_value_change_call(choice)

Called when a choice is selected.

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

Called when the menu is closing.

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

Set the selected choice.