# Released under the MIT License. See LICENSE for details. # """Popup window/menu related functionality.""" from __future__ import annotations import weakref from typing import TYPE_CHECKING import bauiv1 as bui if TYPE_CHECKING: from typing import Any, Sequence, Callable class PopupWindow: """A transient window that positions and scales itself for visibility. Category: UI Classes""" def __init__( self, 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', ): # pylint: disable=too-many-locals if focus_size is None: focus_size = size # In vr mode we can't have windows going outside the screen. if bui.app.env.vr: focus_size = size focus_position = (0, 0) width = focus_size[0] height = focus_size[1] # Ok, we've been given a desired width, height, and scale; # we now need to ensure that we're all onscreen by scaling down if # need be and clamping it to the UI bounds. bounds = bui.uibounds() edge_buffer = 15 bounds_width = bounds[1] - bounds[0] - edge_buffer * 2 bounds_height = bounds[3] - bounds[2] - edge_buffer * 2 fin_width = width * scale fin_height = height * scale if fin_width > bounds_width: scale /= fin_width / bounds_width fin_width = width * scale fin_height = height * scale if fin_height > bounds_height: scale /= fin_height / bounds_height fin_width = width * scale fin_height = height * scale x_min = bounds[0] + edge_buffer + fin_width * 0.5 y_min = bounds[2] + edge_buffer + fin_height * 0.5 x_max = bounds[1] - edge_buffer - fin_width * 0.5 y_max = bounds[3] - edge_buffer - fin_height * 0.5 x_fin = min(max(x_min, position[0] + offset[0]), x_max) y_fin = min(max(y_min, position[1] + offset[1]), y_max) # ok, we've calced a valid x/y position and a scale based on or # focus area. ..now calc the difference between the center of our # focus area and the center of our window to come up with the # offset we'll need to plug in to the window x_offs = ( (focus_position[0] + focus_size[0] * 0.5) - (size[0] * 0.5) ) * scale y_offs = ( (focus_position[1] + focus_size[1] * 0.5) - (size[1] * 0.5) ) * scale self.root_widget = bui.containerwidget( transition='in_scale', scale=scale, toolbar_visibility=toolbar_visibility, size=size, parent=bui.get_special_widget('overlay_stack'), stack_offset=(x_fin - x_offs, y_fin - y_offs), scale_origin_stack_offset=(position[0], position[1]), on_outside_click_call=self.on_popup_cancel, claim_outside_clicks=True, color=bg_color, on_cancel_call=self.on_popup_cancel, ) # complain if we outlive our root widget bui.uicleanupcheck(self, self.root_widget) def on_popup_cancel(self) -> None: """Called when the popup is canceled. Cancels can occur due to clicking outside the window, hitting escape, etc. """ class PopupMenuWindow(PopupWindow): """A menu built using popup-window functionality.""" def __init__( self, 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: Sequence[str] | None = None, choices_display: Sequence[bui.Lstr] | None = None, ): # FIXME: Clean up a bit. # pylint: disable=too-many-branches # pylint: disable=too-many-locals # pylint: disable=too-many-statements if choices_disabled is None: choices_disabled = [] if choices_display is None: choices_display = [] # FIXME: For the moment we base our width on these strings so # we need to flatten them. choices_display_fin: list[str] = [] for choice_display in choices_display: choices_display_fin.append(choice_display.evaluate()) if maxwidth is None: maxwidth = width * 1.5 self._transitioning_out = False self._choices = list(choices) self._choices_display = list(choices_display_fin) self._current_choice = current_choice self._choices_disabled = list(choices_disabled) self._done_building = False if not choices: raise TypeError('Must pass at least one choice') self._width = width self._scale = scale if len(choices) > 8: self._height = 280 self._use_scroll = True else: self._height = 20 + len(choices) * 33 self._use_scroll = False self._delegate = None # don't want this stuff called just yet.. # extend width to fit our longest string (or our max-width) for index, choice in enumerate(choices): if len(choices_display_fin) == len(choices): choice_display_name = choices_display_fin[index] else: choice_display_name = choice if self._use_scroll: self._width = max( self._width, min( maxwidth, bui.get_string_width( choice_display_name, suppress_warning=True ), ) + 75, ) else: self._width = max( self._width, min( maxwidth, bui.get_string_width( choice_display_name, suppress_warning=True ), ) + 60, ) # init parent class - this will rescale and reposition things as # needed and create our root widget super().__init__( position, size=(self._width, self._height), scale=self._scale ) if self._use_scroll: self._scrollwidget = bui.scrollwidget( parent=self.root_widget, position=(20, 20), highlight=False, color=(0.35, 0.55, 0.15), size=(self._width - 40, self._height - 40), ) self._columnwidget = bui.columnwidget( parent=self._scrollwidget, border=2, margin=0 ) else: self._offset_widget = bui.containerwidget( parent=self.root_widget, position=(30, 15), size=(self._width - 40, self._height), background=False, ) self._columnwidget = bui.columnwidget( parent=self._offset_widget, border=2, margin=0 ) for index, choice in enumerate(choices): if len(choices_display_fin) == len(choices): choice_display_name = choices_display_fin[index] else: choice_display_name = choice inactive = choice in self._choices_disabled wdg = bui.textwidget( parent=self._columnwidget, size=(self._width - 40, 28), on_select_call=bui.Call(self._select, index), click_activate=True, color=(0.5, 0.5, 0.5, 0.5) if inactive else ( (0.5, 1, 0.5, 1) if choice == self._current_choice else (0.8, 0.8, 0.8, 1.0) ), padding=0, maxwidth=maxwidth, text=choice_display_name, on_activate_call=self._activate, v_align='center', selectable=(not inactive), ) if choice == self._current_choice: bui.containerwidget( edit=self._columnwidget, selected_child=wdg, visible_child=wdg, ) # ok from now on our delegate can be called self._delegate = weakref.ref(delegate) self._done_building = True def _select(self, index: int) -> None: if self._done_building: self._current_choice = self._choices[index] def _activate(self) -> None: bui.getsound('swish').play() bui.apptimer(0.05, self._transition_out) delegate = self._getdelegate() if delegate is not None: # Call this in a timer so it doesn't interfere with us killing # our widgets and whatnot. call = bui.Call( delegate.popup_menu_selected_choice, self, self._current_choice ) bui.apptimer(0, call) def _getdelegate(self) -> Any: return None if self._delegate is None else self._delegate() def _transition_out(self) -> None: if not self.root_widget: return if not self._transitioning_out: self._transitioning_out = True delegate = self._getdelegate() if delegate is not None: delegate.popup_menu_closing(self) bui.containerwidget(edit=self.root_widget, transition='out_scale') def on_popup_cancel(self) -> None: if not self._transitioning_out: bui.getsound('swish').play() self._transition_out() class PopupMenu: """A complete popup-menu control. This creates a button and wrangles its pop-up menu. """ def __init__( self, parent: bui.Widget, position: tuple[float, float], choices: Sequence[str], current_choice: str | None = None, on_value_change_call: Callable[[str], Any] | None = None, opening_call: Callable[[], Any] | None = None, closing_call: Callable[[], Any] | None = None, width: float = 230.0, maxwidth: float | None = None, scale: float | None = None, choices_disabled: Sequence[str] | None = None, choices_display: Sequence[bui.Lstr] | None = None, button_size: tuple[float, float] = (160.0, 50.0), autoselect: bool = True, ): # pylint: disable=too-many-locals if choices_disabled is None: choices_disabled = [] if choices_display is None: choices_display = [] assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale if scale is None: scale = ( 2.3 if uiscale is bui.UIScale.SMALL else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 ) if current_choice not in choices: current_choice = None self._choices = list(choices) if not choices: raise TypeError('no choices given') self._choices_display = list(choices_display) self._choices_disabled = list(choices_disabled) self._width = width self._maxwidth = maxwidth self._scale = scale self._current_choice = ( current_choice if current_choice is not None else self._choices[0] ) self._position = position self._parent = parent if not choices: raise TypeError('Must pass at least one choice') self._parent = parent self._button_size = button_size self._button = bui.buttonwidget( parent=self._parent, position=(self._position[0], self._position[1]), autoselect=autoselect, size=self._button_size, scale=1.0, label='', on_activate_call=lambda: bui.apptimer(0, self._make_popup), ) self._on_value_change_call = None # Don't wanna call for initial set. self._opening_call = opening_call self._autoselect = autoselect self._closing_call = closing_call self.set_choice(self._current_choice) self._on_value_change_call = on_value_change_call self._window_widget: bui.Widget | None = None # Complain if we outlive our button. bui.uicleanupcheck(self, self._button) def _make_popup(self) -> None: if not self._button: return if self._opening_call: self._opening_call() self._window_widget = PopupMenuWindow( position=self._button.get_screen_space_center(), delegate=self, width=self._width, maxwidth=self._maxwidth, scale=self._scale, choices=self._choices, current_choice=self._current_choice, choices_disabled=self._choices_disabled, choices_display=self._choices_display, ).root_widget def get_button(self) -> bui.Widget: """Return the menu's button widget.""" return self._button def get_window_widget(self) -> bui.Widget | None: """Return the menu's window widget (or None if nonexistent).""" return self._window_widget def popup_menu_selected_choice( self, popup_window: PopupWindow, choice: str ) -> None: """Called when a choice is selected.""" del popup_window # Unused here. self.set_choice(choice) if self._on_value_change_call: self._on_value_change_call(choice) def popup_menu_closing(self, popup_window: PopupWindow) -> None: """Called when the menu is closing.""" del popup_window # Unused here. if self._button: bui.containerwidget(edit=self._parent, selected_child=self._button) self._window_widget = None if self._closing_call: self._closing_call() def set_choice(self, choice: str) -> None: """Set the selected choice.""" self._current_choice = choice displayname: str | bui.Lstr if len(self._choices_display) == len(self._choices): displayname = self._choices_display[self._choices.index(choice)] else: displayname = choice if self._button: bui.buttonwidget(edit=self._button, label=displayname)