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