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 pops up from some position."""
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
# NOTE: We do NOT need to suppress main-window-recreates here
# (like regular windows do) since we are always in the overlay
# stack and thus aren't affected by main-window recreation.
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),
border_opacity=0.5,
)
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)
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum