"""
ae.kivy.widgets module
----------------------
this module provides constants and widgets for your multi-platform apps.
the generic constants for animations and vibration patterns (mostly used on mobile platforms).
most of the widgets provided by this module are based on the widgets of the `Kivy framework <https://kivy.org>`__,
extended to work with :ref:`app-state-variables`, e.g. to support app styles and theming (dark or light) and
user definable font sizes. some of them also change the :ref:`application flow`.
by importing this module the following generic widgets will be registered in the kivy widget class factory maps,
to be available in the kv language for your app:
* :class:`~ae.kivy.widgets.AppStateSlider`: extended version of :class:`~kivy.uix.slider.Slider`, changing the value of
:ref:`app-state-variables`.
* :class:`~ae.kivy.widgets.FlowButton`: button to change the application flow.
* :class:`~ae.kivy.widgets.FlowDropDown`: attachable menu-like popup, based on :class:`~kivy.uix.dropdown.DropDown`.
* :class:`~ae.kivy.widgets.FlowInput`: dynamic kivy widget based on :class:`~kivy.uix.textinput.TextInput` with
application flow support.
* :class:`~ae.kivy.widgets.FlowPopup`: dynamic auto-content-sizing popup to query user input or to show messages.
* :class:`~ae.kivy.widgets.FlowSelector`: attachable popup used for dynamic elliptic auto-spreading menus and toolbars.
* :class:`~ae.kivy.widgets.FlowToggler`: toggle button based on :class:`~ae.kivy.widgets.ImageLabel` and
:class:`~kivy.uix.behaviors.ToggleButtonBehavior` to change the application flow or any flag or application state.
* :class:`~ae.kivy.widgets.HelpToggler` is a toggle button widget that switches the app's help and tour mode on and off.
* :class:`~ae.kivy.widgets.ImageLabel`: dynamic kivy widget extending the Kivy :class:`~kivy.uix.label.Label` widget
with an image.
* :class:`~ae.kivy.widgets.MessageShowPopup`: simple message box widget based on :class:`~ae.kivy.widgets.FlowPopup`.
* :class:`~ae.kivy.widgets.OptionalButton`: dynamic kivy widget based on :class:`~ae.kivy.widgets.FlowButton`
which can be dynamically hidden.
* :class:`~ae.kivy.widgets.ShortenedButton`: dynamic kivy widget based on :class:`~ae.kivy.widgets.FlowButton`
shortening the button text.
* :class:`~ae.kivy.widgets.Tooltip` displays text blocks that are automatically positioned next to any
widget to providing e.g. i18n context help texts or app tour/onboarding info.
* :class:`~ae.kivy.widgets.UserNameEditorPopup`: popup window used e.g. to enter new user, finally registered in the
app config files.
tooltip popup to display context-sensitive help and app tour texts
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
the tooltip popup widget class :class:`~ae.kivy.widgets.Tooltip` allows you to target any widget by pointing with an
arrow to it. the position and size of this widget gets automatically calculated from the targeted widget position and
size and the tooltip text size. and if the screen/window size is not big enough then the tooltip texts get scrollable.
.. hint::
use cases of the class :class:`~ae.kivy.widgets.Tooltip` are e.g. the help texts prepared and displayed by the
method :meth:`~ae.gui_help.HelpAppBase.help_display` as well as the "explaining widget" tooltips in an app tour.
help activation and de-activation
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
use the widget class :class:`~ae.kivy.widgets.HelpToggler` provided by this module to toggle
the active state of the help mode.
.. hint::
the :class:`~ae.kivy.widgets.HelpToggler` class is using the low-level touch events to prevent the dispatch of the
Kivy events `on_press`, `on_release` and `on_dismiss`, to allow to show help texts for opened dropdowns and popups,
without closing/dismissing them.
to attach help texts to your widget instances add the behavior class :class:`~ae.kivy.behaviors.HelpBehavior`.
"""
import os
from math import atan, cos, pi, sin, tau
from typing import Any, Callable, Dict, List, Optional, Tuple
import kivy # type: ignore
from kivy.animation import Animation # type: ignore
from kivy.app import App # type: ignore
from kivy.core.window import Window # type: ignore
from kivy.graphics import Color, Ellipse, Line # type: ignore
from kivy.input import MotionEvent # type: ignore
from kivy.lang import Builder # type: ignore
from kivy.metrics import sp # type: ignore
# pylint: disable=no-name-in-module
from kivy.properties import ( # type: ignore
BooleanProperty, ColorProperty, DictProperty, ListProperty, NumericProperty, ObjectProperty, StringProperty)
from kivy.uix.behaviors import ButtonBehavior, ToggleButtonBehavior # type: ignore
from kivy.uix.boxlayout import BoxLayout # type: ignore
from kivy.uix.bubble import BubbleButton # type: ignore
from kivy.uix.dropdown import DropDown # type: ignore
from kivy.uix.image import Image # type: ignore
from kivy.uix.label import Label # type: ignore
from kivy.uix.relativelayout import RelativeLayout # type: ignore
from kivy.uix.scrollview import ScrollView # type: ignore
from kivy.uix.slider import Slider # type: ignore
import kivy.uix.textinput # type: ignore
# noinspection PyProtectedMember
from kivy.uix.textinput import TextInput, TextInputCutCopyPaste as OriTextInputCutCopyPaste
from kivy.uix.widget import Widget # type: ignore
from ae.gui_app import ( # type: ignore
ColorOrInk, ellipse_polar_radius, ensure_tap_kwargs_refs, id_of_flow, replace_flow_action, update_tap_kwargs)
from ae.gui_help import ( # type: ignore
anchor_layout_x, anchor_layout_y, anchor_points, anchor_spec, help_id_tour_class)
from ae.kivy_auto_width import ContainerChildrenAutoWidthBehavior # type: ignore
from ae.kivy_dyn_chi import DynamicChildrenBehavior # type: ignore
from ae.kivy_glsl import ShadersMixin # type: ignore
from ae.kivy_relief_canvas import ReliefCanvas, relief_colors # type: ignore
from .behaviors import HelpBehavior, ModalBehavior, SlideSelectBehavior, TouchableBehavior
from .i18n import get_txt
PosSizeCallable = Callable[[Widget, List[float]], Any]
BoundWidgetPropertyId = Tuple[Widget, str, int]
PropagatedAttributes = Tuple[Any, str, Optional[PosSizeCallable]]
ANI_SINE_DEEPER_REPEAT3 = \
Animation(ani_value=0.99, t='in_out_sine', d=0.9) + Animation(ani_value=0.87, t='in_out_sine', d=1.2) + \
Animation(ani_value=0.96, t='in_out_sine', d=1.5) + Animation(ani_value=0.75, t='in_out_sine', d=1.2) + \
Animation(ani_value=0.90, t='in_out_sine', d=0.9) + Animation(ani_value=0.45, t='in_out_sine', d=0.6)
""" sine 3 x deeper repeating animation, used e.g. to animate help layout (see :class:`Tooltip` widget) """
ANI_SINE_DEEPER_REPEAT3.repeat = True
CRITICAL_VIBRATE_PATTERN = (0.00, 0.12, 0.12, 0.12, 0.12, 0.12,
0.12, 0.24, 0.12, 0.24, 0.12, 0.24,
0.12, 0.12, 0.12, 0.12, 0.12, 0.12)
""" very long/~2.4s vibrate pattern for critical error notification (sending SOS to the mobile world;) """
ERROR_VIBRATE_PATTERN = (0.0, 0.09, 0.09, 0.18, 0.18, 0.27, 0.18, 0.36, 0.27, 0.45)
""" long/~2s vibrate pattern for error notification. """
LOVE_VIBRATE_PATTERN = (0.0, 0.12, 0.12, 0.21, 0.03, 0.12, 0.12, 0.12)
""" short/~1.2s vibrate pattern for fun/love notification. """
MAIN_KV_FILE_NAME = 'main.kv' #: default file name of the main kv file of your app
# load/declare base widgets with integrated app flow and observers ensuring change of app states (e.g. theme and size)
Builder.load_file(os.path.join(os.path.dirname(__file__), "widgets.kv"))
[docs]class AbsolutePosSizeBinder: # pragma: no cover
""" propagate changes of `pos`/`size` properties of one or more widgets plus their parents to attributes/callbacks.
create an instance of this class passing the widget(s) to observe on change of their pos/size. then call the methods
:meth:`pos_to_attribute`, :meth:`pos_to_callback`, :meth:`size_to_attribute` and :meth:`size_to_callback` to specify
the propagation of the changed `pos` and/or `size`. to remove the change propagation call the method :meth:`unbind`.
.. note:: the `pos` attribute/callback propagations are providing absolute window coordinates.
"""
[docs] def __init__(self, *widgets: Widget, bind_window_size: bool = False):
""" instantiate binder specifying the monitored widget(s).
:param widgets: widget(s) to observe changes of their `pos` and `size` properties. if specified more
than one widget then the pos/size coordinates of the rectangle that is enclosing all
specified widgets are propagated.
:param bind_window_size: pass True to propagate pos and size changes if window size changes.
"""
self.widgets = widgets
self.relatives: List[Widget] = []
self.main_app = App.get_running_app().main_app
self._pos_attributes: List[PropagatedAttributes] = []
self._size_attributes: List[PropagatedAttributes] = []
self._pos_callbacks: List[PosSizeCallable] = []
self._size_callbacks: List[PosSizeCallable] = []
self._bound_wid_properties: List[BoundWidgetPropertyId] = []
self._bound_rel_properties: List[BoundWidgetPropertyId] = []
self._bind()
if bind_window_size:
uid = Window.fbind('size', self._rel_size_changed)
self._bound_rel_properties.append((Window, 'size', uid))
[docs] def _bind(self):
for wid in self.widgets:
uid = wid.fbind('pos', self._wid_pos_changed)
self._bound_wid_properties.append((wid, 'pos', uid))
uid = wid.fbind('size', self._wid_size_changed)
self._bound_wid_properties.append((wid, 'size', uid))
parent = wid.parent
while parent and parent != parent.parent:
if isinstance(parent, (ScrollView, RelativeLayout)) and parent not in self.relatives:
uid = parent.fbind('pos', self._rel_pos_changed)
self._bound_rel_properties.append((parent, 'pos', uid))
uid = parent.fbind('size', self._rel_size_changed)
self._bound_rel_properties.append((parent, 'size', uid))
self.relatives.append(parent)
parent = parent.parent
[docs] def _propagate(self, wid, value, attributes, callbacks):
self.main_app.vpo(f"AbsolutePosSizeBinder._propagate({wid}, {value}, {attributes}, {callbacks})")
for (target, attribute, converter) in attributes:
setattr(target, attribute, converter(wid, value) if converter else value)
for callback in callbacks:
callback(wid, value)
[docs] def _wid_pos_changed(self, wid: Widget, new_pos: List[float]):
""" propagate `pos` property change to target attributes and subscribed observers.
:param wid: bound widget or a ScrollView that is embedding the bound widget, which pos changed.
:param new_pos: new position of the bound widget/ScrollView (unused).
"""
wgs = self.widgets
new_pos = self.main_app.widgets_enclosing_rectangle(wgs)[:2] if len(wgs) > 1 else wid.to_window(*new_pos)
self._propagate(wid, new_pos, self._pos_attributes, self._pos_callbacks)
[docs] def _wid_size_changed(self, wid: Widget, new_size: List[float]):
""" propagate `size` property change to target attributes and subscribed observers.
:param wid: bound widget or a ScrollView that is embedding the bound widget, which pos changed.
:param new_size: new position of the bound widget/ScrollView (unused).
"""
wgs = self.widgets
if len(wgs) > 1:
new_size = self.main_app.widgets_enclosing_rectangle(wgs)[2:]
self._propagate(wid, new_size, self._size_attributes, self._size_callbacks)
[docs] def _rel_pos_changed(self, _rel: Widget, _new_pos: list):
""" propagate `pos` property change of relative/scrollable layout/container.
:param _rel: relative layout or a scroll view, embedding bound widget(s), which pos changed.
:param _new_pos: new position of the RelativeLayout/ScrollView (unused).
"""
wid = self.widgets[0]
self._wid_pos_changed(wid, wid.pos)
[docs] def _rel_size_changed(self, _rel: Widget, _new_size: list):
""" propagate size change of relative/scrollable layout/container.
:param _rel: relative layout or a scroll view, embedding bound widget(s), which size changed.
:param _new_size: new size of the RelativeLayout/ScrollView (unused).
"""
wid = self.widgets[0]
self._wid_size_changed(wid, wid.size)
self._wid_pos_changed(wid, wid.pos) # layout size change mostly does change also the absolute widget pos
[docs] def pos_to_attribute(self, target: Any, attribute: str, converter: Optional[PosSizeCallable] = None):
""" request the propagation of the changed (absolute) widget(s) position to an object attribute.
:param target: the object which attribute will be changed on change of `pos`.
:param attribute: the name of the attribute to assign the new/changed absolute position.
:param converter: optional pos value converter, returning the final value assigned to the attribute.
"""
self._pos_attributes.append((target, attribute, converter))
[docs] def pos_to_callback(self, callback: PosSizeCallable):
""" bind callable to `pos` change event.
:param callback: callable to be called when pos changed with the changed widget and pos as arguments.
"""
self._pos_callbacks.append(callback)
[docs] def size_to_attribute(self, target: Any, attribute: str, converter: Optional[PosSizeCallable] = None):
""" request the propagation of the changed widget(s) size to an object attribute.
:param target: the object which attribute will be changed on change of `size`.
:param attribute: the name of the attribute to assign the new/changed size.
:param converter: optional pos value converter, returning the final value assigned to the attribute.
"""
self._size_attributes.append((target, attribute, converter))
[docs] def size_to_callback(self, callback: PosSizeCallable):
""" bind callable to `size` change event.
:param callback: callable to be called when size changed with the changed widget and size as arguments.
"""
self._size_callbacks.append(callback)
[docs] def unbind(self):
""" unbind the widget(s) of this binder instance.
.. note:: this instance can be destroyed after the call of this method. for new bindings create a new instance.
"""
for (wid, prop, uid) in reversed(self._bound_rel_properties):
wid.unbind_uid(prop, uid)
self._bound_rel_properties.clear()
for (wid, prop, uid) in reversed(self._bound_wid_properties):
wid.unbind_uid(prop, uid)
self._bound_wid_properties.clear()
self.relatives = self._pos_attributes = self._size_attributes = self._pos_callbacks = self._size_callbacks = []
self.widgets = ()
[docs]class AppStateSlider(HelpBehavior, ShadersMixin, Slider): # pragma: no cover
""" slider widget with help text to change app state value. """
app_state_name = StringProperty() #: name of the app state to be changed by this slider value
[docs] def __str__(self):
""" added for easier debugging. """
return f"{self.__class__.__name__}({hex(id(self))} sta={self.app_state_name} val={self.value})"
[docs] def on_value(self, *args):
""" value changed event handler.
:param args: tuple of instance and new value.
"""
App.get_running_app().main_app.change_app_state(self.app_state_name, args[1])
[docs]class HelpToggler(ReliefCanvas, Image): # pragma: no cover
""" widget to activate and deactivate the help mode.
To prevent dismiss of opened popups and dropdowns at help mode activation, this singleton instance has to:
* be registered in its __init__ to the :attr:`~ae.gui_help.HelpAppBase.help_activator` attribute and
* have a :meth:`~HelpToggler.on_touch_down` method that is eating the activation touch event (returning True) and
* a :meth:`~HelpToggler.on_touch_down` method not passing an activation touch in all DropDown/Popup widgets.
"""
ani_value = NumericProperty(0.999) #: float value (range: 0.0 - 1.0) to animate this button in help/tour mode
[docs] def __init__(self, **kwargs):
""" initialize an instance of this class and also :attr:`~ae.gui_help.HelpAppBase.help_activator`. """
self.main_app = App.get_running_app().main_app
self.main_app.help_activator = self
super().__init__(**kwargs)
[docs] def ani_start(self):
""" start animation of this button. """
ANI_SINE_DEEPER_REPEAT3.start(self)
[docs] def ani_stop(self):
""" stop animation of this button. """
ANI_SINE_DEEPER_REPEAT3.stop(self)
self.ani_value = 0.999
[docs] def on_touch_down(self, touch: MotionEvent) -> bool:
""" touch down event handler to toggle help mode while preventing dismiss of open dropdowns/popups.
:param touch: touch event.
:return: True if touch happened on this button (and will get no further processed => eaten).
"""
if self.collide_point(*touch.pos):
self.main_app.help_activation_toggle()
return True
return False
[docs]class ImageLabel(ReliefCanvas, ShadersMixin, Label): # pragma: no cover
""" base label used for all labels and buttons - declared in widgets.kv and also in this module to inherit from.
.. note::
hide-able label needs extra handling, because even setting width/height to zero the text can still be visible,
especially in dark mode and even with having the text-color-alpha==0. to fully hide the texture in all cases,
set either the text to an empty string or the opacity to zero.
"""
[docs] def __repr__(self):
""" added for easier debugging of :class:`FlowButton` and :class:`FlowToggler` widgets. """
flo = f" flo={self.tap_flow_id}" if hasattr(self, 'tap_flow_id') else ""
return f"{self.__class__.__name__}({hex(id(self))}{flo} txt={self.text})"
[docs]class FlowDropDown(ContainerChildrenAutoWidthBehavior, DynamicChildrenBehavior, SlideSelectBehavior, ReliefCanvas,
DropDown): # pragma: no cover
""" flow based widget class to implement dynamic menu-like user selections and toolbars. """
close_kwargs = DictProperty() #: kwargs passed to all close action flow change event handlers
content = ObjectProperty() #: layout container
menu_items = ObjectProperty() #: container/content children, like buttons, text inputs or sliders
parent_popup_to_close = ObjectProperty() #: parent popup widget instance to be closed if this dropdown closes
[docs] def __init__(self, **kwargs):
super().__init__(**kwargs)
self.fw_app = App.get_running_app()
[docs] def __repr__(self):
""" added for easier debugging. """
return f"{self.__class__.__name__}({hex(id(self))} close={self.close_kwargs} {self.parent_popup_to_close})"
[docs] def _real_dismiss(self, *_args):
""" overridden to ensure that return value of on_dismiss-dispatch get recognized. """
if self.dispatch('on_dismiss'):
return # dismiss/close cancelled
if self.parent:
self.parent.remove_widget(self)
if self.attach_to:
self.attach_to.unbind(pos=self._reposition, size=self._reposition)
self.attach_to = None
self._layout_finished = True
[docs] def dismiss(self, *args):
""" override DropDown method to prevent dismiss of any dropdown/popup while clicking on activator widget.
:param args: args to be passed to DropDown.dismiss().
"""
if self.attach_to:
help_layout = self.fw_app.help_layout
if help_layout is None or not isinstance(help_layout.targeted_widget, HelpToggler):
self._layout_finished = False
super().dismiss(*args)
close = dismiss
[docs] def on_container(self, instance: Widget, value: Widget):
""" sync :attr:`content` widget and :attr:`menu_items` list with container widget.
:param instance: self.
:param value: new/changed :attr:`~kivy.uix.dropdown.DropDown.container` widget.
"""
super().on_container(instance, value)
self.content = value # value==self.container
self.menu_items = self.content.children
[docs] def on_dismiss(self) -> Optional[bool]:
""" default dismiss/close default event handler.
:return: True to prevent/cancel the dismiss/close.
"""
return not self.attach_to \
or not self.main_app.change_flow(id_of_flow('close', 'flow_popup'), **self.close_kwargs)
[docs] def on_touch_down(self, touch: MotionEvent) -> bool:
""" prevent the processing of a touch on the help activator widget by this dropdown.
:param touch: motion/touch event data.
:return: True if event got processed/used.
"""
if self.main_app.help_activator.collide_point(*touch.pos):
return False # allow help activator button to process this touch down event
return super().on_touch_down(touch)
[docs] def _reposition(self, *args):
""" fixing Dropdown bug - see issue #7382 and PR #7383. TODO: remove if PR gets merged and distributed. """
if self.attach_to and not self.attach_to.parent:
return
super()._reposition(*args)
[docs]class ExtTextInputCutCopyPaste(OriTextInputCutCopyPaste): # pragma: no cover
""" overwrite/extend :class:`kivy.uix.textinput.TextInputCutCopyPaste` w/ translatable and autocomplete options. """
[docs] def __init__(self, **kwargs):
""" create :class:`~kivy.uix.Bubble` instance to display the cut/copy/paste options.
the monkey patch of :class:`~kivy.uix.textinput.TextInputCutCopyPaste` which was done in
:meth:`FlowInput._show_cut_copy_paste` has to be temporarily reset before the super() call below, to prevent
endless recursion because else the other super(cls, instance) call (in python2 style within
:meth:`TextInputCutCopyPaste.__init__`) results in the same instance (instead of the overwritten instance).
"""
kivy.uix.textinput.TextInputCutCopyPaste = OriTextInputCutCopyPaste # pylint: disable=no-member
self.fw_app = App.get_running_app()
super().__init__(**kwargs)
[docs] def on_parent(self, instance: Widget, value: Widget):
""" overwritten to translate BubbleButton texts and to add extra menus to add/delete ac texts.
:param instance: self.
:param value: kivy main window.
"""
super().on_parent(instance, value)
textinput = self.textinput
if not textinput:
return
cont = self.content
font_size = self.fw_app.main_app.font_size
for child in cont.children:
child.font_size = font_size
child.text = get_txt(child.text)
if not textinput.readonly:
# memorize/forget complete text to/from autocomplete because dropdown is not visible if this bubble is
self.add_widget(BubbleButton(text=get_txt("Memorize"), font_size=font_size,
on_release=textinput.extend_ac_with_text))
self.add_widget(BubbleButton(text=get_txt("Forget"), font_size=font_size,
on_release=textinput.delete_text_from_ac))
# estimate container size (exact calc not possible because button width / texture_size[0] is still 100 / 0)
width = cont.padding[0] + cont.padding[2] + len(cont.children) * (cont.spacing[0] + sp(126))
height = cont.padding[1] + cont.padding[3] + self.fw_app.button_height
self.size = width, height # pylint: disable=attribute-defined-outside-init # false positive
[docs]class FlowSelector(ModalBehavior, DynamicChildrenBehavior, FlowButton): # pragma: no cover
""" attachable popup used for dynamic elliptic auto-spreading menus and toolbars.
this app flow based menu-like popup consists of a central button and animated elliptic-auto-spreading menu items.
any widget class can be used for the menu items of this class, although :class:`ShortenedButton`
instances are best-prepared to auto-shorten the `text` property.
:Events:
`on_pre_open`:
fired before the FlowSelector is opened and got added to the main window.
`on_open`:
fired when the FlowSelector is opened.
`on_pre_dismiss`:
fired before the FlowSelector is closed.
`on_dismiss`:
fired when the FlowSelector is closed. if the callback returns True, the menu will stay opened.
inspired by https://github.com/kivy-garden/garden.modernmenu
"""
attached_widget = ObjectProperty(allownone=True)
""" widget from which this instance got opened.
The :meth:`open` method will automatically set this property whilst
:meth:`close` will set it back to None.
"""
close_kwargs = DictProperty()
""" kwargs passed to all close action flow change event handlers.
:attr:`close_kwargs` is a :class:`~kivy.properties.DictProperty`. the default depends the action of the penultimate
flow id in the :attr:`ae.gui_app.flow_path`: is empty or 'enter' dict then it defaults to an empty flow, else to an
empty dict.
"""
container = ObjectProperty()
""" parent widget of the menu items (for compatibility with other popups/dropdowns).
:attr:`container` is an :class:`~kivy.properties.ObjectProperty` and is read-only.
"""
is_open = BooleanProperty(defaultvalue=False)
""" `True` if the :meth:`.open` method of this instance got called. :meth:`.close` sets this value to `False`.
:attr:`is_open` is a :class:`~kivy.properties.BooleanProperty` and defaults to False.
"""
menu_items = ListProperty()
""" sequence of the menu items widgets (for compatibility with :class:`~ae.kivy.behaviors.SlideSelectBehavior`).
:attr:`menu_items` is an :class:`~kivy.properties.ListProperty` and defaults to the items specified via
the :attr:`~ae.kivy_dyn_chi.DynamicChildrenBehavior.child_data_maps` property and the kv language.
"""
overlay_color = ColorProperty()
""" ink (color + alpha) tuple in the format (red, green, blue, alpha) used for dimming of the main window.
:attr:`overlay_color` is a :class:`~kivy.properties.ColorProperty` and defaults to the current color value
:attr:`~kivy.core.window.Window.clearcolor` with an alpha of 0.6 (set in :meth:`.__init__`).
"""
parent_popup_to_close = ListProperty([])
""" parent menu/popup widget instance to be closed if this menu closes.
:attr:`parent_popup_to_close` is a :class:`~kivy.properties.ListProperty` and defaults to an empty list.
"""
radian_offset = NumericProperty(tau * 9.0 / 360.0)
""" start/end angle offset (in radians) for the elliptically positioned items of an elliptic menu.
:attr:`radian_offset` is a :class:`~kivy.properties.NumericProperty` and defaults to 9 degrees (as radian,
respectively ``tau * 9.0 / 360.0``).
"""
separator_color = ColorProperty()
""" color used to draw the border of the menu-back-button.
:attr:`separator_color` is a :class:`~kivy.properties.ColorProperty` and defaults to the current value of the
:attr:`~FrameworkApp.font_color` property.
"""
separator_height = NumericProperty('1sp')
""" line width of the border of the menu-back-button and of the connectors between the menu and its items.
:attr:`separator_height` is a :class:`~kivy.properties.NumericProperty` and defaults to 1sp.
"""
scale_x = NumericProperty(1.0)
""" spread/widen factor of the menu item ellipse in x direction.
:attr:`scale_x` is a :class:`~kivy.properties.NumericProperty` and defaults to 1.0.
"""
scale_y = NumericProperty(1.0)
""" spread/widen factor of the menu item ellipse in y direction.
:attr:`scale_y` is a :class:`~kivy.properties.NumericProperty` and defaults to 1.0.
"""
_anim_alpha = NumericProperty(0.0) #: internal opacity/alpha for fade-in/-out animations
_anim_duration = NumericProperty(0.69) #: internal time in seconds for fade-in/-out animations
_creation_direction = 1.0 #: creation-ellipse-direction of the menu buttons/items
_start_radian = 0.0 #: angle of the first menu item
_item_radian = 0.0 #: angle of each menu item
__events__ = ('on_pre_open', 'on_open', 'on_pre_dismiss', 'on_dismiss')
[docs] def __init__(self, **kwargs):
self._attached_wid_pos = () # x/y of attached widget in widget coordinates
self._attached_binder = None
if ini_touch := kwargs.pop('touch_event', None):
ini_touch.grab(self)
self._attached_touch_pos = ini_touch.pos # x/y of initial touch in window coordinates
self._ini_touch = ini_touch
self._touch_started_inside = True # for :meth:`ae.kivy.behaviors.ModalBehavior.on_touch_move` slide_select
else:
self._attached_touch_pos = []
self._ini_touch = None
# set default width and colors
self.width = self.height * 2.1
clr_ink = Window.clearcolor
self.overlay_color = clr_ink[:3] + [0.6]
self.fw_app = app = App.get_running_app() # self.main_app get initialized in SlideSelectBehavior.__init__()
self.separator_color = app.font_color
self.container = self
self.button_image = None
self.menu_items = []
super().__init__(**kwargs)
[docs] def _attached_pos(self, widget: Widget, _pos: List[float]): # assert widget.pos == _pos
wid_x, wid_y = widget.to_window(*widget.pos)
wid_r, wid_t = widget.to_window(widget.right, widget.top)
delta_x, delta_y = widget.x - self._attached_wid_pos[0], widget.y - self._attached_wid_pos[1]
mnu_center_x, mnu_center_y = self.center_x + delta_x, self.center_y + delta_y
if self.x + delta_x < 0 or mnu_center_x < wid_x:
mnu_center_x = max(self.width, wid_x + wid_r) / 2.0
elif self.right + delta_x > Window.width or mnu_center_x > wid_r:
mnu_center_x = min(Window.width - self.width / 2, wid_r)
if self.y + delta_y < 0 or mnu_center_y < wid_y:
mnu_center_y = max(self.height, wid_y + wid_t) / 2.0
elif self.top + delta_y > Window.height or mnu_center_y > wid_t:
mnu_center_y = min(Window.height - self.height / 2, wid_t)
self.center = [mnu_center_x, mnu_center_y]
self._attached_touch_pos = self.center
self._attached_wid_pos = widget.pos
self._layout_items()
[docs] def _attached_size(self, widget: Widget, _size: List[float]):
wid_x, wid_y = widget.to_window(*widget.pos) # assert widget is self.attached_widget and widget.size == _size
wid_r, wid_t = widget.to_window(widget.right, widget.top)
self.center = [min(max(wid_x, self.center_x), wid_r), min(max(wid_y, self.center_y), wid_t)]
self._layout_items()
[docs] def _finalize_close(self, *_args):
""" final real dismiss after animations are finished. """
if self._ini_touch:
self._ini_touch.ungrab(self)
self._ini_touch = None
if self._attached_binder:
self.deactivate_modal()
self._attached_binder.unbind()
self._attached_binder = None
if isinstance(self.attached_widget, ToggleButtonBehavior):
self.attached_widget.state = 'normal' # reset Toggler for option/toolbox dropdowns
self.attached_widget = None
self.unbind(menu_items=self._layout_items)
[docs] def _item_moved(self, _anim: Animation, item: Widget, progress: float):
""" draw line from menu-center to animated item-center. """
center_x, center_y = self.center
item_dis = item.menu_distance * 0.81 * progress
item_radian = item.menu_radian * progress
item_group = str(item.child_index)
vis_val = abs((progress - 0.5) * 2)
self.canvas.before.remove_group(item_group)
with self.canvas.before:
Color(rgba=self.separator_color[:3] + [self.separator_color[3] * vis_val],
group=item_group)
dis = ellipse_polar_radius(self.width / 2, self.height / 2, item_radian)
Line(points=(round(center_x + cos(item_radian) * dis),
round(center_y + sin(item_radian) * dis),
round(center_x + cos(item_radian) * item_dis),
round(center_y + sin(item_radian) * item_dis)),
# width=0.003 + vis_val * self.separator_height,
dash_length=3, dash_offset=3, # only works if width == 1.0 (default and is faster)
group=item_group)
[docs] def _layout_items(self, *_args):
""" calculate the radians/angles and positions of the menu-items.
given an offset angle of 9 degrees the (start-angle direction menu-item-angle) for the 9 block regions from
top-left ... center/middle ... bottom-right would be:
.. list-table:: possibly blocked window regions
:widths: 12 9 9 9
:header-rows: 1
:stub-columns: 1
:align: right
* -
- left
- center
- right
* - top
- 0 -72
- 180+180
- 180 +72
* - middle
- 81-162
- 90+360
- 99+162
* - bottom
- 72 -72
- 180-180
- 108 +72
"""
self._layout_finished = False
mnu_chi = self.menu_items # assert set(self.container.children) == set(self.menu_items + [self.button_image])
item_cnt = len(mnu_chi)
if not item_cnt:
return
win_w, win_h = Window.width, Window.height
mnu_w, mnu_h = self.width, self.height
chi_w, chi_h = max(_.width for _ in mnu_chi), max(_.height for _ in mnu_chi)
avl_r, avl_t, avl_l, avl_b = available = win_w - self.right, win_h - self.top, self.x, self.y
sca_x, sca_y, shr_x, shr_y = self.scale_x, self.scale_y, 1.0, 1.0 # user scale and shrinking factors
def _size_space(blk_cnt: int = 2):
siz = (mnu_w + chi_w * sca_x * shr_x * item_cnt * 0.3,
mnu_h + chi_h * sca_y * shr_y * item_cnt / (2 ** (2 - min(blk_cnt, 2))))
spa = [space - siz[_ % 2] for _, space in enumerate(available)]
blk = [_ < 0.0 for _ in spa]
return siz, spa, blk, sum(blk)
mnu_size, spaces, (blk_r, blk_t, blk_l, blk_b), block_cnt = _size_space()
tries = 3
while tries and ((shrink_x := blk_r and blk_l) + (shrink_y := blk_t and blk_b)):
if shrink_x:
shr_x = tries * 0.3
if shrink_y:
shr_y = tries * 0.3
mnu_size, spaces, (blk_r, blk_t, blk_l, blk_b), block_cnt = _size_space(block_cnt)
tries -= 1
if blk_r and blk_l:
if spaces[0] > spaces[2]:
blk_r = False
else:
blk_l = False
block_cnt -= 1
if blk_t and blk_b:
if spaces[1] > spaces[3]:
blk_t = False
else:
blk_b = False
block_cnt -= 1
self._creation_direction = -1.0 if blk_l or blk_b and not blk_r else 1.0
ell_x, ell_y = mnu_size
if blk_l and blk_t:
add_rad = max(0.0, atan(avl_t / ell_x))
self._start_radian = add_rad # 0º
add_rad += max(0.0, atan((avl_l - ell_x / 2.0) * 2.0 / ell_y))
elif blk_t or blk_b and not blk_r and not blk_l:
add_rad = atan((avl_t if blk_t else avl_b) / ell_x)
self._start_radian = pi + add_rad * (-1.0 if blk_t else 1.0) # 180º
add_rad *= 2.0 / block_cnt
elif block_cnt == 2:
add_rad = max(0.0, atan(((avl_r if blk_r else avl_l) - ell_x / 2.0) * 2.0 / ell_y))
self._start_radian = pi / 2.0 + add_rad * (-1.0 if blk_r else 1.0) # 90º (+90º)
add_rad += max(0.0, atan((avl_t if blk_t else avl_b) / ell_x))
elif block_cnt == 1:
add_rad = max(0.0, atan(((avl_r if blk_r else avl_l) - ell_x / 2.0) * 2.0 / ell_y))
self._start_radian = pi / 2.0 + add_rad * (-1.0 if blk_r else 1.0) # 90º (+180º)
add_rad = abs(add_rad) * 2.0
else:
self._start_radian = pi / 2.0 # 90º (+360º)
add_rad = 0.0
if not blk_t and (blk_l or blk_r) or not block_cnt:
self._start_radian += (1 + blk_b) * self.radian_offset * self._creation_direction
radian = add_rad + tau / (2 ** block_cnt)
if blk_l or blk_r:
radian -= self.radian_offset * 2.0
self._item_radian = radian * self._creation_direction / max(1, item_cnt - (1 if block_cnt else 0))
dur = self._anim_duration / 2
center_x, center_y = self.center
for idx, item in enumerate(mnu_chi): # assert idx == item.child_index
radian = self._start_radian + self._item_radian * idx
distance = ellipse_polar_radius(ell_x, ell_y, radian)
width, height = item.size
pos_x = round(center_x + cos(radian) * distance - width / 2)
pos_y = round(center_y + sin(radian) * distance - height / 2)
if pos_y < 0:
distance = ellipse_polar_radius(ell_x, ell_y + pos_y, radian)
pos_y = 0
elif (dif := pos_y + height - win_h) > 0:
distance = ellipse_polar_radius(ell_x, ell_y - dif, radian)
pos_y = win_h - height
if pos_x < 0:
distance = ellipse_polar_radius(ell_x + pos_x, ell_y, radian)
pos_x = 0
elif (dif := pos_x + width - win_w) > 0:
distance = ellipse_polar_radius(ell_x - dif, ell_y, radian)
pos_x = win_w - width
item.menu_distance = distance
item.menu_radian = radian
ani = Animation(x=pos_x, y=pos_y, width=item.width, height=item.height, d=dur)
if item.x or item.y:
ani = Animation(center_x=center_x, center_y=center_y, width=9, height=9, d=dur / 2) + ani
else:
item.center = center_x, center_y
item.size = 9, 9
ani.bind(on_progress=self._item_moved)
ani.start(item)
def _finished_layout(*_args):
self._layout_finished = True
ani = Animation(_anim_alpha=0.0, d=dur) + Animation(_anim_alpha=1.0, d=dur)
ani.bind(on_complete=_finished_layout)
ani.start(self)
[docs] def close(self, *_args, **kwargs):
""" close/dismiss menu (ae.gui_app popup handling compatibility for all GUI frameworks).
.. note:: prevents close/dismiss of any dropdown/popup while clicking on help activator widget.
:param _args: arguments (to have compatible signature for DropDown/Popup/ModalView widgets).
:param kwargs: keyword arguments (compatible signature for DropDown/Popup/ModalView widgets):
`force`: pass `True` to force closing, ignoring return value of `dispatch('on_dismiss')`
`animation`: pass False to close this menu without fade-out animation
"""
help_layout = self.fw_app.help_layout
if help_layout and isinstance(help_layout.targeted_widget, HelpToggler):
return
self.dispatch('on_pre_dismiss') # pylint: disable=no-member
if not self.dispatch('on_dismiss') or kwargs.get('force', False): # pylint: disable=no-member
if not self.is_open: # prevent multiple close from post-dispatches
return
self.is_open = False
self.deactivate_esc_key_close()
if kwargs.get('animation', True):
ani = Animation(_anim_alpha=0.0, d=self._anim_duration)
ani.bind(on_complete=self._finalize_close) # pylint: disable=no-member
ani.start(self)
else:
self._anim_alpha = 0.0
self._finalize_close()
dismiss = close #: alias method of :meth:`~FlowSelector.close`
[docs] def on_dismiss(self) -> Optional[bool]:
""" dismiss/close default event handler.
:return: return True to prevent/cancel the dismiss/close.
"""
return not self.is_open \
or not self.main_app.change_flow(id_of_flow('close', 'flow_popup'), **self.close_kwargs)
[docs] def on_open(self):
""" open default event handler. """
[docs] def on_pre_dismiss(self):
""" pre close/dismiss event handler. """
[docs] def on_pre_open(self):
""" pre open default event handler. """
[docs] def on_release(self):
""" touch release default event handler. """
self.close()
[docs] def open(self, attach_to: Widget, **kwargs: Dict[str, Any]):
""" display flow selector menu items, with animation and as a popup in modal mode.
:param attach_to: the widget to which this menu gets attached to.
:param kwargs: extra arguments that are removed before to be passed to the inheriting open method:
* 'animation': `False` will disable the fade-in-animation (default=True).
"""
if self.is_open:
return
self.is_open = True
self.activate_esc_key_close()
self.attached_widget = attach_to
self.activate_modal(align_center=False)
self._attached_wid_pos = attach_to.pos
self._attached_binder = abi = AbsolutePosSizeBinder(attach_to, bind_window_size=True)
abi.size_to_callback(self._attached_size)
abi.pos_to_callback(self._attached_pos)
self.center = self._attached_touch_pos or attach_to.to_window(*attach_to.center)
self._attached_pos(attach_to, attach_to.to_window(*self._attached_wid_pos))
self.bind(menu_items=self._layout_items)
self.dispatch('on_pre_open') # pylint: disable=no-member
if kwargs.get('animation', True):
ani = Animation(_anim_alpha=1.0, d=self._anim_duration)
ani.bind(on_complete=lambda *_args: self.dispatch('on_open')) # pylint: disable=no-member
ani.start(self)
else:
self._anim_alpha = 1.0
self.dispatch('on_open') # pylint: disable=no-member
[docs] def touch_pos_is_inside(self, pos: List[float]) -> bool:
""" is touch inside of this widget or a group of sub-widgets. overwritten to also include the menu items.
:param pos: touch position (x, y) in window coordinates.
:return: True if this menu and its items would process a touch event at :paramref:`.pos`. """
# assert set(self.container.children) == set(self.menu_items + [self.button_image])
return super().touch_pos_is_inside(pos) or any(_.collide_point(*pos) for _ in self.menu_items)
[docs]class FlowToggler(HelpBehavior, SlideSelectBehavior, TouchableBehavior, ToggleButtonBehavior,
ImageLabel): # pragma: no cover
""" toggle button changing flow id. """
long_tap_flow_id = StringProperty() #: flow id that will be set when this button gets long tap event
tap_flow_id = StringProperty() #: the new flow id that will be set when this toggle button get released
tap_kwargs = DictProperty() #: kwargs dict passed to event handler (change_flow) when button get tapped
[docs] def __init__(self, **kwargs):
ensure_tap_kwargs_refs(kwargs, self)
super().__init__(**kwargs)
self.down_shader = {'add_to': 'before', 'shader_code': '=circled_alpha', 'render_shape': Ellipse}
[docs] def on_long_tap(self, touch: MotionEvent):
""" long tap/click default handler.
:param touch: motion/touch event data with the touched widget in `touch.grab_current`.
"""
super().on_long_tap(touch)
if flow_id := self.long_tap_flow_id:
self.main_app.change_flow(flow_id, **update_tap_kwargs(self, popup_kwargs={'touch_event': touch}))
[docs] def on_release(self):
""" overridable touch release event handler. """
self.main_app.change_flow(self.tap_flow_id, **self.tap_kwargs)