"""
automatic width mix-in classes for kivy widgets
===============================================
this ae portion is providing classes to mix them into any kivy widget with a `texture` property (like e.g.
:class:`~kivy.uix.label.Label` or :class:`~kivy.uix.button.Button`), to automatically size and resize widgets and/or to
display a long text as scrolling ticker within a tall widget.
automatic font size iteration with animation
--------------------------------------------
mix-in the :class:`AutoFontSizeBehavior` into any kivy widget with a `texture` property, to automatically grow or shrink
the font size to fully fill the width/height of the widget with the size of their texture.
more details see in the documentation of the :class:`AutoFontSizeBehavior` class.
automatic container width with opening animation
------------------------------------------------
the class :class:`ContainerChildrenAutoWidthBehavior` determines the optimal width of a container widget, so that the
text of any children widget is fully visible/displayed.
the optimal container width is determined by increasing width of the container in iterations, which are implemented
through a kivy :class:`~kivy.animation.Animation`. as soon as the texts of all children are fully displayed (or the
maximum width is reached) the animation stops.
more details see in the documentation of the :class:`ContainerChildrenAutoWidthBehavior` class.
automatic ticker animation
--------------------------
mix-in the :class:`SimpleAutoTickerBehavior` class to automatic slide the texture of a widget if it is too big to be
completely/fully displayed, like in a news-ping-pong-ticker.
for more details check the documentation of the :class:`SimpleAutoTickerBehavior` class.
"""
from typing import Callable, Dict, Optional
from kivy.animation import Animation # type: ignore
from kivy.app import App # type: ignore
from kivy.clock import Clock # type: ignore
from kivy.core.window import Window # type: ignore
from kivy.factory import Factory # type: ignore
# pylint: disable=no-name-in-module
from kivy.properties import NumericProperty # type: ignore # noqa: E0611
from kivy.uix.label import Label # type: ignore
from kivy.uix.widget import Widget # type: ignore
try: # optional requirement
from ae.gui_app import MAX_FONT_SIZE, MIN_FONT_SIZE # type: ignore
except ImportError: # pragma: no cover
MIN_FONT_SIZE = 15
MAX_FONT_SIZE = 99
__version__ = '0.3.21'
[docs]class AutoFontSizeBehavior:
""" mix-in to interpolate the optimal font size so that the texture is filling the full width of the widget.
the desired spacing (left plus right) between the texture border and the widget borders can be set via the
:attr:`auto_font_text_spacing` property. additional padding can be added via the `padding_x` property of the
mixing-in widget.
to disable the animation set the length of the :data:`auto_font_anim_duration` to zero or very short value.
the minimum and maximum of the texture font size can be restricted by setting the attributes
:attr:`auto_font_min_size` and :attr:`auto_font_max_size`.
"""
# abstracts
bind: Callable
font_size: float
texture_size: tuple
texture_update: Callable
width: float
height: float
# public attributes
auto_font_anim_duration: float = NumericProperty(0.9)
""" duration in seconds of the font size grow/shrink animation.
:attr:`auto_font_anim_duration` is a :class:`~kivy.properties.NumericProperty` and defaults to 0.9 seconds.
"""
auto_font_max_size: float = NumericProperty()
""" maximum font size.
:attr:`auto_font_max_size` is a :class:`~kivy.properties.NumericProperty` and defaults to
min(:data:`~ae.gui_app.MAX_FONT_SIZE`, :attr:`~ae.kivy.apps.FrameworkApp.max_font_size`).
"""
auto_font_min_size: float = NumericProperty()
""" minimum font size.
:attr:`auto_font_min_size` is a :class:`~kivy.properties.NumericProperty` and defaults to
max(:data:`~ae.gui_app.MIN_FONT_SIZE`, :attr:`~ae.kivy.apps.FrameworkApp.min_font_size`).
"""
auto_font_text_spacing: float = NumericProperty('18sp')
""" horizontal padding in pixels between widget and texture width (including the additional horizontal `padding_x`).
:attr:`auto_font_text_spacing` is a :class:`~kivy.properties.NumericProperty` and defaults to 18sp.
"""
# internal attributes
_font_size_anim: Optional[Animation] = None
_font_anim_mode: int = 0
_last_font_size: float = 0.0
[docs] def __init__(self, **kwargs):
app = App.get_running_app()
self.auto_font_max_size = min(MAX_FONT_SIZE, getattr(app, 'max_font_size', MAX_FONT_SIZE))
self.auto_font_min_size = max(MIN_FONT_SIZE, getattr(app, 'min_font_size', MIN_FONT_SIZE))
super().__init__(**kwargs)
self.bind(text=self._start_font_anim)
self.bind(width=self._start_font_anim)
self.bind(height=self._start_font_anim)
[docs] def _font_size_adjustable(self):
""" check if font size need/has to be adjustable. """
if self.texture_size[0] + self.auto_font_text_spacing < self.width \
and self.texture_size[1] + self.auto_font_text_spacing / 1.8 < self.height \
and self.font_size < min(self.auto_font_max_size, self.height):
return 1
if (self.texture_size[0] + self.auto_font_text_spacing > self.width
or self.texture_size[1] + self.auto_font_text_spacing / 1.8 > self.height) \
and self.font_size > self.auto_font_min_size:
return -1
return 0
[docs] def _start_font_anim(self, *_args):
""" delayed anim check """
if getattr(self, '_ticker_text_updating', False):
return # ignore when SimpleAutoTickerBehaviour is shortening text
self._stop_font_anim() # stop just running animation (obsoleted by this new text/size change)
if not self.texture_size[0]:
return
self._font_anim_mode = self._font_size_adjustable()
if not self._font_anim_mode:
return # font size not adjustable
reach_size = min(self.auto_font_max_size, self.height) if self._font_anim_mode == 1 else self.auto_font_min_size
self._font_size_anim = Animation(font_size=reach_size, t='out_quad', d=self.auto_font_anim_duration)
self._font_size_anim.bind(on_progress=self._font_size_progress)
self._font_size_anim.start(self)
[docs] def _stop_font_anim(self):
if self._font_size_anim:
self._font_size_anim.stop(self)
self._font_size_anim = None
self.texture_update()
self._font_anim_mode = 0
[docs] def _font_size_progress(self, _anim: Animation, _self: Widget, _progress: float):
""" animation on_progress event handler. """
if self._font_anim_mode and self._font_anim_mode != self._font_size_adjustable():
self._stop_font_anim()
if self._last_font_size and not self.auto_font_min_size <= self.font_size <= self.auto_font_max_size:
self.font_size = self._last_font_size # correct to last value of out of allowed min/max range
self.texture_update()
self._last_font_size = min(max(self.auto_font_min_size, self.font_size), self.auto_font_max_size)
Factory.register('AutoFontSizeBehavior', cls=AutoFontSizeBehavior)
[docs]class ContainerChildrenAutoWidthBehavior:
""" detect minimum width for the complete display of the textures of all children at opening with animation.
this mix-in class can be added to any type of container or layout widget to provide a consistent API with
:meth:`.open` and :meth:`.close` methods, a :meth:`.on_complete_opened` event and a :attr:`.container` attribute.
.. note:: a `container` attribute will be automatically created for container classes without it.
the animation starts when the :meth:`~ContainerChildrenAutoWidthBehavior.open` method get called. this call will be
forwarded via `super()` to the container if it has an `open` method.
at animation start the width of this container will be set to value of the :attr:`auto_width_start` attribute. then
the container width increases via the running animation until, either:
* the container width is greater than the value of the :attr:`auto_width_minimum` attribute
and the textures of all children are fully visible or
* the container width reaches the app window width minus the window padding specified in the
:attr:`auto_width_window_padding` attribute.
the window width gets bound to the container width to ensure proper displaying if the window width changes.
:Events:
`on_complete_opened`:
fired when the container width animation is finished or stopped because all children are fully visible.
"""
container: Widget #: widget to add the dynamic children to (provided by the widget to be mixed into)
dismiss: Callable #: optional method provided by the widget to be mixed into
dispatch: Callable #: event dispatch method, provided by the widget to be mixed into
opacity: float #: opacity of the widget to be mixed into
parent: Widget #: parent of this widget/container.
width: float #: width of the widget to be mixed into (mostly a parent of self.container)
auto_width_anim_duration: float = NumericProperty(0.9)
""" duration in seconds of the auto-width-animation.
:attr:`auto_width_anim_duration` is a :class:`~kivy.properties.NumericProperty` and defaults to 0.9 seconds.
"""
auto_width_window_padding: float = NumericProperty('96sp')
""" horizontal padding in pixels between the window and the container.
:attr:`auto_width_window_padding` is a :class:`~kivy.properties.NumericProperty` and defaults to 96sp.
"""
auto_width_minimum: float = NumericProperty('369sp')
""" minimum container width in pixels (before the width animation will be stopped).
:attr:`auto_width_minimum` is a :class:`~kivy.properties.NumericProperty` and defaults to 369sp.
"""
auto_width_child_padding: float = NumericProperty('87sp')
""" horizontal padding in pixels between child widget and child texture.
:attr:`auto_width_child_padding` is a :class:`~kivy.properties.NumericProperty` and defaults to 87sp.
"""
auto_width_start: float = NumericProperty('3sp')
""" container width in pixels at the start of the width animation.
:attr:`auto_width_start` is a :class:`~kivy.properties.NumericProperty` and defaults to 3sp.
"""
# internal attributes
_width_anim: Animation = None
_complete_width: float = 0.0
__events__ = ('on_complete_opened', )
[docs] def close(self, *_args, **_kwargs):
""" close/dismiss container/layout (ae.gui_app popup handling compatibility for all GUI frameworks).
:param _args: unused argument (to have compatible signature for DropDown/Popup/ModalView widgets).
:param _kwargs: unused argument (to have compatible signature for DropDown/Popup/ModalView widgets).
"""
try:
# noinspection PyUnresolvedReferences
super().close(*_args, **_kwargs)
except AttributeError:
try:
self.dismiss(*_args, **_kwargs)
except AttributeError:
if self.parent and self in self.parent.children:
self.parent.remove_widget(self)
[docs] def on_complete_opened(self):
""" dispatch event default handler, called on opening when the final width got determined. """
[docs] def open(self, *_args, **kwargs):
""" open container, optionally starting auto-width-animation.
:param _args: unused argument (to have compatible signature for Popup/ModalView and DropDown
widgets passing the parent widget).
:param kwargs: optional extra arguments:
* 'animation': `False` will disable the `width` and open()-animations (default=True).
"""
if not hasattr(super(), 'container'):
self.container = getattr(self, '_container', self) # Popup has _container attribute, BoxLayout=self
if callable(getattr(super(), 'open', None)): # call open method if exists in inheriting container/layout
# noinspection PyUnresolvedReferences
super().open(*_args, **kwargs) # pylint: disable=maybe-no-member
if kwargs.get('animation', True):
container_max_width = Window.width - self.auto_width_window_padding
self._width_anim = Animation(opacity=1.0, width=container_max_width,
t='in_out_sine', d=self.auto_width_anim_duration)
self._width_anim.bind(on_progress=self._open_width_progress)
self._width_anim.bind(on_complete=self._on_complete_opened)
self.opacity = 0.0
self.width = self.auto_width_start
self._width_anim.start(self)
else:
self._win_width_bind()
[docs] def reset_width_detection(self):
""" call to reset the last detected minimum container width (e.g. if the children text got changed). """
self._complete_width = 0.0
# internal methods
[docs] def _detect_complete_width(self) -> float:
""" check clients textures until widest child texture got detected.
:return: 0.0 until complete width got detected, then the last detected minimum container width.
"""
if not self._complete_width and all(chi.texture_size[0] + self.auto_width_child_padding < self.width
for chi in self.container.children if isinstance(chi, Label)):
self._complete_width = self.width
return self._complete_width
[docs] def _on_complete_opened(self, *_args):
""" open animation completion callback/event. """
self.opacity = 1.0
self._win_width_bind()
self.dispatch('on_complete_opened')
[docs] def _on_win_width(self, *_args):
""" Window.width event handler. """
self.width = min(max(self.auto_width_minimum, Window.width),
self._detect_complete_width() or Window.width - self.auto_width_window_padding)
[docs] def _open_width_progress(self, _anim: Animation, _self: Widget, _progress: float):
""" animation on_progress event handler. """
if self.width > self.auto_width_minimum and self._detect_complete_width():
self._width_anim.stop(self)
[docs] def _win_width_bind(self):
""" bind :class:`~kivy.core.window.Window` width property to container width. """
Window.bind(width=self._on_win_width)
Factory.register('ContainerChildrenAutoWidthBehavior', cls=ContainerChildrenAutoWidthBehavior)
[docs]class SimpleAutoTickerBehavior:
""" mix-in class to slide texture in a ping-pong-like-ticker animation, if too long to be displayed completely.
if the `text` or `size` of the widget where this class get mixed in changes then this instance is first determining
the number of characters that can be displayed completely in this widget. this is done with a kivy animation. the
duration of this animation can be set via the property :attr:`auto_ticker_length_anim_duration`.
to adjust the padding space between the widget border and their texture width, the property
:attr:`auto_ticker_text_spacing` can be set accordingly.
after determining the maximum number of characters that can be displayed and storing this value into the private
attribute :attr:`_ticker_text_length` a second animation - the offset animation - gets started to slide/scroll the
text. the speed of the offset animation can be set via the property :attr:`auto_ticker_offset_anim_speed`.
.. note::
while the ticker animations are running the `text` property of the widget is only containing the visible part of
the full initial text string. use the private attribute :attr:`_ori_text` to determine the full text string.
"""
# abstracts
bind: Callable
get_root_window: Callable
text: str
texture_size: tuple
texture_update: Callable
unbind: Callable
width: float
# public attributes
auto_ticker_length_anim_duration: float = NumericProperty(0.9)
""" duration in seconds of the iteration animation to determine the maximum text length.
:attr:`auto_ticker_length_anim_duration` is a :class:`~kivy.properties.NumericProperty` and defaults to 0.9 seconds.
"""
auto_ticker_offset_anim_speed: float = NumericProperty(9.6)
""" speed of the ticker text offset animation in characters per second.
:attr:`auto_ticker_length_anim_duration` is a :class:`~kivy.properties.NumericProperty` and defaults to 9.6.
"""
auto_ticker_text_spacing: float = NumericProperty('18sp')
""" horizontal padding between widget and texture width in pixels.
:attr:`auto_ticker_text_spacing` is a :class:`~kivy.properties.NumericProperty` and defaults to 18sp.
"""
# internal attributes
_bound_properties: Dict[str, Callable] #: properties of the mixing in widget to bind
_length_anim: Optional[Animation] = None #: shorten length animation
_min_text_len: int = 6 #: minimal length of shortened text
_offset_anim: Optional[Animation] = None #: ticker text offset animation
_ori_text: str = "" #: original/full text string
_ticker_text_offset: int = 0 #: current animated offset in the ticker text
_ticker_text_length: int = _min_text_len #: number of characters that are completely visible in widget
_ticker_text_updating: bool = False #: flag to block restart of ticker on internal update of `text` property
[docs] def __init__(self, **kwargs):
super().__init__(**kwargs)
self._bind_properties()
[docs] def _bind_properties(self):
self._bound_properties = dict(
height=self._start_length_anim,
text=self._text_changed,
width=self._start_length_anim,
)
self.bind(**self._bound_properties)
[docs] def _start_length_anim(self, *_args):
self.text = self._ori_text
self._stop_offset_anim()
self._stop_length_anim()
self._length_anim = Animation(_ticker_text_length=self._min_text_len,
d=self.auto_ticker_length_anim_duration, t='out_quad')
self._length_anim.bind(on_progress=self._ticker_length_progress)
self._length_anim.start(self)
[docs] def _start_offset_anim(self, offset_on_complete: int = 0):
self._offset_anim = Animation(
_ticker_text_offset=offset_on_complete,
d=(len(self._ori_text) - self._ticker_text_length) / self.auto_ticker_offset_anim_speed)
self._offset_anim.bind(on_progress=self._ticker_offset_progress)
self._offset_anim.start(self)
[docs] def _stop_length_anim(self, reset=True):
if self._length_anim:
self._length_anim.stop(self)
self._length_anim = None
if reset:
self._ticker_text_length = len(self._ori_text)
[docs] def _stop_offset_anim(self, reset=True):
if self._offset_anim:
self._offset_anim.stop(self)
self._offset_anim = None
if reset:
self._ticker_text_offset = 0
[docs] def _text_changed(self, *_args):
""" called on change of label text. assert _args[1] == self.text """
if not self._ticker_text_updating:
self._ori_text = self.text
self._start_length_anim()
[docs] def _ticker_length_progress(self, _anim: Animation, _self: Widget, progress: float):
if self.texture_size[0] < self.width - self.auto_ticker_text_spacing:
self._ticker_text_length = len(self.text)
self._ticker_text_offset = round(self._ticker_max_offset() / 2)
self._stop_length_anim(reset=False)
self._start_offset_anim()
else:
cut_off = int(progress * len(self._ori_text) / 2) + 3
self._ticker_text_update(self._ori_text[cut_off:-cut_off])
[docs] def _ticker_max_offset(self) -> int:
return round(len(self._ori_text) - self._ticker_text_length)
[docs] def _ticker_offset_progress(self, _anim: Animation, _self: Widget, progress: float):
beg = self._ticker_text_offset
end = beg + self._ticker_text_length
self._ticker_text_update(self._ori_text[int(beg):int(end)])
if progress >= 1.0 and self._offset_anim: # added `and self._offset_anim` for mypy
self._stop_offset_anim(reset=False)
if self.get_root_window():
Clock.schedule_once( # pragma: no cover
lambda _dt: self._start_offset_anim(offset_on_complete=0 if beg else self._ticker_max_offset()), 3)
else:
self._unbind_properties()
[docs] def _ticker_text_update(self, text: str):
if text != self.text:
self._ticker_text_updating = True
self.text = text
self.texture_update()
self._ticker_text_updating = False
[docs] def _unbind_properties(self):
if self._bound_properties:
self.unbind(**self._bound_properties)
Factory.register('SimpleAutoTickerBehavior', cls=SimpleAutoTickerBehavior)