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