Source code for bauiv1lib.popup
# 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, override
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Sequence, Callable, Literal
[docs]
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: Literal[
'inherit',
'menu_minimal_no_back',
'menu_store_no_back',
] = 'menu_minimal_no_back',
edge_buffer_scale: float = 1.0,
):
# 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 * edge_buffer_scale
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)
[docs]
def on_popup_cancel(self) -> None:
"""Called when the popup is canceled.
Cancels can occur due to clicking outside the window,
hitting escape, etc.
"""
[docs]
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=(12, 12),
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),
glow_type='uniform',
)
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')
[docs]
@override
def on_popup_cancel(self) -> None:
if not self._transitioning_out:
bui.getsound('swish').play()
self._transition_out()
[docs]
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
[docs]
def get_button(self) -> bui.Widget:
"""Return the menu's button widget."""
return self._button
[docs]
def get_window_widget(self) -> bui.Widget | None:
"""Return the menu's window widget (or None if nonexistent)."""
return self._window_widget
[docs]
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)