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

Called when the popup is canceled.

Cancels can occur due to clicking outside the window, hitting escape, etc.

class PopupMenu:
262class PopupMenu:
263    """A complete popup-menu control.
264
265    This creates a button and wrangles its pop-up menu.
266    """
267
268    def __init__(self,
269                 parent: ba.Widget,
270                 position: tuple[float, float],
271                 choices: Sequence[str],
272                 current_choice: str | None = None,
273                 on_value_change_call: Callable[[str], Any] | None = None,
274                 opening_call: Callable[[], Any] | None = None,
275                 closing_call: Callable[[], Any] | None = None,
276                 width: float = 230.0,
277                 maxwidth: float | None = None,
278                 scale: float | None = None,
279                 choices_disabled: Sequence[str] | None = None,
280                 choices_display: Sequence[ba.Lstr] | None = None,
281                 button_size: tuple[float, float] = (160.0, 50.0),
282                 autoselect: bool = True):
283        # pylint: disable=too-many-locals
284        if choices_disabled is None:
285            choices_disabled = []
286        if choices_display is None:
287            choices_display = []
288        uiscale = ba.app.ui.uiscale
289        if scale is None:
290            scale = (2.3 if uiscale is ba.UIScale.SMALL else
291                     1.65 if uiscale is ba.UIScale.MEDIUM else 1.23)
292        if current_choice not in choices:
293            current_choice = None
294        self._choices = list(choices)
295        if not choices:
296            raise TypeError('no choices given')
297        self._choices_display = list(choices_display)
298        self._choices_disabled = list(choices_disabled)
299        self._width = width
300        self._maxwidth = maxwidth
301        self._scale = scale
302        self._current_choice = (current_choice if current_choice is not None
303                                else self._choices[0])
304        self._position = position
305        self._parent = parent
306        if not choices:
307            raise TypeError('Must pass at least one choice')
308        self._parent = parent
309        self._button_size = button_size
310
311        self._button = ba.buttonwidget(
312            parent=self._parent,
313            position=(self._position[0], self._position[1]),
314            autoselect=autoselect,
315            size=self._button_size,
316            scale=1.0,
317            label='',
318            on_activate_call=lambda: ba.timer(
319                0, self._make_popup, timetype=ba.TimeType.REAL))
320        self._on_value_change_call = None  # Don't wanna call for initial set.
321        self._opening_call = opening_call
322        self._autoselect = autoselect
323        self._closing_call = closing_call
324        self.set_choice(self._current_choice)
325        self._on_value_change_call = on_value_change_call
326        self._window_widget: ba.Widget | None = None
327
328        # Complain if we outlive our button.
329        ba.uicleanupcheck(self, self._button)
330
331    def _make_popup(self) -> None:
332        if not self._button:
333            return
334        if self._opening_call:
335            self._opening_call()
336        self._window_widget = PopupMenuWindow(
337            position=self._button.get_screen_space_center(),
338            delegate=self,
339            width=self._width,
340            maxwidth=self._maxwidth,
341            scale=self._scale,
342            choices=self._choices,
343            current_choice=self._current_choice,
344            choices_disabled=self._choices_disabled,
345            choices_display=self._choices_display).root_widget
346
347    def get_button(self) -> ba.Widget:
348        """Return the menu's button widget."""
349        return self._button
350
351    def get_window_widget(self) -> ba.Widget | None:
352        """Return the menu's window widget (or None if nonexistent)."""
353        return self._window_widget
354
355    def popup_menu_selected_choice(self, popup_window: PopupWindow,
356                                   choice: str) -> None:
357        """Called when a choice is selected."""
358        del popup_window  # Unused here.
359        self.set_choice(choice)
360        if self._on_value_change_call:
361            self._on_value_change_call(choice)
362
363    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
364        """Called when the menu is closing."""
365        del popup_window  # Unused here.
366        if self._button:
367            ba.containerwidget(edit=self._parent, selected_child=self._button)
368        self._window_widget = None
369        if self._closing_call:
370            self._closing_call()
371
372    def set_choice(self, choice: str) -> None:
373        """Set the selected choice."""
374        self._current_choice = choice
375        displayname: str | ba.Lstr
376        if len(self._choices_display) == len(self._choices):
377            displayname = self._choices_display[self._choices.index(choice)]
378        else:
379            displayname = choice
380        if self._button:
381            ba.buttonwidget(edit=self._button, label=displayname)

A complete popup-menu control.

This creates a button and wrangles its pop-up menu.

PopupMenu( parent: _ba.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[ba._language.Lstr]] = None, button_size: tuple[float, float] = (160.0, 50.0), autoselect: bool = True)
268    def __init__(self,
269                 parent: ba.Widget,
270                 position: tuple[float, float],
271                 choices: Sequence[str],
272                 current_choice: str | None = None,
273                 on_value_change_call: Callable[[str], Any] | None = None,
274                 opening_call: Callable[[], Any] | None = None,
275                 closing_call: Callable[[], Any] | None = None,
276                 width: float = 230.0,
277                 maxwidth: float | None = None,
278                 scale: float | None = None,
279                 choices_disabled: Sequence[str] | None = None,
280                 choices_display: Sequence[ba.Lstr] | None = None,
281                 button_size: tuple[float, float] = (160.0, 50.0),
282                 autoselect: bool = True):
283        # pylint: disable=too-many-locals
284        if choices_disabled is None:
285            choices_disabled = []
286        if choices_display is None:
287            choices_display = []
288        uiscale = ba.app.ui.uiscale
289        if scale is None:
290            scale = (2.3 if uiscale is ba.UIScale.SMALL else
291                     1.65 if uiscale is ba.UIScale.MEDIUM else 1.23)
292        if current_choice not in choices:
293            current_choice = None
294        self._choices = list(choices)
295        if not choices:
296            raise TypeError('no choices given')
297        self._choices_display = list(choices_display)
298        self._choices_disabled = list(choices_disabled)
299        self._width = width
300        self._maxwidth = maxwidth
301        self._scale = scale
302        self._current_choice = (current_choice if current_choice is not None
303                                else self._choices[0])
304        self._position = position
305        self._parent = parent
306        if not choices:
307            raise TypeError('Must pass at least one choice')
308        self._parent = parent
309        self._button_size = button_size
310
311        self._button = ba.buttonwidget(
312            parent=self._parent,
313            position=(self._position[0], self._position[1]),
314            autoselect=autoselect,
315            size=self._button_size,
316            scale=1.0,
317            label='',
318            on_activate_call=lambda: ba.timer(
319                0, self._make_popup, timetype=ba.TimeType.REAL))
320        self._on_value_change_call = None  # Don't wanna call for initial set.
321        self._opening_call = opening_call
322        self._autoselect = autoselect
323        self._closing_call = closing_call
324        self.set_choice(self._current_choice)
325        self._on_value_change_call = on_value_change_call
326        self._window_widget: ba.Widget | None = None
327
328        # Complain if we outlive our button.
329        ba.uicleanupcheck(self, self._button)
def get_button(self) -> _ba.Widget:
347    def get_button(self) -> ba.Widget:
348        """Return the menu's button widget."""
349        return self._button

Return the menu's button widget.

def get_window_widget(self) -> _ba.Widget | None:
351    def get_window_widget(self) -> ba.Widget | None:
352        """Return the menu's window widget (or None if nonexistent)."""
353        return self._window_widget

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

def popup_menu_selected_choice(self, popup_window: bastd.ui.popup.PopupWindow, choice: str) -> None:
355    def popup_menu_selected_choice(self, popup_window: PopupWindow,
356                                   choice: str) -> None:
357        """Called when a choice is selected."""
358        del popup_window  # Unused here.
359        self.set_choice(choice)
360        if self._on_value_change_call:
361            self._on_value_change_call(choice)

Called when a choice is selected.

def popup_menu_closing(self, popup_window: bastd.ui.popup.PopupWindow) -> None:
363    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
364        """Called when the menu is closing."""
365        del popup_window  # Unused here.
366        if self._button:
367            ba.containerwidget(edit=self._parent, selected_child=self._button)
368        self._window_widget = None
369        if self._closing_call:
370            self._closing_call()

Called when the menu is closing.

def set_choice(self, choice: str) -> None:
372    def set_choice(self, choice: str) -> None:
373        """Set the selected choice."""
374        self._current_choice = choice
375        displayname: str | ba.Lstr
376        if len(self._choices_display) == len(self._choices):
377            displayname = self._choices_display[self._choices.index(choice)]
378        else:
379            displayname = choice
380        if self._button:
381            ba.buttonwidget(edit=self._button, label=displayname)

Set the selected choice.