Stav 23.06.2026

This commit is contained in:
2026-06-23 15:20:56 +02:00
commit 6d91e83e8c
5670 changed files with 1145969 additions and 0 deletions
@@ -0,0 +1,56 @@
'''
Widgets
=======
Widgets are elements of a graphical user interface that form part of the
`User Experience <http://en.wikipedia.org/wiki/User_experience>`_.
The `kivy.uix` module contains classes for creating and managing Widgets.
Please refer to the :doc:`api-kivy.uix.widget` documentation for further
information.
Kivy widgets can be categorized as follows:
- **UX widgets**: Classical user interface widgets, ready to be assembled to
create more complex widgets.
:doc:`api-kivy.uix.label`, :doc:`api-kivy.uix.button`,
:doc:`api-kivy.uix.checkbox`,
:doc:`api-kivy.uix.image`, :doc:`api-kivy.uix.slider`,
:doc:`api-kivy.uix.progressbar`, :doc:`api-kivy.uix.textinput`,
:doc:`api-kivy.uix.togglebutton`, :doc:`api-kivy.uix.switch`,
:doc:`api-kivy.uix.video`
- **Layouts**: A layout widget does no rendering but just acts as a trigger
that arranges its children in a specific way. Read more on
:doc:`Layouts here <api-kivy.uix.layout>`.
:doc:`api-kivy.uix.anchorlayout`, :doc:`api-kivy.uix.boxlayout`,
:doc:`api-kivy.uix.floatlayout`,
:doc:`api-kivy.uix.gridlayout`, :doc:`api-kivy.uix.pagelayout`,
:doc:`api-kivy.uix.relativelayout`, :doc:`api-kivy.uix.scatterlayout`,
:doc:`api-kivy.uix.stacklayout`
- **Complex UX widgets**: Non-atomic widgets that are the result of
combining multiple classic widgets.
We call them complex because their assembly and usage are not as
generic as the classical widgets.
:doc:`api-kivy.uix.bubble`, :doc:`api-kivy.uix.dropdown`,
:doc:`api-kivy.uix.filechooser`, :doc:`api-kivy.uix.popup`,
:doc:`api-kivy.uix.spinner`,
:doc:`api-kivy.uix.recycleview`,
:doc:`api-kivy.uix.tabbedpanel`, :doc:`api-kivy.uix.videoplayer`,
:doc:`api-kivy.uix.vkeyboard`,
- **Behaviors widgets**: These widgets do no rendering but act on the
graphics instructions or interaction (touch) behavior of their children.
:doc:`api-kivy.uix.scatter`, :doc:`api-kivy.uix.stencilview`
- **Screen manager**: Manages screens and transitions when switching
from one to another.
:doc:`api-kivy.uix.screenmanager`
----
'''
@@ -0,0 +1,484 @@
'''
Accordion
=========
.. versionadded:: 1.0.8
.. image:: images/accordion.jpg
:align: right
The Accordion widget is a form of menu where the options are stacked either
vertically or horizontally and the item in focus (when touched) opens up to
display its content.
The :class:`Accordion` should contain one or many :class:`AccordionItem`
instances, each of which should contain one root content widget. You'll end up
with a Tree something like this:
- Accordion
- AccordionItem
- YourContent
- AccordionItem
- BoxLayout
- Another user content 1
- Another user content 2
- AccordionItem
- Another user content
The current implementation divides the :class:`AccordionItem` into two parts:
#. One container for the title bar
#. One container for the content
The title bar is made from a Kv template. We'll see how to create a new
template to customize the design of the title bar.
.. warning::
If you see message like::
[WARNING] [Accordion] not have enough space for displaying all children
[WARNING] [Accordion] need 440px, got 100px
[WARNING] [Accordion] layout aborted.
That means you have too many children and there is no more space to
display the content. This is "normal" and nothing will be done. Try to
increase the space for the accordion or reduce the number of children. You
can also reduce the :attr:`Accordion.min_space`.
Simple example
--------------
.. include:: ../../examples/widgets/accordion_1.py
:literal:
Customize the accordion
-----------------------
You can increase the default size of the title bar::
root = Accordion(min_space=60)
Or change the orientation to vertical::
root = Accordion(orientation='vertical')
The AccordionItem is more configurable and you can set your own title
background when the item is collapsed or opened::
item = AccordionItem(background_normal='image_when_collapsed.png',
background_selected='image_when_selected.png')
'''
__all__ = ('Accordion', 'AccordionItem', 'AccordionException')
from kivy.animation import Animation
from kivy.uix.floatlayout import FloatLayout
from kivy.clock import Clock
from kivy.lang import Builder
from kivy.properties import (ObjectProperty, StringProperty,
BooleanProperty, NumericProperty,
ListProperty, OptionProperty, DictProperty)
from kivy.uix.widget import Widget
from kivy.logger import Logger
class AccordionException(Exception):
'''AccordionException class.
'''
pass
class AccordionItem(FloatLayout):
'''AccordionItem class that must be used in conjunction with the
:class:`Accordion` class. See the module documentation for more
information.
'''
title = StringProperty('')
'''Title string of the item. The title might be used in conjunction with the
`AccordionItemTitle` template. If you are using a custom template, you can
use that property as a text entry, or not. By default, it's used for the
title text. See title_template and the example below.
:attr:`title` is a :class:`~kivy.properties.StringProperty` and defaults
to ''.
'''
title_template = StringProperty('AccordionItemTitle')
'''Template to use for creating the title part of the accordion item. The
default template is a simple Label, not customizable (except the text) that
supports vertical and horizontal orientation and different backgrounds for
collapse and selected mode.
It's better to create and use your own template if the default template
does not suffice.
:attr:`title` is a :class:`~kivy.properties.StringProperty` and defaults to
'AccordionItemTitle'. The current default template lives in the
`kivy/data/style.kv` file.
Here is the code if you want to build your own template::
[AccordionItemTitle@Label]:
text: ctx.title
canvas.before:
Color:
rgb: 1, 1, 1
BorderImage:
source:
ctx.item.background_normal \
if ctx.item.collapse \
else ctx.item.background_selected
pos: self.pos
size: self.size
PushMatrix
Translate:
xy: self.center_x, self.center_y
Rotate:
angle: 90 if ctx.item.orientation == 'horizontal' else 0
axis: 0, 0, 1
Translate:
xy: -self.center_x, -self.center_y
canvas.after:
PopMatrix
'''
title_args = DictProperty({})
'''Default arguments that will be passed to the
:meth:`kivy.lang.Builder.template` method.
:attr:`title_args` is a :class:`~kivy.properties.DictProperty` and defaults
to {}.
'''
collapse = BooleanProperty(True)
'''Boolean to indicate if the current item is collapsed or not.
:attr:`collapse` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
collapse_alpha = NumericProperty(1.)
'''Value between 0 and 1 to indicate how much the item is collapsed (1) or
whether it is selected (0). It's mostly used for animation.
:attr:`collapse_alpha` is a :class:`~kivy.properties.NumericProperty` and
defaults to 1.
'''
accordion = ObjectProperty(None)
'''Instance of the :class:`Accordion` that the item belongs to.
:attr:`accordion` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None.
'''
background_normal = StringProperty(
'atlas://data/images/defaulttheme/button')
'''Background image of the accordion item used for the default graphical
representation when the item is collapsed.
:attr:`background_normal` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/button'.
'''
background_disabled_normal = StringProperty(
'atlas://data/images/defaulttheme/button_disabled')
'''Background image of the accordion item used for the default graphical
representation when the item is collapsed and disabled.
.. versionadded:: 1.8.0
:attr:`background__disabled_normal` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/button_disabled'.
'''
background_selected = StringProperty(
'atlas://data/images/defaulttheme/button_pressed')
'''Background image of the accordion item used for the default graphical
representation when the item is selected (not collapsed).
:attr:`background_normal` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/button_pressed'.
'''
background_disabled_selected = StringProperty(
'atlas://data/images/defaulttheme/button_disabled_pressed')
'''Background image of the accordion item used for the default graphical
representation when the item is selected (not collapsed) and disabled.
.. versionadded:: 1.8.0
:attr:`background_disabled_selected` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/button_disabled_pressed'.
'''
orientation = OptionProperty('vertical', options=(
'horizontal', 'vertical'))
'''Link to the :attr:`Accordion.orientation` property.
'''
min_space = NumericProperty('44dp')
'''Link to the :attr:`Accordion.min_space` property.
'''
content_size = ListProperty([100, 100])
'''(internal) Set by the :class:`Accordion` to the size allocated for the
content.
'''
container = ObjectProperty(None)
'''(internal) Property that will be set to the container of children inside
the AccordionItem representation.
'''
container_title = ObjectProperty(None)
'''(internal) Property that will be set to the container of title inside
the AccordionItem representation.
'''
def __init__(self, **kwargs):
self._trigger_title = Clock.create_trigger(self._update_title, -1)
self._anim_collapse = None
super(AccordionItem, self).__init__(**kwargs)
trigger_title = self._trigger_title
fbind = self.fbind
fbind('title', trigger_title)
fbind('title_template', trigger_title)
fbind('title_args', trigger_title)
trigger_title()
def add_widget(self, *args, **kwargs):
if self.container is None:
super(AccordionItem, self).add_widget(*args, **kwargs)
return
self.container.add_widget(*args, **kwargs)
def remove_widget(self, *args, **kwargs):
if self.container:
self.container.remove_widget(*args, **kwargs)
return
super(AccordionItem, self).remove_widget(*args, **kwargs)
def on_collapse(self, instance, value):
accordion = self.accordion
if accordion is None:
return
if not value:
self.accordion.select(self)
collapse_alpha = float(value)
if self._anim_collapse:
self._anim_collapse.stop(self)
self._anim_collapse = None
if self.collapse_alpha != collapse_alpha:
self._anim_collapse = Animation(
collapse_alpha=collapse_alpha,
t=accordion.anim_func,
d=accordion.anim_duration)
self._anim_collapse.start(self)
def on_collapse_alpha(self, instance, value):
self.accordion._trigger_layout()
def on_touch_down(self, touch):
if not self.collide_point(*touch.pos):
return
if self.disabled:
return True
if self.collapse:
self.collapse = False
return True
else:
return super(AccordionItem, self).on_touch_down(touch)
def _update_title(self, dt):
if not self.container_title:
self._trigger_title()
return
c = self.container_title
c.clear_widgets()
instance = Builder.template(self.title_template,
title=self.title,
item=self,
**self.title_args)
c.add_widget(instance)
class Accordion(Widget):
'''Accordion class. See module documentation for more information.
'''
orientation = OptionProperty('horizontal', options=(
'horizontal', 'vertical'))
'''Orientation of the layout.
:attr:`orientation` is an :class:`~kivy.properties.OptionProperty`
and defaults to 'horizontal'. Can take a value of 'vertical' or
'horizontal'.
'''
anim_duration = NumericProperty(.25)
'''Duration of the animation in seconds when a new accordion item is
selected.
:attr:`anim_duration` is a :class:`~kivy.properties.NumericProperty` and
defaults to .25 (250ms).
'''
anim_func = ObjectProperty('out_expo')
'''Easing function to use for the animation. Check
:class:`kivy.animation.AnimationTransition` for more information about
available animation functions.
:attr:`anim_func` is an :class:`~kivy.properties.ObjectProperty` and
defaults to 'out_expo'. You can set a string or a function to use as an
easing function.
'''
min_space = NumericProperty('44dp')
'''Minimum space to use for the title of each item. This value is
automatically set for each child every time the layout event occurs.
:attr:`min_space` is a :class:`~kivy.properties.NumericProperty` and
defaults to 44 (px).
'''
def __init__(self, **kwargs):
super(Accordion, self).__init__(**kwargs)
update = self._trigger_layout = \
Clock.create_trigger(self._do_layout, -1)
fbind = self.fbind
fbind('orientation', update)
fbind('children', update)
fbind('size', update)
fbind('pos', update)
fbind('min_space', update)
def add_widget(self, widget, *args, **kwargs):
if not isinstance(widget, AccordionItem):
raise AccordionException('Accordion accept only AccordionItem')
widget.accordion = self
super(Accordion, self).add_widget(widget, *args, **kwargs)
def select(self, instance):
if instance not in self.children:
raise AccordionException(
'Accordion: instance not found in children')
for widget in self.children:
widget.collapse = widget is not instance
self._trigger_layout()
def _do_layout(self, dt):
children = self.children
if children:
all_collapsed = all(x.collapse for x in children)
else:
all_collapsed = False
if all_collapsed:
children[0].collapse = False
orientation = self.orientation
min_space = self.min_space
min_space_total = len(children) * self.min_space
w, h = self.size
x, y = self.pos
if orientation == 'horizontal':
display_space = self.width - min_space_total
else:
display_space = self.height - min_space_total
if display_space <= 0:
Logger.warning('Accordion: not enough space '
'for displaying all children')
Logger.warning('Accordion: need %dpx, got %dpx' % (
min_space_total, min_space_total + display_space))
Logger.warning('Accordion: layout aborted.')
return
if orientation == 'horizontal':
children = reversed(children)
for child in children:
child_space = min_space
child_space += display_space * (1 - child.collapse_alpha)
child._min_space = min_space
child.x = x
child.y = y
child.orientation = self.orientation
if orientation == 'horizontal':
child.content_size = display_space, h
child.width = child_space
child.height = h
x += child_space
else:
child.content_size = w, display_space
child.width = w
child.height = child_space
y += child_space
if __name__ == '__main__':
from kivy.base import runTouchApp
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.label import Label
acc = Accordion()
for x in range(10):
item = AccordionItem(title='Title %d' % x)
if x == 0:
item.add_widget(Button(text='Content %d' % x))
elif x == 1:
z = BoxLayout(orientation='vertical')
z.add_widget(Button(text=str(x), size_hint_y=None, height=35))
z.add_widget(Label(text='Content %d' % x))
item.add_widget(z)
else:
item.add_widget(Label(text='This is a big content\n' * 20))
acc.add_widget(item)
def toggle_layout(*l):
o = acc.orientation
acc.orientation = 'vertical' if o == 'horizontal' else 'horizontal'
btn = Button(text='Toggle layout')
btn.bind(on_release=toggle_layout)
def select_2nd_item(*l):
acc.select(acc.children[-2])
btn2 = Button(text='Select 2nd item')
btn2.bind(on_release=select_2nd_item)
from kivy.uix.slider import Slider
slider = Slider()
def update_min_space(instance, value):
acc.min_space = value
slider.bind(value=update_min_space)
root = BoxLayout(spacing=20, padding=20)
controls = BoxLayout(orientation='vertical', size_hint_x=.3)
controls.add_widget(btn)
controls.add_widget(btn2)
controls.add_widget(slider)
root.add_widget(controls)
root.add_widget(acc)
runTouchApp(root)
@@ -0,0 +1,934 @@
'''
Action Bar
==========
.. versionadded:: 1.8.0
.. image:: images/actionbar.png
:align: right
The ActionBar widget is like Android's `ActionBar
<http://developer.android.com/guide/topics/ui/actionbar.html>`_, where items
are stacked horizontally. When the area becomes to small, widgets are moved
into the :class:`ActionOverflow` area.
An :class:`ActionBar` must contain an :class:`ActionView` with various
:class:`ContextualActionViews <kivy.uix.actionbar.ContextualActionView>`.
An :class:`ActionView` must contain a child :class:`ActionPrevious` which may
have title, app_icon and previous_icon properties. :class:`ActionView` children
must be
subclasses of :class:`ActionItems <ActionItem>`. Some predefined ones include
an :class:`ActionButton`, an :class:`ActionToggleButton`, an
:class:`ActionCheck`, an :class:`ActionSeparator` and an :class:`ActionGroup`.
An :class:`ActionGroup` is used to display :class:`ActionItems <ActionItem>`
in a group. An :class:`ActionView` will always display an :class:`ActionGroup`
after other :class:`ActionItems <ActionItem>`. An :class:`ActionView` contains
an :class:`ActionOverflow`, but this is only made visible when required i.e.
the available area is too small to fit all the widgets. A
:class:`ContextualActionView` is a subclass of an:class:`ActionView`.
.. versionchanged:: 1.10.1
:class:`ActionGroup` core rewritten from :class:`Spinner` to pure
:class:`DropDown`
'''
__all__ = ('ActionBarException', 'ActionItem', 'ActionButton',
'ActionToggleButton', 'ActionCheck', 'ActionSeparator',
'ActionDropDown', 'ActionGroup', 'ActionOverflow',
'ActionView', 'ContextualActionView', 'ActionPrevious',
'ActionBar')
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.dropdown import DropDown
from kivy.uix.widget import Widget
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton
from kivy.uix.checkbox import CheckBox
from kivy.uix.spinner import Spinner
from kivy.uix.label import Label
from kivy.config import Config
from kivy.properties import ObjectProperty, NumericProperty, BooleanProperty, \
StringProperty, ListProperty, OptionProperty, AliasProperty, ColorProperty
from kivy.metrics import sp
from kivy.lang import Builder
from functools import partial
window_icon = ''
if Config:
window_icon = Config.get('kivy', 'window_icon')
class ActionBarException(Exception):
'''
ActionBarException class
'''
pass
class ActionItem(object):
'''
ActionItem class, an abstract class for all ActionBar widgets. To create a
custom widget for an ActionBar, inherit from this class. See module
documentation for more information.
'''
minimum_width = NumericProperty('90sp')
'''
Minimum Width required by an ActionItem.
:attr:`minimum_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to '90sp'.
'''
def get_pack_width(self):
return max(self.minimum_width, self.width)
pack_width = AliasProperty(get_pack_width,
bind=('minimum_width', 'width'),
cache=True)
'''
(read-only) The actual width to use when packing the items. Equal to the
greater of minimum_width and width.
:attr:`pack_width` is an :class:`~kivy.properties.AliasProperty`.
'''
important = BooleanProperty(False)
'''
Determines if an ActionItem is important or not. If an item is important
and space is limited, this item will be displayed in preference to others.
:attr:`important` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
inside_group = BooleanProperty(False)
'''
(internal) Determines if an ActionItem is displayed inside an
ActionGroup or not.
:attr:`inside_group` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
background_normal = StringProperty(
'atlas://data/images/defaulttheme/action_item')
'''
Background image of the ActionItem used for the default graphical
representation when the ActionItem is not pressed.
:attr:`background_normal` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/action_item'.
'''
background_down = StringProperty(
'atlas://data/images/defaulttheme/action_item_down')
'''
Background image of the ActionItem used for the default graphical
representation when an ActionItem is pressed.
:attr:`background_down` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/action_item_down'.
'''
mipmap = BooleanProperty(True)
'''
Defines whether the image/icon dispayed on top of the button uses a
mipmap or not.
:attr:`mipmap` is a :class:`~kivy.properties.BooleanProperty` and
defaults to `True`.
'''
class ActionButton(Button, ActionItem):
'''
ActionButton class, see module documentation for more information.
The text color, width and size_hint_x are set manually via the Kv language
file. It covers a lot of cases: with/without an icon, with/without a group
and takes care of the padding between elements.
You don't have much control over these properties, so if you want to
customize its appearance, we suggest you create you own button
representation. You can do this by creating a class that subclasses an
existing widget and an :class:`ActionItem`::
class MyOwnActionButton(Button, ActionItem):
pass
You can then create your own style using the Kv language.
'''
icon = StringProperty(None, allownone=True)
'''
Source image to use when the Button is part of the ActionBar. If the
Button is in a group, the text will be preferred.
:attr:`icon` is a :class:`~kivy.properties.StringProperty` and defaults
to None.
'''
class ActionPrevious(BoxLayout, ActionItem):
'''
ActionPrevious class, see module documentation for more information.
'''
with_previous = BooleanProperty(True)
'''
Specifies whether the previous_icon will be shown or not. Note that it is
up to the user to implement the desired behavior using the *on_press* or
similar events.
:attr:`with_previous` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
app_icon = StringProperty(window_icon)
'''
Application icon for the ActionView.
:attr:`app_icon` is a :class:`~kivy.properties.StringProperty`
and defaults to the window icon if set, otherwise
'data/logo/kivy-icon-32.png'.
'''
app_icon_width = NumericProperty(0)
'''
Width of app_icon image.
:attr:`app_icon_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
app_icon_height = NumericProperty(0)
'''
Height of app_icon image.
:attr:`app_icon_height` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.
'''
color = ColorProperty([1, 1, 1, 1])
'''
Text color, in the format (r, g, b, a)
:attr:`color` is a :class:`~kivy.properties.ColorProperty` and defaults
to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
previous_image = StringProperty(
'atlas://data/images/defaulttheme/previous_normal')
'''
Image for the 'previous' ActionButtons default graphical representation.
:attr:`previous_image` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/previous_normal'.
'''
previous_image_width = NumericProperty(0)
'''
Width of previous_image image.
:attr:`width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
previous_image_height = NumericProperty(0)
'''
Height of previous_image image.
:attr:`app_icon_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
title = StringProperty('')
'''
Title for ActionView.
:attr:`title` is a :class:`~kivy.properties.StringProperty` and
defaults to ''.
'''
markup = BooleanProperty(False)
'''
If True, the text will be rendered using the
:class:`~kivy.core.text.markup.MarkupLabel`: you can change the style of
the text using tags. Check the :doc:`api-kivy.core.text.markup`
documentation for more information.
:attr:`markup` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
def __init__(self, **kwargs):
self.register_event_type('on_press')
self.register_event_type('on_release')
super(ActionPrevious, self).__init__(**kwargs)
if not self.app_icon:
self.app_icon = 'data/logo/kivy-icon-32.png'
def on_press(self):
pass
def on_release(self):
pass
class ActionToggleButton(ActionItem, ToggleButton):
'''
ActionToggleButton class, see module documentation for more information.
'''
icon = StringProperty(None, allownone=True)
'''
Source image to use when the Button is part of the ActionBar. If the
Button is in a group, the text will be preferred.
'''
class ActionLabel(ActionItem, Label):
'''
ActionLabel class, see module documentation for more information.
'''
pass
class ActionCheck(ActionItem, CheckBox):
'''
ActionCheck class, see module documentation for more information.
'''
pass
class ActionSeparator(ActionItem, Widget):
'''
ActionSeparator class, see module documentation for more information.
'''
background_image = StringProperty(
'atlas://data/images/defaulttheme/separator')
'''
Background image for the separators default graphical representation.
:attr:`background_image` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/separator'.
'''
class ActionDropDown(DropDown):
'''
ActionDropDown class, see module documentation for more information.
'''
class ActionGroup(ActionItem, Button):
'''
ActionGroup class, see module documentation for more information.
'''
use_separator = BooleanProperty(False)
'''
Specifies whether to use a separator after/before this group or not.
:attr:`use_separator` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
separator_image = StringProperty(
'atlas://data/images/defaulttheme/separator')
'''
Background Image for an ActionSeparator in an ActionView.
:attr:`separator_image` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/separator'.
'''
separator_width = NumericProperty(0)
'''
Width of the ActionSeparator in an ActionView.
:attr:`separator_width` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.
'''
mode = OptionProperty('normal', options=('normal', 'spinner'))
'''
Sets the current mode of an ActionGroup. If mode is 'normal', the
ActionGroups children will be displayed normally if there is enough
space, otherwise they will be displayed in a spinner. If mode is
'spinner', then the children will always be displayed in a spinner.
:attr:`mode` is an :class:`~kivy.properties.OptionProperty` and defaults
to 'normal'.
'''
dropdown_width = NumericProperty(0)
'''
If non zero, provides the width for the associated DropDown. This is
useful when some items in the ActionGroup's DropDown are wider than usual
and you don't want to make the ActionGroup widget itself wider.
:attr:`dropdown_width` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.
.. versionadded:: 1.10.0
'''
is_open = BooleanProperty(False)
'''By default, the DropDown is not open. Set to True to open it.
:attr:`is_open` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
def __init__(self, **kwargs):
self.list_action_item = []
self._list_overflow_items = []
super(ActionGroup, self).__init__(**kwargs)
# real is_open independent on public event
self._is_open = False
# create DropDown for the group and save its state to _is_open
self._dropdown = ActionDropDown()
self._dropdown.bind(attach_to=lambda ins, value: setattr(
self, '_is_open', True if value else False
))
# put open/close responsibility to the event
# - trigger dropdown opening when clicked
self.bind(on_release=lambda *args: setattr(
self, 'is_open', True
))
# - trigger dropdown closing when an item
# in the dropdown is clicked
self._dropdown.bind(on_dismiss=lambda *args: setattr(
self, 'is_open', False
))
def on_is_open(self, instance, value):
# opening only if the DropDown is closed
if value and not self._is_open:
self._toggle_dropdown()
self._dropdown.open(self)
return
# closing is_open manually, dismiss manually
if not value and self._is_open:
self._dropdown.dismiss()
def _toggle_dropdown(self, *largs):
ddn = self._dropdown
ddn.size_hint_x = None
# if container was set incorrectly and/or is missing
if not ddn.container:
return
children = ddn.container.children
# set DropDown width manually or if not set, then widen
# the ActionGroup + DropDown until the widest child fits
if children:
ddn.width = self.dropdown_width or max(
self.width, max(c.pack_width for c in children)
)
else:
ddn.width = self.width
# set the DropDown children's height
for item in children:
item.size_hint_y = None
item.height = max([self.height, sp(48)])
# dismiss DropDown manually
# auto_dismiss applies to touching outside of the DropDown
item.bind(on_release=ddn.dismiss)
def add_widget(self, widget, *args, **kwargs):
'''
.. versionchanged:: 2.1.0
Renamed argument `item` to `widget`.
'''
# if adding ActionSeparator ('normal' mode,
# everything visible), add it to the parent
if isinstance(widget, ActionSeparator):
super(ActionGroup, self).add_widget(widget, *args, **kwargs)
return
if not isinstance(widget, ActionItem):
raise ActionBarException('ActionGroup only accepts ActionItem')
self.list_action_item.append(widget)
def show_group(self):
# 'normal' mode, items can fit to the view
self.clear_widgets()
for item in self._list_overflow_items + self.list_action_item:
item.inside_group = True
self._dropdown.add_widget(item)
def clear_widgets(self, *args, **kwargs):
self._dropdown.clear_widgets(*args, **kwargs)
class ActionOverflow(ActionGroup):
'''
ActionOverflow class, see module documentation for more information.
'''
overflow_image = StringProperty(
'atlas://data/images/defaulttheme/overflow')
'''
Image to be used as an Overflow Image.
:attr:`overflow_image` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/overflow'.
'''
def add_widget(self, widget, index=0, *args, **kwargs):
'''
.. versionchanged:: 2.1.0
Renamed argument `action_item` to `widget`.
'''
if widget is None:
return
if isinstance(widget, ActionSeparator):
return
if not isinstance(widget, ActionItem):
raise ActionBarException('ActionView only accepts ActionItem'
' (got {!r}'.format(widget))
else:
if index == 0:
index = len(self._list_overflow_items)
self._list_overflow_items.insert(index, widget)
def show_default_items(self, parent):
# display overflow and its items if widget's directly added to it
if self._list_overflow_items == []:
return
self.show_group()
super(ActionView, parent).add_widget(self)
class ActionView(BoxLayout):
'''
ActionView class, see module documentation for more information.
'''
action_previous = ObjectProperty(None)
'''
Previous button for an ActionView.
:attr:`action_previous` is an :class:`~kivy.properties.ObjectProperty`
and defaults to None.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''
Background color in the format (r, g, b, a).
:attr:`background_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
background_image = StringProperty(
'atlas://data/images/defaulttheme/action_view')
'''
Background image of an ActionViews default graphical representation.
:attr:`background_image` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/action_view'.
'''
use_separator = BooleanProperty(False)
'''
Specify whether to use a separator before every ActionGroup or not.
:attr:`use_separator` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
overflow_group = ObjectProperty(None)
'''
Widget to be used for the overflow.
:attr:`overflow_group` is an :class:`~kivy.properties.ObjectProperty` and
defaults to an instance of :class:`ActionOverflow`.
'''
def __init__(self, **kwargs):
self._list_action_items = []
self._list_action_group = []
super(ActionView, self).__init__(**kwargs)
self._state = ''
if not self.overflow_group:
self.overflow_group = ActionOverflow(
use_separator=self.use_separator)
def on_action_previous(self, instance, value):
self._list_action_items.insert(0, value)
def add_widget(self, widget, index=0, *args, **kwargs):
'''
.. versionchanged:: 2.1.0
Renamed argument `action_item` to `widget`.
'''
if widget is None:
return
if not isinstance(widget, ActionItem):
raise ActionBarException('ActionView only accepts ActionItem'
' (got {!r}'.format(widget))
elif isinstance(widget, ActionOverflow):
self.overflow_group = widget
widget.use_separator = self.use_separator
elif isinstance(widget, ActionGroup):
self._list_action_group.append(widget)
widget.use_separator = self.use_separator
elif isinstance(widget, ActionPrevious):
self.action_previous = widget
else:
super(ActionView, self).add_widget(widget, index, *args, **kwargs)
if index == 0:
index = len(self._list_action_items)
self._list_action_items.insert(index, widget)
def on_use_separator(self, instance, value):
for group in self._list_action_group:
group.use_separator = value
if self.overflow_group:
self.overflow_group.use_separator = value
def remove_widget(self, widget, *args, **kwargs):
super(ActionView, self).remove_widget(widget, *args, **kwargs)
if isinstance(widget, ActionOverflow):
for item in widget.list_action_item:
if item in self._list_action_items:
self._list_action_items.remove(item)
if widget in self._list_action_items:
self._list_action_items.remove(widget)
def _clear_all(self):
lst = self._list_action_items[:]
self.clear_widgets()
for group in self._list_action_group:
group.clear_widgets()
self.overflow_group.clear_widgets()
self.overflow_group.list_action_item = []
self._list_action_items = lst
def _layout_all(self):
# all the items can fit to the view, so expand everything
super_add = super(ActionView, self).add_widget
self._state = 'all'
self._clear_all()
if not self.action_previous.parent:
super_add(self.action_previous)
if len(self._list_action_items) > 1:
for child in self._list_action_items[1:]:
child.inside_group = False
super_add(child)
for group in self._list_action_group:
if group.mode == 'spinner':
super_add(group)
group.show_group()
else:
if group.list_action_item != []:
super_add(ActionSeparator())
for child in group.list_action_item:
child.inside_group = False
super_add(child)
self.overflow_group.show_default_items(self)
def _layout_group(self):
# layout all the items in order to pack them per group
super_add = super(ActionView, self).add_widget
self._state = 'group'
self._clear_all()
if not self.action_previous.parent:
super_add(self.action_previous)
if len(self._list_action_items) > 1:
for child in self._list_action_items[1:]:
super_add(child)
child.inside_group = False
for group in self._list_action_group:
super_add(group)
group.show_group()
self.overflow_group.show_default_items(self)
def _layout_random(self):
# layout the items in order to pack all of them grouped, and display
# only the action items having 'important'
super_add = super(ActionView, self).add_widget
self._state = 'random'
self._clear_all()
hidden_items = []
hidden_groups = []
total_width = 0
if not self.action_previous.parent:
super_add(self.action_previous)
width = (self.width - self.overflow_group.pack_width -
self.action_previous.minimum_width)
if len(self._list_action_items):
for child in self._list_action_items[1:]:
if child.important:
if child.pack_width + total_width < width:
super_add(child)
child.inside_group = False
total_width += child.pack_width
else:
hidden_items.append(child)
else:
hidden_items.append(child)
# if space is left then display ActionItem inside their
# ActionGroup
if total_width < self.width:
for group in self._list_action_group:
if group.pack_width + total_width +\
group.separator_width < width:
super_add(group)
group.show_group()
total_width += (group.pack_width +
group.separator_width)
else:
hidden_groups.append(group)
group_index = len(self.children) - 1
# if space is left then display other ActionItems
if total_width < self.width:
for child in hidden_items[:]:
if child.pack_width + total_width < width:
super_add(child, group_index)
total_width += child.pack_width
child.inside_group = False
hidden_items.remove(child)
# for all the remaining ActionItems and ActionItems with in
# ActionGroups, Display them inside overflow_group
extend_hidden = hidden_items.extend
for group in hidden_groups:
extend_hidden(group.list_action_item)
overflow_group = self.overflow_group
if hidden_items != []:
over_add = super(overflow_group.__class__,
overflow_group).add_widget
for child in hidden_items:
over_add(child)
overflow_group.show_group()
if not self.overflow_group.parent:
super_add(overflow_group)
def on_width(self, width, *args):
# determine the layout to use
# can we display all of them?
total_width = 0
for child in self._list_action_items:
total_width += child.pack_width
for group in self._list_action_group:
for child in group.list_action_item:
total_width += child.pack_width
if total_width <= self.width:
if self._state != 'all':
self._layout_all()
return
# can we display them per group?
total_width = 0
for child in self._list_action_items:
total_width += child.pack_width
for group in self._list_action_group:
total_width += group.pack_width
if total_width < self.width:
# ok, we can display all the items grouped
if self._state != 'group':
self._layout_group()
return
# none of the solutions worked, display them in pack mode
self._layout_random()
class ContextualActionView(ActionView):
'''
ContextualActionView class, see the module documentation for more
information.
'''
pass
class ActionBar(BoxLayout):
'''
ActionBar class, which acts as the main container for an
:class:`ActionView` instance. The ActionBar determines the overall
styling aspects of the bar. :class:`ActionItem`\\s are not added to
this class directly, but to the contained :class:`ActionView` instance.
:Events:
`on_previous`
Fired when action_previous of action_view is pressed.
Please see the module documentation for more information.
'''
action_view = ObjectProperty(None)
'''
action_view of the ActionBar.
:attr:`action_view` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None or the last ActionView instance added to the ActionBar.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''
Background color, in the format (r, g, b, a).
:attr:`background_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
background_image = StringProperty(
'atlas://data/images/defaulttheme/action_bar')
'''
Background image of the ActionBars default graphical representation.
:attr:`background_image` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/action_bar'.
'''
border = ListProperty([2, 2, 2, 2])
'''
The border to be applied to the :attr:`background_image`.
:attr:`border` is a :class:`~kivy.properties.ListProperty` and defaults to
[2, 2, 2, 2]
'''
__events__ = ('on_previous',)
def __init__(self, **kwargs):
super(ActionBar, self).__init__(**kwargs)
self._stack_cont_action_view = []
self._emit_previous = partial(self.dispatch, 'on_previous')
def add_widget(self, widget, *args, **kwargs):
'''
.. versionchanged:: 2.1.0
Renamed argument `view` to `widget`.
'''
if isinstance(widget, ContextualActionView):
self._stack_cont_action_view.append(widget)
if widget.action_previous is not None:
widget.action_previous.unbind(on_release=self._emit_previous)
widget.action_previous.bind(on_release=self._emit_previous)
self.clear_widgets()
super(ActionBar, self).add_widget(widget, *args, **kwargs)
elif isinstance(widget, ActionView):
self.action_view = widget
super(ActionBar, self).add_widget(widget, *args, **kwargs)
else:
raise ActionBarException(
'ActionBar can only add ContextualActionView or ActionView')
def on_previous(self, *args):
self._pop_contextual_action_view()
def _pop_contextual_action_view(self):
'''Remove the current ContextualActionView and display either the
previous one or the ActionView.
'''
self._stack_cont_action_view.pop()
self.clear_widgets()
if self._stack_cont_action_view == []:
super(ActionBar, self).add_widget(self.action_view)
else:
super(ActionBar, self).add_widget(self._stack_cont_action_view[-1])
if __name__ == "__main__":
from kivy.base import runTouchApp
from kivy.uix.floatlayout import FloatLayout
from kivy.factory import Factory
# XXX clean the first registration done from '__main__' here.
# otherwise kivy.uix.actionbar.ActionPrevious != __main__.ActionPrevious
Factory.unregister('ActionPrevious')
Builder.load_string('''
<MainWindow>:
ActionBar:
pos_hint: {'top':1}
ActionView:
use_separator: True
ActionPrevious:
title: 'Action Bar'
with_previous: False
ActionOverflow:
ActionButton:
text: 'Btn0'
icon: 'atlas://data/images/defaulttheme/audio-volume-high'
ActionButton:
text: 'Btn1'
ActionButton:
text: 'Btn2'
ActionGroup:
text: 'Group 1'
ActionButton:
text: 'Btn3'
ActionButton:
text: 'Btn4'
ActionGroup:
dropdown_width: 200
text: 'Group 2'
ActionButton:
text: 'Btn5'
ActionButton:
text: 'Btn6'
ActionButton:
text: 'Btn7'
''')
class MainWindow(FloatLayout):
pass
float_layout = MainWindow()
runTouchApp(float_layout)
@@ -0,0 +1,122 @@
'''
Anchor Layout
=============
.. only:: html
.. image:: images/anchorlayout.gif
:align: right
.. only:: latex
.. image:: images/anchorlayout.png
:align: right
The :class:`AnchorLayout` aligns its children to a border (top, bottom,
left, right) or center.
To draw a button in the lower-right corner::
layout = AnchorLayout(
anchor_x='right', anchor_y='bottom')
btn = Button(text='Hello World')
layout.add_widget(btn)
'''
__all__ = ('AnchorLayout', )
from kivy.uix.layout import Layout
from kivy.properties import OptionProperty, VariableListProperty
class AnchorLayout(Layout):
'''Anchor layout class. See the module documentation for more information.
'''
padding = VariableListProperty([0, 0, 0, 0])
'''Padding between the widget box and its children, in pixels:
[padding_left, padding_top, padding_right, padding_bottom].
padding also accepts a two argument form [padding_horizontal,
padding_vertical] and a one argument form [padding].
:attr:`padding` is a :class:`~kivy.properties.VariableListProperty` and
defaults to [0, 0, 0, 0].
'''
anchor_x = OptionProperty('center', options=(
'left', 'center', 'right'))
'''Horizontal anchor.
:attr:`anchor_x` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'center'. It accepts values of 'left', 'center' or
'right'.
'''
anchor_y = OptionProperty('center', options=(
'top', 'center', 'bottom'))
'''Vertical anchor.
:attr:`anchor_y` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'center'. It accepts values of 'top', 'center' or
'bottom'.
'''
def __init__(self, **kwargs):
super(AnchorLayout, self).__init__(**kwargs)
fbind = self.fbind
update = self._trigger_layout
fbind('children', update)
fbind('parent', update)
fbind('padding', update)
fbind('anchor_x', update)
fbind('anchor_y', update)
fbind('size', update)
fbind('pos', update)
def do_layout(self, *largs):
_x, _y = self.pos
width = self.width
height = self.height
anchor_x = self.anchor_x
anchor_y = self.anchor_y
pad_left, pad_top, pad_right, pad_bottom = self.padding
for c in self.children:
x, y = _x, _y
cw, ch = c.size
shw, shh = c.size_hint
shw_min, shh_min = c.size_hint_min
shw_max, shh_max = c.size_hint_max
if shw is not None:
cw = shw * (width - pad_left - pad_right)
if shw_min is not None and cw < shw_min:
cw = shw_min
elif shw_max is not None and cw > shw_max:
cw = shw_max
if shh is not None:
ch = shh * (height - pad_top - pad_bottom)
if shh_min is not None and ch < shh_min:
ch = shh_min
elif shh_max is not None and ch > shh_max:
ch = shh_max
if anchor_x == 'left':
x = x + pad_left
elif anchor_x == 'right':
x = x + width - (cw + pad_right)
else:
x = x + (width - pad_right + pad_left - cw) / 2
if anchor_y == 'bottom':
y = y + pad_bottom
elif anchor_y == 'top':
y = y + height - (ch + pad_top)
else:
y = y + (height - pad_top + pad_bottom - ch) / 2
c.pos = x, y
c.size = cw, ch
@@ -0,0 +1,95 @@
'''
Behaviors
=========
.. versionadded:: 1.8.0
Behavior mixin classes
----------------------
This module implements behaviors that can be
`mixed in <https://en.wikipedia.org/wiki/Mixin>`_
with existing base widgets. The idea behind these classes is to encapsulate
properties and events associated with certain types of widgets.
Isolating these properties and events in a mixin class allows you to define
your own implementation for standard kivy widgets that can act as drop-in
replacements. This means you can re-style and re-define widgets as desired
without breaking compatibility: as long as they implement the behaviors
correctly, they can simply replace the standard widgets.
Adding behaviors
----------------
Say you want to add :class:`~kivy.uix.button.Button` capabilities to an
:class:`~kivy.uix.image.Image`, you could do::
class IconButton(ButtonBehavior, Image):
pass
This would give you an :class:`~kivy.uix.image.Image` with the events and
properties inherited from :class:`ButtonBehavior`. For example, the *on_press*
and *on_release* events would be fired when appropriate::
class IconButton(ButtonBehavior, Image):
def on_press(self):
print("on_press")
Or in kv:
.. code-block:: kv
IconButton:
on_press: print('on_press')
Naturally, you could also bind to any property changes the behavior class
offers:
.. code-block:: python
def state_changed(*args):
print('state changed')
button = IconButton()
button.bind(state=state_changed)
.. note::
The behavior class must always be _before_ the widget class. If you don't
specify the inheritance in this order, the behavior will not work because
the behavior methods are overwritten by the class method listed first.
Similarly, if you combine a behavior class with a class which
requires the use of the methods also defined by the behavior class, the
resulting class may not function properly. For example, when combining the
:class:`ButtonBehavior` with a :class:`~kivy.uix.slider.Slider`, both of
which use the :meth:`~kivy.uix.widget.Widget.on_touch_up` method,
the resulting class may not work properly.
.. versionchanged:: 1.9.1
The individual behavior classes, previously in one big `behaviors.py`
file, has been split into a single file for each class under the
:mod:`~kivy.uix.behaviors` module. All the behaviors are still imported
in the :mod:`~kivy.uix.behaviors` module so they are accessible as before
(e.g. both `from kivy.uix.behaviors import ButtonBehavior` and
`from kivy.uix.behaviors.button import ButtonBehavior` work).
'''
__all__ = ('ButtonBehavior', 'ToggleButtonBehavior', 'DragBehavior',
'FocusBehavior', 'CompoundSelectionBehavior',
'CodeNavigationBehavior', 'EmacsBehavior', 'CoverBehavior',
'TouchRippleBehavior', 'TouchRippleButtonBehavior')
from kivy.uix.behaviors.button import ButtonBehavior
from kivy.uix.behaviors.togglebutton import ToggleButtonBehavior
from kivy.uix.behaviors.drag import DragBehavior
from kivy.uix.behaviors.focus import FocusBehavior
from kivy.uix.behaviors.compoundselection import CompoundSelectionBehavior
from kivy.uix.behaviors.codenavigation import CodeNavigationBehavior
from kivy.uix.behaviors.emacs import EmacsBehavior
from kivy.uix.behaviors.cover import CoverBehavior
from kivy.uix.behaviors.touchripple import TouchRippleBehavior
from kivy.uix.behaviors.touchripple import TouchRippleButtonBehavior
@@ -0,0 +1,212 @@
'''
Button Behavior
===============
The :class:`~kivy.uix.behaviors.button.ButtonBehavior`
`mixin <https://en.wikipedia.org/wiki/Mixin>`_ class provides
:class:`~kivy.uix.button.Button` behavior. You can combine this class with
other widgets, such as an :class:`~kivy.uix.image.Image`, to provide
alternative buttons that preserve Kivy button behavior.
For an overview of behaviors, please refer to the :mod:`~kivy.uix.behaviors`
documentation.
Example
-------
The following example adds button behavior to an image to make a checkbox that
behaves like a button::
from kivy.app import App
from kivy.uix.image import Image
from kivy.uix.behaviors import ButtonBehavior
class MyButton(ButtonBehavior, Image):
def __init__(self, **kwargs):
super(MyButton, self).__init__(**kwargs)
self.source = 'atlas://data/images/defaulttheme/checkbox_off'
def on_press(self):
self.source = 'atlas://data/images/defaulttheme/checkbox_on'
def on_release(self):
self.source = 'atlas://data/images/defaulttheme/checkbox_off'
class SampleApp(App):
def build(self):
return MyButton()
SampleApp().run()
See :class:`~kivy.uix.behaviors.ButtonBehavior` for details.
'''
__all__ = ('ButtonBehavior', )
from kivy.clock import Clock
from kivy.config import Config
from kivy.properties import OptionProperty, ObjectProperty, \
BooleanProperty, NumericProperty
from time import time
class ButtonBehavior(object):
'''
This `mixin <https://en.wikipedia.org/wiki/Mixin>`_ class provides
:class:`~kivy.uix.button.Button` behavior. Please see the
:mod:`button behaviors module <kivy.uix.behaviors.button>` documentation
for more information.
:Events:
`on_press`
Fired when the button is pressed.
`on_release`
Fired when the button is released (i.e. the touch/click that
pressed the button goes away).
'''
state = OptionProperty('normal', options=('normal', 'down'))
'''The state of the button, must be one of 'normal' or 'down'.
The state is 'down' only when the button is currently touched/clicked,
otherwise its 'normal'.
:attr:`state` is an :class:`~kivy.properties.OptionProperty` and defaults
to 'normal'.
'''
last_touch = ObjectProperty(None)
'''Contains the last relevant touch received by the Button. This can
be used in `on_press` or `on_release` in order to know which touch
dispatched the event.
.. versionadded:: 1.8.0
:attr:`last_touch` is a :class:`~kivy.properties.ObjectProperty` and
defaults to `None`.
'''
min_state_time = NumericProperty(0)
'''The minimum period of time which the widget must remain in the
`'down'` state.
.. versionadded:: 1.9.1
:attr:`min_state_time` is a float and defaults to 0.035. This value is
taken from :class:`~kivy.config.Config`.
'''
always_release = BooleanProperty(False)
'''This determines whether or not the widget fires an `on_release` event if
the touch_up is outside the widget.
.. versionadded:: 1.9.0
.. versionchanged:: 1.10.0
The default value is now False.
:attr:`always_release` is a :class:`~kivy.properties.BooleanProperty` and
defaults to `False`.
'''
def __init__(self, **kwargs):
self.register_event_type('on_press')
self.register_event_type('on_release')
if 'min_state_time' not in kwargs:
self.min_state_time = float(Config.get('graphics',
'min_state_time'))
super(ButtonBehavior, self).__init__(**kwargs)
self.__state_event = None
self.__touch_time = None
self.fbind('state', self.cancel_event)
def _do_press(self):
self.state = 'down'
def _do_release(self, *args):
self.state = 'normal'
def cancel_event(self, *args):
if self.__state_event:
self.__state_event.cancel()
self.__state_event = None
def on_touch_down(self, touch):
if super(ButtonBehavior, self).on_touch_down(touch):
return True
if touch.is_mouse_scrolling:
return False
if not self.collide_point(touch.x, touch.y):
return False
if self in touch.ud:
return False
touch.grab(self)
touch.ud[self] = True
self.last_touch = touch
self.__touch_time = time()
self._do_press()
self.dispatch('on_press')
return True
def on_touch_move(self, touch):
if touch.grab_current is self:
return True
if super(ButtonBehavior, self).on_touch_move(touch):
return True
return self in touch.ud
def on_touch_up(self, touch):
if touch.grab_current is not self:
return super(ButtonBehavior, self).on_touch_up(touch)
assert self in touch.ud
touch.ungrab(self)
self.last_touch = touch
if (not self.always_release and
not self.collide_point(*touch.pos)):
self._do_release()
return
touchtime = time() - self.__touch_time
if touchtime < self.min_state_time:
self.__state_event = Clock.schedule_once(
self._do_release, self.min_state_time - touchtime)
else:
self._do_release()
self.dispatch('on_release')
return True
def on_press(self):
pass
def on_release(self):
pass
def trigger_action(self, duration=0.1):
'''Trigger whatever action(s) have been bound to the button by calling
both the on_press and on_release callbacks.
This is similar to a quick button press without using any touch events,
but note that like most kivy code, this is not guaranteed to be safe to
call from external threads. If needed use
:class:`Clock <kivy.clock.Clock>` to safely schedule this function and
the resulting callbacks to be called from the main thread.
Duration is the length of the press in seconds. Pass 0 if you want
the action to happen instantly.
.. versionadded:: 1.8.0
'''
self._do_press()
self.dispatch('on_press')
def trigger_release(dt):
self._do_release()
self.dispatch('on_release')
if not duration:
trigger_release(0)
else:
Clock.schedule_once(trigger_release, duration)
@@ -0,0 +1,167 @@
'''
Code Navigation Behavior
========================
The :class:`~kivy.uix.bahviors.CodeNavigationBehavior` modifies navigation
behavior in the :class:`~kivy.uix.textinput.TextInput`, making it work like an
IDE instead of a word processor.
Using this mixin gives the TextInput the ability to recognize whitespace,
punctuation and case variations (e.g. CamelCase) when moving over text. It
is currently used by the :class:`~kivy.uix.codeinput.CodeInput` widget.
'''
__all__ = ('CodeNavigationBehavior', )
from kivy.event import EventDispatcher
import string
class CodeNavigationBehavior(EventDispatcher):
'''Code navigation behavior. Modifies the navigation behavior in TextInput
to work like an IDE instead of a word processor. Please see the
:mod:`code navigation behaviors module <kivy.uix.behaviors.codenavigation>`
documentation for more information.
.. versionadded:: 1.9.1
'''
def _move_cursor_word_left(self, index=None):
pos = index or self.cursor_index()
pos -= 1
if pos == 0:
return 0, 0
col, row = self.get_cursor_from_index(pos)
lines = self._lines
ucase = string.ascii_uppercase
lcase = string.ascii_lowercase
ws = string.whitespace
punct = string.punctuation
mode = 'normal'
rline = lines[row]
c = rline[col] if len(rline) > col else '\n'
if c in ws:
mode = 'ws'
elif c == '_':
mode = 'us'
elif c in punct:
mode = 'punct'
elif c not in ucase:
mode = 'camel'
while True:
if col == -1:
if row == 0:
return 0, 0
row -= 1
rline = lines[row]
col = len(rline)
lc = c
c = rline[col] if len(rline) > col else '\n'
if c == '\n':
if lc not in ws:
col += 1
break
if mode in ('normal', 'camel') and c in ws:
col += 1
break
if mode in ('normal', 'camel') and c in punct:
col += 1
break
if mode == 'camel' and c in ucase:
break
if mode == 'punct' and (c == '_' or c not in punct):
col += 1
break
if mode == 'us' and c != '_' and (c in punct or c in ws):
col += 1
break
if mode == 'us' and c != '_':
mode = ('normal' if c in ucase
else 'ws' if c in ws
else 'camel')
elif mode == 'ws' and c not in ws:
mode = ('normal' if c in ucase
else 'us' if c == '_'
else 'punct' if c in punct
else 'camel')
col -= 1
if col > len(rline):
if row == len(lines) - 1:
return row, len(lines[row])
row += 1
col = 0
return col, row
def _move_cursor_word_right(self, index=None):
pos = index or self.cursor_index()
col, row = self.get_cursor_from_index(pos)
lines = self._lines
mrow = len(lines) - 1
if row == mrow and col == len(lines[row]):
return col, row
ucase = string.ascii_uppercase
lcase = string.ascii_lowercase
ws = string.whitespace
punct = string.punctuation
mode = 'normal'
rline = lines[row]
c = rline[col] if len(rline) > col else '\n'
if c in ws:
mode = 'ws'
elif c == '_':
mode = 'us'
elif c in punct:
mode = 'punct'
elif c in lcase:
mode = 'camel'
while True:
if mode in ('normal', 'camel', 'punct') and c in ws:
mode = 'ws'
elif mode in ('normal', 'camel') and c == '_':
mode = 'us'
elif mode == 'normal' and c not in ucase:
mode = 'camel'
if mode == 'us':
if c in ws:
mode = 'ws'
elif c != '_':
break
if mode == 'ws' and c not in ws:
break
if mode == 'camel' and c in ucase:
break
if mode == 'punct' and (c == '_' or c not in punct):
break
if mode != 'punct' and c != '_' and c in punct:
break
col += 1
if col > len(rline):
if row == mrow:
return len(rline), mrow
row += 1
rline = lines[row]
col = 0
c = rline[col] if len(rline) > col else '\n'
if c == '\n':
break
return col, row
@@ -0,0 +1,688 @@
'''
Compound Selection Behavior
===========================
The :class:`~kivy.uix.behaviors.compoundselection.CompoundSelectionBehavior`
`mixin <https://en.wikipedia.org/wiki/Mixin>`_ class implements the logic
behind keyboard and touch selection of selectable widgets managed by the
derived widget. For example, it can be combined with a
:class:`~kivy.uix.gridlayout.GridLayout` to add selection to the layout.
Compound selection concepts
---------------------------
At its core, it keeps a dynamic list of widgets that can be selected.
Then, as the touches and keyboard input are passed in, it selects one or
more of the widgets based on these inputs. For example, it uses the mouse
scroll and keyboard up/down buttons to scroll through the list of widgets.
Multiselection can also be achieved using the keyboard shift and ctrl keys.
Finally, in addition to the up/down type keyboard inputs, compound selection
can also accept letters from the keyboard to be used to select nodes with
associated strings that start with those letters, similar to how files
are selected by a file browser.
Selection mechanics
-------------------
When the controller needs to select a node, it calls :meth:`select_node` and
:meth:`deselect_node`. Therefore, they must be overwritten in order alter
node selection. By default, the class doesn't listen for keyboard or
touch events, so the derived widget must call
:meth:`select_with_touch`, :meth:`select_with_key_down`, and
:meth:`select_with_key_up` on events that it wants to pass on for selection
purposes.
Example
-------
To add selection to a grid layout which will contain
:class:`~kivy.uix.Button` widgets. For each button added to the layout, you
need to bind the :attr:`~kivy.uix.widget.Widget.on_touch_down` of the button
to :meth:`select_with_touch` to pass on the touch events::
from kivy.uix.behaviors.compoundselection import CompoundSelectionBehavior
from kivy.uix.button import Button
from kivy.uix.gridlayout import GridLayout
from kivy.uix.behaviors import FocusBehavior
from kivy.core.window import Window
from kivy.app import App
class SelectableGrid(FocusBehavior, CompoundSelectionBehavior, GridLayout):
def keyboard_on_key_down(self, window, keycode, text, modifiers):
"""Based on FocusBehavior that provides automatic keyboard
access, key presses will be used to select children.
"""
if super(SelectableGrid, self).keyboard_on_key_down(
window, keycode, text, modifiers):
return True
if self.select_with_key_down(window, keycode, text, modifiers):
return True
return False
def keyboard_on_key_up(self, window, keycode):
"""Based on FocusBehavior that provides automatic keyboard
access, key release will be used to select children.
"""
if super(SelectableGrid, self).keyboard_on_key_up(window, keycode):
return True
if self.select_with_key_up(window, keycode):
return True
return False
def add_widget(self, widget, *args, **kwargs):
""" Override the adding of widgets so we can bind and catch their
*on_touch_down* events. """
widget.bind(on_touch_down=self.button_touch_down,
on_touch_up=self.button_touch_up)
return super(SelectableGrid, self)\
.add_widget(widget, *args, **kwargs)
def button_touch_down(self, button, touch):
""" Use collision detection to select buttons when the touch occurs
within their area. """
if button.collide_point(*touch.pos):
self.select_with_touch(button, touch)
def button_touch_up(self, button, touch):
""" Use collision detection to de-select buttons when the touch
occurs outside their area and *touch_multiselect* is not True. """
if not (button.collide_point(*touch.pos) or
self.touch_multiselect):
self.deselect_node(button)
def select_node(self, node):
node.background_color = (1, 0, 0, 1)
return super(SelectableGrid, self).select_node(node)
def deselect_node(self, node):
node.background_color = (1, 1, 1, 1)
super(SelectableGrid, self).deselect_node(node)
def on_selected_nodes(self, grid, nodes):
print("Selected nodes = {0}".format(nodes))
class TestApp(App):
def build(self):
grid = SelectableGrid(cols=3, rows=2, touch_multiselect=True,
multiselect=True)
for i in range(0, 6):
grid.add_widget(Button(text="Button {0}".format(i)))
return grid
TestApp().run()
.. warning::
This code is still experimental, and its API is subject to change in a
future version.
'''
__all__ = ('CompoundSelectionBehavior', )
from time import time
from os import environ
from kivy.properties import NumericProperty, BooleanProperty, ListProperty
if 'KIVY_DOC' not in environ:
from kivy.config import Config
_is_desktop = Config.getboolean('kivy', 'desktop')
else:
_is_desktop = False
class CompoundSelectionBehavior(object):
'''The Selection behavior `mixin <https://en.wikipedia.org/wiki/Mixin>`_
implements the logic behind keyboard and touch
selection of selectable widgets managed by the derived widget. Please see
the :mod:`compound selection behaviors module
<kivy.uix.behaviors.compoundselection>` documentation
for more information.
.. versionadded:: 1.9.0
'''
selected_nodes = ListProperty([])
'''The list of selected nodes.
.. note::
Multiple nodes can be selected right after one another e.g. using the
keyboard. When listening to :attr:`selected_nodes`, one should be
aware of this.
:attr:`selected_nodes` is a :class:`~kivy.properties.ListProperty` and
defaults to the empty list, []. It is read-only and should not be modified.
'''
touch_multiselect = BooleanProperty(False)
'''A special touch mode which determines whether touch events, as
processed by :meth:`select_with_touch`, will add the currently touched
node to the selection, or if it will clear the selection before adding the
node. This allows the selection of multiple nodes by simply touching them.
This is different from :attr:`multiselect` because when it is True,
simply touching an unselected node will select it, even if ctrl is not
pressed. If it is False, however, ctrl must be pressed in order to
add to the selection when :attr:`multiselect` is True.
.. note::
:attr:`multiselect`, when False, will disable
:attr:`touch_multiselect`.
:attr:`touch_multiselect` is a :class:`~kivy.properties.BooleanProperty`
and defaults to False.
'''
multiselect = BooleanProperty(False)
'''Determines whether multiple nodes can be selected. If enabled, keyboard
shift and ctrl selection, optionally combined with touch, for example, will
be able to select multiple widgets in the normally expected manner.
This dominates :attr:`touch_multiselect` when False.
:attr:`multiselect` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
touch_deselect_last = BooleanProperty(not _is_desktop)
'''Determines whether the last selected node can be deselected when
:attr:`multiselect` or :attr:`touch_multiselect` is False.
.. versionadded:: 1.10.0
:attr:`touch_deselect_last` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True on mobile, False on desktop platforms.
'''
keyboard_select = BooleanProperty(True)
'''Determines whether the keyboard can be used for selection. If False,
keyboard inputs will be ignored.
:attr:`keyboard_select` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True.
'''
page_count = NumericProperty(10)
'''Determines by how much the selected node is moved up or down, relative
to the position of the last selected node, when pageup (or pagedown) is
pressed.
:attr:`page_count` is a :class:`~kivy.properties.NumericProperty` and
defaults to 10.
'''
up_count = NumericProperty(1)
'''Determines by how much the selected node is moved up or down, relative
to the position of the last selected node, when the up (or down) arrow on
the keyboard is pressed.
:attr:`up_count` is a :class:`~kivy.properties.NumericProperty` and
defaults to 1.
'''
right_count = NumericProperty(1)
'''Determines by how much the selected node is moved up or down, relative
to the position of the last selected node, when the right (or left) arrow
on the keyboard is pressed.
:attr:`right_count` is a :class:`~kivy.properties.NumericProperty` and
defaults to 1.
'''
scroll_count = NumericProperty(0)
'''Determines by how much the selected node is moved up or down, relative
to the position of the last selected node, when the mouse scroll wheel is
scrolled.
:attr:`right_count` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
nodes_order_reversed = BooleanProperty(True)
''' (Internal) Indicates whether the order of the nodes as displayed top-
down is reversed compared to their order in :meth:`get_selectable_nodes`
(e.g. how the children property is reversed compared to how
it's displayed).
'''
text_entry_timeout = NumericProperty(1.)
'''When typing characters in rapid succession (i.e. the time difference
since the last character is less than :attr:`text_entry_timeout`), the
keys get concatenated and the combined text is passed as the key argument
of :meth:`goto_node`.
.. versionadded:: 1.10.0
'''
_anchor = None # the last anchor node selected (e.g. shift relative node)
# the idx may be out of sync
_anchor_idx = 0 # cache indexes in case list hasn't changed
_last_selected_node = None # the absolute last node selected
_last_node_idx = 0
_ctrl_down = False # if it's pressed - for e.g. shift selection
_shift_down = False
# holds str used to find node, e.g. if word is typed. passed to goto_node
_word_filter = ''
_last_key_time = 0 # time since last press, for finding whole strs in node
_key_list = [] # keys that are already pressed, to not press continuously
_offset_counts = {} # cache of counts for faster access
def __init__(self, **kwargs):
super(CompoundSelectionBehavior, self).__init__(**kwargs)
self._key_list = []
def ensure_single_select(*l):
if (not self.multiselect) and len(self.selected_nodes) > 1:
self.clear_selection()
update_counts = self._update_counts
update_counts()
fbind = self.fbind
fbind('multiselect', ensure_single_select)
fbind('page_count', update_counts)
fbind('up_count', update_counts)
fbind('right_count', update_counts)
fbind('scroll_count', update_counts)
def select_with_touch(self, node, touch=None):
'''(internal) Processes a touch on the node. This should be called by
the derived widget when a node is touched and is to be used for
selection. Depending on the keyboard keys pressed and the
configuration, it could select or deslect this and other nodes in the
selectable nodes list, :meth:`get_selectable_nodes`.
:Parameters:
`node`
The node that received the touch. Can be None for a scroll
type touch.
`touch`
Optionally, the touch. Defaults to None.
:Returns:
bool, True if the touch was used, False otherwise.
'''
multi = self.multiselect
multiselect = multi and (self._ctrl_down or self.touch_multiselect)
range_select = multi and self._shift_down
if touch and 'button' in touch.profile and touch.button in\
('scrollup', 'scrolldown', 'scrollleft', 'scrollright'):
node_src, idx_src = self._resolve_last_node()
node, idx = self.goto_node(touch.button, node_src, idx_src)
if node == node_src:
return False
if range_select:
self._select_range(multiselect, True, node, idx)
else:
if not multiselect:
self.clear_selection()
self.select_node(node)
return True
if node is None:
return False
if (node in self.selected_nodes and (not range_select)): # selected
if multiselect:
self.deselect_node(node)
else:
selected_node_count = len(self.selected_nodes)
self.clear_selection()
if not self.touch_deselect_last or selected_node_count > 1:
self.select_node(node)
elif range_select:
# keep anchor only if not multiselect (ctrl-type selection)
self._select_range(multiselect, not multiselect, node, 0)
else: # it's not selected at this point
if not multiselect:
self.clear_selection()
self.select_node(node)
return True
def select_with_key_down(self, keyboard, scancode, codepoint, modifiers,
**kwargs):
'''Processes a key press. This is called when a key press is to be used
for selection. Depending on the keyboard keys pressed and the
configuration, it could select or deselect nodes or node ranges
from the selectable nodes list, :meth:`get_selectable_nodes`.
The parameters are such that it could be bound directly to the
on_key_down event of a keyboard. Therefore, it is safe to be called
repeatedly when the key is held down as is done by the keyboard.
:Returns:
bool, True if the keypress was used, False otherwise.
'''
if not self.keyboard_select:
return False
keys = self._key_list
multi = self.multiselect
node_src, idx_src = self._resolve_last_node()
text = scancode[1]
if text == 'shift':
self._shift_down = True
elif text in ('ctrl', 'lctrl', 'rctrl'):
self._ctrl_down = True
elif (multi and 'ctrl' in modifiers and text in ('a', 'A') and
text not in keys):
sister_nodes = self.get_selectable_nodes()
select = self.select_node
for node in sister_nodes:
select(node)
keys.append(text)
else:
s = text
if len(text) > 1:
d = {'divide': '/', 'mul': '*', 'substract': '-', 'add': '+',
'decimal': '.'}
if text.startswith('numpad'):
s = text[6:]
if len(s) > 1:
if s in d:
s = d[s]
else:
s = None
else:
s = None
if s is not None:
if s not in keys: # don't keep adding while holding down
if time() - self._last_key_time <= self.text_entry_timeout:
self._word_filter += s
else:
self._word_filter = s
keys.append(s)
self._last_key_time = time()
node, idx = self.goto_node(self._word_filter, node_src,
idx_src)
else:
self._word_filter = ''
node, idx = self.goto_node(text, node_src, idx_src)
if node == node_src:
return False
multiselect = multi and 'ctrl' in modifiers
if multi and 'shift' in modifiers:
self._select_range(multiselect, True, node, idx)
else:
if not multiselect:
self.clear_selection()
self.select_node(node)
return True
self._word_filter = ''
return False
def select_with_key_up(self, keyboard, scancode, **kwargs):
'''(internal) Processes a key release. This must be called by the
derived widget when a key that :meth:`select_with_key_down` returned
True is released.
The parameters are such that it could be bound directly to the
on_key_up event of a keyboard.
:Returns:
bool, True if the key release was used, False otherwise.
'''
if scancode[1] == 'shift':
self._shift_down = False
elif scancode[1] in ('ctrl', 'lctrl', 'rctrl'):
self._ctrl_down = False
else:
try:
self._key_list.remove(scancode[1])
return True
except ValueError:
return False
return True
def _update_counts(self, *largs):
# doesn't invert indices here
pc = self.page_count
uc = self.up_count
rc = self.right_count
sc = self.scroll_count
self._offset_counts = {'pageup': -pc, 'pagedown': pc, 'up': -uc,
'down': uc, 'right': rc, 'left': -rc, 'scrollup': sc,
'scrolldown': -sc, 'scrollright': -sc, 'scrollleft': sc}
def _resolve_last_node(self):
# for offset selection, we have a anchor, and we select everything
# between anchor and added offset relative to last node
sister_nodes = self.get_selectable_nodes()
if not len(sister_nodes):
return None, 0
last_node = self._last_selected_node
last_idx = self._last_node_idx
end = len(sister_nodes) - 1
if last_node is None:
last_node = self._anchor
last_idx = self._anchor_idx
if last_node is None:
return sister_nodes[end], end
if last_idx > end or sister_nodes[last_idx] != last_node:
try:
return last_node, self.get_index_of_node(last_node,
sister_nodes)
except ValueError:
return sister_nodes[end], end
return last_node, last_idx
def _select_range(self, multiselect, keep_anchor, node, idx):
'''Selects a range between self._anchor and node or idx.
If multiselect is True, it will be added to the selection, otherwise
it will unselect everything before selecting the range. This is only
called if self.multiselect is True.
If keep anchor is False, the anchor is moved to node. This should
always be True for keyboard selection.
'''
select = self.select_node
sister_nodes = self.get_selectable_nodes()
end = len(sister_nodes) - 1
last_node = self._anchor
last_idx = self._anchor_idx
if last_node is None:
last_idx = end
last_node = sister_nodes[end]
else:
if last_idx > end or sister_nodes[last_idx] != last_node:
try:
last_idx = self.get_index_of_node(last_node, sister_nodes)
except ValueError:
# list changed - cannot do select across them
return
if idx > end or sister_nodes[idx] != node:
try: # just in case
idx = self.get_index_of_node(node, sister_nodes)
except ValueError:
return
if last_idx > idx:
last_idx, idx = idx, last_idx
if not multiselect:
self.clear_selection()
for item in sister_nodes[last_idx:idx + 1]:
select(item)
if keep_anchor:
self._anchor = last_node
self._anchor_idx = last_idx
else:
self._anchor = node # in case idx was reversed, reset
self._anchor_idx = idx
self._last_selected_node = node
self._last_node_idx = idx
def clear_selection(self):
''' Deselects all the currently selected nodes.
'''
# keep the anchor and last selected node
deselect = self.deselect_node
nodes = self.selected_nodes
# empty beforehand so lookup in deselect will be fast
for node in nodes[:]:
deselect(node)
def get_selectable_nodes(self):
'''(internal) Returns a list of the nodes that can be selected. It can
be overwritten by the derived widget to return the correct list.
This list is used to determine which nodes to select with group
selection. E.g. the last element in the list will be selected when
home is pressed, pagedown will move (or add to, if shift is held) the
selection from the current position by negative :attr:`page_count`
nodes starting from the position of the currently selected node in
this list and so on. Still, nodes can be selected even if they are not
in this list.
.. note::
It is safe to dynamically change this list including removing,
adding, or re-arranging its elements. Nodes can be selected even
if they are not on this list. And selected nodes removed from the
list will remain selected until :meth:`deselect_node` is called.
.. warning::
Layouts display their children in the reverse order. That is, the
contents of :attr:`~kivy.uix.widget.Widget.children` is displayed
form right to left, bottom to top. Therefore, internally, the
indices of the elements returned by this function are reversed to
make it work by default for most layouts so that the final result
is consistent e.g. home, although it will select the last element
in this list visually, will select the first element when
counting from top to bottom and left to right. If this behavior is
not desired, a reversed list should be returned instead.
Defaults to returning :attr:`~kivy.uix.widget.Widget.children`.
'''
return self.children
def get_index_of_node(self, node, selectable_nodes):
'''(internal) Returns the index of the `node` within the
`selectable_nodes` returned by :meth:`get_selectable_nodes`.
'''
return selectable_nodes.index(node)
def goto_node(self, key, last_node, last_node_idx):
'''(internal) Used by the controller to get the node at the position
indicated by key. The key can be keyboard inputs, e.g. pageup,
or scroll inputs from the mouse scroll wheel, e.g. scrollup.
'last_node' is the last node selected and is used to find the resulting
node. For example, if the key is up, the returned node is one node
up from the last node.
It can be overwritten by the derived widget.
:Parameters:
`key`
str, the string used to find the desired node. It can be any
of the keyboard keys, as well as the mouse scrollup,
scrolldown, scrollright, and scrollleft strings. If letters
are typed in quick succession, the letters will be combined
before it's passed in as key and can be used to find nodes that
have an associated string that starts with those letters.
`last_node`
The last node that was selected.
`last_node_idx`
The cached index of the last node selected in the
:meth:`get_selectable_nodes` list. If the list hasn't changed
it saves having to look up the index of `last_node` in that
list.
:Returns:
tuple, the node targeted by key and its index in the
:meth:`get_selectable_nodes` list. Returning
`(last_node, last_node_idx)` indicates a node wasn't found.
'''
sister_nodes = self.get_selectable_nodes()
end = len(sister_nodes) - 1
counts = self._offset_counts
if end == -1:
return last_node, last_node_idx
if last_node_idx > end or sister_nodes[last_node_idx] != last_node:
try: # just in case
last_node_idx = self.get_index_of_node(last_node, sister_nodes)
except ValueError:
return last_node, last_node_idx
is_reversed = self.nodes_order_reversed
if key in counts:
count = -counts[key] if is_reversed else counts[key]
idx = max(min(count + last_node_idx, end), 0)
return sister_nodes[idx], idx
elif key == 'home':
if is_reversed:
return sister_nodes[end], end
return sister_nodes[0], 0
elif key == 'end':
if is_reversed:
return sister_nodes[0], 0
return sister_nodes[end], end
else:
return last_node, last_node_idx
def select_node(self, node):
''' Selects a node.
It is called by the controller when it selects a node and can be
called from the outside to select a node directly. The derived widget
should overwrite this method and change the node state to selected
when called.
:Parameters:
`node`
The node to be selected.
:Returns:
bool, True if the node was selected, False otherwise.
.. warning::
This method must be called by the derived widget using super if it
is overwritten.
'''
nodes = self.selected_nodes
if node in nodes:
return False
if (not self.multiselect) and len(nodes):
self.clear_selection()
if node not in nodes:
nodes.append(node)
self._anchor = node
self._last_selected_node = node
return True
def deselect_node(self, node):
''' Deselects a possibly selected node.
It is called by the controller when it deselects a node and can also
be called from the outside to deselect a node directly. The derived
widget should overwrite this method and change the node to its
unselected state when this is called
:Parameters:
`node`
The node to be deselected.
.. warning::
This method must be called by the derived widget using super if it
is overwritten.
'''
if node in self.selected_nodes:
self.selected_nodes.remove(node)
return True
return False
@@ -0,0 +1,160 @@
'''
Cover Behavior
==============
The :class:`~kivy.uix.behaviors.cover.CoverBehavior`
`mixin <https://en.wikipedia.org/wiki/Mixin>`_ is intended for rendering
textures to full widget size keeping the aspect ratio of the original texture.
Use cases are i.e. rendering full size background images or video content in
a dynamic layout.
For an overview of behaviors, please refer to the :mod:`~kivy.uix.behaviors`
documentation.
Example
-------
The following examples add cover behavior to an image:
In python:
.. code-block:: python
from kivy.app import App
from kivy.uix.behaviors import CoverBehavior
from kivy.uix.image import Image
class CoverImage(CoverBehavior, Image):
def __init__(self, **kwargs):
super(CoverImage, self).__init__(**kwargs)
texture = self._coreimage.texture
self.reference_size = texture.size
self.texture = texture
class MainApp(App):
def build(self):
return CoverImage(source='image.jpg')
MainApp().run()
In Kivy Language:
.. code-block:: kv
CoverImage:
source: 'image.png'
<CoverImage@CoverBehavior+Image>:
reference_size: self.texture_size
See :class:`~kivy.uix.behaviors.cover.CoverBehavior` for details.
'''
__all__ = ('CoverBehavior', )
from decimal import Decimal
from kivy.lang import Builder
from kivy.properties import ListProperty
Builder.load_string("""
<-CoverBehavior>:
canvas.before:
StencilPush
Rectangle:
pos: self.pos
size: self.size
StencilUse
canvas:
Rectangle:
texture: self.texture
size: self.cover_size
pos: self.cover_pos
canvas.after:
StencilUnUse
Rectangle:
pos: self.pos
size: self.size
StencilPop
""")
class CoverBehavior(object):
'''The CoverBehavior `mixin <https://en.wikipedia.org/wiki/Mixin>`_
provides rendering a texture covering full widget size keeping aspect ratio
of the original texture.
.. versionadded:: 1.10.0
'''
reference_size = ListProperty([])
'''Reference size used for aspect ratio approximation calculation.
:attr:`reference_size` is a :class:`~kivy.properties.ListProperty` and
defaults to `[]`.
'''
cover_size = ListProperty([0, 0])
'''Size of the aspect ratio aware texture. Gets calculated in
``CoverBehavior.calculate_cover``.
:attr:`cover_size` is a :class:`~kivy.properties.ListProperty` and
defaults to `[0, 0]`.
'''
cover_pos = ListProperty([0, 0])
'''Position of the aspect ratio aware texture. Gets calculated in
``CoverBehavior.calculate_cover``.
:attr:`cover_pos` is a :class:`~kivy.properties.ListProperty` and
defaults to `[0, 0]`.
'''
def __init__(self, **kwargs):
super(CoverBehavior, self).__init__(**kwargs)
# bind covering
self.bind(
size=self.calculate_cover,
pos=self.calculate_cover
)
def _aspect_ratio_approximate(self, size):
# return a decimal approximation of an aspect ratio.
return Decimal('%.2f' % (float(size[0]) / size[1]))
def _scale_size(self, size, sizer):
# return scaled size based on sizer, where sizer (n, None) scales x
# to n and (None, n) scales y to n
size_new = list(sizer)
i = size_new.index(None)
j = i * -1 + 1
size_new[i] = (size_new[j] * size[i]) / size[j]
return tuple(size_new)
def calculate_cover(self, *args):
# return if no reference size yet
if not self.reference_size:
return
size = self.size
origin_appr = self._aspect_ratio_approximate(self.reference_size)
crop_appr = self._aspect_ratio_approximate(size)
# same aspect ratio
if origin_appr == crop_appr:
crop_size = self.size
offset = (0, 0)
# scale x
elif origin_appr < crop_appr:
crop_size = self._scale_size(self.reference_size, (size[0], None))
offset = (0, ((crop_size[1] - size[1]) / 2) * -1)
# scale y
else:
crop_size = self._scale_size(self.reference_size, (None, size[1]))
offset = (((crop_size[0] - size[0]) / 2) * -1, 0)
# set background size and position
self.cover_size = crop_size
self.cover_pos = offset
@@ -0,0 +1,234 @@
"""
Drag Behavior
=============
The :class:`~kivy.uix.behaviors.drag.DragBehavior`
`mixin <https://en.wikipedia.org/wiki/Mixin>`_ class provides Drag behavior.
When combined with a widget, dragging in the rectangle defined by the
:attr:`~kivy.uix.behaviors.drag.DragBehavior.drag_rectangle` will drag the
widget.
Example
-------
The following example creates a draggable label::
from kivy.uix.label import Label
from kivy.app import App
from kivy.uix.behaviors import DragBehavior
from kivy.lang import Builder
# You could also put the following in your kv file...
kv = '''
<DragLabel>:
# Define the properties for the DragLabel
drag_rectangle: self.x, self.y, self.width, self.height
drag_timeout: 10000000
drag_distance: 0
FloatLayout:
# Define the root widget
DragLabel:
size_hint: 0.25, 0.2
text: 'Drag me'
'''
class DragLabel(DragBehavior, Label):
pass
class TestApp(App):
def build(self):
return Builder.load_string(kv)
TestApp().run()
"""
__all__ = ('DragBehavior', )
from kivy.clock import Clock
from kivy.properties import NumericProperty, ReferenceListProperty
from kivy.config import Config
from kivy.metrics import sp
from functools import partial
# When we are generating documentation, Config doesn't exist
_scroll_timeout = _scroll_distance = 0
if Config:
_scroll_timeout = Config.getint('widgets', 'scroll_timeout')
_scroll_distance = Config.getint('widgets', 'scroll_distance')
class DragBehavior(object):
'''
The DragBehavior `mixin <https://en.wikipedia.org/wiki/Mixin>`_ provides
Drag behavior. When combined with a widget, dragging in the rectangle
defined by :attr:`drag_rectangle` will drag the widget. Please see
the :mod:`drag behaviors module <kivy.uix.behaviors.drag>` documentation
for more information.
.. versionadded:: 1.8.0
'''
drag_distance = NumericProperty(_scroll_distance)
'''Distance to move before dragging the :class:`DragBehavior`, in pixels.
As soon as the distance has been traveled, the :class:`DragBehavior` will
start to drag, and no touch event will be dispatched to the children.
It is advisable that you base this value on the dpi of your target device's
screen.
:attr:`drag_distance` is a :class:`~kivy.properties.NumericProperty` and
defaults to the `scroll_distance` as defined in the user
:class:`~kivy.config.Config` (20 pixels by default).
'''
drag_timeout = NumericProperty(_scroll_timeout)
'''Timeout allowed to trigger the :attr:`drag_distance`, in milliseconds.
If the user has not moved :attr:`drag_distance` within the timeout,
dragging will be disabled, and the touch event will be dispatched to the
children.
:attr:`drag_timeout` is a :class:`~kivy.properties.NumericProperty` and
defaults to the `scroll_timeout` as defined in the user
:class:`~kivy.config.Config` (55 milliseconds by default).
'''
drag_rect_x = NumericProperty(0)
'''X position of the axis aligned bounding rectangle where dragging
is allowed (in window coordinates).
:attr:`drag_rect_x` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
drag_rect_y = NumericProperty(0)
'''Y position of the axis aligned bounding rectangle where dragging
is allowed (in window coordinates).
:attr:`drag_rect_Y` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
drag_rect_width = NumericProperty(100)
'''Width of the axis aligned bounding rectangle where dragging is allowed.
:attr:`drag_rect_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 100.
'''
drag_rect_height = NumericProperty(100)
'''Height of the axis aligned bounding rectangle where dragging is allowed.
:attr:`drag_rect_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to 100.
'''
drag_rectangle = ReferenceListProperty(drag_rect_x, drag_rect_y,
drag_rect_width, drag_rect_height)
'''Position and size of the axis aligned bounding rectangle where dragging
is allowed.
:attr:`drag_rectangle` is a :class:`~kivy.properties.ReferenceListProperty`
of (:attr:`drag_rect_x`, :attr:`drag_rect_y`, :attr:`drag_rect_width`,
:attr:`drag_rect_height`) properties.
'''
def __init__(self, **kwargs):
self._drag_touch = None
super(DragBehavior, self).__init__(**kwargs)
def _get_uid(self, prefix='sv'):
return '{0}.{1}'.format(prefix, self.uid)
def on_touch_down(self, touch):
xx, yy, w, h = self.drag_rectangle
x, y = touch.pos
if not self.collide_point(x, y):
touch.ud[self._get_uid('svavoid')] = True
return super(DragBehavior, self).on_touch_down(touch)
if self._drag_touch or ('button' in touch.profile and
touch.button.startswith('scroll')) or\
not ((xx < x <= xx + w) and (yy < y <= yy + h)):
return super(DragBehavior, self).on_touch_down(touch)
# no mouse scrolling, so the user is going to drag with this touch.
self._drag_touch = touch
uid = self._get_uid()
touch.grab(self)
touch.ud[uid] = {
'mode': 'unknown',
'dx': 0,
'dy': 0}
Clock.schedule_once(self._change_touch_mode,
self.drag_timeout / 1000.)
return True
def on_touch_move(self, touch):
if self._get_uid('svavoid') in touch.ud or\
self._drag_touch is not touch:
return super(DragBehavior, self).on_touch_move(touch) or\
self._get_uid() in touch.ud
if touch.grab_current is not self:
return True
uid = self._get_uid()
ud = touch.ud[uid]
mode = ud['mode']
if mode == 'unknown':
ud['dx'] += abs(touch.dx)
ud['dy'] += abs(touch.dy)
if ud['dx'] > sp(self.drag_distance):
mode = 'drag'
if ud['dy'] > sp(self.drag_distance):
mode = 'drag'
ud['mode'] = mode
if mode == 'drag':
self.x += touch.dx
self.y += touch.dy
return True
def on_touch_up(self, touch):
if self._get_uid('svavoid') in touch.ud:
return super(DragBehavior, self).on_touch_up(touch)
if self._drag_touch and self in [x() for x in touch.grab_list]:
touch.ungrab(self)
self._drag_touch = None
ud = touch.ud[self._get_uid()]
if ud['mode'] == 'unknown':
super(DragBehavior, self).on_touch_down(touch)
Clock.schedule_once(partial(self._do_touch_up, touch), .1)
else:
if self._drag_touch is not touch:
super(DragBehavior, self).on_touch_up(touch)
return self._get_uid() in touch.ud
def _do_touch_up(self, touch, *largs):
super(DragBehavior, self).on_touch_up(touch)
# don't forget about grab event!
for x in touch.grab_list[:]:
touch.grab_list.remove(x)
x = x()
if not x:
continue
touch.grab_current = x
super(DragBehavior, self).on_touch_up(touch)
touch.grab_current = None
def _change_touch_mode(self, *largs):
if not self._drag_touch:
return
uid = self._get_uid()
touch = self._drag_touch
ud = touch.ud[uid]
if ud['mode'] != 'unknown':
return
touch.ungrab(self)
self._drag_touch = None
touch.push()
touch.apply_transform_2d(self.parent.to_widget)
super(DragBehavior, self).on_touch_down(touch)
touch.pop()
return
@@ -0,0 +1,140 @@
# -*- encoding: utf-8 -*-
'''
Emacs Behavior
==============
The :class:`~kivy.uix.behaviors.emacs.EmacsBehavior`
`mixin <https://en.wikipedia.org/wiki/Mixin>`_ allows you to add
`Emacs <https://www.gnu.org/software/emacs/>`_ keyboard shortcuts for basic
movement and editing to the :class:`~kivy.uix.textinput.TextInput` widget.
The shortcuts currently available are listed below:
Emacs shortcuts
---------------
=============== ========================================================
Shortcut Description
--------------- --------------------------------------------------------
Control + a Move cursor to the beginning of the line
Control + e Move cursor to the end of the line
Control + f Move cursor one character to the right
Control + b Move cursor one character to the left
Alt + f Move cursor to the end of the word to the right
Alt + b Move cursor to the start of the word to the left
Alt + Backspace Delete text left of the cursor to the beginning of word
Alt + d Delete text right of the cursor to the end of the word
Alt + w Copy selection
Control + w Cut selection
Control + y Paste selection
=============== ========================================================
.. warning::
If you have the :mod:`~kivy.modules.inspector` module enabled, the
shortcut for opening the inspector (Control + e) conflicts with the
Emacs shortcut to move to the end of the line (it will still move the
cursor to the end of the line, but the inspector will open as well).
'''
from kivy.properties import StringProperty
__all__ = ('EmacsBehavior', )
class EmacsBehavior(object):
'''
A `mixin <https://en.wikipedia.org/wiki/Mixin>`_ that enables Emacs-style
keyboard shortcuts for the :class:`~kivy.uix.textinput.TextInput` widget.
Please see the :mod:`Emacs behaviors module <kivy.uix.behaviors.emacs>`
documentation for more information.
.. versionadded:: 1.9.1
'''
key_bindings = StringProperty('emacs')
'''String name which determines the type of key bindings to use with the
:class:`~kivy.uix.textinput.TextInput`. This allows Emacs key bindings to
be enabled/disabled programmatically for widgets that inherit from
:class:`EmacsBehavior`. If the value is not ``'emacs'``, Emacs bindings
will be disabled. Use ``'default'`` for switching to the default key
bindings of TextInput.
:attr:`key_bindings` is a :class:`~kivy.properties.StringProperty`
and defaults to ``'emacs'``.
.. versionadded:: 1.10.0
'''
def __init__(self, **kwargs):
super(EmacsBehavior, self).__init__(**kwargs)
self.bindings = {
'ctrl': {
'a': lambda: self.do_cursor_movement('cursor_home'),
'e': lambda: self.do_cursor_movement('cursor_end'),
'f': lambda: self.do_cursor_movement('cursor_right'),
'b': lambda: self.do_cursor_movement('cursor_left'),
'w': lambda: self._cut(self.selection_text),
'y': self.paste,
},
'alt': {
'w': self.copy,
'f': lambda: self.do_cursor_movement('cursor_right',
control=True),
'b': lambda: self.do_cursor_movement('cursor_left',
control=True),
'd': self.delete_word_right,
'\x08': self.delete_word_left, # alt + backspace
},
}
def keyboard_on_key_down(self, window, keycode, text, modifiers):
key, key_str = keycode
# join the modifiers e.g. ['alt', 'ctrl']
mod = '+'.join(modifiers) if modifiers else None
is_emacs_shortcut = False
if key in range(256) and self.key_bindings == 'emacs':
if mod == 'ctrl' and chr(key) in self.bindings['ctrl'].keys():
is_emacs_shortcut = True
elif mod == 'alt' and chr(key) in self.bindings['alt'].keys():
is_emacs_shortcut = True
else: # e.g. ctrl+alt or alt+ctrl (alt-gr key)
is_emacs_shortcut = False
if is_emacs_shortcut:
# Look up mod and key
emacs_shortcut = self.bindings[mod][chr(key)]
emacs_shortcut()
else:
super(EmacsBehavior, self).keyboard_on_key_down(window, keycode,
text, modifiers)
def delete_word_right(self):
'''Delete text right of the cursor to the end of the word'''
if self._selection:
return
start_index = self.cursor_index()
start_cursor = self.cursor
self.do_cursor_movement('cursor_right', control=True)
end_index = self.cursor_index()
if start_index != end_index:
s = self.text[start_index:end_index]
self._set_unredo_delsel(start_index, end_index, s, from_undo=False)
self.text = self.text[:start_index] + self.text[end_index:]
self._set_cursor(pos=start_cursor)
def delete_word_left(self):
'''Delete text left of the cursor to the beginning of word'''
if self._selection:
return
start_index = self.cursor_index()
self.do_cursor_movement('cursor_left', control=True)
end_cursor = self.cursor
end_index = self.cursor_index()
if start_index != end_index:
s = self.text[end_index:start_index]
self._set_unredo_delsel(end_index, start_index, s, from_undo=False)
self.text = self.text[:end_index] + self.text[start_index:]
self._set_cursor(pos=end_cursor)
@@ -0,0 +1,595 @@
'''
Focus Behavior
==============
The :class:`~kivy.uix.behaviors.FocusBehavior`
`mixin <https://en.wikipedia.org/wiki/Mixin>`_ class provides
keyboard focus behavior. When combined with other
FocusBehavior widgets it allows one to cycle focus among them by pressing
tab. In addition, upon gaining focus, the instance will automatically
receive keyboard input.
Focus, very different from selection, is intimately tied with the keyboard;
each keyboard can focus on zero or one widgets, and each widget can only
have the focus of one keyboard. However, multiple keyboards can focus
simultaneously on different widgets. When escape is hit, the widget having
the focus of that keyboard will de-focus.
Managing focus
--------------
In essence, focus is implemented as a doubly linked list, where each
node holds a (weak) reference to the instance before it and after it,
as visualized when cycling through the nodes using tab (forward) or
shift+tab (backward). If a previous or next widget is not specified,
:attr:`focus_next` and :attr:`focus_previous` defaults to `None`. This
means that the :attr:`~kivy.uix.widget.Widget.children` list and
:attr:`parents <kivy.uix.widget.Widget.parent>` are
walked to find the next focusable widget, unless :attr:`focus_next` or
:attr:`focus_previous` is set to the `StopIteration` class, in which case
focus stops there.
For example, to cycle focus between :class:`~kivy.uix.button.Button`
elements of a :class:`~kivy.uix.gridlayout.GridLayout`::
class FocusButton(FocusBehavior, Button):
pass
grid = GridLayout(cols=4)
for i in range(40):
grid.add_widget(FocusButton(text=str(i)))
# clicking on a widget will activate focus, and tab can now be used
# to cycle through
When using a software keyboard, typical on mobile and touch devices, the
keyboard display behavior is determined by the
:attr:`~kivy.core.window.WindowBase.softinput_mode` property. You can use
this property to ensure the focused widget is not covered or obscured by the
keyboard.
Initializing focus
------------------
Widgets needs to be visible before they can receive the focus. This means that
setting their *focus* property to True before they are visible will have no
effect. To initialize focus, you can use the 'on_parent' event::
from kivy.app import App
from kivy.uix.textinput import TextInput
class MyTextInput(TextInput):
def on_parent(self, widget, parent):
self.focus = True
class SampleApp(App):
def build(self):
return MyTextInput()
SampleApp().run()
If you are using a :class:`~kivy.uix.popup`, you can use the 'on_open' event.
For an overview of behaviors, please refer to the :mod:`~kivy.uix.behaviors`
documentation.
.. warning::
This code is still experimental, and its API is subject to change in a
future version.
'''
__all__ = ('FocusBehavior', )
from kivy.properties import OptionProperty, ObjectProperty, BooleanProperty, \
AliasProperty
from kivy.config import Config
from kivy.base import EventLoop
# When we are generating documentation, Config doesn't exist
_is_desktop = False
_keyboard_mode = 'system'
if Config:
_is_desktop = Config.getboolean('kivy', 'desktop')
_keyboard_mode = Config.get('kivy', 'keyboard_mode')
class FocusBehavior(object):
'''Provides keyboard focus behavior. When combined with other
FocusBehavior widgets it allows one to cycle focus among them by pressing
tab. Please see the
:mod:`focus behavior module documentation <kivy.uix.behaviors.focus>`
for more information.
.. versionadded:: 1.9.0
'''
_requested_keyboard = False
_keyboard = ObjectProperty(None, allownone=True)
_keyboards = {}
ignored_touch = []
'''A list of touches that should not be used to defocus. After on_touch_up,
every touch that is not in :attr:`ignored_touch` will defocus all the
focused widgets if the config keyboard mode is not multi. Touches on
focusable widgets that were used to focus are automatically added here.
Example usage::
class Unfocusable(Widget):
def on_touch_down(self, touch):
if self.collide_point(*touch.pos):
FocusBehavior.ignored_touch.append(touch)
Notice that you need to access this as a class, not an instance variable.
'''
def _set_keyboard(self, value):
focus = self.focus
keyboard = self._keyboard
keyboards = FocusBehavior._keyboards
if keyboard:
self.focus = False # this'll unbind
if self._keyboard: # remove assigned keyboard from dict
del keyboards[keyboard]
if value and value not in keyboards:
keyboards[value] = None
self._keyboard = value
self.focus = focus
def _get_keyboard(self):
return self._keyboard
keyboard = AliasProperty(_get_keyboard, _set_keyboard,
bind=('_keyboard', ))
'''The keyboard to bind to (or bound to the widget) when focused.
When None, a keyboard is requested and released whenever the widget comes
into and out of focus. If not None, it must be a keyboard, which gets
bound and unbound from the widget whenever it's in or out of focus. It is
useful only when more than one keyboard is available, so it is recommended
to be set to None when only one keyboard is available.
If more than one keyboard is available, whenever an instance gets focused
a new keyboard will be requested if None. Unless the other instances lose
focus (e.g. if tab was used), a new keyboard will appear. When this is
undesired, the keyboard property can be used. For example, if there are
two users with two keyboards, then each keyboard can be assigned to
different groups of instances of FocusBehavior, ensuring that within
each group, only one FocusBehavior will have focus, and will receive input
from the correct keyboard. See `keyboard_mode` in :mod:`~kivy.config` for
more information on the keyboard modes.
**Keyboard and focus behavior**
When using the keyboard, there are some important default behaviors you
should keep in mind.
* When Config's `keyboard_mode` is multi, each new touch is considered
a touch by a different user and will set the focus (if clicked on a
focusable) with a new keyboard. Already focused elements will not lose
their focus (even if an unfocusable widget is touched).
* If the keyboard property is set, that keyboard will be used when the
instance gets focused. If widgets with different keyboards are linked
through :attr:`focus_next` and :attr:`focus_previous`, then as they are
tabbed through, different keyboards will become active. Therefore,
typically it's undesirable to link instances which are assigned
different keyboards.
* When a widget has focus, setting its keyboard to None will remove its
keyboard, but the widget will then immediately try to get
another keyboard. In order to remove its keyboard, rather set its
:attr:`focus` to False.
* When using a software keyboard, typical on mobile and touch devices, the
keyboard display behavior is determined by the
:attr:`~kivy.core.window.WindowBase.softinput_mode` property. You can use
this property to ensure the focused widget is not covered or obscured.
:attr:`keyboard` is an :class:`~kivy.properties.AliasProperty` and defaults
to None.
.. warning:
When assigning a keyboard, the keyboard must not be released while
it is still assigned to an instance. Similarly, the keyboard created
by the instance on focus and assigned to :attr:`keyboard` if None,
will be released by the instance when the instance loses focus.
Therefore, it is not safe to assign this keyboard to another instance's
:attr:`keyboard`.
'''
is_focusable = BooleanProperty(_is_desktop)
'''Whether the instance can become focused. If focused, it'll lose focus
when set to False.
:attr:`is_focusable` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True on a desktop (i.e. `desktop` is True in
:mod:`~kivy.config`), False otherwise.
'''
focus = BooleanProperty(False)
'''Whether the instance currently has focus.
Setting it to True will bind to and/or request the keyboard, and input
will be forwarded to the instance. Setting it to False will unbind
and/or release the keyboard. For a given keyboard, only one widget can
have its focus, so focusing one will automatically unfocus the other
instance holding its focus.
When using a software keyboard, please refer to the
:attr:`~kivy.core.window.WindowBase.softinput_mode` property to determine
how the keyboard display is handled.
:attr:`focus` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
focused = focus
'''An alias of :attr:`focus`.
:attr:`focused` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
.. warning::
:attr:`focused` is an alias of :attr:`focus` and will be removed in
2.0.0.
'''
keyboard_suggestions = BooleanProperty(True)
'''If True provides auto suggestions on top of keyboard.
This will only work if :attr:`input_type` is set to `text`, `url`, `mail` or
`address`.
.. warning::
On Android, `keyboard_suggestions` relies on
`InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS` to work, but some keyboards
just ignore this flag. If you want to disable suggestions at all on
Android, you can set `input_type` to `null`, which will request the
input method to run in a limited "generate key events" mode.
.. versionadded:: 2.1.0
:attr:`keyboard_suggestions` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True
'''
def _set_on_focus_next(self, instance, value):
'''If changing focus, ensure your code does not create an infinite loop.
eg:
```python
widget.focus_next = widget
widget.focus_previous = widget
```
'''
next_ = self._old_focus_next
if next_ is value: # prevent infinite loop
return
if isinstance(next_, FocusBehavior):
next_.focus_previous = None
self._old_focus_next = value
if value is None or value is StopIteration:
return
if not isinstance(value, FocusBehavior):
raise ValueError('focus_next accepts only objects based on'
' FocusBehavior, or the `StopIteration` class.')
value.focus_previous = self
focus_next = ObjectProperty(None, allownone=True)
'''The :class:`FocusBehavior` instance to acquire focus when
tab is pressed and this instance has focus, if not `None` or
`StopIteration`.
When tab is pressed, focus cycles through all the :class:`FocusBehavior`
widgets that are linked through :attr:`focus_next` and are focusable. If
:attr:`focus_next` is `None`, it instead walks the children lists to find
the next focusable widget. Finally, if :attr:`focus_next` is
the `StopIteration` class, focus won't move forward, but end here.
.. note:
Setting :attr:`focus_next` automatically sets :attr:`focus_previous`
of the other instance to point to this instance, if not None or
`StopIteration`. Similarly, if it wasn't None or `StopIteration`, it
also sets the :attr:`focus_previous` property of the instance
previously in :attr:`focus_next` to `None`. Therefore, it is only
required to set one of the :attr:`focus_previous` or
:attr:`focus_next` links since the other side will be set
automatically.
:attr:`focus_next` is an :class:`~kivy.properties.ObjectProperty` and
defaults to `None`.
'''
def _set_on_focus_previous(self, instance, value):
prev = self._old_focus_previous
if prev is value:
return
if isinstance(prev, FocusBehavior):
prev.focus_next = None
self._old_focus_previous = value
if value is None or value is StopIteration:
return
if not isinstance(value, FocusBehavior):
raise ValueError('focus_previous accepts only objects based'
'on FocusBehavior, or the `StopIteration` class.')
value.focus_next = self
focus_previous = ObjectProperty(None, allownone=True)
'''The :class:`FocusBehavior` instance to acquire focus when
shift+tab is pressed on this instance, if not None or `StopIteration`.
When shift+tab is pressed, focus cycles through all the
:class:`FocusBehavior` widgets that are linked through
:attr:`focus_previous` and are focusable. If :attr:`focus_previous` is
`None`, it instead walks the children tree to find the
previous focusable widget. Finally, if :attr:`focus_previous` is the
`StopIteration` class, focus won't move backward, but end here.
.. note:
Setting :attr:`focus_previous` automatically sets :attr:`focus_next`
of the other instance to point to this instance, if not None or
`StopIteration`. Similarly, if it wasn't None or `StopIteration`, it
also sets the :attr:`focus_next` property of the instance previously in
:attr:`focus_previous` to `None`. Therefore, it is only required
to set one of the :attr:`focus_previous` or :attr:`focus_next`
links since the other side will be set automatically.
:attr:`focus_previous` is an :class:`~kivy.properties.ObjectProperty` and
defaults to `None`.
'''
keyboard_mode = OptionProperty('auto', options=('auto', 'managed'))
'''Determines how the keyboard visibility should be managed. 'auto' will
result in the standard behavior of showing/hiding on focus. 'managed'
requires setting the keyboard visibility manually, or calling the helper
functions :meth:`show_keyboard` and :meth:`hide_keyboard`.
:attr:`keyboard_mode` is an :class:`~kivy.properties.OptionsProperty` and
defaults to 'auto'. Can be one of 'auto' or 'managed'.
'''
input_type = OptionProperty('null', options=('null', 'text', 'number',
'url', 'mail', 'datetime',
'tel', 'address'))
'''The kind of input keyboard to request.
.. versionadded:: 1.8.0
.. versionchanged:: 2.1.0
Changed default value from `text` to `null`. Added `null` to options.
.. warning::
As the default value has been changed, you may need to adjust
`input_type` in your code.
:attr:`input_type` is an :class:`~kivy.properties.OptionsProperty` and
defaults to 'null'. Can be one of 'null', 'text', 'number', 'url', 'mail',
'datetime', 'tel' or 'address'.
'''
unfocus_on_touch = BooleanProperty(_keyboard_mode not in
('multi', 'systemandmulti'))
'''Whether a instance should lose focus when clicked outside the instance.
When a user clicks on a widget that is focus aware and shares the same
keyboard as this widget (which in the case with only one keyboard),
then as the other widgets gain focus, this widget loses focus. In addition
to that, if this property is `True`, clicking on any widget other than this
widget, will remove focus from this widget.
:attr:`unfocus_on_touch` is a :class:`~kivy.properties.BooleanProperty` and
defaults to `False` if the `keyboard_mode` in :attr:`~kivy.config.Config`
is `'multi'` or `'systemandmulti'`, otherwise it defaults to `True`.
'''
def __init__(self, **kwargs):
self._old_focus_next = None
self._old_focus_previous = None
super(FocusBehavior, self).__init__(**kwargs)
self._keyboard_mode = _keyboard_mode
fbind = self.fbind
fbind('focus', self._on_focus)
fbind('disabled', self._on_focusable)
fbind('is_focusable', self._on_focusable)
fbind('focus_next', self._set_on_focus_next)
fbind('focus_previous', self._set_on_focus_previous)
def _on_focusable(self, instance, value):
if self.disabled or not self.is_focusable:
self.focus = False
def _on_focus(self, instance, value, *largs):
if self.keyboard_mode == 'auto':
if value:
self._bind_keyboard()
else:
self._unbind_keyboard()
def _ensure_keyboard(self):
if self._keyboard is None:
self._requested_keyboard = True
keyboard = self._keyboard = EventLoop.window.request_keyboard(
self._keyboard_released,
self,
input_type=self.input_type,
keyboard_suggestions=self.keyboard_suggestions,
)
keyboards = FocusBehavior._keyboards
if keyboard not in keyboards:
keyboards[keyboard] = None
def _bind_keyboard(self):
self._ensure_keyboard()
keyboard = self._keyboard
if not keyboard or self.disabled or not self.is_focusable:
self.focus = False
return
keyboards = FocusBehavior._keyboards
old_focus = keyboards[keyboard] # keyboard should be in dict
if old_focus:
old_focus.focus = False
# keyboard shouldn't have been released here, see keyboard warning
keyboards[keyboard] = self
keyboard.bind(on_key_down=self.keyboard_on_key_down,
on_key_up=self.keyboard_on_key_up,
on_textinput=self.keyboard_on_textinput)
def _unbind_keyboard(self):
keyboard = self._keyboard
if keyboard:
keyboard.unbind(on_key_down=self.keyboard_on_key_down,
on_key_up=self.keyboard_on_key_up,
on_textinput=self.keyboard_on_textinput)
if self._requested_keyboard:
keyboard.release()
self._keyboard = None
self._requested_keyboard = False
del FocusBehavior._keyboards[keyboard]
else:
FocusBehavior._keyboards[keyboard] = None
def keyboard_on_textinput(self, window, text):
pass
def _keyboard_released(self):
self.focus = False
def on_touch_down(self, touch):
if not self.collide_point(*touch.pos):
return
if (not self.disabled and self.is_focusable and
('button' not in touch.profile or
not touch.button.startswith('scroll'))):
self.focus = True
FocusBehavior.ignored_touch.append(touch)
return super(FocusBehavior, self).on_touch_down(touch)
@staticmethod
def _handle_post_on_touch_up(touch):
''' Called by window after each touch has finished.
'''
touches = FocusBehavior.ignored_touch
if touch in touches:
touches.remove(touch)
return
if 'button' in touch.profile and touch.button in\
('scrollup', 'scrolldown', 'scrollleft', 'scrollright'):
return
for focusable in list(FocusBehavior._keyboards.values()):
if focusable is None or not focusable.unfocus_on_touch:
continue
focusable.focus = False
def _get_focus_next(self, focus_dir):
current = self
walk_tree = 'walk' if focus_dir == 'focus_next' else 'walk_reverse'
while 1:
# if we hit a focusable, walk through focus_xxx
while getattr(current, focus_dir) is not None:
current = getattr(current, focus_dir)
if current is self or current is StopIteration:
return None # make sure we don't loop forever
if current.is_focusable and not current.disabled:
return current
# hit unfocusable, walk widget tree
itr = getattr(current, walk_tree)(loopback=True)
if focus_dir == 'focus_next':
next(itr) # current is returned first when walking forward
for current in itr:
if isinstance(current, FocusBehavior):
break
# why did we stop
if isinstance(current, FocusBehavior):
if current is self:
return None
if current.is_focusable and not current.disabled:
return current
else:
return None
def get_focus_next(self):
'''Returns the next focusable widget using either :attr:`focus_next`
or the :attr:`children` similar to the order when tabbing forwards
with the ``tab`` key.
'''
return self._get_focus_next('focus_next')
def get_focus_previous(self):
'''Returns the previous focusable widget using either
:attr:`focus_previous` or the :attr:`children` similar to the
order when the ``tab`` + ``shift`` keys are triggered together.
'''
return self._get_focus_next('focus_previous')
def keyboard_on_key_down(self, window, keycode, text, modifiers):
'''The method bound to the keyboard when the instance has focus.
When the instance becomes focused, this method is bound to the
keyboard and will be called for every input press. The parameters are
the same as :meth:`kivy.core.window.WindowBase.on_key_down`.
When overwriting the method in the derived widget, super should be
called to enable tab cycling. If the derived widget wishes to use tab
for its own purposes, it can call super after it has processed the
character (if it does not wish to consume the tab).
Similar to other keyboard functions, it should return True if the
key was consumed.
'''
if keycode[1] == 'tab': # deal with cycle
modifiers = set(modifiers)
if {'ctrl', 'alt', 'meta', 'super', 'compose'} & modifiers:
return False
if 'shift' in modifiers:
next = self.get_focus_previous()
else:
next = self.get_focus_next()
if next:
self.focus = False
next.focus = True
return True
return False
def keyboard_on_key_up(self, window, keycode):
'''The method bound to the keyboard when the instance has focus.
When the instance becomes focused, this method is bound to the
keyboard and will be called for every input release. The parameters are
the same as :meth:`kivy.core.window.WindowBase.on_key_up`.
When overwriting the method in the derived widget, super should be
called to enable de-focusing on escape. If the derived widget wishes
to use escape for its own purposes, it can call super after it has
processed the character (if it does not wish to consume the escape).
See :meth:`keyboard_on_key_down`
'''
if keycode[1] == 'escape':
self.focus = False
return True
return False
def show_keyboard(self):
'''
Convenience function to show the keyboard in managed mode.
'''
if self.keyboard_mode == 'managed':
self._bind_keyboard()
def hide_keyboard(self):
'''
Convenience function to hide the keyboard in managed mode.
'''
if self.keyboard_mode == 'managed':
self._unbind_keyboard()
@@ -0,0 +1,590 @@
'''
Kivy Namespaces
===============
.. versionadded:: 1.9.1
.. warning::
This code is still experimental, and its API is subject to change in a
future version.
The :class:`KNSpaceBehavior` `mixin <https://en.wikipedia.org/wiki/Mixin>`_
class provides namespace functionality for Kivy objects. It allows kivy objects
to be named and then accessed using namespaces.
:class:`KNSpace` instances are the namespaces that store the named objects
in Kivy :class:`~kivy.properties.ObjectProperty` instances.
In addition, when inheriting from :class:`KNSpaceBehavior`, if the derived
object is named, the name will automatically be added to the associated
namespace and will point to a :attr:`~kivy.uix.widget.proxy_ref` of the
derived object.
Basic examples
--------------
By default, there's only a single namespace: the :attr:`knspace` namespace. The
simplest example is adding a widget to the namespace:
.. code-block:: python
from kivy.uix.behaviors.knspace import knspace
widget = Widget()
knspace.my_widget = widget
This adds a kivy :class:`~kivy.properties.ObjectProperty` with `rebind=True`
and `allownone=True` to the :attr:`knspace` namespace with a property name
`my_widget`. And the property now also points to this widget.
This can be done automatically with:
.. code-block:: python
class MyWidget(KNSpaceBehavior, Widget):
pass
widget = MyWidget(knsname='my_widget')
Or in kv:
.. code-block:: kv
<MyWidget@KNSpaceBehavior+Widget>
MyWidget:
knsname: 'my_widget'
Now, `knspace.my_widget` will point to that widget.
When one creates a second widget with the same name, the namespace will
also change to point to the new widget. E.g.:
.. code-block:: python
widget = MyWidget(knsname='my_widget')
# knspace.my_widget now points to widget
widget2 = MyWidget(knsname='my_widget')
# knspace.my_widget now points to widget2
Setting the namespace
---------------------
One can also create ones own namespace rather than using the default
:attr:`knspace` by directly setting :attr:`KNSpaceBehavior.knspace`:
.. code-block:: python
class MyWidget(KNSpaceBehavior, Widget):
pass
widget = MyWidget(knsname='my_widget')
my_new_namespace = KNSpace()
widget.knspace = my_new_namespace
Initially, `my_widget` is added to the default namespace, but when the widget's
namespace is changed to `my_new_namespace`, the reference to `my_widget` is
moved to that namespace. We could have also of course first set the namespace
to `my_new_namespace` and then have named the widget `my_widget`, thereby
avoiding the initial assignment to the default namespace.
Similarly, in kv:
.. code-block:: kv
<MyWidget@KNSpaceBehavior+Widget>
MyWidget:
knspace: KNSpace()
knsname: 'my_widget'
Inheriting the namespace
------------------------
In the previous example, we directly set the namespace we wished to use.
In the following example, we inherit it from the parent, so we only have to set
it once:
.. code-block:: kv
<MyWidget@KNSpaceBehavior+Widget>
<MyLabel@KNSpaceBehavior+Label>
<MyComplexWidget@MyWidget>:
knsname: 'my_complex'
MyLabel:
knsname: 'label1'
MyLabel:
knsname: 'label2'
Then, we do:
.. code-block:: python
widget = MyComplexWidget()
new_knspace = KNSpace()
widget.knspace = new_knspace
The rule is that if no knspace has been assigned to a widget, it looks for a
namespace in its parent and parent's parent and so on until it find one to
use. If none are found, it uses the default :attr:`knspace`.
When `MyComplexWidget` is created, it still used the default namespace.
However, when we assigned the root widget its new namespace, all its
children switched to using that new namespace as well. So `new_knspace` now
contains `label1` and `label2` as well as `my_complex`.
If we had first done:
.. code-block:: python
widget = MyComplexWidget()
new_knspace = KNSpace()
knspace.label1.knspace = knspace
widget.knspace = new_knspace
Then `label1` would remain stored in the default :attr:`knspace` since it was
directly set, but `label2` and `my_complex` would still be added to the new
namespace.
One can customize the attribute used to search the parent tree by changing
:attr:`KNSpaceBehavior.knspace_key`. If the desired knspace is not reachable
through a widgets parent tree, e.g. in a popup that is not a widget's child,
:attr:`KNSpaceBehavior.knspace_key` can be used to establish a different
search order.
Accessing the namespace
-----------------------
As seen in the previous example, if not directly assigned, the namespace is
found by searching the parent tree. Consequently, if a namespace was assigned
further up the parent tree, all its children and below could access that
namespace through their :attr:`KNSpaceBehavior.knspace` property.
This allows the creation of multiple widgets with identically given names
if each root widget instance is assigned a new namespace. For example:
.. code-block:: kv
<MyComplexWidget@KNSpaceBehavior+Widget>:
Label:
text: root.knspace.pretty.text if root.knspace.pretty else ''
<MyPrettyWidget@KNSpaceBehavior+TextInput>:
knsname: 'pretty'
text: 'Hello'
<MyCompositeWidget@KNSpaceBehavior+BoxLayout>:
MyComplexWidget
MyPrettyWidget
Now, when we do:
.. code-block:: python
knspace1, knspace2 = KNSpace(), KNSpace()
composite1 = MyCompositeWidget()
composite1.knspace = knspace1
composite2 = MyCompositeWidget()
composite2.knspace = knspace2
knspace1.pretty = "Here's the ladder, now fix the roof!"
knspace2.pretty = "Get that raccoon off me!"
Because each of the `MyCompositeWidget` instances have a different namespace
their children also use different namespaces. Consequently, the
pretty and complex widgets of each instance will have different text.
Further, because both the namespace :class:`~kivy.properties.ObjectProperty`
references, and :attr:`KNSpaceBehavior.knspace` have `rebind=True`, the
text of the `MyComplexWidget` label is rebound to match the text of
`MyPrettyWidget` when either the root's namespace changes or when the
`root.knspace.pretty` property changes, as expected.
Forking a namespace
-------------------
Forking a namespace provides the opportunity to create a new namespace
from a parent namespace so that the forked namespace will contain everything
in the origin namespace, but the origin namespace will not have access to
anything added to the forked namespace.
For example:
.. code-block:: python
child = knspace.fork()
grandchild = child.fork()
child.label = Label()
grandchild.button = Button()
Now label is accessible by both child and grandchild, but not by knspace. And
button is only accessible by the grandchild but not by the child or by knspace.
Finally, doing `grandchild.label = Label()` will leave `grandchild.label`
and `child.label` pointing to different labels.
A motivating example is the example from above:
.. code-block:: kv
<MyComplexWidget@KNSpaceBehavior+Widget>:
Label:
text: root.knspace.pretty.text if root.knspace.pretty else ''
<MyPrettyWidget@KNSpaceBehavior+TextInput>:
knsname: 'pretty'
text: 'Hello'
<MyCompositeWidget@KNSpaceBehavior+BoxLayout>:
knspace: 'fork'
MyComplexWidget
MyPrettyWidget
Notice the addition of `knspace: 'fork'`. This is identical to doing
`knspace: self.knspace.fork()`. However, doing that would lead to infinite
recursion as that kv rule would be executed recursively because `self.knspace`
will keep on changing. However, allowing `knspace: 'fork'` cirumvents that.
See :attr:`KNSpaceBehavior.knspace`.
Now, having forked, we just need to do:
.. code-block:: python
composite1 = MyCompositeWidget()
composite2 = MyCompositeWidget()
composite1.knspace.pretty = "Here's the ladder, now fix the roof!"
composite2.knspace.pretty = "Get that raccoon off me!"
Since by forking we automatically created a unique namespace for each
`MyCompositeWidget` instance.
'''
__all__ = ('KNSpace', 'KNSpaceBehavior', 'knspace')
from kivy.event import EventDispatcher
from kivy.properties import StringProperty, ObjectProperty, AliasProperty
from kivy.context import register_context
class KNSpace(EventDispatcher):
'''Each :class:`KNSpace` instance is a namespace that stores the named Kivy
objects associated with this namespace. Each named object is
stored as the value of a Kivy :class:`~kivy.properties.ObjectProperty` of
this instance whose property name is the object's given name. Both `rebind`
and `allownone` are set to `True` for the property.
See :attr:`KNSpaceBehavior.knspace` for details on how a namespace is
associated with a named object.
When storing an object in the namespace, the object's `proxy_ref` is
stored if the object has such an attribute.
:Parameters:
`parent`: (internal) A :class:`KNSpace` instance or None.
If specified, it's a parent namespace, in which case, the current
namespace will have in its namespace all its named objects
as well as the named objects of its parent and parent's parent
etc. See :meth:`fork` for more details.
'''
parent = None
'''(internal) The parent namespace instance, :class:`KNSpace`, or None. See
:meth:`fork`.
'''
__has_applied = None
keep_ref = False
'''Whether a direct reference should be kept to the stored objects.
If ``True``, we use the direct object, otherwise we use
:attr:`~kivy.uix.widget.proxy_ref` when present.
Defaults to False.
'''
def __init__(self, parent=None, keep_ref=False, **kwargs):
self.keep_ref = keep_ref
super(KNSpace, self).__init__(**kwargs)
self.parent = parent
self.__has_applied = set(self.properties().keys())
def __setattr__(self, name, value):
prop = super(KNSpace, self).property(name, quiet=True)
has_applied = self.__has_applied
if prop is None:
if hasattr(self, name):
super(KNSpace, self).__setattr__(name, value)
else:
self.apply_property(
**{name:
ObjectProperty(None, rebind=True, allownone=True)}
)
if not self.keep_ref:
value = getattr(value, 'proxy_ref', value)
has_applied.add(name)
super(KNSpace, self).__setattr__(name, value)
elif name not in has_applied:
self.apply_property(**{name: prop})
has_applied.add(name)
if not self.keep_ref:
value = getattr(value, 'proxy_ref', value)
super(KNSpace, self).__setattr__(name, value)
else:
if not self.keep_ref:
value = getattr(value, 'proxy_ref', value)
super(KNSpace, self).__setattr__(name, value)
def __getattribute__(self, name):
if name in super(KNSpace, self).__getattribute__('__dict__'):
return super(KNSpace, self).__getattribute__(name)
try:
value = super(KNSpace, self).__getattribute__(name)
except AttributeError:
parent = super(KNSpace, self).__getattribute__('parent')
if parent is None:
raise AttributeError(name)
return getattr(parent, name)
if value is not None:
return value
parent = super(KNSpace, self).__getattribute__('parent')
if parent is None:
return None
try:
return getattr(parent, name) # if parent doesn't have it
except AttributeError:
return None
def property(self, name, quiet=False):
# needs to overwrite EventDispatcher.property so kv lang will work
prop = super(KNSpace, self).property(name, quiet=True)
if prop is not None:
return prop
prop = ObjectProperty(None, rebind=True, allownone=True)
self.apply_property(**{name: prop})
self.__has_applied.add(name)
return prop
def fork(self):
'''Returns a new :class:`KNSpace` instance which will have access to
all the named objects in the current namespace but will also have a
namespace of its own that is unique to it.
For example:
.. code-block:: python
forked_knspace1 = knspace.fork()
forked_knspace2 = knspace.fork()
Now, any names added to `knspace` will be accessible by the
`forked_knspace1` and `forked_knspace2` namespaces by the normal means.
However, any names added to `forked_knspace1` will not be accessible
from `knspace` or `forked_knspace2`. Similar for `forked_knspace2`.
'''
return KNSpace(parent=self)
class KNSpaceBehavior(object):
'''Inheriting from this class allows naming of the inherited objects, which
are then added to the associated namespace :attr:`knspace` and accessible
through it.
Please see the :mod:`knspace behaviors module <kivy.uix.behaviors.knspace>`
documentation for more information.
'''
_knspace = ObjectProperty(None, allownone=True)
_knsname = StringProperty('')
__last_knspace = None
__callbacks = None
def __init__(self, knspace=None, **kwargs):
self.knspace = knspace
super(KNSpaceBehavior, self).__init__(**kwargs)
def __knspace_clear_callbacks(self, *largs):
for obj, name, uid in self.__callbacks:
obj.unbind_uid(name, uid)
last = self.__last_knspace
self.__last_knspace = self.__callbacks = None
assert self._knspace is None
assert last
new = self.__set_parent_knspace()
if new is last:
return
self.property('_knspace').dispatch(self)
name = self.knsname
if not name:
return
if getattr(last, name) == self:
setattr(last, name, None)
if new:
setattr(new, name, self)
else:
raise ValueError('Object has name "{}", but no namespace'.
format(name))
def __set_parent_knspace(self):
callbacks = self.__callbacks = []
fbind = self.fbind
append = callbacks.append
parent_key = self.knspace_key
clear = self.__knspace_clear_callbacks
append((self, 'knspace_key', fbind('knspace_key', clear)))
if not parent_key:
self.__last_knspace = knspace
return knspace
append((self, parent_key, fbind(parent_key, clear)))
parent = getattr(self, parent_key, None)
while parent is not None:
fbind = parent.fbind
parent_knspace = getattr(parent, 'knspace', 0)
if parent_knspace != 0:
append((parent, 'knspace', fbind('knspace', clear)))
self.__last_knspace = parent_knspace
return parent_knspace
append((parent, parent_key, fbind(parent_key, clear)))
new_parent = getattr(parent, parent_key, None)
if new_parent is parent:
break
parent = new_parent
self.__last_knspace = knspace
return knspace
def _get_knspace(self):
_knspace = self._knspace
if _knspace is not None:
return _knspace
if self.__callbacks is not None:
return self.__last_knspace
# we only get here if we never accessed our knspace
return self.__set_parent_knspace()
def _set_knspace(self, value):
if value is self._knspace:
return
knspace = self._knspace or self.__last_knspace
name = self.knsname
if name and knspace and getattr(knspace, name) == self:
setattr(knspace, name, None) # reset old namespace
if value == 'fork':
if not knspace:
knspace = self.knspace # get parents in case we haven't before
if knspace:
value = knspace.fork()
else:
raise ValueError('Cannot fork with no namespace')
for obj, prop_name, uid in self.__callbacks or []:
obj.unbind_uid(prop_name, uid)
self.__last_knspace = self.__callbacks = None
if name:
if value is None: # if None, first update the recursive knspace
knspace = self.__set_parent_knspace()
if knspace:
setattr(knspace, name, self)
self._knspace = None # cause a kv trigger
else:
setattr(value, name, self)
knspace = self._knspace = value
if not knspace:
raise ValueError('Object has name "{}", but no namespace'.
format(name))
else:
if value is None:
self.__set_parent_knspace() # update before trigger below
self._knspace = value
knspace = AliasProperty(
_get_knspace, _set_knspace, bind=('_knspace', ), cache=False,
rebind=True, allownone=True)
'''The namespace instance, :class:`KNSpace`, associated with this widget.
The :attr:`knspace` namespace stores this widget when naming this widget
with :attr:`knsname`.
If the namespace has been set with a :class:`KNSpace` instance, e.g. with
`self.knspace = KNSpace()`, then that instance is returned (setting with
`None` doesn't count). Otherwise, if :attr:`knspace_key` is not None, we
look for a namespace to use in the object that is stored in the property
named :attr:`knspace_key`, of this instance. I.e.
`object = getattr(self, self.knspace_key)`.
If that object has a knspace property, then we return its value. Otherwise,
we go further up, e.g. with `getattr(object, self.knspace_key)` and look
for its `knspace` property.
Finally, if we reach a value of `None`, or :attr:`knspace_key` was `None`,
the default :attr:`~kivy.uix.behaviors.knspace.knspace` namespace is
returned.
If :attr:`knspace` is set to the string `'fork'`, the current namespace
in :attr:`knspace` will be forked with :meth:`KNSpace.fork` and the
resulting namespace will be assigned to this instance's :attr:`knspace`.
See the module examples for a motivating example.
Both `rebind` and `allownone` are `True`.
'''
knspace_key = StringProperty('parent', allownone=True)
'''The name of the property of this instance, to use to search upwards for
a namespace to use by this instance. Defaults to `'parent'` so that we'll
search the parent tree. See :attr:`knspace`.
When `None`, we won't search the parent tree for the namespace.
`allownone` is `True`.
'''
def _get_knsname(self):
return self._knsname
def _set_knsname(self, value):
old_name = self._knsname
knspace = self.knspace
if old_name and knspace and getattr(knspace, old_name) == self:
setattr(knspace, old_name, None)
self._knsname = value
if value:
if knspace:
setattr(knspace, value, self)
else:
raise ValueError('Object has name "{}", but no namespace'.
format(value))
knsname = AliasProperty(
_get_knsname, _set_knsname, bind=('_knsname', ), cache=False)
'''The name given to this instance. If named, the name will be added to the
associated :attr:`knspace` namespace, which will then point to the
`proxy_ref` of this instance.
When named, one can access this object by e.g. self.knspace.name, where
`name` is the given name of this instance. See :attr:`knspace` and the
module description for more details.
'''
knspace = register_context('knspace', KNSpace)
'''The default :class:`KNSpace` namespace. See :attr:`KNSpaceBehavior.knspace`
for more details.
'''
@@ -0,0 +1,156 @@
'''
ToggleButton Behavior
=====================
The :class:`~kivy.uix.behaviors.togglebutton.ToggleButtonBehavior`
`mixin <https://en.wikipedia.org/wiki/Mixin>`_ class provides
:class:`~kivy.uix.togglebutton.ToggleButton` behavior. You can combine this
class with other widgets, such as an :class:`~kivy.uix.image.Image`, to provide
alternative togglebuttons that preserve Kivy togglebutton behavior.
For an overview of behaviors, please refer to the :mod:`~kivy.uix.behaviors`
documentation.
Example
-------
The following example adds togglebutton behavior to an image to make a checkbox
that behaves like a togglebutton::
from kivy.app import App
from kivy.uix.image import Image
from kivy.uix.behaviors import ToggleButtonBehavior
class MyButton(ToggleButtonBehavior, Image):
def __init__(self, **kwargs):
super(MyButton, self).__init__(**kwargs)
self.source = 'atlas://data/images/defaulttheme/checkbox_off'
def on_state(self, widget, value):
if value == 'down':
self.source = 'atlas://data/images/defaulttheme/checkbox_on'
else:
self.source = 'atlas://data/images/defaulttheme/checkbox_off'
class SampleApp(App):
def build(self):
return MyButton()
SampleApp().run()
'''
__all__ = ('ToggleButtonBehavior', )
from kivy.properties import ObjectProperty, BooleanProperty
from kivy.uix.behaviors.button import ButtonBehavior
from weakref import ref
class ToggleButtonBehavior(ButtonBehavior):
'''This `mixin <https://en.wikipedia.org/wiki/Mixin>`_ class provides
:mod:`~kivy.uix.togglebutton` behavior. Please see the
:mod:`togglebutton behaviors module <kivy.uix.behaviors.togglebutton>`
documentation for more information.
.. versionadded:: 1.8.0
'''
__groups = {}
group = ObjectProperty(None, allownone=True)
'''Group of the button. If `None`, no group will be used (the button will be
independent). If specified, :attr:`group` must be a hashable object, like
a string. Only one button in a group can be in a 'down' state.
:attr:`group` is a :class:`~kivy.properties.ObjectProperty` and defaults to
`None`.
'''
allow_no_selection = BooleanProperty(True)
'''This specifies whether the widgets in a group allow no selection i.e.
everything to be deselected.
.. versionadded:: 1.9.0
:attr:`allow_no_selection` is a :class:`BooleanProperty` and defaults to
`True`
'''
def __init__(self, **kwargs):
self._previous_group = None
super(ToggleButtonBehavior, self).__init__(**kwargs)
def on_group(self, *largs):
groups = ToggleButtonBehavior.__groups
if self._previous_group:
group = groups[self._previous_group]
for item in group[:]:
if item() is self:
group.remove(item)
break
group = self._previous_group = self.group
if group not in groups:
groups[group] = []
r = ref(self, ToggleButtonBehavior._clear_groups)
groups[group].append(r)
def _release_group(self, current):
if self.group is None:
return
group = self.__groups[self.group]
for item in group[:]:
widget = item()
if widget is None:
group.remove(item)
if widget is current:
continue
widget.state = 'normal'
def _do_press(self):
if (not self.allow_no_selection and
self.group and self.state == 'down'):
return
self._release_group(self)
self.state = 'normal' if self.state == 'down' else 'down'
def _do_release(self, *args):
pass
@staticmethod
def _clear_groups(wk):
# auto flush the element when the weak reference have been deleted
groups = ToggleButtonBehavior.__groups
for group in list(groups.values()):
if wk in group:
group.remove(wk)
break
@staticmethod
def get_widgets(groupname):
'''Return a list of the widgets contained in a specific group. If the
group doesn't exist, an empty list will be returned.
.. note::
Always release the result of this method! Holding a reference to
any of these widgets can prevent them from being garbage collected.
If in doubt, do::
l = ToggleButtonBehavior.get_widgets('mygroup')
# do your job
del l
.. warning::
It's possible that some widgets that you have previously
deleted are still in the list. The garbage collector might need
to release other objects before flushing them.
'''
groups = ToggleButtonBehavior.__groups
if groupname not in groups:
return []
return [x() for x in groups[groupname] if x()][:]
@@ -0,0 +1,318 @@
'''
Touch Ripple
============
.. versionadded:: 1.10.1
.. warning::
This code is still experimental, and its API is subject to change in a
future version.
This module contains `mixin <https://en.wikipedia.org/wiki/Mixin>`_ classes
to add a touch ripple visual effect known from `Google Material Design
<https://en.wikipedia.org/wiki/Material_Design>_` to widgets.
For an overview of behaviors, please refer to the :mod:`~kivy.uix.behaviors`
documentation.
The class :class:`~kivy.uix.behaviors.touchripple.TouchRippleBehavior` provides
rendering the ripple animation.
The class :class:`~kivy.uix.behaviors.touchripple.TouchRippleButtonBehavior`
basically provides the same functionality as
:class:`~kivy.uix.behaviors.button.ButtonBehavior` but rendering the ripple
animation instead of default press/release visualization.
'''
from kivy.animation import Animation
from kivy.clock import Clock
from kivy.graphics import CanvasBase, Color, Ellipse, ScissorPush, ScissorPop
from kivy.properties import BooleanProperty, ListProperty, NumericProperty, \
ObjectProperty, StringProperty
from kivy.uix.relativelayout import RelativeLayout
__all__ = (
'TouchRippleBehavior',
'TouchRippleButtonBehavior'
)
class TouchRippleBehavior(object):
'''Touch ripple behavior.
Supposed to be used as mixin on widget classes.
Ripple behavior does not trigger automatically, concrete implementation
needs to call :func:`ripple_show` respective :func:`ripple_fade` manually.
Example
-------
Here we create a Label which renders the touch ripple animation on
interaction::
class RippleLabel(TouchRippleBehavior, Label):
def __init__(self, **kwargs):
super(RippleLabel, self).__init__(**kwargs)
def on_touch_down(self, touch):
collide_point = self.collide_point(touch.x, touch.y)
if collide_point:
touch.grab(self)
self.ripple_show(touch)
return True
return False
def on_touch_up(self, touch):
if touch.grab_current is self:
touch.ungrab(self)
self.ripple_fade()
return True
return False
'''
ripple_rad_default = NumericProperty(10)
'''Default radius the animation starts from.
:attr:`ripple_rad_default` is a :class:`~kivy.properties.NumericProperty`
and defaults to `10`.
'''
ripple_duration_in = NumericProperty(.5)
'''Animation duration taken to show the overlay.
:attr:`ripple_duration_in` is a :class:`~kivy.properties.NumericProperty`
and defaults to `0.5`.
'''
ripple_duration_out = NumericProperty(.2)
'''Animation duration taken to fade the overlay.
:attr:`ripple_duration_out` is a :class:`~kivy.properties.NumericProperty`
and defaults to `0.2`.
'''
ripple_fade_from_alpha = NumericProperty(.5)
'''Alpha channel for ripple color the animation starts with.
:attr:`ripple_fade_from_alpha` is a
:class:`~kivy.properties.NumericProperty` and defaults to `0.5`.
'''
ripple_fade_to_alpha = NumericProperty(.8)
'''Alpha channel for ripple color the animation targets to.
:attr:`ripple_fade_to_alpha` is a :class:`~kivy.properties.NumericProperty`
and defaults to `0.8`.
'''
ripple_scale = NumericProperty(2.)
'''Max scale of the animation overlay calculated from max(width/height) of
the decorated widget.
:attr:`ripple_scale` is a :class:`~kivy.properties.NumericProperty`
and defaults to `2.0`.
'''
ripple_func_in = StringProperty('in_cubic')
'''Animation callback for showing the overlay.
:attr:`ripple_func_in` is a :class:`~kivy.properties.StringProperty`
and defaults to `in_cubic`.
'''
ripple_func_out = StringProperty('out_quad')
'''Animation callback for hiding the overlay.
:attr:`ripple_func_out` is a :class:`~kivy.properties.StringProperty`
and defaults to `out_quad`.
'''
ripple_rad = NumericProperty(10)
ripple_pos = ListProperty([0, 0])
ripple_color = ListProperty((1., 1., 1., .5))
def __init__(self, **kwargs):
super(TouchRippleBehavior, self).__init__(**kwargs)
self.ripple_pane = CanvasBase()
self.canvas.add(self.ripple_pane)
self.bind(
ripple_color=self._ripple_set_color,
ripple_pos=self._ripple_set_ellipse,
ripple_rad=self._ripple_set_ellipse
)
self.ripple_ellipse = None
self.ripple_col_instruction = None
def ripple_show(self, touch):
'''Begin ripple animation on current widget.
Expects touch event as argument.
'''
Animation.cancel_all(self, 'ripple_rad', 'ripple_color')
self._ripple_reset_pane()
x, y = self.to_window(*self.pos)
width, height = self.size
if isinstance(self, RelativeLayout):
self.ripple_pos = ripple_pos = (touch.x - x, touch.y - y)
else:
self.ripple_pos = ripple_pos = (touch.x, touch.y)
rc = self.ripple_color
ripple_rad = self.ripple_rad
self.ripple_color = [rc[0], rc[1], rc[2], self.ripple_fade_from_alpha]
with self.ripple_pane:
ScissorPush(
x=int(round(x)),
y=int(round(y)),
width=int(round(width)),
height=int(round(height))
)
self.ripple_col_instruction = Color(rgba=self.ripple_color)
self.ripple_ellipse = Ellipse(
size=(ripple_rad, ripple_rad),
pos=(
ripple_pos[0] - ripple_rad / 2.,
ripple_pos[1] - ripple_rad / 2.
)
)
ScissorPop()
anim = Animation(
ripple_rad=max(width, height) * self.ripple_scale,
t=self.ripple_func_in,
ripple_color=[rc[0], rc[1], rc[2], self.ripple_fade_to_alpha],
duration=self.ripple_duration_in
)
anim.start(self)
def ripple_fade(self):
'''Finish ripple animation on current widget.
'''
Animation.cancel_all(self, 'ripple_rad', 'ripple_color')
width, height = self.size
rc = self.ripple_color
duration = self.ripple_duration_out
anim = Animation(
ripple_rad=max(width, height) * self.ripple_scale,
ripple_color=[rc[0], rc[1], rc[2], 0.],
t=self.ripple_func_out,
duration=duration
)
anim.bind(on_complete=self._ripple_anim_complete)
anim.start(self)
def _ripple_set_ellipse(self, instance, value):
ellipse = self.ripple_ellipse
if not ellipse:
return
ripple_pos = self.ripple_pos
ripple_rad = self.ripple_rad
ellipse.size = (ripple_rad, ripple_rad)
ellipse.pos = (
ripple_pos[0] - ripple_rad / 2.,
ripple_pos[1] - ripple_rad / 2.
)
def _ripple_set_color(self, instance, value):
if not self.ripple_col_instruction:
return
self.ripple_col_instruction.rgba = value
def _ripple_anim_complete(self, anim, instance):
self._ripple_reset_pane()
def _ripple_reset_pane(self):
self.ripple_rad = self.ripple_rad_default
self.ripple_pane.clear()
class TouchRippleButtonBehavior(TouchRippleBehavior):
'''
This `mixin <https://en.wikipedia.org/wiki/Mixin>`_ class provides
a similar behavior to :class:`~kivy.uix.behaviors.button.ButtonBehavior`
but provides touch ripple animation instead of button pressed/released as
visual effect.
:Events:
`on_press`
Fired when the button is pressed.
`on_release`
Fired when the button is released (i.e. the touch/click that
pressed the button goes away).
'''
last_touch = ObjectProperty(None)
'''Contains the last relevant touch received by the Button. This can
be used in `on_press` or `on_release` in order to know which touch
dispatched the event.
:attr:`last_touch` is a :class:`~kivy.properties.ObjectProperty` and
defaults to `None`.
'''
always_release = BooleanProperty(False)
'''This determines whether or not the widget fires an `on_release` event if
the touch_up is outside the widget.
:attr:`always_release` is a :class:`~kivy.properties.BooleanProperty` and
defaults to `False`.
'''
def __init__(self, **kwargs):
self.register_event_type('on_press')
self.register_event_type('on_release')
super(TouchRippleButtonBehavior, self).__init__(**kwargs)
def on_touch_down(self, touch):
if super(TouchRippleButtonBehavior, self).on_touch_down(touch):
return True
if touch.is_mouse_scrolling:
return False
if not self.collide_point(touch.x, touch.y):
return False
if self in touch.ud:
return False
touch.grab(self)
touch.ud[self] = True
self.last_touch = touch
self.ripple_show(touch)
self.dispatch('on_press')
return True
def on_touch_move(self, touch):
if touch.grab_current is self:
return True
if super(TouchRippleButtonBehavior, self).on_touch_move(touch):
return True
return self in touch.ud
def on_touch_up(self, touch):
if touch.grab_current is not self:
return super(TouchRippleButtonBehavior, self).on_touch_up(touch)
assert self in touch.ud
touch.ungrab(self)
self.last_touch = touch
if self.disabled:
return
self.ripple_fade()
if not self.always_release and not self.collide_point(*touch.pos):
return
# defer on_release until ripple_fade has completed
def defer_release(dt):
self.dispatch('on_release')
Clock.schedule_once(defer_release, self.ripple_duration_out)
return True
def on_disabled(self, instance, value):
# ensure ripple animation completes if disabled gets set to True
if value:
self.ripple_fade()
return super(TouchRippleButtonBehavior, self).on_disabled(
instance, value)
def on_press(self):
pass
def on_release(self):
pass
@@ -0,0 +1,331 @@
'''
Box Layout
==========
.. only:: html
.. image:: images/boxlayout.gif
:align: right
.. only:: latex
.. image:: images/boxlayout.png
:align: right
:class:`BoxLayout` arranges children in a vertical or horizontal box.
To position widgets above/below each other, use a vertical BoxLayout::
layout = BoxLayout(orientation='vertical')
btn1 = Button(text='Hello')
btn2 = Button(text='World')
layout.add_widget(btn1)
layout.add_widget(btn2)
To position widgets next to each other, use a horizontal BoxLayout. In this
example, we use 10 pixel spacing between children; the first button covers
70% of the horizontal space, the second covers 30%::
layout = BoxLayout(spacing=10)
btn1 = Button(text='Hello', size_hint=(.7, 1))
btn2 = Button(text='World', size_hint=(.3, 1))
layout.add_widget(btn1)
layout.add_widget(btn2)
Position hints are partially working, depending on the orientation:
* If the orientation is `vertical`: `x`, `right` and `center_x` will be used.
* If the orientation is `horizontal`: `y`, `top` and `center_y` will be used.
Kv Example::
BoxLayout:
orientation: 'vertical'
Label:
text: 'this on top'
Label:
text: 'this right aligned'
size_hint_x: None
size: self.texture_size
pos_hint: {'right': 1}
Label:
text: 'this on bottom'
You can check the `examples/widgets/boxlayout_poshint.py` for a live example.
.. note::
The `size_hint` uses the available space after subtracting all the
fixed-size widgets. For example, if you have a layout that is 800px
wide, and add three buttons like this::
btn1 = Button(text='Hello', size=(200, 100), size_hint=(None, None))
btn2 = Button(text='Kivy', size_hint=(.5, 1))
btn3 = Button(text='World', size_hint=(.5, 1))
The first button will be 200px wide as specified, the second and third
will be 300px each, e.g. (800-200) * 0.5
.. versionchanged:: 1.4.1
Added support for `pos_hint`.
'''
__all__ = ('BoxLayout', )
from kivy.uix.layout import Layout
from kivy.properties import (NumericProperty, OptionProperty,
VariableListProperty, ReferenceListProperty)
class BoxLayout(Layout):
'''Box layout class. See module documentation for more information.
'''
spacing = NumericProperty(0)
'''Spacing between children, in pixels.
:attr:`spacing` is a :class:`~kivy.properties.NumericProperty` and defaults
to 0.
'''
padding = VariableListProperty([0, 0, 0, 0])
'''Padding between layout box and children: [padding_left, padding_top,
padding_right, padding_bottom].
padding also accepts a two argument form [padding_horizontal,
padding_vertical] and a one argument form [padding].
.. versionchanged:: 1.7.0
Replaced NumericProperty with VariableListProperty.
:attr:`padding` is a :class:`~kivy.properties.VariableListProperty` and
defaults to [0, 0, 0, 0].
'''
orientation = OptionProperty('horizontal', options=(
'horizontal', 'vertical'))
'''Orientation of the layout.
:attr:`orientation` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'horizontal'. Can be 'vertical' or 'horizontal'.
'''
minimum_width = NumericProperty(0)
'''Automatically computed minimum width needed to contain all children.
.. versionadded:: 1.10.0
:attr:`minimum_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0. It is read only.
'''
minimum_height = NumericProperty(0)
'''Automatically computed minimum height needed to contain all children.
.. versionadded:: 1.10.0
:attr:`minimum_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0. It is read only.
'''
minimum_size = ReferenceListProperty(minimum_width, minimum_height)
'''Automatically computed minimum size needed to contain all children.
.. versionadded:: 1.10.0
:attr:`minimum_size` is a
:class:`~kivy.properties.ReferenceListProperty` of
(:attr:`minimum_width`, :attr:`minimum_height`) properties. It is read
only.
'''
def __init__(self, **kwargs):
super(BoxLayout, self).__init__(**kwargs)
update = self._trigger_layout
fbind = self.fbind
fbind('spacing', update)
fbind('padding', update)
fbind('children', update)
fbind('orientation', update)
fbind('parent', update)
fbind('size', update)
fbind('pos', update)
def _iterate_layout(self, sizes):
# optimize layout by preventing looking at the same attribute in a loop
len_children = len(sizes)
padding_left, padding_top, padding_right, padding_bottom = self.padding
spacing = self.spacing
orientation = self.orientation
padding_x = padding_left + padding_right
padding_y = padding_top + padding_bottom
# calculate maximum space used by size_hint
stretch_sum = 0.
has_bound = False
hint = [None] * len_children
# min size from all the None hint, and from those with sh_min
minimum_size_bounded = 0
if orientation == 'horizontal':
minimum_size_y = 0
minimum_size_none = padding_x + spacing * (len_children - 1)
for i, ((w, h), (shw, shh), _, (shw_min, shh_min),
(shw_max, _)) in enumerate(sizes):
if shw is None:
minimum_size_none += w
else:
hint[i] = shw
if shw_min:
has_bound = True
minimum_size_bounded += shw_min
elif shw_max is not None:
has_bound = True
stretch_sum += shw
if shh is None:
minimum_size_y = max(minimum_size_y, h)
elif shh_min:
minimum_size_y = max(minimum_size_y, shh_min)
minimum_size_x = minimum_size_bounded + minimum_size_none
minimum_size_y += padding_y
else:
minimum_size_x = 0
minimum_size_none = padding_y + spacing * (len_children - 1)
for i, ((w, h), (shw, shh), _, (shw_min, shh_min),
(_, shh_max)) in enumerate(sizes):
if shh is None:
minimum_size_none += h
else:
hint[i] = shh
if shh_min:
has_bound = True
minimum_size_bounded += shh_min
elif shh_max is not None:
has_bound = True
stretch_sum += shh
if shw is None:
minimum_size_x = max(minimum_size_x, w)
elif shw_min:
minimum_size_x = max(minimum_size_x, shw_min)
minimum_size_y = minimum_size_bounded + minimum_size_none
minimum_size_x += padding_x
self.minimum_size = minimum_size_x, minimum_size_y
# do not move the w/h get above, it's likely to change on above line
selfx = self.x
selfy = self.y
if orientation == 'horizontal':
stretch_space = max(0.0, self.width - minimum_size_none)
dim = 0
else:
stretch_space = max(0.0, self.height - minimum_size_none)
dim = 1
if has_bound:
# make sure the size_hint_min/max are not violated
if stretch_space < 1e-9:
# there's no space, so just set to min size or zero
stretch_sum = stretch_space = 1.
for i, val in enumerate(sizes):
sh = val[1][dim]
if sh is None:
continue
sh_min = val[3][dim]
if sh_min is not None:
hint[i] = sh_min
else:
hint[i] = 0. # everything else is zero
else:
# hint gets updated in place
self.layout_hint_with_bounds(
stretch_sum, stretch_space, minimum_size_bounded,
(val[3][dim] for val in sizes),
(elem[4][dim] for elem in sizes), hint)
if orientation == 'horizontal':
x = padding_left + selfx
size_y = self.height - padding_y
for i, (sh, ((w, h), (_, shh), pos_hint, _, _)) in enumerate(
zip(reversed(hint), reversed(sizes))):
cy = selfy + padding_bottom
if sh:
w = max(0., stretch_space * sh / stretch_sum)
if shh:
h = max(0, shh * size_y)
for key, value in pos_hint.items():
posy = value * size_y
if key == 'y':
cy += posy
elif key == 'top':
cy += posy - h
elif key == 'center_y':
cy += posy - (h / 2.)
yield len_children - i - 1, x, cy, w, h
x += w + spacing
else:
y = padding_bottom + selfy
size_x = self.width - padding_x
for i, (sh, ((w, h), (shw, _), pos_hint, _, _)) in enumerate(
zip(hint, sizes)):
cx = selfx + padding_left
if sh:
h = max(0., stretch_space * sh / stretch_sum)
if shw:
w = max(0, shw * size_x)
for key, value in pos_hint.items():
posx = value * size_x
if key == 'x':
cx += posx
elif key == 'right':
cx += posx - w
elif key == 'center_x':
cx += posx - (w / 2.)
yield i, cx, y, w, h
y += h + spacing
def do_layout(self, *largs):
children = self.children
if not children:
l, t, r, b = self.padding
self.minimum_size = l + r, t + b
return
for i, x, y, w, h in self._iterate_layout(
[(c.size, c.size_hint, c.pos_hint, c.size_hint_min,
c.size_hint_max) for c in children]):
c = children[i]
c.pos = x, y
shw, shh = c.size_hint
if shw is None:
if shh is not None:
c.height = h
else:
if shh is None:
c.width = w
else:
c.size = (w, h)
def add_widget(self, widget, *args, **kwargs):
widget.fbind('pos_hint', self._trigger_layout)
return super(BoxLayout, self).add_widget(widget, *args, **kwargs)
def remove_widget(self, widget, *args, **kwargs):
widget.funbind('pos_hint', self._trigger_layout)
return super(BoxLayout, self).remove_widget(widget, *args, **kwargs)
+576
View File
@@ -0,0 +1,576 @@
'''
Bubble
======
.. versionadded:: 1.1.0
.. image:: images/bubble.jpg
:align: right
The :class:`Bubble` widget is a form of menu or a small popup with an arrow
arranged on one side of it's content.
The :class:`Bubble` contains an arrow attached to the content
(e.g., :class:`BubbleContent`) pointing in the direction you choose. It can
be placed either at a predefined location or flexibly by specifying a relative
position on the border of the widget.
The :class:`BubbleContent` is a styled BoxLayout and is thought to be added to
the :class:`Bubble` as a child widget. The :class:`Bubble` will then arrange
an arrow around the content as desired. Instead of the class:`BubbleContent`,
you can theoretically use any other :class:`Widget` as well as long as it
supports the 'bind' and 'unbind' function of the :class:`EventDispatcher` and
is compatible with Kivy to be placed inside a :class:`BoxLayout`.
The :class:`BubbleButton`is a styled Button. It suits to the style of
:class:`Bubble` and :class:`BubbleContent`. Feel free to place other Widgets
inside the 'content' of the :class:`Bubble`.
.. versionchanged:: 2.2.0
The properties :attr:`background_image`, :attr:`background_color`,
:attr:`border` and :attr:`border_auto_scale` were removed from :class:`Bubble`.
These properties had only been used by the content widget that now uses it's
own properties instead. The color of the arrow is now changed with
:attr:`arrow_color` instead of :attr:`background_color`.
These changes makes the :class:`Bubble` transparent to use with other layouts
as content without any side-effects due to property inheritance.
The property :attr:`flex_arrow_pos` has been added to allow further
customization of the arrow positioning.
The properties :attr:`arrow_margin`, :attr:`arrow_margin_x`,
:attr:`arrow_margin_y`, :attr:`content_size`, :attr:`content_width` and
:attr:`content_height` have been added to ease proper sizing of a
:class:`Bubble` e.g., based on it's content size.
BubbleContent
=============
The :class:`BubbleContent` is a styled BoxLayout that can be used to
add e.g., :class:`BubbleButtons` as menu items.
.. versionchanged:: 2.2.0
The properties :attr:`background_image`, :attr:`background_color`,
:attr:`border` and :attr:`border_auto_scale` were added to the
:class:`BubbleContent`. The :class:`BubbleContent` does no longer rely on these
properties being present in the parent class.
BubbleButton
============
The :class:`BubbleButton` is a styled :class:`Button` that can be used to be
added to the :class:`BubbleContent`.
Simple example
--------------
.. include:: ../../examples/widgets/bubble_test.py
:literal:
Customize the Bubble
--------------------
You can choose the direction in which the arrow points::
Bubble(arrow_pos='top_mid')
or
Bubble(size=(200, 40), flex_arrow_pos=(175, 40))
Similarly, the corresponding properties in the '.kv' language can be used
as well.
You can change the appearance of the bubble::
Bubble(
arrow_image='/path/to/arrow/image',
arrow_color=(1, 0, 0, .5)),
)
BubbleContent(
background_image='/path/to/background/image',
background_color=(1, 0, 0, .5), # 50% translucent red
border=(0,0,0,0),
)
Similarly, the corresponding properties in the '.kv' language can be used
as well.
-----------------------------
'''
__all__ = ('Bubble', 'BubbleButton', 'BubbleContent')
from kivy.uix.image import Image
from kivy.uix.scatter import Scatter
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.relativelayout import RelativeLayout
from kivy.uix.button import Button
from kivy.properties import ObjectProperty
from kivy.properties import StringProperty
from kivy.properties import OptionProperty
from kivy.properties import ListProperty
from kivy.properties import BooleanProperty
from kivy.properties import ColorProperty
from kivy.properties import NumericProperty
from kivy.properties import ReferenceListProperty
from kivy.base import EventLoop
from kivy.metrics import dp
class BubbleException(Exception):
pass
class BubbleButton(Button):
'''A button intended for use in a BubbleContent widget.
You can use a "normal" button class, but it will not look good unless the
background is changed.
Rather use this BubbleButton widget that is already defined and provides a
suitable background for you.
'''
pass
class BubbleContent(BoxLayout):
'''A styled BoxLayout that can be used as the content widget of a Bubble.
.. versionchanged:: 2.2.0
The graphical appearance of :class:`BubbleContent` is now based on it's
own properties :attr:`background_image`, :attr:`background_color`,
:attr:`border` and :attr:`border_auto_scale`. The parent widget properties
are no longer considered. This makes the BubbleContent a standalone themed
BoxLayout.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''Background color, in the format (r, g, b, a). To use it you have to set
:attr:`background_image` first.
.. versionadded:: 2.2.0
:attr:`background_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 1, 1, 1].
'''
background_image = StringProperty('atlas://data/images/defaulttheme/bubble')
'''Background image of the bubble.
.. versionadded:: 2.2.0
:attr:`background_image` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/bubble'.
'''
border = ListProperty([16, 16, 16, 16])
'''Border used for :class:`~kivy.graphics.vertex_instructions.BorderImage`
graphics instruction. Used with the :attr:`background_image`.
It should be used when using custom backgrounds.
It must be a list of 4 values: (bottom, right, top, left). Read the
BorderImage instructions for more information about how to use it.
.. versionadded:: 2.2.0
:attr:`border` is a :class:`~kivy.properties.ListProperty` and defaults to
(16, 16, 16, 16)
'''
border_auto_scale = OptionProperty(
'both_lower',
options=[
'off', 'both', 'x_only', 'y_only', 'y_full_x_lower',
'x_full_y_lower', 'both_lower'
]
)
'''Specifies the :attr:`kivy.graphics.BorderImage.auto_scale`
value on the background BorderImage.
.. versionadded:: 2.2.0
:attr:`border_auto_scale` is a
:class:`~kivy.properties.OptionProperty` and defaults to
'both_lower'.
'''
class Bubble(BoxLayout):
'''Bubble class. See module documentation for more information.
'''
content = ObjectProperty(allownone=True)
'''This is the object where the main content of the bubble is held.
The content of the Bubble set by 'add_widget' and removed with
'remove_widget' similarly to the :class:`ActionView` which is placed into
a class:`ActionBar`
:attr:`content` is a :class:`~kivy.properties.ObjectProperty` and defaults
to None.
'''
arrow_image = StringProperty(
'atlas://data/images/defaulttheme/bubble_arrow'
)
''' Image of the arrow pointing to the bubble.
:attr:`arrow_image` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/bubble_arrow'.
'''
arrow_color = ColorProperty([1, 1, 1, 1])
'''Arrow color, in the format (r, g, b, a). To use it you have to set
:attr:`arrow_image` first.
.. versionadded:: 2.2.0
:attr:`arrow_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 1, 1, 1].
'''
show_arrow = BooleanProperty(True)
''' Indicates whether to show arrow.
.. versionadded:: 1.8.0
:attr:`show_arrow` is a :class:`~kivy.properties.BooleanProperty` and
defaults to `True`.
'''
arrow_pos = OptionProperty(
'bottom_mid',
options=(
'left_top', 'left_mid', 'left_bottom',
'top_left', 'top_mid', 'top_right',
'right_top', 'right_mid', 'right_bottom',
'bottom_left', 'bottom_mid', 'bottom_right',
)
)
'''Specifies the position of the arrow as predefined relative position to
the bubble.
Can be one of: left_top, left_mid, left_bottom top_left, top_mid, top_right
right_top, right_mid, right_bottom bottom_left, bottom_mid, bottom_right.
:attr:`arrow_pos` is a :class:`~kivy.properties.OptionProperty` and
defaults to 'bottom_mid'.
'''
flex_arrow_pos = ListProperty(None)
'''Specifies the position of the arrow as flex coordinate around the
border of the :class:`Bubble` Widget.
If this property is set to a proper position (relative pixel coordinates
within the :class:`Bubble` widget, it overwrites the setting
:attr:`arrow_pos`.
.. versionadded:: 2.2.0
:attr:`flex_arrow_pos` is a :class:`~kivy.properties.ListProperty` and
defaults to None.
'''
limit_to = ObjectProperty(None, allownone=True)
'''Specifies the widget to which the bubbles position is restricted.
.. versionadded:: 1.6.0
:attr:`limit_to` is a :class:`~kivy.properties.ObjectProperty` and defaults
to 'None'.
'''
arrow_margin_x = NumericProperty(0)
'''Automatically computed margin in x direction that the arrow widget
occupies in pixel.
In combination with the :attr:`content_width`, this property can be used
to determine the correct width of the Bubble to exactly enclose the
arrow + content by adding :attr:`content_width` and :attr:`arrow_margin_x`
.. versionadded:: 2.2.0
:attr:`arrow_margin_x` is a :class:`~kivy.properties.NumericProperty` and
represents the added margin in x direction due to the arrow widget.
It defaults to 0 and is read only.
'''
arrow_margin_y = NumericProperty(0)
'''Automatically computed margin in y direction that the arrow widget
occupies in pixel.
In combination with the :attr:`content_height`, this property can be used
to determine the correct height of the Bubble to exactly enclose the
arrow + content by adding :attr:`content_height` and :attr:`arrow_margin_y`
.. versionadded:: 2.2.0
:attr:`arrow_margin_y` is a :class:`~kivy.properties.NumericProperty` and
represents the added margin in y direction due to the arrow widget.
It defaults to 0 and is read only.
'''
arrow_margin = ReferenceListProperty(arrow_margin_x, arrow_margin_y)
'''Automatically computed margin that the arrow widget occupies in
x and y direction in pixel.
Check the description of :attr:`arrow_margin_x` and :attr:`arrow_margin_y`.
.. versionadded:: 2.2.0
:attr:`arrow_margin` is a :class:`~kivy.properties.ReferenceListProperty`
of (:attr:`arrow_margin_x`, :attr:`arrow_margin_y`) properties.
It is read only.
'''
content_width = NumericProperty(0)
'''The width of the content Widget.
.. versionadded:: 2.2.0
:attr:`content_width` is a :class:`~kivy.properties.NumericProperty` and
is the same as self.content.width if content is not None, else it defaults
to 0. It is read only.
'''
content_height = NumericProperty(0)
'''The height of the content Widget.
.. versionadded:: 2.2.0
:attr:`content_height` is a :class:`~kivy.properties.NumericProperty` and
is the same as self.content.height if content is not None, else it defaults
to 0. It is read only.
'''
content_size = ReferenceListProperty(content_width, content_height)
''' The size of the content Widget.
.. versionadded:: 2.2.0
:attr:`content_size` is a :class:`~kivy.properties.ReferenceListProperty`
of (:attr:`content_width`, :attr:`content_height`) properties.
It is read only.
'''
# Internal map that specifies the different parameters for fixed arrow
# position layouts. The flex_arrow_pos uses these parameter sets
# as a template.
# 0: orientation of the children of Bubble ([content, arrow])
# 1: order of widgets to add to the BoxLayout (default: [content, arrow])
# 2: size_hint of _arrow_image_layout
# 3: rotation of the _arrow_image
# 4: pos_hint of the _arrow_image_layout
ARROW_LAYOUTS = {
"bottom_left": ( "vertical", 1, ( 1, None), 0, { "top": 1.0, "x": 0.05}), # noqa: E201,E241,E501
"bottom_mid": ( "vertical", 1, ( 1, None), 0, { "top": 1.0, "center_x": 0.50}), # noqa: E201,E241,E501
"bottom_right": ( "vertical", 1, ( 1, None), 0, { "top": 1.0, "right": 0.95}), # noqa: E201,E241,E501
"right_bottom": ( "horizontal", 1, (None, 1), 90, { "left": 0.0, "y": 0.05}), # noqa: E201,E241,E501
"right_mid": ( "horizontal", 1, (None, 1), 90, { "left": 0.0, "center_y": 0.50}), # noqa: E201,E241,E501
"right_top": ( "horizontal", 1, (None, 1), 90, { "left": 0.0, "top": 0.95}), # noqa: E201,E241,E501
"top_left": ( "vertical", -1, ( 1, None), 180, {"bottom": 0.0, "x": 0.05}), # noqa: E201,E241,E501
"top_mid": ( "vertical", -1, ( 1, None), 180, {"bottom": 0.0, "center_x": 0.50}), # noqa: E201,E241,E501
"top_right": ( "vertical", -1, ( 1, None), 180, {"bottom": 0.0, "right": 0.95}), # noqa: E201,E241,E501
"left_bottom": ( "horizontal", -1, (None, 1), -90, {"right": 1.0, "y": 0.05}), # noqa: E201,E241,E501
"left_mid": ( "horizontal", -1, (None, 1), -90, {"right": 1.0, "center_y": 0.50}), # noqa: E201,E241,E501
"left_top": ( "horizontal", -1, (None, 1), -90, {"right": 1.0, "top": 0.95}), # noqa: E201,E241,E501
}
def __init__(self, **kwargs):
self.content = None
self._flex_arrow_layout_params = None
self._temporarily_ignore_limits = False
self._arrow_image = Image(
source=self.arrow_image,
fit_mode="scale-down",
color=self.arrow_color
)
self._arrow_image.width = self._arrow_image.texture_size[0]
self._arrow_image.height = dp(self._arrow_image.texture_size[1])
self._arrow_image_scatter = Scatter(
size_hint=(None, None),
do_scale=False,
do_rotation=False,
do_translation=False,
)
self._arrow_image_scatter.add_widget(self._arrow_image)
self._arrow_image_scatter.size = self._arrow_image.texture_size
self._arrow_image_scatter_wrapper = BoxLayout(
size_hint=(None, None),
)
self._arrow_image_scatter_wrapper.add_widget(self._arrow_image_scatter)
self._arrow_image_layout = RelativeLayout()
self._arrow_image_layout.add_widget(self._arrow_image_scatter_wrapper)
self._arrow_layout = None
super().__init__(**kwargs)
self.reposition_inner_widgets()
def add_widget(self, widget, *args, **kwargs):
if self.content is None:
self.content = widget
self.content_size = widget.size
self.content.bind(size=self.update_content_size)
self.reposition_inner_widgets()
else:
raise BubbleException(
"Bubble can only contain a single Widget or Layout"
)
def remove_widget(self, widget, *args, **kwargs):
if widget == self.content:
self.content.unbind(size=self.update_content_size)
self.content = None
self.content_size = [0, 0]
self.reposition_inner_widgets()
return
super().remove_widget(widget, *args, **kwargs)
def on_content_size(self, instance, value):
self.adjust_position()
def on_limit_to(self, instance, value):
self.adjust_position()
def on_pos(self, instance, value):
self.adjust_position()
def on_size(self, instance, value):
self.reposition_inner_widgets()
def on_arrow_image(self, instance, value):
self._arrow_image.source = self.arrow_image
self._arrow_image.width = self._arrow_image.texture_size[0]
self._arrow_image.height = dp(self._arrow_image.texture_size[1])
self._arrow_image_scatter.size = self._arrow_image.texture_size
self.reposition_inner_widgets()
def on_arrow_color(self, instance, value):
self._arrow_image.color = self.arrow_color
def on_arrow_pos(self, instance, value):
self.reposition_inner_widgets()
def on_flex_arrow_pos(self, instance, value):
self._flex_arrow_layout_params = self.get_flex_arrow_layout_params()
self.reposition_inner_widgets()
def get_flex_arrow_layout_params(self):
pos = self.flex_arrow_pos
if pos is None:
return None
x, y = pos
if not (0 <= x <= self.width and 0 <= y <= self.height):
return None
# the order of the following list defines the side that the arrow
# will be attached to in case of ambiguity (same distances)
base_layouts_map = [
("bottom_mid", y),
("top_mid", self.height - y),
("left_mid", x),
("right_mid", self.width - x),
]
base_layout_key = min(base_layouts_map, key=lambda val: val[1])[0]
arrow_layout = list(Bubble.ARROW_LAYOUTS[base_layout_key])
arrow_width = self._arrow_image.width
# This function calculates the proper value for pos_hint, i.e., the
# arrow texture does not 'overflow' and stays entirely connected to
# the side of the content.
def calc_x0(x, length):
return x * (length - arrow_width) / (length * length)
if base_layout_key == "bottom_mid":
arrow_layout[-1] = {"top": 1.0, "x": calc_x0(x, self.width)}
elif base_layout_key == "top_mid":
arrow_layout[-1] = {"bottom": 0.0, "x": calc_x0(x, self.width)}
elif base_layout_key == "left_mid":
arrow_layout[-1] = {"right": 1.0, "y": calc_x0(y, self.height)}
elif base_layout_key == "right_mid":
arrow_layout[-1] = {"left": 0.0, "y": calc_x0(y, self.height)}
return arrow_layout
def update_content_size(self, instance, value):
self.content_size = self.content.size
def adjust_position(self):
if self.limit_to is not None and not self._temporarily_ignore_limits:
if self.limit_to is EventLoop.window:
lim_x, lim_y = 0, 0
lim_top, lim_right = self.limit_to.size
else:
lim_x = self.limit_to.x
lim_y = self.limit_to.y
lim_top = self.limit_to.top
lim_right = self.limit_to.right
self._temporarily_ignore_limits = True
if not (lim_x > self.x and lim_right < self.right):
self.x = max(lim_x, min(lim_right - self.width, self.x))
if not (lim_y > self.y and lim_right < self.right):
self.y = min(lim_top - self.height, max(lim_y, self.y))
self._temporarily_ignore_limits = False
def reposition_inner_widgets(self):
arrow_image_layout = self._arrow_image_layout
arrow_image_scatter = self._arrow_image_scatter
arrow_image_scatter_wrapper = self._arrow_image_scatter_wrapper
content = self.content
# Remove the children of the Bubble (BoxLayout) as a first step
for child in list(self.children):
super().remove_widget(child)
if self.canvas is None or content is None:
return
# find the layout parameters that define a specific bubble setup
if self._flex_arrow_layout_params is not None:
layout_params = self._flex_arrow_layout_params
else:
layout_params = Bubble.ARROW_LAYOUTS[self.arrow_pos]
(bubble_orientation,
widget_order,
arrow_size_hint,
arrow_rotation,
arrow_pos_hint) = layout_params
# rotate the arrow, place it at the right pos and setup the size
# of the widget, so the BoxLayout can do the rest.
arrow_image_scatter.rotation = arrow_rotation
arrow_image_scatter_wrapper.size = arrow_image_scatter.bbox[1]
arrow_image_scatter_wrapper.pos_hint = arrow_pos_hint
arrow_image_layout.size_hint = arrow_size_hint
arrow_image_layout.size = arrow_image_scatter.bbox[1]
# set the orientation of the Bubble (BoxLayout)
self.orientation = bubble_orientation
# Add the updated children of the Bubble (BoxLayout) and update
# properties
widgets_to_add = [content, arrow_image_layout]
# Set the arrow_margin, so we can use this property for proper sizing
# of the Bubble Widget.
# Determine whether to add the arrow_image_layout to the
# Bubble (BoxLayout) or not.
arrow_margin_x, arrow_margin_y = (0, 0)
if self.show_arrow:
if bubble_orientation[0] == "h":
arrow_margin_x = arrow_image_layout.width
elif bubble_orientation[0] == "v":
arrow_margin_y = arrow_image_layout.height
else:
widgets_to_add.pop(1)
for widget in widgets_to_add[::widget_order]:
super().add_widget(widget)
self.arrow_margin = (arrow_margin_x, arrow_margin_y)
+137
View File
@@ -0,0 +1,137 @@
'''
Button
======
.. image:: images/button.jpg
:align: right
The :class:`Button` is a :class:`~kivy.uix.label.Label` with associated actions
that are triggered when the button is pressed (or released after a
click/touch). To configure the button, the same properties (padding,
font_size, etc) and
:ref:`sizing system <kivy-uix-label-sizing-and-text-content>`
are used as for the :class:`~kivy.uix.label.Label` class::
button = Button(text='Hello world', font_size=14)
To attach a callback when the button is pressed (clicked/touched), use
:class:`~kivy.uix.widget.Widget.bind`::
def callback(instance):
print('The button <%s> is being pressed' % instance.text)
btn1 = Button(text='Hello world 1')
btn1.bind(on_press=callback)
btn2 = Button(text='Hello world 2')
btn2.bind(on_press=callback)
If you want to be notified every time the button state changes, you can bind
to the :attr:`Button.state` property::
def callback(instance, value):
print('My button <%s> state is <%s>' % (instance, value))
btn1 = Button(text='Hello world 1')
btn1.bind(state=callback)
Kv Example::
Button:
text: 'press me'
on_press: print("ouch! More gently please")
on_release: print("ahhh")
on_state:
print("my current state is {}".format(self.state))
'''
__all__ = ('Button', )
from kivy.uix.label import Label
from kivy.properties import StringProperty, ListProperty, ColorProperty
from kivy.uix.behaviors import ButtonBehavior
class Button(ButtonBehavior, Label):
'''Button class, see module documentation for more information.
.. versionchanged:: 1.8.0
The behavior / logic of the button has been moved to
:class:`~kivy.uix.behaviors.ButtonBehaviors`.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''Background color, in the format (r, g, b, a).
This acts as a *multiplier* to the texture color. The default
texture is grey, so just setting the background color will give
a darker result. To set a plain color, set the
:attr:`background_normal` to ``''``.
.. versionadded:: 1.0.8
The :attr:`background_color` is a
:class:`~kivy.properties.ColorProperty` and defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
background_normal = StringProperty(
'atlas://data/images/defaulttheme/button')
'''Background image of the button used for the default graphical
representation when the button is not pressed.
.. versionadded:: 1.0.4
:attr:`background_normal` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/button'.
'''
background_down = StringProperty(
'atlas://data/images/defaulttheme/button_pressed')
'''Background image of the button used for the default graphical
representation when the button is pressed.
.. versionadded:: 1.0.4
:attr:`background_down` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/button_pressed'.
'''
background_disabled_normal = StringProperty(
'atlas://data/images/defaulttheme/button_disabled')
'''Background image of the button used for the default graphical
representation when the button is disabled and not pressed.
.. versionadded:: 1.8.0
:attr:`background_disabled_normal` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/button_disabled'.
'''
background_disabled_down = StringProperty(
'atlas://data/images/defaulttheme/button_disabled_pressed')
'''Background image of the button used for the default graphical
representation when the button is disabled and pressed.
.. versionadded:: 1.8.0
:attr:`background_disabled_down` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/button_disabled_pressed'.
'''
border = ListProperty([16, 16, 16, 16])
'''Border used for :class:`~kivy.graphics.vertex_instructions.BorderImage`
graphics instruction. Used with :attr:`background_normal` and
:attr:`background_down`. Can be used for custom backgrounds.
It must be a list of four values: (bottom, right, top, left). Read the
BorderImage instruction for more information about how to use it.
:attr:`border` is a :class:`~kivy.properties.ListProperty` and defaults to
(16, 16, 16, 16)
'''
+118
View File
@@ -0,0 +1,118 @@
'''
Camera
======
The :class:`Camera` widget is used to capture and display video from a camera.
Once the widget is created, the texture inside the widget will be automatically
updated. Our :class:`~kivy.core.camera.CameraBase` implementation is used under
the hood::
cam = Camera()
By default, the first camera found on your system is used. To use a different
camera, set the index property::
cam = Camera(index=1)
You can also select the camera resolution::
cam = Camera(resolution=(320, 240))
.. warning::
The camera texture is not updated as soon as you have created the object.
The camera initialization is asynchronous, so there may be a delay before
the requested texture is created.
'''
__all__ = ('Camera', )
from kivy.uix.image import Image
from kivy.core.camera import Camera as CoreCamera
from kivy.properties import NumericProperty, ListProperty, \
BooleanProperty
class Camera(Image):
'''Camera class. See module documentation for more information.
'''
play = BooleanProperty(False)
'''Boolean indicating whether the camera is playing or not.
You can start/stop the camera by setting this property::
# start the camera playing at creation
cam = Camera(play=True)
# create the camera, and start later (default)
cam = Camera(play=False)
# and later
cam.play = True
:attr:`play` is a :class:`~kivy.properties.BooleanProperty` and defaults to
False.
'''
index = NumericProperty(-1)
'''Index of the used camera, starting from 0.
:attr:`index` is a :class:`~kivy.properties.NumericProperty` and defaults
to -1 to allow auto selection.
'''
resolution = ListProperty([-1, -1])
'''Preferred resolution to use when invoking the camera. If you are using
[-1, -1], the resolution will be the default one::
# create a camera object with the best image available
cam = Camera()
# create a camera object with an image of 320x240 if possible
cam = Camera(resolution=(320, 240))
.. warning::
Depending on the implementation, the camera may not respect this
property.
:attr:`resolution` is a :class:`~kivy.properties.ListProperty` and defaults
to [-1, -1].
'''
def __init__(self, **kwargs):
self._camera = None
super(Camera, self).__init__(**kwargs)
if self.index == -1:
self.index = 0
on_index = self._on_index
fbind = self.fbind
fbind('index', on_index)
fbind('resolution', on_index)
on_index()
def on_tex(self, camera):
self.texture = texture = camera.texture
self.texture_size = list(texture.size)
self.canvas.ask_update()
def _on_index(self, *largs):
self._camera = None
if self.index < 0:
return
if self.resolution[0] < 0 or self.resolution[1] < 0:
self._camera = CoreCamera(index=self.index, stopped=True)
else:
self._camera = CoreCamera(index=self.index,
resolution=self.resolution, stopped=True)
if self.play:
self._camera.start()
self._camera.bind(on_texture=self.on_tex)
def on_play(self, instance, value):
if not self._camera:
return
if value:
self._camera.start()
else:
self._camera.stop()
@@ -0,0 +1,695 @@
'''
Carousel
========
.. image:: images/carousel.gif
:align: right
.. versionadded:: 1.4.0
The :class:`Carousel` widget provides the classic mobile-friendly carousel view
where you can swipe between slides.
You can add any content to the carousel and have it move horizontally or
vertically. The carousel can display pages in a sequence or a loop.
Example::
from kivy.app import App
from kivy.uix.carousel import Carousel
from kivy.uix.image import AsyncImage
class CarouselApp(App):
def build(self):
carousel = Carousel(direction='right')
for i in range(10):
src = "http://placehold.it/480x270.png&text=slide-%d&.png" % i
image = AsyncImage(source=src, fit_mode="contain")
carousel.add_widget(image)
return carousel
CarouselApp().run()
Kv Example::
Carousel:
direction: 'right'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-1.png'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-2.png'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-3.png'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-4.png'
.. versionchanged:: 1.5.0
The carousel now supports active children, like the
:class:`~kivy.uix.scrollview.ScrollView`. It will detect a swipe gesture
according to the :attr:`Carousel.scroll_timeout` and
:attr:`Carousel.scroll_distance` properties.
In addition, the slide container is no longer exposed by the API.
The impacted properties are
:attr:`Carousel.slides`, :attr:`Carousel.current_slide`,
:attr:`Carousel.previous_slide` and :attr:`Carousel.next_slide`.
'''
__all__ = ('Carousel', )
from functools import partial
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.animation import Animation
from kivy.uix.stencilview import StencilView
from kivy.uix.relativelayout import RelativeLayout
from kivy.properties import BooleanProperty, OptionProperty, AliasProperty, \
NumericProperty, ListProperty, ObjectProperty, StringProperty
class Carousel(StencilView):
'''Carousel class. See module documentation for more information.
'''
slides = ListProperty([])
'''List of slides inside the Carousel. The slides are the
widgets added to the Carousel using the :attr:`add_widget` method.
:attr:`slides` is a :class:`~kivy.properties.ListProperty` and is
read-only.
'''
def _get_slides_container(self):
return [x.parent for x in self.slides]
slides_container = AliasProperty(_get_slides_container, bind=('slides',))
direction = OptionProperty('right',
options=('right', 'left', 'top', 'bottom'))
'''Specifies the direction in which the slides are ordered. This
corresponds to the direction from which the user swipes to go from one
slide to the next. It
can be `right`, `left`, `top`, or `bottom`. For example, with
the default value of `right`, the second slide is to the right
of the first and the user would swipe from the right towards the
left to get to the second slide.
:attr:`direction` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'right'.
'''
min_move = NumericProperty(0.2)
'''Defines the minimum distance to be covered before the touch is
considered a swipe gesture and the Carousel content changed.
This is a expressed as a fraction of the Carousel's width.
If the movement doesn't reach this minimum value, the movement is
cancelled and the content is restored to its original position.
:attr:`min_move` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.2.
'''
anim_move_duration = NumericProperty(0.5)
'''Defines the duration of the Carousel animation between pages.
:attr:`anim_move_duration` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.5.
'''
anim_cancel_duration = NumericProperty(0.3)
'''Defines the duration of the animation when a swipe movement is not
accepted. This is generally when the user does not make a large enough
swipe. See :attr:`min_move`.
:attr:`anim_cancel_duration` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.3.
'''
loop = BooleanProperty(False)
'''Allow the Carousel to loop infinitely. If True, when the user tries to
swipe beyond last page, it will return to the first. If False, it will
remain on the last page.
:attr:`loop` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
def _get_index(self):
if self.slides:
return self._index % len(self.slides)
return None
def _set_index(self, value):
if self.slides:
self._index = value % len(self.slides)
else:
self._index = None
index = AliasProperty(_get_index, _set_index,
bind=('_index', 'slides'),
cache=True)
'''Get/Set the current slide based on the index.
:attr:`index` is an :class:`~kivy.properties.AliasProperty` and defaults
to 0 (the first item).
'''
def _prev_slide(self):
slides = self.slides
len_slides = len(slides)
index = self.index
if len_slides < 2: # None, or 1 slide
return None
if self.loop and index == 0:
return slides[-1]
if index > 0:
return slides[index - 1]
previous_slide = AliasProperty(_prev_slide,
bind=('slides', 'index', 'loop'),
cache=True)
'''The previous slide in the Carousel. It is None if the current slide is
the first slide in the Carousel. This ordering reflects the order in which
the slides are added: their presentation varies according to the
:attr:`direction` property.
:attr:`previous_slide` is an :class:`~kivy.properties.AliasProperty`.
.. versionchanged:: 1.5.0
This property no longer exposes the slides container. It returns
the widget you have added.
'''
def _curr_slide(self):
if len(self.slides):
return self.slides[self.index or 0]
current_slide = AliasProperty(_curr_slide,
bind=('slides', 'index'),
cache=True)
'''The currently shown slide.
:attr:`current_slide` is an :class:`~kivy.properties.AliasProperty`.
.. versionchanged:: 1.5.0
The property no longer exposes the slides container. It returns
the widget you have added.
'''
def _next_slide(self):
if len(self.slides) < 2: # None, or 1 slide
return None
if self.loop and self.index == len(self.slides) - 1:
return self.slides[0]
if self.index < len(self.slides) - 1:
return self.slides[self.index + 1]
next_slide = AliasProperty(_next_slide,
bind=('slides', 'index', 'loop'),
cache=True)
'''The next slide in the Carousel. It is None if the current slide is
the last slide in the Carousel. This ordering reflects the order in which
the slides are added: their presentation varies according to the
:attr:`direction` property.
:attr:`next_slide` is an :class:`~kivy.properties.AliasProperty`.
.. versionchanged:: 1.5.0
The property no longer exposes the slides container.
It returns the widget you have added.
'''
scroll_timeout = NumericProperty(200)
'''Timeout allowed to trigger the :attr:`scroll_distance`, in milliseconds.
If the user has not moved :attr:`scroll_distance` within the timeout,
no scrolling will occur and the touch event will go to the children.
:attr:`scroll_timeout` is a :class:`~kivy.properties.NumericProperty` and
defaults to 200 (milliseconds)
.. versionadded:: 1.5.0
'''
scroll_distance = NumericProperty('20dp')
'''Distance to move before scrolling the :class:`Carousel` in pixels. As
soon as the distance has been traveled, the :class:`Carousel` will start
to scroll, and no touch event will go to children.
It is advisable that you base this value on the dpi of your target device's
screen.
:attr:`scroll_distance` is a :class:`~kivy.properties.NumericProperty` and
defaults to 20dp.
.. versionadded:: 1.5.0
'''
anim_type = StringProperty('out_quad')
'''Type of animation to use while animating to the next/previous slide.
This should be the name of an
:class:`~kivy.animation.AnimationTransition` function.
:attr:`anim_type` is a :class:`~kivy.properties.StringProperty` and
defaults to 'out_quad'.
.. versionadded:: 1.8.0
'''
ignore_perpendicular_swipes = BooleanProperty(False)
'''Ignore swipes on axis perpendicular to direction.
:attr:`ignore_perpendicular_swipes` is a
:class:`~kivy.properties.BooleanProperty` and defaults to False.
.. versionadded:: 1.10.0
'''
# private properties, for internal use only ###
_index = NumericProperty(0, allownone=True)
_prev = ObjectProperty(None, allownone=True)
_current = ObjectProperty(None, allownone=True)
_next = ObjectProperty(None, allownone=True)
_offset = NumericProperty(0)
_touch = ObjectProperty(None, allownone=True)
_change_touch_mode_ev = None
def __init__(self, **kwargs):
self._trigger_position_visible_slides = Clock.create_trigger(
self._position_visible_slides, -1)
super(Carousel, self).__init__(**kwargs)
self._skip_slide = None
self.touch_mode_change = False
self._prioritize_next = False
self.fbind('loop', lambda *args: self._insert_visible_slides())
def load_slide(self, slide):
'''Animate to the slide that is passed as the argument.
.. versionchanged:: 1.8.0
'''
slides = self.slides
start, stop = slides.index(self.current_slide), slides.index(slide)
if start == stop:
return
self._skip_slide = stop
if stop > start:
self._prioritize_next = True
self._insert_visible_slides(_next_slide=slide)
self.load_next()
else:
self._prioritize_next = False
self._insert_visible_slides(_prev_slide=slide)
self.load_previous()
def load_previous(self):
'''Animate to the previous slide.
.. versionadded:: 1.7.0
'''
self.load_next(mode='prev')
def load_next(self, mode='next'):
'''Animate to the next slide.
.. versionadded:: 1.7.0
'''
if self.index is not None:
w, h = self.size
_direction = {
'top': -h / 2,
'bottom': h / 2,
'left': w / 2,
'right': -w / 2}
_offset = _direction[self.direction]
if mode == 'prev':
_offset = -_offset
self._start_animation(min_move=0, offset=_offset)
def get_slide_container(self, slide):
return slide.parent
@property
def _prev_equals_next(self):
return self.loop and len(self.slides) == 2
def _insert_visible_slides(self, _next_slide=None, _prev_slide=None):
get_slide_container = self.get_slide_container
previous_slide = _prev_slide if _prev_slide else self.previous_slide
if previous_slide:
self._prev = get_slide_container(previous_slide)
else:
self._prev = None
current_slide = self.current_slide
if current_slide:
self._current = get_slide_container(current_slide)
else:
self._current = None
next_slide = _next_slide if _next_slide else self.next_slide
if next_slide:
self._next = get_slide_container(next_slide)
else:
self._next = None
if self._prev_equals_next:
setattr(self, '_prev' if self._prioritize_next else '_next', None)
super_remove = super(Carousel, self).remove_widget
for container in self.slides_container:
super_remove(container)
if self._prev and self._prev.parent is not self:
super(Carousel, self).add_widget(self._prev)
if self._next and self._next.parent is not self:
super(Carousel, self).add_widget(self._next)
if self._current:
super(Carousel, self).add_widget(self._current)
def _position_visible_slides(self, *args):
slides, index = self.slides, self.index
no_of_slides = len(slides) - 1
if not slides:
return
x, y, width, height = self.x, self.y, self.width, self.height
_offset, direction = self._offset, self.direction[0]
_prev, _next, _current = self._prev, self._next, self._current
get_slide_container = self.get_slide_container
last_slide = get_slide_container(slides[-1])
first_slide = get_slide_container(slides[0])
skip_next = False
_loop = self.loop
if direction in 'rl':
xoff = x + _offset
x_prev = {'l': xoff + width, 'r': xoff - width}
x_next = {'l': xoff - width, 'r': xoff + width}
if _prev:
_prev.pos = (x_prev[direction], y)
elif _loop and _next and index == 0:
# if first slide is moving to right with direction set to right
# or toward left with direction set to left
if ((_offset > 0 and direction == 'r') or
(_offset < 0 and direction == 'l')):
# put last_slide before first slide
last_slide.pos = (x_prev[direction], y)
skip_next = True
if _current:
_current.pos = (xoff, y)
if skip_next:
return
if _next:
_next.pos = (x_next[direction], y)
elif _loop and _prev and index == no_of_slides:
if ((_offset < 0 and direction == 'r') or
(_offset > 0 and direction == 'l')):
first_slide.pos = (x_next[direction], y)
if direction in 'tb':
yoff = y + _offset
y_prev = {'t': yoff - height, 'b': yoff + height}
y_next = {'t': yoff + height, 'b': yoff - height}
if _prev:
_prev.pos = (x, y_prev[direction])
elif _loop and _next and index == 0:
if ((_offset > 0 and direction == 't') or
(_offset < 0 and direction == 'b')):
last_slide.pos = (x, y_prev[direction])
skip_next = True
if _current:
_current.pos = (x, yoff)
if skip_next:
return
if _next:
_next.pos = (x, y_next[direction])
elif _loop and _prev and index == no_of_slides:
if ((_offset < 0 and direction == 't') or
(_offset > 0 and direction == 'b')):
first_slide.pos = (x, y_next[direction])
def on_size(self, *args):
size = self.size
for slide in self.slides_container:
slide.size = size
self._trigger_position_visible_slides()
def on_pos(self, *args):
self._trigger_position_visible_slides()
def on_index(self, *args):
self._insert_visible_slides()
self._trigger_position_visible_slides()
self._offset = 0
def on_slides(self, *args):
if self.slides:
self.index = self.index % len(self.slides)
self._insert_visible_slides()
self._trigger_position_visible_slides()
def on__offset(self, *args):
self._trigger_position_visible_slides()
# if reached full offset, switch index to next or prev
direction = self.direction[0]
_offset = self._offset
width = self.width
height = self.height
index = self.index
if self._skip_slide is not None or index is None:
return
# Move to next slide?
if (direction == 'r' and _offset <= -width) or \
(direction == 'l' and _offset >= width) or \
(direction == 't' and _offset <= - height) or \
(direction == 'b' and _offset >= height):
if self.next_slide:
self.index += 1
# Move to previous slide?
elif (direction == 'r' and _offset >= width) or \
(direction == 'l' and _offset <= -width) or \
(direction == 't' and _offset >= height) or \
(direction == 'b' and _offset <= -height):
if self.previous_slide:
self.index -= 1
elif self._prev_equals_next:
new_value = (_offset < 0) is (direction in 'rt')
if self._prioritize_next is not new_value:
self._prioritize_next = new_value
if new_value is (self._next is None):
self._prev, self._next = self._next, self._prev
def _start_animation(self, *args, **kwargs):
# compute target offset for ease back, next or prev
new_offset = 0
direction = kwargs.get('direction', self.direction)[0]
is_horizontal = direction in 'rl'
extent = self.width if is_horizontal else self.height
min_move = kwargs.get('min_move', self.min_move)
_offset = kwargs.get('offset', self._offset)
if _offset < min_move * -extent:
new_offset = -extent
elif _offset > min_move * extent:
new_offset = extent
# if new_offset is 0, it wasn't enough to go next/prev
dur = self.anim_move_duration
if new_offset == 0:
dur = self.anim_cancel_duration
# detect edge cases if not looping
len_slides = len(self.slides)
index = self.index
if not self.loop or len_slides == 1:
is_first = (index == 0)
is_last = (index == len_slides - 1)
if direction in 'rt':
towards_prev = (new_offset > 0)
towards_next = (new_offset < 0)
else:
towards_prev = (new_offset < 0)
towards_next = (new_offset > 0)
if (is_first and towards_prev) or (is_last and towards_next):
new_offset = 0
anim = Animation(_offset=new_offset, d=dur, t=self.anim_type)
anim.cancel_all(self)
def _cmp(*l):
if self._skip_slide is not None:
self.index = self._skip_slide
self._skip_slide = None
anim.bind(on_complete=_cmp)
anim.start(self)
def _get_uid(self, prefix='sv'):
return '{0}.{1}'.format(prefix, self.uid)
def on_touch_down(self, touch):
if not self.collide_point(*touch.pos):
touch.ud[self._get_uid('cavoid')] = True
return
if self.disabled:
return True
if self._touch:
return super(Carousel, self).on_touch_down(touch)
Animation.cancel_all(self)
self._touch = touch
uid = self._get_uid()
touch.grab(self)
touch.ud[uid] = {
'mode': 'unknown',
'time': touch.time_start}
self._change_touch_mode_ev = Clock.schedule_once(
self._change_touch_mode, self.scroll_timeout / 1000.)
self.touch_mode_change = False
return True
def on_touch_move(self, touch):
if not self.touch_mode_change:
if self.ignore_perpendicular_swipes and \
self.direction in ('top', 'bottom'):
if abs(touch.oy - touch.y) < self.scroll_distance:
if abs(touch.ox - touch.x) > self.scroll_distance:
self._change_touch_mode()
self.touch_mode_change = True
elif self.ignore_perpendicular_swipes and \
self.direction in ('right', 'left'):
if abs(touch.ox - touch.x) < self.scroll_distance:
if abs(touch.oy - touch.y) > self.scroll_distance:
self._change_touch_mode()
self.touch_mode_change = True
if self._get_uid('cavoid') in touch.ud:
return
if self._touch is not touch:
super(Carousel, self).on_touch_move(touch)
return self._get_uid() in touch.ud
if touch.grab_current is not self:
return True
ud = touch.ud[self._get_uid()]
direction = self.direction[0]
if ud['mode'] == 'unknown':
if direction in 'rl':
distance = abs(touch.ox - touch.x)
else:
distance = abs(touch.oy - touch.y)
if distance > self.scroll_distance:
ev = self._change_touch_mode_ev
if ev is not None:
ev.cancel()
ud['mode'] = 'scroll'
else:
if direction in 'rl':
self._offset += touch.dx
if direction in 'tb':
self._offset += touch.dy
return True
def on_touch_up(self, touch):
if self._get_uid('cavoid') in touch.ud:
return
if self in [x() for x in touch.grab_list]:
touch.ungrab(self)
self._touch = None
ud = touch.ud[self._get_uid()]
if ud['mode'] == 'unknown':
ev = self._change_touch_mode_ev
if ev is not None:
ev.cancel()
super(Carousel, self).on_touch_down(touch)
Clock.schedule_once(partial(self._do_touch_up, touch), .1)
else:
self._start_animation()
else:
if self._touch is not touch and self.uid not in touch.ud:
super(Carousel, self).on_touch_up(touch)
return self._get_uid() in touch.ud
def _do_touch_up(self, touch, *largs):
super(Carousel, self).on_touch_up(touch)
# don't forget about grab event!
for x in touch.grab_list[:]:
touch.grab_list.remove(x)
x = x()
if not x:
continue
touch.grab_current = x
super(Carousel, self).on_touch_up(touch)
touch.grab_current = None
def _change_touch_mode(self, *largs):
if not self._touch:
return
self._start_animation()
uid = self._get_uid()
touch = self._touch
ud = touch.ud[uid]
if ud['mode'] == 'unknown':
touch.ungrab(self)
self._touch = None
super(Carousel, self).on_touch_down(touch)
return
def add_widget(self, widget, index=0, *args, **kwargs):
container = RelativeLayout(
size=self.size, x=self.x - self.width, y=self.y)
container.add_widget(widget)
super(Carousel, self).add_widget(container, index, *args, **kwargs)
if index != 0:
self.slides.insert(index - len(self.slides), widget)
else:
self.slides.append(widget)
def remove_widget(self, widget, *args, **kwargs):
# XXX be careful, the widget.parent refer to the RelativeLayout
# added in add_widget(). But it will break if RelativeLayout
# implementation change.
# if we passed the real widget
slides = self.slides
if widget in slides:
if self.index >= slides.index(widget):
self.index = max(0, self.index - 1)
container = widget.parent
slides.remove(widget)
super(Carousel, self).remove_widget(container, *args, **kwargs)
container.remove_widget(widget)
return
super(Carousel, self).remove_widget(widget, *args, **kwargs)
def clear_widgets(self, children=None, *args, **kwargs):
# `children` must be a list of slides or None
if children is None:
children = self.slides[:]
remove_widget = self.remove_widget
for widget in children:
remove_widget(widget)
super(Carousel, self).clear_widgets()
if __name__ == '__main__':
from kivy.app import App
class Example1(App):
def build(self):
carousel = Carousel(direction='left',
loop=True)
for i in range(4):
src = "http://placehold.it/480x270.png&text=slide-%d&.png" % i
image = Factory.AsyncImage(source=src, fit_mode="contain")
carousel.add_widget(image)
return carousel
Example1().run()
@@ -0,0 +1,197 @@
'''
CheckBox
========
.. versionadded:: 1.4.0
.. image:: images/checkbox.png
:align: right
:class:`CheckBox` is a specific two-state button that can be either checked or
unchecked. If the CheckBox is in a Group, it becomes a Radio button.
As with the :class:`~kivy.uix.togglebutton.ToggleButton`, only one Radio button
at a time can be selected when the :attr:`CheckBox.group` is set.
An example usage::
from kivy.uix.checkbox import CheckBox
# ...
def on_checkbox_active(checkbox, value):
if value:
print('The checkbox', checkbox, 'is active')
else:
print('The checkbox', checkbox, 'is inactive')
checkbox = CheckBox()
checkbox.bind(active=on_checkbox_active)
'''
__all__ = ('CheckBox', )
from kivy.properties import AliasProperty, StringProperty, ColorProperty
from kivy.uix.behaviors import ToggleButtonBehavior
from kivy.uix.widget import Widget
class CheckBox(ToggleButtonBehavior, Widget):
'''CheckBox class, see module documentation for more information.
'''
def _get_active(self):
return self.state == 'down'
def _set_active(self, value):
self.state = 'down' if value else 'normal'
active = AliasProperty(
_get_active, _set_active, bind=('state', ), cache=True)
'''Indicates if the switch is active or inactive.
:attr:`active` is a boolean and reflects and sets whether the underlying
:attr:`~kivy.uix.button.Button.state` is 'down' (True) or 'normal' (False).
It is a :class:`~kivy.properties.AliasProperty`, which accepts boolean
values and defaults to False.
.. versionchanged:: 1.11.0
It changed from a BooleanProperty to a AliasProperty.
'''
background_checkbox_normal = StringProperty(
'atlas://data/images/defaulttheme/checkbox_off')
'''Background image of the checkbox used for the default graphical
representation when the checkbox is not active.
.. versionadded:: 1.9.0
:attr:`background_checkbox_normal` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_off'.
'''
background_checkbox_down = StringProperty(
'atlas://data/images/defaulttheme/checkbox_on')
'''Background image of the checkbox used for the default graphical
representation when the checkbox is active.
.. versionadded:: 1.9.0
:attr:`background_checkbox_down` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_on'.
'''
background_checkbox_disabled_normal = StringProperty(
'atlas://data/images/defaulttheme/checkbox_disabled_off')
'''Background image of the checkbox used for the default graphical
representation when the checkbox is disabled and not active.
.. versionadded:: 1.9.0
:attr:`background_checkbox_disabled_normal` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_disabled_off'.
'''
background_checkbox_disabled_down = StringProperty(
'atlas://data/images/defaulttheme/checkbox_disabled_on')
'''Background image of the checkbox used for the default graphical
representation when the checkbox is disabled and active.
.. versionadded:: 1.9.0
:attr:`background_checkbox_disabled_down` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_disabled_on'.
'''
background_radio_normal = StringProperty(
'atlas://data/images/defaulttheme/checkbox_radio_off')
'''Background image of the radio button used for the default graphical
representation when the radio button is not active.
.. versionadded:: 1.9.0
:attr:`background_radio_normal` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_radio_off'.
'''
background_radio_down = StringProperty(
'atlas://data/images/defaulttheme/checkbox_radio_on')
'''Background image of the radio button used for the default graphical
representation when the radio button is active.
.. versionadded:: 1.9.0
:attr:`background_radio_down` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_radio_on'.
'''
background_radio_disabled_normal = StringProperty(
'atlas://data/images/defaulttheme/checkbox_radio_disabled_off')
'''Background image of the radio button used for the default graphical
representation when the radio button is disabled and not active.
.. versionadded:: 1.9.0
:attr:`background_radio_disabled_normal` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_radio_disabled_off'.
'''
background_radio_disabled_down = StringProperty(
'atlas://data/images/defaulttheme/checkbox_radio_disabled_on')
'''Background image of the radio button used for the default graphical
representation when the radio button is disabled and active.
.. versionadded:: 1.9.0
:attr:`background_radio_disabled_down` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/checkbox_radio_disabled_on'.
'''
color = ColorProperty([1, 1, 1, 1])
'''Color is used for tinting the default graphical representation
of checkbox and radio button (images).
Color is in the format (r, g, b, a).
.. versionadded:: 1.10.0
:attr:`color` is a
:class:`~kivy.properties.ColorProperty` and defaults to
'[1, 1, 1, 1]'.
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
def __init__(self, **kwargs):
self.fbind('state', self._on_state)
super(CheckBox, self).__init__(**kwargs)
def _on_state(self, instance, value):
if self.group and self.state == 'down':
self._release_group(self)
def on_group(self, *largs):
super(CheckBox, self).on_group(*largs)
if self.active:
self._release_group(self)
if __name__ == '__main__':
from random import uniform
from kivy.base import runTouchApp
from kivy.uix.gridlayout import GridLayout
x = GridLayout(cols=4)
for i in range(36):
r, g, b = [uniform(0.2, 1.0) for j in range(3)]
x.add_widget(CheckBox(group='1' if i % 2 else '', color=[r, g, b, 2]))
runTouchApp(x)
@@ -0,0 +1,238 @@
'''
Code Input
==========
.. versionadded:: 1.5.0
.. image:: images/codeinput.jpg
.. note::
This widget requires ``pygments`` package to run. Install it with ``pip``.
The :class:`CodeInput` provides a box of editable highlighted text like the one
shown in the image.
It supports all the features provided by the :class:`~kivy.uix.textinput` as
well as code highlighting for `languages supported by pygments
<http://pygments.org/docs/lexers/>`_ along with `KivyLexer` for
:mod:`kivy.lang` highlighting.
Usage example
-------------
To create a CodeInput with highlighting for `KV language`::
from kivy.uix.codeinput import CodeInput
from kivy.extras.highlight import KivyLexer
codeinput = CodeInput(lexer=KivyLexer())
To create a CodeInput with highlighting for `Cython`::
from kivy.uix.codeinput import CodeInput
from pygments.lexers import CythonLexer
codeinput = CodeInput(lexer=CythonLexer())
'''
__all__ = ('CodeInput', )
from pygments import highlight
from pygments import lexers
from pygments import styles
from pygments.formatters import BBCodeFormatter
from kivy.uix.textinput import TextInput
from kivy.core.text.markup import MarkupLabel as Label
from kivy.cache import Cache
from kivy.properties import ObjectProperty, OptionProperty
from kivy.utils import get_hex_from_color, get_color_from_hex
from kivy.uix.behaviors import CodeNavigationBehavior
Cache_get = Cache.get
Cache_append = Cache.append
# TODO: color chooser for keywords/strings/...
class CodeInput(CodeNavigationBehavior, TextInput):
'''CodeInput class, used for displaying highlighted code.
'''
lexer = ObjectProperty(None)
'''This holds the selected Lexer used by pygments to highlight the code.
:attr:`lexer` is an :class:`~kivy.properties.ObjectProperty` and
defaults to `PythonLexer`.
'''
style_name = OptionProperty(
'default', options=list(styles.get_all_styles())
)
'''Name of the pygments style to use for formatting.
:attr:`style_name` is an :class:`~kivy.properties.OptionProperty`
and defaults to ``'default'``.
'''
style = ObjectProperty(None)
'''The pygments style object to use for formatting.
When ``style_name`` is set, this will be changed to the
corresponding style object.
:attr:`style` is a :class:`~kivy.properties.ObjectProperty` and
defaults to ``None``
'''
def __init__(self, **kwargs):
stylename = kwargs.get('style_name', 'default')
style = kwargs['style'] if 'style' in kwargs \
else styles.get_style_by_name(stylename)
self.formatter = BBCodeFormatter(style=style)
self.lexer = lexers.PythonLexer()
self.text_color = '#000000'
self._label_cached = Label()
self.use_text_color = True
super(CodeInput, self).__init__(**kwargs)
self._line_options = kw = self._get_line_options()
self._label_cached = Label(**kw)
# use text_color as foreground color
text_color = kwargs.get('foreground_color')
if text_color:
self.text_color = get_hex_from_color(text_color)
# set foreground to white to allow text colors to show
# use text_color as the default color in bbcodes
self.use_text_color = False
self.foreground_color = [1, 1, 1, .999]
if not kwargs.get('background_color'):
self.background_color = [.9, .92, .92, 1]
def on_style_name(self, *args):
self.style = styles.get_style_by_name(self.style_name)
self.background_color = get_color_from_hex(self.style.background_color)
self._trigger_refresh_text()
def on_style(self, *args):
self.formatter = BBCodeFormatter(style=self.style)
self._trigger_update_graphics()
def _create_line_label(self, text, hint=False):
# Create a label from a text, using line options
ntext = text.replace(u'\n', u'').replace(u'\t', u' ' * self.tab_width)
if self.password and not hint: # Don't replace hint_text with *
ntext = u'*' * len(ntext)
ntext = self._get_bbcode(ntext)
kw = self._get_line_options()
cid = u'{}\0{}\0{}'.format(text, self.password, kw)
texture = Cache_get('textinput.label', cid)
if texture is None:
# FIXME right now, we can't render very long line...
# if we move on "VBO" version as fallback, we won't need to
# do this.
# try to find the maximum text we can handle
label = Label(text=ntext, **kw)
if text.find(u'\n') > 0:
label.text = u''
else:
label.text = ntext
label.refresh()
# ok, we found it.
texture = label.texture
Cache_append('textinput.label', cid, texture)
label.text = ''
return texture
def _get_line_options(self):
kw = super(CodeInput, self)._get_line_options()
kw['markup'] = True
kw['valign'] = 'top'
kw['codeinput'] = repr(self.lexer)
return kw
def _get_text_width(self, text, tab_width, _label_cached):
# Return the width of a text, according to the current line options.
cid = u'{}\0{}\0{}'.format(text, self.password,
self._get_line_options())
width = Cache_get('textinput.width', cid)
if width is not None:
return width
lbl = self._create_line_label(text)
width = lbl.width
Cache_append('textinput.width', cid, width)
return width
def _get_bbcode(self, ntext):
# get bbcoded text for python
try:
ntext[0]
# replace brackets with special chars that aren't highlighted
# by pygment. can't use &bl; ... cause & is highlighted
ntext = ntext.replace(u'[', u'\x01').replace(u']', u'\x02')
ntext = highlight(ntext, self.lexer, self.formatter)
ntext = ntext.replace(u'\x01', u'&bl;').replace(u'\x02', u'&br;')
# replace special chars with &bl; and &br;
ntext = ''.join((u'[color=', str(self.text_color), u']',
ntext, u'[/color]'))
ntext = ntext.replace(u'\n', u'')
# remove possible extra highlight options
ntext = ntext.replace(u'[u]', '').replace(u'[/u]', '')
return ntext
except IndexError:
return ''
# overridden to prevent cursor position off screen
def _cursor_offset(self):
'''Get the cursor x offset on the current line
'''
offset = 0
try:
if self.cursor_col:
offset = self._get_text_width(
self._lines[self.cursor_row][:self.cursor_col])
return offset
except:
pass
finally:
return offset
def on_lexer(self, instance, value):
self._trigger_refresh_text()
def on_foreground_color(self, instance, text_color):
if not self.use_text_color:
self.use_text_color = True
return
self.text_color = get_hex_from_color(text_color)
self.use_text_color = False
self.foreground_color = (1, 1, 1, .999)
self._trigger_refresh_text()
if __name__ == '__main__':
from kivy.extras.highlight import KivyLexer
from kivy.app import App
class CodeInputTest(App):
def build(self):
return CodeInput(lexer=KivyLexer(),
font_size=12,
text='''
#:kivy 1.0
<YourWidget>:
canvas:
Color:
rgb: .5, .5, .5
Rectangle:
pos: self.pos
size: self.size''')
CodeInputTest().run()
@@ -0,0 +1,486 @@
'''
Color Picker
============
.. versionadded:: 1.7.0
.. warning::
This widget is experimental. Its use and API can change at any time until
this warning is removed.
.. image:: images/colorpicker.png
:align: right
The ColorPicker widget allows a user to select a color from a chromatic
wheel where pinch and zoom can be used to change the wheel's saturation.
Sliders and TextInputs are also provided for entering the RGBA/HSV/HEX values
directly.
Usage::
clr_picker = ColorPicker()
parent.add_widget(clr_picker)
# To monitor changes, we can bind to color property changes
def on_color(instance, value):
print("RGBA = ", str(value)) # or instance.color
print("HSV = ", str(instance.hsv))
print("HEX = ", str(instance.hex_color))
clr_picker.bind(color=on_color)
'''
__all__ = ('ColorPicker', 'ColorWheel')
from math import cos, sin, pi, sqrt, atan
from colorsys import rgb_to_hsv, hsv_to_rgb
from kivy.clock import Clock
from kivy.graphics import Mesh, InstructionGroup, Color
from kivy.logger import Logger
from kivy.properties import (NumericProperty, BoundedNumericProperty,
ListProperty, ObjectProperty,
ReferenceListProperty, StringProperty,
AliasProperty)
from kivy.uix.relativelayout import RelativeLayout
from kivy.uix.widget import Widget
from kivy.utils import get_color_from_hex, get_hex_from_color
def distance(pt1, pt2):
return sqrt((pt1[0] - pt2[0]) ** 2. + (pt1[1] - pt2[1]) ** 2.)
def polar_to_rect(origin, r, theta):
return origin[0] + r * cos(theta), origin[1] + r * sin(theta)
def rect_to_polar(origin, x, y):
if x == origin[0]:
if y == origin[1]:
return 0, 0
elif y > origin[1]:
return y - origin[1], pi / 2
else:
return origin[1] - y, 3 * pi / 2
t = atan(float((y - origin[1])) / (x - origin[0]))
if x - origin[0] < 0:
t += pi
if t < 0:
t += 2 * pi
return distance((x, y), origin), t
class ColorWheel(Widget):
'''Chromatic wheel for the ColorPicker.
.. versionchanged:: 1.7.1
`font_size`, `font_name` and `foreground_color` have been removed. The
sizing is now the same as others widget, based on 'sp'. Orientation is
also automatically determined according to the width/height ratio.
'''
r = BoundedNumericProperty(0, min=0, max=1)
'''The Red value of the color currently selected.
:attr:`r` is a :class:`~kivy.properties.BoundedNumericProperty` and
can be a value from 0 to 1. It defaults to 0.
'''
g = BoundedNumericProperty(0, min=0, max=1)
'''The Green value of the color currently selected.
:attr:`g` is a :class:`~kivy.properties.BoundedNumericProperty`
and can be a value from 0 to 1. It defaults to 0.
'''
b = BoundedNumericProperty(0, min=0, max=1)
'''The Blue value of the color currently selected.
:attr:`b` is a :class:`~kivy.properties.BoundedNumericProperty` and
can be a value from 0 to 1. It defaults to 0.
'''
a = BoundedNumericProperty(0, min=0, max=1)
'''The Alpha value of the color currently selected.
:attr:`a` is a :class:`~kivy.properties.BoundedNumericProperty` and
can be a value from 0 to 1. It defaults to 0.
'''
color = ReferenceListProperty(r, g, b, a)
'''The holds the color currently selected.
:attr:`color` is a :class:`~kivy.properties.ReferenceListProperty` and
contains a list of `r`, `g`, `b`, `a` values. It defaults to `[0, 0, 0, 0]`.
'''
_origin = ListProperty((100, 100))
_radius = NumericProperty(100)
_piece_divisions = NumericProperty(10)
_pieces_of_pie = NumericProperty(16)
_inertia_slowdown = 1.25
_inertia_cutoff = .25
_num_touches = 0
_pinch_flag = False
def __init__(self, **kwargs):
self.arcs = []
self.sv_idx = 0
pdv = self._piece_divisions
self.sv_s = [(float(x) / pdv, 1) for x in range(pdv)] + [
(1, float(y) / pdv) for y in reversed(range(pdv))]
super(ColorWheel, self).__init__(**kwargs)
def on__origin(self, _instance, _value):
self._reset_canvas()
def on__radius(self, _instance, _value):
self._reset_canvas()
def _reset_canvas(self):
# initialize list to hold all meshes
self.canvas.clear()
self.arcs = []
self.sv_idx = 0
pdv = self._piece_divisions
ppie = self._pieces_of_pie
for r in range(pdv):
for t in range(ppie):
self.arcs.append(
_ColorArc(
self._radius * (float(r) / float(pdv)),
self._radius * (float(r + 1) / float(pdv)),
2 * pi * (float(t) / float(ppie)),
2 * pi * (float(t + 1) / float(ppie)),
origin=self._origin,
color=(float(t) / ppie,
self.sv_s[self.sv_idx + r][0],
self.sv_s[self.sv_idx + r][1],
1)))
self.canvas.add(self.arcs[-1])
def recolor_wheel(self):
ppie = self._pieces_of_pie
for idx, segment in enumerate(self.arcs):
segment.change_color(
sv=self.sv_s[int(self.sv_idx + idx / ppie)])
def change_alpha(self, val):
for idx, segment in enumerate(self.arcs):
segment.change_color(a=val)
def inertial_incr_sv_idx(self, dt):
# if its already zoomed all the way out, cancel the inertial zoom
if self.sv_idx == len(self.sv_s) - self._piece_divisions:
return False
self.sv_idx += 1
self.recolor_wheel()
if dt * self._inertia_slowdown > self._inertia_cutoff:
return False
else:
Clock.schedule_once(self.inertial_incr_sv_idx,
dt * self._inertia_slowdown)
def inertial_decr_sv_idx(self, dt):
# if its already zoomed all the way in, cancel the inertial zoom
if self.sv_idx == 0:
return False
self.sv_idx -= 1
self.recolor_wheel()
if dt * self._inertia_slowdown > self._inertia_cutoff:
return False
else:
Clock.schedule_once(self.inertial_decr_sv_idx,
dt * self._inertia_slowdown)
def on_touch_down(self, touch):
r = self._get_touch_r(touch.pos)
if r > self._radius:
return False
# code is still set up to allow pinch to zoom, but this is
# disabled for now since it was fiddly with small wheels.
# Comment out these lines and adjust on_touch_move to reenable
# this.
if self._num_touches != 0:
return False
touch.grab(self)
self._num_touches += 1
touch.ud['anchor_r'] = r
touch.ud['orig_sv_idx'] = self.sv_idx
touch.ud['orig_time'] = Clock.get_time()
def on_touch_move(self, touch):
if touch.grab_current is not self:
return
r = self._get_touch_r(touch.pos)
goal_sv_idx = (touch.ud['orig_sv_idx'] -
int((r - touch.ud['anchor_r']) /
(float(self._radius) / self._piece_divisions)))
if (
goal_sv_idx != self.sv_idx and
0 <= goal_sv_idx <= len(self.sv_s) - self._piece_divisions
):
# this is a pinch to zoom
self._pinch_flag = True
self.sv_idx = goal_sv_idx
self.recolor_wheel()
def on_touch_up(self, touch):
if touch.grab_current is not self:
return
touch.ungrab(self)
self._num_touches -= 1
if self._pinch_flag:
if self._num_touches == 0:
# user was pinching, and now both fingers are up. Return
# to normal
if self.sv_idx > touch.ud['orig_sv_idx']:
Clock.schedule_once(
self.inertial_incr_sv_idx,
(Clock.get_time() - touch.ud['orig_time']) /
(self.sv_idx - touch.ud['orig_sv_idx']))
if self.sv_idx < touch.ud['orig_sv_idx']:
Clock.schedule_once(
self.inertial_decr_sv_idx,
(Clock.get_time() - touch.ud['orig_time']) /
(self.sv_idx - touch.ud['orig_sv_idx']))
self._pinch_flag = False
return
else:
# user was pinching, and at least one finger remains. We
# don't want to treat the remaining fingers as touches
return
else:
r, theta = rect_to_polar(self._origin, *touch.pos)
# if touch up is outside the wheel, ignore
if r >= self._radius:
return
# compute which ColorArc is being touched (they aren't
# widgets so we don't get collide_point) and set
# _hsv based on the selected ColorArc
piece = int((theta / (2 * pi)) * self._pieces_of_pie)
division = int((r / self._radius) * self._piece_divisions)
hsva = list(
self.arcs[self._pieces_of_pie * division + piece].color)
self.color = list(hsv_to_rgb(*hsva[:3])) + hsva[-1:]
def _get_touch_r(self, pos):
return distance(pos, self._origin)
class _ColorArc(InstructionGroup):
def __init__(self, r_min, r_max, theta_min, theta_max,
color=(0, 0, 1, 1), origin=(0, 0), **kwargs):
super(_ColorArc, self).__init__(**kwargs)
self.origin = origin
self.r_min = r_min
self.r_max = r_max
self.theta_min = theta_min
self.theta_max = theta_max
self.color = color
self.color_instr = Color(*color, mode='hsv')
self.add(self.color_instr)
self.mesh = self.get_mesh()
self.add(self.mesh)
def __str__(self):
return "r_min: %s r_max: %s theta_min: %s theta_max: %s color: %s" % (
self.r_min, self.r_max, self.theta_min, self.theta_max, self.color
)
def get_mesh(self):
v = []
# first calculate the distance between endpoints of the outer
# arc, so we know how many steps to use when calculating
# vertices
theta_step_outer = 0.1
theta = self.theta_max - self.theta_min
d_outer = int(theta / theta_step_outer)
theta_step_outer = theta / d_outer
if self.r_min == 0:
for x in range(0, d_outer, 2):
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + x * theta_step_outer
) * 2)
v += polar_to_rect(self.origin, 0, 0) * 2
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + (x + 1) * theta_step_outer
) * 2)
if not d_outer & 1: # add a last point if d_outer is even
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + d_outer * theta_step_outer
) * 2)
else:
for x in range(d_outer + 1):
v += (polar_to_rect(self.origin, self.r_min,
self.theta_min + x * theta_step_outer
) * 2)
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + x * theta_step_outer
) * 2)
return Mesh(vertices=v, indices=range(int(len(v) / 4)),
mode='triangle_strip')
def change_color(self, color=None, color_delta=None, sv=None, a=None):
self.remove(self.color_instr)
if color is not None:
self.color = color
elif color_delta is not None:
self.color = [self.color[i] + color_delta[i] for i in range(4)]
elif sv is not None:
self.color = (self.color[0], sv[0], sv[1], self.color[3])
elif a is not None:
self.color = (self.color[0], self.color[1], self.color[2], a)
self.color_instr = Color(*self.color, mode='hsv')
self.insert(0, self.color_instr)
class ColorPicker(RelativeLayout):
'''
See module documentation.
'''
font_name = StringProperty('data/fonts/RobotoMono-Regular.ttf')
'''Specifies the font used on the ColorPicker.
:attr:`font_name` is a :class:`~kivy.properties.StringProperty` and
defaults to 'data/fonts/RobotoMono-Regular.ttf'.
'''
color = ListProperty((1, 1, 1, 1))
'''The :attr:`color` holds the color currently selected in rgba format.
:attr:`color` is a :class:`~kivy.properties.ListProperty` and defaults to
(1, 1, 1, 1).
'''
def _get_hsv(self):
return rgb_to_hsv(*self.color[:3])
def _set_hsv(self, value):
if self._updating_clr:
return
self.set_color(value)
hsv = AliasProperty(_get_hsv, _set_hsv, bind=('color', ))
'''The :attr:`hsv` holds the color currently selected in hsv format.
:attr:`hsv` is a :class:`~kivy.properties.ListProperty` and defaults to
(1, 1, 1).
'''
def _get_hex(self):
return get_hex_from_color(self.color)
def _set_hex(self, value):
if self._updating_clr:
return
self.set_color(get_color_from_hex(value)[:4])
hex_color = AliasProperty(_get_hex, _set_hex, bind=('color',), cache=True)
'''The :attr:`hex_color` holds the currently selected color in hex.
:attr:`hex_color` is an :class:`~kivy.properties.AliasProperty` and
defaults to `#ffffffff`.
'''
wheel = ObjectProperty(None)
'''The :attr:`wheel` holds the color wheel.
:attr:`wheel` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None.
'''
_update_clr_ev = _update_hex_ev = None
# now used only internally.
foreground_color = ListProperty((1, 1, 1, 1))
def _trigger_update_clr(self, mode, clr_idx, text):
if self._updating_clr:
return
self._updating_clr = True
self._upd_clr_list = mode, clr_idx, text
ev = self._update_clr_ev
if ev is None:
ev = self._update_clr_ev = Clock.create_trigger(self._update_clr)
ev()
def _update_clr(self, dt):
# to prevent interaction between hsv/rgba, we work internally using rgba
mode, clr_idx, text = self._upd_clr_list
try:
text = min(255.0, max(0.0, float(text)))
if mode == 'rgb':
self.color[clr_idx] = text / 255
else:
hsv = list(self.hsv[:])
hsv[clr_idx] = text / 255
self.color[:3] = hsv_to_rgb(*hsv)
except ValueError:
Logger.warning('ColorPicker: invalid value : {}'.format(text))
finally:
self._updating_clr = False
def _update_hex(self, dt):
try:
if len(self._upd_hex_list) != 9:
return
self._updating_clr = False
self.hex_color = self._upd_hex_list
finally:
self._updating_clr = False
def _trigger_update_hex(self, text):
if self._updating_clr:
return
self._updating_clr = True
self._upd_hex_list = text
ev = self._update_hex_ev
if ev is None:
ev = self._update_hex_ev = Clock.create_trigger(self._update_hex)
ev()
def set_color(self, color):
self._updating_clr = True
if len(color) == 3:
self.color[:3] = color
else:
self.color = color
self._updating_clr = False
def __init__(self, **kwargs):
self._updating_clr = False
super(ColorPicker, self).__init__(**kwargs)
if __name__ in ('__android__', '__main__'):
from kivy.app import App
class ColorPickerApp(App):
def build(self):
cp = ColorPicker(pos_hint={'center_x': .5, 'center_y': .5},
size_hint=(1, 1))
return cp
ColorPickerApp().run()
@@ -0,0 +1,391 @@
'''
Drop-Down List
==============
.. image:: images/dropdown.gif
:align: right
.. versionadded:: 1.4.0
A versatile drop-down list that can be used with custom widgets. It allows you
to display a list of widgets under a displayed widget. Unlike other toolkits,
the list of widgets can contain any type of widget: simple buttons,
images etc.
The positioning of the drop-down list is fully automatic: we will always try to
place the dropdown list in a way that the user can select an item in the list.
Basic example
-------------
A button with a dropdown list of 10 possible values. All the buttons within the
dropdown list will trigger the dropdown :meth:`DropDown.select` method. After
being called, the main button text will display the selection of the
dropdown. ::
from kivy.uix.dropdown import DropDown
from kivy.uix.button import Button
from kivy.base import runTouchApp
# create a dropdown with 10 buttons
dropdown = DropDown()
for index in range(10):
# When adding widgets, we need to specify the height manually
# (disabling the size_hint_y) so the dropdown can calculate
# the area it needs.
btn = Button(text='Value %d' % index, size_hint_y=None, height=44)
# for each button, attach a callback that will call the select() method
# on the dropdown. We'll pass the text of the button as the data of the
# selection.
btn.bind(on_release=lambda btn: dropdown.select(btn.text))
# then add the button inside the dropdown
dropdown.add_widget(btn)
# create a big main button
mainbutton = Button(text='Hello', size_hint=(None, None))
# show the dropdown menu when the main button is released
# note: all the bind() calls pass the instance of the caller (here, the
# mainbutton instance) as the first argument of the callback (here,
# dropdown.open.).
mainbutton.bind(on_release=dropdown.open)
# one last thing, listen for the selection in the dropdown list and
# assign the data to the button text.
dropdown.bind(on_select=lambda instance, x: setattr(mainbutton, 'text', x))
runTouchApp(mainbutton)
Extending dropdown in Kv
------------------------
You could create a dropdown directly from your kv::
#:kivy 1.4.0
<CustomDropDown>:
Button:
text: 'My first Item'
size_hint_y: None
height: 44
on_release: root.select('item1')
Label:
text: 'Unselectable item'
size_hint_y: None
height: 44
Button:
text: 'My second Item'
size_hint_y: None
height: 44
on_release: root.select('item2')
And then, create the associated python class and use it::
class CustomDropDown(DropDown):
pass
dropdown = CustomDropDown()
mainbutton = Button(text='Hello', size_hint=(None, None))
mainbutton.bind(on_release=dropdown.open)
dropdown.bind(on_select=lambda instance, x: setattr(mainbutton, 'text', x))
'''
__all__ = ('DropDown', )
from kivy.uix.scrollview import ScrollView
from kivy.properties import ObjectProperty, NumericProperty, BooleanProperty
from kivy.core.window import Window
from kivy.lang import Builder
from kivy.clock import Clock
from kivy.config import Config
_grid_kv = '''
GridLayout:
size_hint_y: None
height: self.minimum_size[1]
cols: 1
'''
class DropDownException(Exception):
'''DropDownException class.
'''
pass
class DropDown(ScrollView):
'''DropDown class. See module documentation for more information.
:Events:
`on_select`: data
Fired when a selection is done. The data of the selection is passed
in as the first argument and is what you pass in the :meth:`select`
method as the first argument.
`on_dismiss`:
.. versionadded:: 1.8.0
Fired when the DropDown is dismissed, either on selection or on
touching outside the widget.
'''
auto_width = BooleanProperty(True)
'''By default, the width of the dropdown will be the same as the width of
the attached widget. Set to False if you want to provide your own width.
:attr:`auto_width` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True.
'''
max_height = NumericProperty(None, allownone=True)
'''Indicate the maximum height that the dropdown can take. If None, it will
take the maximum height available until the top or bottom of the screen
is reached.
:attr:`max_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to None.
'''
dismiss_on_select = BooleanProperty(True)
'''By default, the dropdown will be automatically dismissed when a
selection has been done. Set to False to prevent the dismiss.
:attr:`dismiss_on_select` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True.
'''
auto_dismiss = BooleanProperty(True)
'''By default, the dropdown will be automatically dismissed when a
touch happens outside of it, this option allows to disable this
feature
:attr:`auto_dismiss` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True.
.. versionadded:: 1.8.0
'''
min_state_time = NumericProperty(0)
'''Minimum time before the :class:`~kivy.uix.DropDown` is dismissed.
This is used to allow for the widget inside the dropdown to display
a down state or for the :class:`~kivy.uix.DropDown` itself to
display a animation for closing.
:attr:`min_state_time` is a :class:`~kivy.properties.NumericProperty`
and defaults to the `Config` value `min_state_time`.
.. versionadded:: 1.10.0
'''
attach_to = ObjectProperty(allownone=True)
'''(internal) Property that will be set to the widget to which the
drop down list is attached.
The :meth:`open` method will automatically set this property whilst
:meth:`dismiss` will set it back to None.
'''
container = ObjectProperty()
'''(internal) Property that will be set to the container of the dropdown
list. It is a :class:`~kivy.uix.gridlayout.GridLayout` by default.
'''
_touch_started_inside = None
__events__ = ('on_select', 'on_dismiss')
def __init__(self, **kwargs):
self._win = None
if 'min_state_time' not in kwargs:
self.min_state_time = float(
Config.get('graphics', 'min_state_time'))
if 'container' not in kwargs:
c = self.container = Builder.load_string(_grid_kv)
else:
c = None
if 'do_scroll_x' not in kwargs:
self.do_scroll_x = False
if 'size_hint' not in kwargs:
if 'size_hint_x' not in kwargs:
self.size_hint_x = None
if 'size_hint_y' not in kwargs:
self.size_hint_y = None
super(DropDown, self).__init__(**kwargs)
if c is not None:
super(DropDown, self).add_widget(c)
self.on_container(self, c)
Window.bind(
on_key_down=self.on_key_down,
size=self._reposition)
self.fbind('size', self._reposition)
def on_key_down(self, instance, key, scancode, codepoint, modifiers):
if key == 27 and self.get_parent_window():
self.dismiss()
return True
def on_container(self, instance, value):
if value is not None:
self.container.bind(minimum_size=self._reposition)
def open(self, widget):
'''Open the dropdown list and attach it to a specific widget.
Depending on the position of the widget within the window and
the height of the dropdown, the dropdown might be above or below
that widget.
'''
# ensure we are not already attached
if self.attach_to is not None:
self.dismiss()
# we will attach ourself to the main window, so ensure the
# widget we are looking for have a window
self._win = widget.get_parent_window()
if self._win is None:
raise DropDownException(
'Cannot open a dropdown list on a hidden widget')
self.attach_to = widget
widget.bind(pos=self._reposition, size=self._reposition)
self._reposition()
# attach ourself to the main window
self._win.add_widget(self)
def dismiss(self, *largs):
'''Remove the dropdown widget from the window and detach it from
the attached widget.
'''
Clock.schedule_once(self._real_dismiss, self.min_state_time)
def _real_dismiss(self, *largs):
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.dispatch('on_dismiss')
def on_dismiss(self):
pass
def select(self, data):
'''Call this method to trigger the `on_select` event with the `data`
selection. The `data` can be anything you want.
'''
self.dispatch('on_select', data)
if self.dismiss_on_select:
self.dismiss()
def on_select(self, data):
pass
def add_widget(self, *args, **kwargs):
if self.container:
return self.container.add_widget(*args, **kwargs)
return super(DropDown, self).add_widget(*args, **kwargs)
def remove_widget(self, *args, **kwargs):
if self.container:
return self.container.remove_widget(*args, **kwargs)
return super(DropDown, self).remove_widget(*args, **kwargs)
def clear_widgets(self, *args, **kwargs):
if self.container:
return self.container.clear_widgets(*args, **kwargs)
return super(DropDown, self).clear_widgets(*args, **kwargs)
def on_motion(self, etype, me):
super().on_motion(etype, me)
return True
def on_touch_down(self, touch):
self._touch_started_inside = self.collide_point(*touch.pos)
if not self.auto_dismiss or self._touch_started_inside:
super(DropDown, self).on_touch_down(touch)
return True
def on_touch_move(self, touch):
if not self.auto_dismiss or self._touch_started_inside:
super(DropDown, self).on_touch_move(touch)
return True
def on_touch_up(self, touch):
# Explicitly test for False as None occurs when shown by on_touch_down
if self.auto_dismiss and self._touch_started_inside is False:
self.dismiss()
else:
super(DropDown, self).on_touch_up(touch)
self._touch_started_inside = None
return True
def _reposition(self, *largs):
# calculate the coordinate of the attached widget in the window
# coordinate system
win = self._win
if not win:
return
widget = self.attach_to
if not widget or not widget.get_parent_window():
return
wx, wy = widget.to_window(*widget.pos)
wright, wtop = widget.to_window(widget.right, widget.top)
if self.auto_width:
self.width = wright - wx
# ensure the dropdown list doesn't get out on the X axis, with a
# preference to 0 in case the list is too wide.
x = wx
if x + self.width > win.width:
x = win.width - self.width
if x < 0:
x = 0
self.x = x
# determine if we display the dropdown upper or lower to the widget
if self.max_height is not None:
height = min(self.max_height, self.container.minimum_height)
else:
height = self.container.minimum_height
h_bottom = wy - height
h_top = win.height - (wtop + height)
if h_bottom > 0:
self.top = wy
self.height = height
elif h_top > 0:
self.y = wtop
self.height = height
else:
# none of both top/bottom have enough place to display the
# widget at the current size. Take the best side, and fit to
# it.
if h_top < h_bottom:
self.top = self.height = wy
else:
self.y = wtop
self.height = win.height - wtop
if __name__ == '__main__':
from kivy.uix.button import Button
from kivy.base import runTouchApp
def show_dropdown(button, *largs):
dp = DropDown()
dp.bind(on_select=lambda instance, x: setattr(button, 'text', x))
for i in range(10):
item = Button(text='hello %d' % i, size_hint_y=None, height=44)
item.bind(on_release=lambda btn: dp.select(btn.text))
dp.add_widget(item)
dp.open(button)
def touch_move(instance, touch):
instance.center = touch.pos
btn = Button(text='SHOW', size_hint=(None, None), pos=(300, 200))
btn.bind(on_release=show_dropdown, on_touch_move=touch_move)
runTouchApp(btn)
@@ -0,0 +1,772 @@
'''
EffectWidget
============
.. versionadded:: 1.9.0
The :class:`EffectWidget` is able to apply a variety of fancy
graphical effects to
its children. It works by rendering to a series of
:class:`~kivy.graphics.Fbo` instances with custom opengl fragment shaders.
As such, effects can freely do almost anything, from inverting the
colors of the widget, to anti-aliasing, to emulating the appearance of a
crt monitor!
.. warning::
This code is still experimental, and its API is subject to change in a
future version.
The basic usage is as follows::
w = EffectWidget()
w.add_widget(Button(text='Hello!')
w.effects = [InvertEffect(), HorizontalBlurEffect(size=2.0)]
The equivalent in kv would be::
#: import ew kivy.uix.effectwidget
EffectWidget:
effects: ew.InvertEffect(), ew.HorizontalBlurEffect(size=2.0)
Button:
text: 'Hello!'
The effects can be a list of effects of any length, and they will be
applied sequentially.
The module comes with a range of prebuilt effects, but the interface
is designed to make it easy to create your own. Instead of writing a
full glsl shader, you provide a single function that takes
some inputs based on the screen (current pixel color, current widget
texture etc.). See the sections below for more information.
Usage Guidelines
----------------
It is not efficient to resize an :class:`EffectWidget`, as
the :class:`~kivy.graphics.Fbo` is recreated on each resize event.
If you need to resize frequently, consider doing things a different
way.
Although some effects have adjustable parameters, it is
*not* efficient to animate these, as the entire
shader is reconstructed every time. You should use glsl
uniform variables instead. The :class:`AdvancedEffectBase`
may make this easier.
.. note:: The :class:`EffectWidget` *cannot* draw outside its own
widget area (pos -> pos + size). Any child widgets
overlapping the boundary will be cut off at this point.
Provided Effects
----------------
The module comes with several pre-written effects. Some have
adjustable properties (e.g. blur radius). Please see the individual
effect documentation for more details.
- :class:`MonochromeEffect` - makes the widget grayscale.
- :class:`InvertEffect` - inverts the widget colors.
- :class:`ChannelMixEffect` - swaps color channels.
- :class:`ScanlinesEffect` - displays flickering scanlines.
- :class:`PixelateEffect` - pixelates the image.
- :class:`HorizontalBlurEffect` - Gaussuan blurs horizontally.
- :class:`VerticalBlurEffect` - Gaussuan blurs vertically.
- :class:`FXAAEffect` - applies a very basic anti-aliasing.
Creating Effects
----------------
Effects are designed to make it easy to create and use your own
transformations. You do this by creating and using an instance of
:class:`EffectBase` with your own custom :attr:`EffectBase.glsl`
property.
The glsl property is a string representing part of a glsl fragment
shader. You can include as many functions as you like (the string
is simply spliced into the whole shader), but it
must implement a function :code:`effect` as below::
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{
// ... your code here
return something; // must be a vec4 representing the new color
}
The full shader will calculate the normal pixel color at each point,
then call your :code:`effect` function to transform it. The
parameters are:
- **color**: The normal color of the current pixel (i.e. texture
sampled at tex_coords).
- **texture**: The texture containing the widget's normal background.
- **tex_coords**: The normal texture_coords used to access texture.
- **coords**: The pixel indices of the current pixel.
The shader code also has access to two useful uniform variables,
:code:`time` containing the time (in seconds) since the program start,
and :code:`resolution` containing the shape (x pixels, y pixels) of
the widget.
For instance, the following simple string (taken from the `InvertEffect`)
would invert the input color but set alpha to 1.0::
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{
return vec4(1.0 - color.xyz, 1.0);
}
You can also set the glsl by automatically loading the string from a
file, simply set the :attr:`EffectBase.source` property of an effect.
'''
from kivy.clock import Clock
from kivy.uix.relativelayout import RelativeLayout
from kivy.properties import (StringProperty, ObjectProperty, ListProperty,
NumericProperty, DictProperty)
from kivy.graphics import (RenderContext, Fbo, Color, Rectangle,
Translate, PushMatrix, PopMatrix, ClearColor,
ClearBuffers)
from kivy.event import EventDispatcher
from kivy.base import EventLoop
from kivy.resources import resource_find
from kivy.logger import Logger
__all__ = ('EffectWidget', 'EffectBase', 'AdvancedEffectBase',
'MonochromeEffect', 'InvertEffect', 'ChannelMixEffect',
'ScanlinesEffect', 'PixelateEffect',
'HorizontalBlurEffect', 'VerticalBlurEffect',
'FXAAEffect')
shader_header = '''
#ifdef GL_ES
precision highp float;
#endif
/* Outputs from the vertex shader */
varying vec4 frag_color;
varying vec2 tex_coord0;
/* uniform texture samplers */
uniform sampler2D texture0;
'''
shader_uniforms = '''
uniform vec2 resolution;
uniform float time;
'''
shader_footer_trivial = '''
void main (void){
gl_FragColor = frag_color * texture2D(texture0, tex_coord0);
}
'''
shader_footer_effect = '''
void main (void){
vec4 normal_color = frag_color * texture2D(texture0, tex_coord0);
vec4 effect_color = effect(normal_color, texture0, tex_coord0,
gl_FragCoord.xy);
gl_FragColor = effect_color;
}
'''
effect_trivial = '''
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{
return color;
}
'''
effect_monochrome = '''
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{
float mag = 1.0/3.0 * (color.x + color.y + color.z);
return vec4(mag, mag, mag, color.w);
}
'''
effect_invert = '''
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{
return vec4(1.0 - color.xyz, color.w);
}
'''
effect_mix = '''
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{{
return vec4(color.{}, color.{}, color.{}, color.w);
}}
'''
effect_blur_h = '''
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{{
float dt = ({} / 4.0) * 1.0 / resolution.x;
vec4 sum = vec4(0.0);
sum += texture2D(texture, vec2(tex_coords.x - 4.0*dt, tex_coords.y))
* 0.05;
sum += texture2D(texture, vec2(tex_coords.x - 3.0*dt, tex_coords.y))
* 0.09;
sum += texture2D(texture, vec2(tex_coords.x - 2.0*dt, tex_coords.y))
* 0.12;
sum += texture2D(texture, vec2(tex_coords.x - dt, tex_coords.y))
* 0.15;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y))
* 0.16;
sum += texture2D(texture, vec2(tex_coords.x + dt, tex_coords.y))
* 0.15;
sum += texture2D(texture, vec2(tex_coords.x + 2.0*dt, tex_coords.y))
* 0.12;
sum += texture2D(texture, vec2(tex_coords.x + 3.0*dt, tex_coords.y))
* 0.09;
sum += texture2D(texture, vec2(tex_coords.x + 4.0*dt, tex_coords.y))
* 0.05;
return vec4(sum.xyz, color.w);
}}
'''
effect_blur_v = '''
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{{
float dt = ({} / 4.0)
* 1.0 / resolution.x;
vec4 sum = vec4(0.0);
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y - 4.0*dt))
* 0.05;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y - 3.0*dt))
* 0.09;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y - 2.0*dt))
* 0.12;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y - dt))
* 0.15;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y))
* 0.16;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y + dt))
* 0.15;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y + 2.0*dt))
* 0.12;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y + 3.0*dt))
* 0.09;
sum += texture2D(texture, vec2(tex_coords.x, tex_coords.y + 4.0*dt))
* 0.05;
return vec4(sum.xyz, color.w);
}}
'''
effect_postprocessing = '''
vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords)
{
vec2 q = tex_coords * vec2(1, -1);
vec2 uv = 0.5 + (q-0.5);//*(0.9);// + 0.1*sin(0.2*time));
vec3 oricol = texture2D(texture,vec2(q.x,1.0-q.y)).xyz;
vec3 col;
col.r = texture2D(texture,vec2(uv.x+0.003,-uv.y)).x;
col.g = texture2D(texture,vec2(uv.x+0.000,-uv.y)).y;
col.b = texture2D(texture,vec2(uv.x-0.003,-uv.y)).z;
col = clamp(col*0.5+0.5*col*col*1.2,0.0,1.0);
//col *= 0.5 + 0.5*16.0*uv.x*uv.y*(1.0-uv.x)*(1.0-uv.y);
col *= vec3(0.8,1.0,0.7);
col *= 0.9+0.1*sin(10.0*time+uv.y*1000.0);
col *= 0.97+0.03*sin(110.0*time);
float comp = smoothstep( 0.2, 0.7, sin(time) );
//col = mix( col, oricol, clamp(-2.0+2.0*q.x+3.0*comp,0.0,1.0) );
return vec4(col, color.w);
}
'''
effect_pixelate = '''
vec4 effect(vec4 vcolor, sampler2D texture, vec2 texcoord, vec2 pixel_coords)
{{
vec2 pixelSize = {} / resolution;
vec2 xy = floor(texcoord/pixelSize)*pixelSize + pixelSize/2.0;
return texture2D(texture, xy);
}}
'''
effect_fxaa = '''
vec4 effect( vec4 color, sampler2D buf0, vec2 texCoords, vec2 coords)
{
vec2 frameBufSize = resolution;
float FXAA_SPAN_MAX = 8.0;
float FXAA_REDUCE_MUL = 1.0/8.0;
float FXAA_REDUCE_MIN = 1.0/128.0;
vec3 rgbNW=texture2D(buf0,texCoords+(vec2(-1.0,-1.0)/frameBufSize)).xyz;
vec3 rgbNE=texture2D(buf0,texCoords+(vec2(1.0,-1.0)/frameBufSize)).xyz;
vec3 rgbSW=texture2D(buf0,texCoords+(vec2(-1.0,1.0)/frameBufSize)).xyz;
vec3 rgbSE=texture2D(buf0,texCoords+(vec2(1.0,1.0)/frameBufSize)).xyz;
vec3 rgbM=texture2D(buf0,texCoords).xyz;
vec3 luma=vec3(0.299, 0.587, 0.114);
float lumaNW = dot(rgbNW, luma);
float lumaNE = dot(rgbNE, luma);
float lumaSW = dot(rgbSW, luma);
float lumaSE = dot(rgbSE, luma);
float lumaM = dot(rgbM, luma);
float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));
float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));
vec2 dir;
dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
dir.y = ((lumaNW + lumaSW) - (lumaNE + lumaSE));
float dirReduce = max(
(lumaNW + lumaNE + lumaSW + lumaSE) * (0.25 * FXAA_REDUCE_MUL),
FXAA_REDUCE_MIN);
float rcpDirMin = 1.0/(min(abs(dir.x), abs(dir.y)) + dirReduce);
dir = min(vec2(FXAA_SPAN_MAX, FXAA_SPAN_MAX),
max(vec2(-FXAA_SPAN_MAX, -FXAA_SPAN_MAX),
dir * rcpDirMin)) / frameBufSize;
vec3 rgbA = (1.0/2.0) * (
texture2D(buf0, texCoords.xy + dir * (1.0/3.0 - 0.5)).xyz +
texture2D(buf0, texCoords.xy + dir * (2.0/3.0 - 0.5)).xyz);
vec3 rgbB = rgbA * (1.0/2.0) + (1.0/4.0) * (
texture2D(buf0, texCoords.xy + dir * (0.0/3.0 - 0.5)).xyz +
texture2D(buf0, texCoords.xy + dir * (3.0/3.0 - 0.5)).xyz);
float lumaB = dot(rgbB, luma);
vec4 return_color;
if((lumaB < lumaMin) || (lumaB > lumaMax)){
return_color = vec4(rgbA, color.w);
}else{
return_color = vec4(rgbB, color.w);
}
return return_color;
}
'''
class EffectBase(EventDispatcher):
'''The base class for GLSL effects. It simply returns its input.
See the module documentation for more details.
'''
glsl = StringProperty(effect_trivial)
'''The glsl string defining your effect function. See the
module documentation for more details.
:attr:`glsl` is a :class:`~kivy.properties.StringProperty` and
defaults to
a trivial effect that returns its input.
'''
source = StringProperty('')
'''The (optional) filename from which to load the :attr:`glsl`
string.
:attr:`source` is a :class:`~kivy.properties.StringProperty` and
defaults to ''.
'''
fbo = ObjectProperty(None, allownone=True)
'''The fbo currently using this effect. The :class:`EffectBase`
automatically handles this.
:attr:`fbo` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None.
'''
def __init__(self, *args, **kwargs):
super(EffectBase, self).__init__(*args, **kwargs)
fbind = self.fbind
fbo_shader = self.set_fbo_shader
fbind('fbo', fbo_shader)
fbind('glsl', fbo_shader)
fbind('source', self._load_from_source)
def set_fbo_shader(self, *args):
'''Sets the :class:`~kivy.graphics.Fbo`'s shader by splicing
the :attr:`glsl` string into a full fragment shader.
The full shader is made up of :code:`shader_header +
shader_uniforms + self.glsl + shader_footer_effect`.
'''
if self.fbo is None:
return
self.fbo.set_fs(shader_header + shader_uniforms + self.glsl +
shader_footer_effect)
def _load_from_source(self, *args):
'''(internal) Loads the glsl string from a source file.'''
source = self.source
if not source:
return
filename = resource_find(source)
if filename is None:
return Logger.error('Error reading file {filename}'.
format(filename=source))
with open(filename) as fileh:
self.glsl = fileh.read()
class AdvancedEffectBase(EffectBase):
'''An :class:`EffectBase` with additional behavior to easily
set and update uniform variables in your shader.
This class is provided for convenience when implementing your own
effects: it is not used by any of those provided with Kivy.
In addition to your base glsl string that must be provided as
normal, the :class:`AdvancedEffectBase` has an extra property
:attr:`uniforms`, a dictionary of name-value pairs. Whenever
a value is changed, the new value for the uniform variable is
uploaded to the shader.
You must still manually declare your uniform variables at the top
of your glsl string.
'''
uniforms = DictProperty({})
'''A dictionary of uniform variable names and their values. These
are automatically uploaded to the :attr:`fbo` shader if appropriate.
uniforms is a :class:`~kivy.properties.DictProperty` and
defaults to {}.
'''
def __init__(self, *args, **kwargs):
super(AdvancedEffectBase, self).__init__(*args, **kwargs)
self.fbind('uniforms', self._update_uniforms)
def _update_uniforms(self, *args):
if self.fbo is None:
return
for key, value in self.uniforms.items():
self.fbo[key] = value
def set_fbo_shader(self, *args):
super(AdvancedEffectBase, self).set_fbo_shader(*args)
self._update_uniforms()
class MonochromeEffect(EffectBase):
'''Returns its input colors in monochrome.'''
def __init__(self, *args, **kwargs):
super(MonochromeEffect, self).__init__(*args, **kwargs)
self.glsl = effect_monochrome
class InvertEffect(EffectBase):
'''Inverts the colors in the input.'''
def __init__(self, *args, **kwargs):
super(InvertEffect, self).__init__(*args, **kwargs)
self.glsl = effect_invert
class ScanlinesEffect(EffectBase):
'''Adds scanlines to the input.'''
def __init__(self, *args, **kwargs):
super(ScanlinesEffect, self).__init__(*args, **kwargs)
self.glsl = effect_postprocessing
class ChannelMixEffect(EffectBase):
'''Mixes the color channels of the input according to the order
property. Channels may be arbitrarily rearranged or repeated.'''
order = ListProperty([1, 2, 0])
'''The new sorted order of the rgb channels.
order is a :class:`~kivy.properties.ListProperty` and defaults to
[1, 2, 0], corresponding to (g, b, r).
'''
def __init__(self, *args, **kwargs):
super(ChannelMixEffect, self).__init__(*args, **kwargs)
self.do_glsl()
def on_order(self, *args):
self.do_glsl()
def do_glsl(self):
letters = [{0: 'x', 1: 'y', 2: 'z'}[i] for i in self.order]
self.glsl = effect_mix.format(*letters)
class PixelateEffect(EffectBase):
'''Pixelates the input according to its
:attr:`~PixelateEffect.pixel_size`'''
pixel_size = NumericProperty(10)
'''
Sets the size of a new 'pixel' in the effect, in terms of number of
'real' pixels.
pixel_size is a :class:`~kivy.properties.NumericProperty` and
defaults to 10.
'''
def __init__(self, *args, **kwargs):
super(PixelateEffect, self).__init__(*args, **kwargs)
self.do_glsl()
def on_pixel_size(self, *args):
self.do_glsl()
def do_glsl(self):
self.glsl = effect_pixelate.format(float(self.pixel_size))
class HorizontalBlurEffect(EffectBase):
'''Blurs the input horizontally, with the width given by
:attr:`~HorizontalBlurEffect.size`.'''
size = NumericProperty(4.0)
'''The blur width in pixels.
size is a :class:`~kivy.properties.NumericProperty` and defaults to
4.0.
'''
def __init__(self, *args, **kwargs):
super(HorizontalBlurEffect, self).__init__(*args, **kwargs)
self.do_glsl()
def on_size(self, *args):
self.do_glsl()
def do_glsl(self):
self.glsl = effect_blur_h.format(float(self.size))
class VerticalBlurEffect(EffectBase):
'''Blurs the input vertically, with the width given by
:attr:`~VerticalBlurEffect.size`.'''
size = NumericProperty(4.0)
'''The blur width in pixels.
size is a :class:`~kivy.properties.NumericProperty` and defaults to
4.0.
'''
def __init__(self, *args, **kwargs):
super(VerticalBlurEffect, self).__init__(*args, **kwargs)
self.do_glsl()
def on_size(self, *args):
self.do_glsl()
def do_glsl(self):
self.glsl = effect_blur_v.format(float(self.size))
class FXAAEffect(EffectBase):
'''Applies very simple anti-aliasing via fxaa.'''
def __init__(self, *args, **kwargs):
super(FXAAEffect, self).__init__(*args, **kwargs)
self.glsl = effect_fxaa
class EffectFbo(Fbo):
'''An :class:`~kivy.graphics.Fbo` with extra functionality that allows
attempts to set a new shader. See :meth:`set_fs`.
'''
def __init__(self, *args, **kwargs):
kwargs.setdefault("with_stencilbuffer", True)
super(EffectFbo, self).__init__(*args, **kwargs)
self.texture_rectangle = None
def set_fs(self, value):
'''Attempt to set the fragment shader to the given value.
If setting the shader fails, the existing one is preserved and an
exception is raised.
'''
shader = self.shader
old_value = shader.fs
shader.fs = value
if not shader.success:
shader.fs = old_value
raise Exception('Setting new shader failed.')
class EffectWidget(RelativeLayout):
'''
Widget with the ability to apply a series of graphical effects to
its children. See the module documentation for more information on
setting effects and creating your own.
'''
background_color = ListProperty((0, 0, 0, 0))
'''This defines the background color to be used for the fbo in the
EffectWidget.
:attr:`background_color` is a :class:`ListProperty` defaults to
(0, 0, 0, 0)
'''
texture = ObjectProperty(None)
'''The output texture of the final :class:`~kivy.graphics.Fbo` after
all effects have been applied.
texture is an :class:`~kivy.properties.ObjectProperty` and defaults
to None.
'''
effects = ListProperty([])
'''List of all the effects to be applied. These should all be
instances or subclasses of :class:`EffectBase`.
effects is a :class:`ListProperty` and defaults to [].
'''
fbo_list = ListProperty([])
'''(internal) List of all the fbos that are being used to apply
the effects.
fbo_list is a :class:`ListProperty` and defaults to [].
'''
_bound_effects = ListProperty([])
'''(internal) List of effect classes that have been given an fbo to
manage. This is necessary so that the fbo can be removed if the
effect is no longer in use.
_bound_effects is a :class:`ListProperty` and defaults to [].
'''
def __init__(self, **kwargs):
# Make sure opengl context exists
EventLoop.ensure_window()
self.canvas = RenderContext(use_parent_projection=True,
use_parent_modelview=True)
with self.canvas:
self.fbo = Fbo(size=self.size)
with self.fbo.before:
PushMatrix()
with self.fbo:
ClearColor(0, 0, 0, 0)
ClearBuffers()
self._background_color = Color(*self.background_color)
self.fbo_rectangle = Rectangle(size=self.size)
with self.fbo.after:
PopMatrix()
super(EffectWidget, self).__init__(**kwargs)
Clock.schedule_interval(self._update_glsl, 0)
fbind = self.fbind
fbo_setup = self.refresh_fbo_setup
fbind('size', fbo_setup)
fbind('effects', fbo_setup)
fbind('background_color', self._refresh_background_color)
self.refresh_fbo_setup()
self._refresh_background_color() # In case this was changed in kwargs
def _refresh_background_color(self, *args):
self._background_color.rgba = self.background_color
def _update_glsl(self, *largs):
'''(internal) Passes new time and resolution uniform
variables to the shader.
'''
time = Clock.get_boottime()
resolution = [float(size) for size in self.size]
self.canvas['time'] = time
self.canvas['resolution'] = resolution
for fbo in self.fbo_list:
fbo['time'] = time
fbo['resolution'] = resolution
def refresh_fbo_setup(self, *args):
'''(internal) Creates and assigns one :class:`~kivy.graphics.Fbo`
per effect, and makes sure all sizes etc. are correct and
consistent.
'''
# Add/remove fbos until there is one per effect
while len(self.fbo_list) < len(self.effects):
with self.canvas:
new_fbo = EffectFbo(size=self.size)
with new_fbo:
ClearColor(0, 0, 0, 0)
ClearBuffers()
Color(1, 1, 1, 1)
new_fbo.texture_rectangle = Rectangle(size=self.size)
new_fbo.texture_rectangle.size = self.size
self.fbo_list.append(new_fbo)
while len(self.fbo_list) > len(self.effects):
old_fbo = self.fbo_list.pop()
self.canvas.remove(old_fbo)
# Remove fbos from unused effects
for effect in self._bound_effects:
if effect not in self.effects:
effect.fbo = None
self._bound_effects = self.effects
# Do resizing etc.
self.fbo.size = self.size
self.fbo_rectangle.size = self.size
for i in range(len(self.fbo_list)):
self.fbo_list[i].size = self.size
self.fbo_list[i].texture_rectangle.size = self.size
# If there are no effects, just draw our main fbo
if len(self.fbo_list) == 0:
self.texture = self.fbo.texture
return
for i in range(1, len(self.fbo_list)):
fbo = self.fbo_list[i]
fbo.texture_rectangle.texture = self.fbo_list[i - 1].texture
# Build effect shaders
for effect, fbo in zip(self.effects, self.fbo_list):
effect.fbo = fbo
self.fbo_list[0].texture_rectangle.texture = self.fbo.texture
self.texture = self.fbo_list[-1].texture
for fbo in self.fbo_list:
fbo.draw()
self.fbo.draw()
def add_widget(self, *args, **kwargs):
# Add the widget to our Fbo instead of the normal canvas
c = self.canvas
self.canvas = self.fbo
super(EffectWidget, self).add_widget(*args, **kwargs)
self.canvas = c
def remove_widget(self, *args, **kwargs):
# Remove the widget from our Fbo instead of the normal canvas
c = self.canvas
self.canvas = self.fbo
super(EffectWidget, self).remove_widget(*args, **kwargs)
self.canvas = c
def clear_widgets(self, *args, **kwargs):
# Clear widgets from our Fbo instead of the normal canvas
c = self.canvas
self.canvas = self.fbo
super(EffectWidget, self).clear_widgets(*args, **kwargs)
self.canvas = c
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
'''
Float Layout
============
:class:`FloatLayout` honors the :attr:`~kivy.uix.widget.Widget.pos_hint`
and the :attr:`~kivy.uix.widget.Widget.size_hint` properties of its children.
.. only:: html
.. image:: images/floatlayout.gif
:align: right
.. only:: latex
.. image:: images/floatlayout.png
:align: right
For example, a FloatLayout with a size of (300, 300) is created::
layout = FloatLayout(size=(300, 300))
By default, all widgets have their size_hint=(1, 1), so this button will adopt
the same size as the layout::
button = Button(text='Hello world')
layout.add_widget(button)
To create a button 50% of the width and 25% of the height of the layout and
positioned at (20, 20), you can do::
button = Button(
text='Hello world',
size_hint=(.5, .25),
pos=(20, 20))
If you want to create a button that will always be the size of layout minus
20% on each side::
button = Button(text='Hello world', size_hint=(.6, .6),
pos_hint={'x':.2, 'y':.2})
.. note::
This layout can be used for an application. Most of the time, you will
use the size of Window.
.. warning::
If you are not using pos_hint, you must handle the positioning of the
children: if the float layout is moving, you must handle moving the
children too.
'''
__all__ = ('FloatLayout', )
from kivy.uix.layout import Layout
class FloatLayout(Layout):
'''Float layout class. See module documentation for more information.
'''
def __init__(self, **kwargs):
super(FloatLayout, self).__init__(**kwargs)
fbind = self.fbind
update = self._trigger_layout
fbind('children', update)
fbind('pos', update)
fbind('pos_hint', update)
fbind('size_hint', update)
fbind('size', update)
def do_layout(self, *largs, **kwargs):
# optimize layout by preventing looking at the same attribute in a loop
w, h = kwargs.get('size', self.size)
x, y = kwargs.get('pos', self.pos)
for c in self.children:
# size
shw, shh = c.size_hint
shw_min, shh_min = c.size_hint_min
shw_max, shh_max = c.size_hint_max
if shw is not None and shh is not None:
c_w = shw * w
c_h = shh * h
if shw_min is not None and c_w < shw_min:
c_w = shw_min
elif shw_max is not None and c_w > shw_max:
c_w = shw_max
if shh_min is not None and c_h < shh_min:
c_h = shh_min
elif shh_max is not None and c_h > shh_max:
c_h = shh_max
c.size = c_w, c_h
elif shw is not None:
c_w = shw * w
if shw_min is not None and c_w < shw_min:
c_w = shw_min
elif shw_max is not None and c_w > shw_max:
c_w = shw_max
c.width = c_w
elif shh is not None:
c_h = shh * h
if shh_min is not None and c_h < shh_min:
c_h = shh_min
elif shh_max is not None and c_h > shh_max:
c_h = shh_max
c.height = c_h
# pos
for key, value in c.pos_hint.items():
if key == 'x':
c.x = x + value * w
elif key == 'right':
c.right = x + value * w
elif key == 'pos':
c.pos = x + value[0] * w, y + value[1] * h
elif key == 'y':
c.y = y + value * h
elif key == 'top':
c.top = y + value * h
elif key == 'center':
c.center = x + value[0] * w, y + value[1] * h
elif key == 'center_x':
c.center_x = x + value * w
elif key == 'center_y':
c.center_y = y + value * h
def add_widget(self, widget, *args, **kwargs):
widget.bind(
# size=self._trigger_layout,
# size_hint=self._trigger_layout,
pos=self._trigger_layout,
pos_hint=self._trigger_layout)
return super(FloatLayout, self).add_widget(widget, *args, **kwargs)
def remove_widget(self, widget, *args, **kwargs):
widget.unbind(
# size=self._trigger_layout,
# size_hint=self._trigger_layout,
pos=self._trigger_layout,
pos_hint=self._trigger_layout)
return super(FloatLayout, self).remove_widget(widget, *args, **kwargs)
@@ -0,0 +1,625 @@
'''
Gesture Surface
===============
.. versionadded::
1.9.0
.. warning::
This is experimental and subject to change as long as this warning notice
is present.
See :file:`kivy/examples/demo/multistroke/main.py` for a complete application
example.
'''
__all__ = ('GestureSurface', 'GestureContainer')
from random import random
from kivy.event import EventDispatcher
from kivy.clock import Clock
from kivy.vector import Vector
from kivy.uix.floatlayout import FloatLayout
from kivy.graphics import Color, Line, Rectangle
from kivy.properties import (NumericProperty, BooleanProperty,
DictProperty, ColorProperty)
from colorsys import hsv_to_rgb
# Clock undershoot margin, FIXME: this is probably too high?
UNDERSHOOT_MARGIN = 0.1
class GestureContainer(EventDispatcher):
'''Container object that stores information about a gesture. It has
various properties that are updated by `GestureSurface` as drawing
progresses.
:Arguments:
`touch`
Touch object (as received by on_touch_down) used to initialize
the gesture container. Required.
:Properties:
`active`
Set to False once the gesture is complete (meets
`max_stroke` setting or `GestureSurface.temporal_window`)
:attr:`active` is a
:class:`~kivy.properties.BooleanProperty`
`active_strokes`
Number of strokes currently active in the gesture, ie
concurrent touches associated with this gesture.
:attr:`active_strokes` is a
:class:`~kivy.properties.NumericProperty`
`max_strokes`
Max number of strokes allowed in the gesture. This
is set by `GestureSurface.max_strokes` but can
be overridden for example from `on_gesture_start`.
:attr:`max_strokes` is a
:class:`~kivy.properties.NumericProperty`
`was_merged`
Indicates that this gesture has been merged with another
gesture and should be considered discarded.
:attr:`was_merged` is a
:class:`~kivy.properties.BooleanProperty`
`bbox`
Dictionary with keys minx, miny, maxx, maxy. Represents the size
of the gesture bounding box.
:attr:`bbox` is a
:class:`~kivy.properties.DictProperty`
`width`
Represents the width of the gesture.
:attr:`width` is a
:class:`~kivy.properties.NumericProperty`
`height`
Represents the height of the gesture.
:attr:`height` is a
:class:`~kivy.properties.NumericProperty`
'''
active = BooleanProperty(True)
active_strokes = NumericProperty(0)
max_strokes = NumericProperty(0)
was_merged = BooleanProperty(False)
bbox = DictProperty({'minx': float('inf'), 'miny': float('inf'),
'maxx': float('-inf'), 'maxy': float('-inf')})
width = NumericProperty(0)
height = NumericProperty(0)
def __init__(self, touch, **kwargs):
# The color is applied to all canvas items of this gesture
self.color = kwargs.pop('color', [1., 1., 1.])
super(GestureContainer, self).__init__(**kwargs)
# This is the touch.uid of the oldest touch represented
self.id = str(touch.uid)
# Store various timestamps for decision making
self._create_time = Clock.get_time()
self._update_time = None
self._cleanup_time = None
self._cache_time = 0
# We can cache the candidate here to save zip()/Vector instantiation
self._vectors = None
# Key is touch.uid; value is a kivy.graphics.Line(); it's used even
# if line_width is 0 (i.e. not actually drawn anywhere)
self._strokes = {}
# Make sure the bbox is up to date with the first touch position
self.update_bbox(touch)
def get_vectors(self, **kwargs):
'''Return strokes in a format that is acceptable for
`kivy.multistroke.Recognizer` as a gesture candidate or template. The
result is cached automatically; the cache is invalidated at the start
and end of a stroke and if `update_bbox` is called. If you are going
to analyze a gesture mid-stroke, you may need to set the `no_cache`
argument to True.'''
if self._cache_time == self._update_time and \
not kwargs.get('no_cache'):
return self._vectors
vecs = []
append = vecs.append
for tuid, l in self._strokes.items():
lpts = l.points
append([Vector(*pts) for pts in zip(lpts[::2], lpts[1::2])])
self._vectors = vecs
self._cache_time = self._update_time
return vecs
def handles(self, touch):
'''Returns True if this container handles the given touch'''
if not self.active:
return False
return str(touch.uid) in self._strokes
def accept_stroke(self, count=1):
'''Returns True if this container can accept `count` new strokes'''
if not self.max_strokes:
return True
return len(self._strokes) + count <= self.max_strokes
def update_bbox(self, touch):
'''Update gesture bbox from a touch coordinate'''
x, y = touch.x, touch.y
bb = self.bbox
if x < bb['minx']:
bb['minx'] = x
if y < bb['miny']:
bb['miny'] = y
if x > bb['maxx']:
bb['maxx'] = x
if y > bb['maxy']:
bb['maxy'] = y
self.width = bb['maxx'] - bb['minx']
self.height = bb['maxy'] - bb['miny']
self._update_time = Clock.get_time()
def add_stroke(self, touch, line):
'''Associate a list of points with a touch.uid; the line itself is
created by the caller, but subsequent move/up events look it
up via us. This is done to avoid problems during merge.'''
self._update_time = Clock.get_time()
self._strokes[str(touch.uid)] = line
self.active_strokes += 1
def complete_stroke(self):
'''Called on touch up events to keep track of how many strokes
are active in the gesture (we only want to dispatch event when
the *last* stroke in the gesture is released)'''
self._update_time = Clock.get_time()
self.active_strokes -= 1
def single_points_test(self):
'''Returns True if the gesture consists only of single-point strokes,
we must discard it in this case, or an exception will be raised'''
for tuid, l in self._strokes.items():
if len(l.points) > 2:
return False
return True
class GestureSurface(FloatLayout):
'''Simple gesture surface to track/draw touch movements. Typically used
to gather user input suitable for :class:`kivy.multistroke.Recognizer`.
:Properties:
`temporal_window`
Time to wait from the last touch_up event before attempting
to recognize the gesture. If you set this to 0, the
`on_gesture_complete` event is not fired unless the
:attr:`max_strokes` condition is met.
:attr:`temporal_window` is a
:class:`~kivy.properties.NumericProperty` and defaults to 2.0
`max_strokes`
Max number of strokes in a single gesture; if this is reached,
recognition will start immediately on the final touch_up event.
If this is set to 0, the `on_gesture_complete` event is not
fired unless the :attr:`temporal_window` expires.
:attr:`max_strokes` is a
:class:`~kivy.properties.NumericProperty` and defaults to 2.0
`bbox_margin`
Bounding box margin for detecting gesture collisions, in
pixels.
:attr:`bbox_margin` is a
:class:`~kivy.properties.NumericProperty` and defaults to 30
`draw_timeout`
Number of seconds to keep lines/bbox on canvas after the
`on_gesture_complete` event is fired. If this is set to 0,
gestures are immediately removed from the surface when
complete.
:attr:`draw_timeout` is a
:class:`~kivy.properties.NumericProperty` and defaults to 3.0
`color`
Color used to draw the gesture, in RGB. This option does not
have an effect if :attr:`use_random_color` is True.
:attr:`color` is a
:class:`~kivy.properties.ColorProperty` and defaults to
[1, 1, 1, 1] (white)
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
`use_random_color`
Set to True to pick a random color for each gesture, if you do
this then `color` is ignored. Defaults to False.
:attr:`use_random_color` is a
:class:`~kivy.properties.BooleanProperty` and defaults to False
`line_width`
Line width used for tracing touches on the surface. Set to 0
if you only want to detect gestures without drawing anything.
If you use 1.0, OpenGL GL_LINE is used for drawing; values > 1
will use an internal drawing method based on triangles (less
efficient), see :mod:`kivy.graphics`.
:attr:`line_width` is a
:class:`~kivy.properties.NumericProperty` and defaults to 2
`draw_bbox`
Set to True if you want to draw bounding box behind gestures.
This only works if `line_width` >= 1. Default is False.
:attr:`draw_bbox` is a
:class:`~kivy.properties.BooleanProperty` and defaults to True
`bbox_alpha`
Opacity for bounding box if `draw_bbox` is True. Default 0.1
:attr:`bbox_alpha` is a
:class:`~kivy.properties.NumericProperty` and defaults to 0.1
:Events:
`on_gesture_start` :class:`GestureContainer`
Fired when a new gesture is initiated on the surface, i.e. the
first on_touch_down that does not collide with an existing
gesture on the surface.
`on_gesture_extend` :class:`GestureContainer`
Fired when a touch_down event occurs within an existing gesture.
`on_gesture_merge` :class:`GestureContainer`, :class:`GestureContainer`
Fired when two gestures collide and get merged to one gesture.
The first argument is the gesture that has been merged (no longer
valid); the second is the combined (resulting) gesture.
`on_gesture_complete` :class:`GestureContainer`
Fired when a set of strokes is considered a complete gesture,
this happens when `temporal_window` expires or `max_strokes`
is reached. Typically you will bind to this event and use
the provided `GestureContainer` get_vectors() method to
match against your gesture database.
`on_gesture_cleanup` :class:`GestureContainer`
Fired `draw_timeout` seconds after `on_gesture_complete`,
The gesture will be removed from the canvas (if line_width > 0 or
draw_bbox is True) and the internal gesture list before this.
`on_gesture_discard` :class:`GestureContainer`
Fired when a gesture does not meet the minimum size requirements
for recognition (width/height < 5, or consists only of single-
point strokes).
'''
temporal_window = NumericProperty(2.0)
draw_timeout = NumericProperty(3.0)
max_strokes = NumericProperty(4)
bbox_margin = NumericProperty(30)
line_width = NumericProperty(2)
color = ColorProperty([1., 1., 1., 1.])
use_random_color = BooleanProperty(False)
draw_bbox = BooleanProperty(False)
bbox_alpha = NumericProperty(0.1)
def __init__(self, **kwargs):
super(GestureSurface, self).__init__(**kwargs)
# A list of GestureContainer objects (all gestures on the surface)
self._gestures = []
self.register_event_type('on_gesture_start')
self.register_event_type('on_gesture_extend')
self.register_event_type('on_gesture_merge')
self.register_event_type('on_gesture_complete')
self.register_event_type('on_gesture_cleanup')
self.register_event_type('on_gesture_discard')
# -----------------------------------------------------------------------------
# Touch Events
# -----------------------------------------------------------------------------
def on_touch_down(self, touch):
'''When a new touch is registered, the first thing we do is to test if
it collides with the bounding box of another known gesture. If so, it
is assumed to be part of that gesture.
'''
# If the touch originates outside the surface, ignore it.
if not self.collide_point(touch.x, touch.y):
return
touch.grab(self)
# Add the stroke to existing gesture, or make a new one
g = self.find_colliding_gesture(touch)
new = False
if g is None:
g = self.init_gesture(touch)
new = True
# We now belong to a gesture (new or old); start a new stroke.
self.init_stroke(g, touch)
if new:
self.dispatch('on_gesture_start', g, touch)
else:
self.dispatch('on_gesture_extend', g, touch)
return True
def on_touch_move(self, touch):
'''When a touch moves, we add a point to the line on the canvas so the
path is updated. We must also check if the new point collides with the
bounding box of another gesture - if so, they should be merged.'''
if touch.grab_current is not self:
return
if not self.collide_point(touch.x, touch.y):
return
# Retrieve the GestureContainer object that handles this touch, and
# test for colliding gestures. If found, merge them to one.
g = self.get_gesture(touch)
collision = self.find_colliding_gesture(touch)
if collision is not None and g.accept_stroke(len(collision._strokes)):
merge = self.merge_gestures(g, collision)
if g.was_merged:
self.dispatch('on_gesture_merge', g, collision)
else:
self.dispatch('on_gesture_merge', collision, g)
g = merge
else:
g.update_bbox(touch)
# Add the new point to gesture stroke list and update the canvas line
g._strokes[str(touch.uid)].points += (touch.x, touch.y)
# Draw the gesture bounding box; if it is a single press that
# does not trigger a move event, we would miss it otherwise.
if self.draw_bbox:
self._update_canvas_bbox(g)
return True
def on_touch_up(self, touch):
if touch.grab_current is not self:
return
touch.ungrab(self)
g = self.get_gesture(touch)
g.complete_stroke()
# If this stroke hit the maximum limit, dispatch immediately
if not g.accept_stroke():
self._complete_dispatcher(0)
# dispatch later only if we have a window
elif self.temporal_window > 0:
Clock.schedule_once(self._complete_dispatcher,
self.temporal_window)
# -----------------------------------------------------------------------------
# Gesture related methods
# -----------------------------------------------------------------------------
def init_gesture(self, touch):
'''Create a new gesture from touch, i.e. it's the first on
surface, or was not close enough to any existing gesture (yet)'''
col = self.color
if self.use_random_color:
col = hsv_to_rgb(random(), 1., 1.)
g = GestureContainer(touch, max_strokes=self.max_strokes, color=col)
# Create the bounding box Rectangle for the gesture
if self.draw_bbox:
bb = g.bbox
with self.canvas:
Color(col[0], col[1], col[2], self.bbox_alpha, mode='rgba',
group=g.id)
g._bbrect = Rectangle(
group=g.id,
pos=(bb['minx'], bb['miny']),
size=(bb['maxx'] - bb['minx'],
bb['maxy'] - bb['miny']))
self._gestures.append(g)
return g
def init_stroke(self, g, touch):
points = [touch.x, touch.y]
col = g.color
new_line = Line(
points=points,
width=self.line_width,
group=g.id)
g._strokes[str(touch.uid)] = new_line
if self.line_width:
canvas_add = self.canvas.add
canvas_add(Color(col[0], col[1], col[2], mode='rgb', group=g.id))
canvas_add(new_line)
# Update the bbox in case; this will normally be done in on_touch_move,
# but we want to update it also for a single press, force that here:
g.update_bbox(touch)
if self.draw_bbox:
self._update_canvas_bbox(g)
# Register the stroke in GestureContainer so we can look it up later
g.add_stroke(touch, new_line)
def get_gesture(self, touch):
'''Returns GestureContainer associated with given touch'''
for g in self._gestures:
if g.active and g.handles(touch):
return g
raise Exception('get_gesture() failed to identify ' + str(touch.uid))
def find_colliding_gesture(self, touch):
'''Checks if a touch x/y collides with the bounding box of an existing
gesture. If so, return it (otherwise returns None)
'''
touch_x, touch_y = touch.pos
for g in self._gestures:
if g.active and not g.handles(touch) and g.accept_stroke():
bb = g.bbox
margin = self.bbox_margin
minx = bb['minx'] - margin
miny = bb['miny'] - margin
maxx = bb['maxx'] + margin
maxy = bb['maxy'] + margin
if minx <= touch_x <= maxx and miny <= touch_y <= maxy:
return g
return None
def merge_gestures(self, g, other):
'''Merges two gestures together, the oldest one is retained and the
newer one gets the `GestureContainer.was_merged` flag raised.'''
# Swap order depending on gesture age (the merged gesture gets
# the color from the oldest one of the two).
swap = other._create_time < g._create_time
a = swap and other or g
b = swap and g or other
# Apply the outer limits of bbox to the merged gesture
abbox = a.bbox
bbbox = b.bbox
if bbbox['minx'] < abbox['minx']:
abbox['minx'] = bbbox['minx']
if bbbox['miny'] < abbox['miny']:
abbox['miny'] = bbbox['miny']
if bbbox['maxx'] > abbox['maxx']:
abbox['maxx'] = bbbox['maxx']
if bbbox['maxy'] > abbox['maxy']:
abbox['maxy'] = bbbox['maxy']
# Now transfer the coordinates from old to new gesture;
# FIXME: This can probably be copied more efficiently?
astrokes = a._strokes
lw = self.line_width
a_id = a.id
col = a.color
self.canvas.remove_group(b.id)
canv_add = self.canvas.add
for uid, old in b._strokes.items():
# FIXME: Can't figure out how to change group= for existing Line()
new_line = Line(
points=old.points,
width=old.width,
group=a_id)
astrokes[uid] = new_line
if lw:
canv_add(Color(col[0], col[1], col[2], mode='rgb', group=a_id))
canv_add(new_line)
b.active = False
b.was_merged = True
a.active_strokes += b.active_strokes
a._update_time = Clock.get_time()
return a
def _update_canvas_bbox(self, g):
# If draw_bbox is changed while two gestures are active,
# we might not have a bbrect member
if not hasattr(g, '_bbrect'):
return
bb = g.bbox
g._bbrect.pos = (bb['minx'], bb['miny'])
g._bbrect.size = (bb['maxx'] - bb['minx'],
bb['maxy'] - bb['miny'])
# -----------------------------------------------------------------------------
# Timeout callbacks
# -----------------------------------------------------------------------------
def _complete_dispatcher(self, dt):
'''This method is scheduled on all touch up events. It will dispatch
the `on_gesture_complete` event for all completed gestures, and remove
merged gestures from the internal gesture list.'''
need_cleanup = False
gest = self._gestures
timeout = self.draw_timeout
twin = self.temporal_window
get_time = Clock.get_time
for idx, g in enumerate(gest):
# Gesture is part of another gesture, just delete it
if g.was_merged:
del gest[idx]
continue
# Not active == already handled, or has active strokes (it cannot
# possibly be complete). Proceed to next gesture on surface.
if not g.active or g.active_strokes != 0:
continue
t1 = g._update_time + twin
t2 = get_time() + UNDERSHOOT_MARGIN
# max_strokes reached, or temporal window has expired. The gesture
# is complete; need to dispatch _complete or _discard event.
if not g.accept_stroke() or t1 <= t2:
discard = False
if g.width < 5 and g.height < 5:
discard = True
elif g.single_points_test():
discard = True
need_cleanup = True
g.active = False
g._cleanup_time = get_time() + timeout
if discard:
self.dispatch('on_gesture_discard', g)
else:
self.dispatch('on_gesture_complete', g)
if need_cleanup:
Clock.schedule_once(self._cleanup, timeout)
def _cleanup(self, dt):
'''This method is scheduled from _complete_dispatcher to clean up the
canvas and internal gesture list after a gesture is completed.'''
m = UNDERSHOOT_MARGIN
rg = self.canvas.remove_group
gestures = self._gestures
for idx, g in enumerate(gestures):
if g._cleanup_time is None:
continue
if g._cleanup_time <= Clock.get_time() + m:
rg(g.id)
del gestures[idx]
self.dispatch('on_gesture_cleanup', g)
def on_gesture_start(self, *l):
pass
def on_gesture_extend(self, *l):
pass
def on_gesture_merge(self, *l):
pass
def on_gesture_complete(self, *l):
pass
def on_gesture_discard(self, *l):
pass
def on_gesture_cleanup(self, *l):
pass
@@ -0,0 +1,629 @@
'''
Grid Layout
===========
.. only:: html
.. image:: images/gridlayout.gif
:align: right
.. only:: latex
.. image:: images/gridlayout.png
:align: right
.. versionadded:: 1.0.4
The :class:`GridLayout` arranges children in a matrix. It takes the available
space and divides it into columns and rows, then adds widgets to the resulting
"cells".
.. versionchanged:: 1.0.7
The implementation has changed to use the widget size_hint for calculating
column/row sizes. `uniform_width` and `uniform_height` have been removed
and other properties have added to give you more control.
Background
----------
Unlike many other toolkits, you cannot explicitly place a widget in a specific
column/row. Each child is automatically assigned a position determined by the
layout configuration and the child's index in the children list.
A GridLayout must always have at least one input constraint:
:attr:`GridLayout.cols` or :attr:`GridLayout.rows`. If you do not specify cols
or rows, the Layout will throw an exception.
Column Width and Row Height
---------------------------
The column width/row height are determined in 3 steps:
- The initial size is given by the :attr:`col_default_width` and
:attr:`row_default_height` properties. To customize the size of a single
column or row, use :attr:`cols_minimum` or :attr:`rows_minimum`.
- The `size_hint_x`/`size_hint_y` of the children are taken into account.
If no widgets have a size hint, the maximum size is used for all
children.
- You can force the default size by setting the :attr:`col_force_default`
or :attr:`row_force_default` property. This will force the layout to
ignore the `width` and `size_hint` properties of children and use the
default size.
Using a GridLayout
------------------
In the example below, all widgets will have an equal size. By default, the
`size_hint` is (1, 1), so a Widget will take the full size of the parent::
layout = GridLayout(cols=2)
layout.add_widget(Button(text='Hello 1'))
layout.add_widget(Button(text='World 1'))
layout.add_widget(Button(text='Hello 2'))
layout.add_widget(Button(text='World 2'))
.. image:: images/gridlayout_1.jpg
Now, let's fix the size of Hello buttons to 100px instead of using
size_hint_x=1::
layout = GridLayout(cols=2)
layout.add_widget(Button(text='Hello 1', size_hint_x=None, width=100))
layout.add_widget(Button(text='World 1'))
layout.add_widget(Button(text='Hello 2', size_hint_x=None, width=100))
layout.add_widget(Button(text='World 2'))
.. image:: images/gridlayout_2.jpg
Next, let's fix the row height to a specific size::
layout = GridLayout(cols=2, row_force_default=True, row_default_height=40)
layout.add_widget(Button(text='Hello 1', size_hint_x=None, width=100))
layout.add_widget(Button(text='World 1'))
layout.add_widget(Button(text='Hello 2', size_hint_x=None, width=100))
layout.add_widget(Button(text='World 2'))
.. image:: images/gridlayout_3.jpg
'''
__all__ = ('GridLayout', 'GridLayoutException')
from kivy.logger import Logger
from kivy.uix.layout import Layout
from kivy.properties import NumericProperty, BooleanProperty, DictProperty, \
BoundedNumericProperty, ReferenceListProperty, VariableListProperty, \
ObjectProperty, StringProperty, OptionProperty
from math import ceil
from itertools import accumulate, product, chain, islice
from operator import sub
def nmax(*args):
# merge into one list
args = [x for x in args if x is not None]
return max(args)
def nmin(*args):
# merge into one list
args = [x for x in args if x is not None]
return min(args)
class GridLayoutException(Exception):
'''Exception for errors if the grid layout manipulation fails.
'''
pass
class GridLayout(Layout):
'''Grid layout class. See module documentation for more information.
'''
spacing = VariableListProperty([0, 0], length=2)
'''Spacing between children: [spacing_horizontal, spacing_vertical].
spacing also accepts a one argument form [spacing].
:attr:`spacing` is a
:class:`~kivy.properties.VariableListProperty` and defaults to [0, 0].
'''
padding = VariableListProperty([0, 0, 0, 0])
'''Padding between the layout box and its children: [padding_left,
padding_top, padding_right, padding_bottom].
padding also accepts a two argument form [padding_horizontal,
padding_vertical] and a one argument form [padding].
.. versionchanged:: 1.7.0
Replaced NumericProperty with VariableListProperty.
:attr:`padding` is a :class:`~kivy.properties.VariableListProperty` and
defaults to [0, 0, 0, 0].
'''
cols = BoundedNumericProperty(None, min=0, allownone=True)
'''Number of columns in the grid.
.. versionchanged:: 1.0.8
Changed from a NumericProperty to BoundedNumericProperty. You can no
longer set this to a negative value.
:attr:`cols` is a :class:`~kivy.properties.NumericProperty` and defaults to
None.
'''
rows = BoundedNumericProperty(None, min=0, allownone=True)
'''Number of rows in the grid.
.. versionchanged:: 1.0.8
Changed from a NumericProperty to a BoundedNumericProperty. You can no
longer set this to a negative value.
:attr:`rows` is a :class:`~kivy.properties.NumericProperty` and defaults to
None.
'''
col_default_width = NumericProperty(0)
'''Default minimum size to use for a column.
.. versionadded:: 1.0.7
:attr:`col_default_width` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.
'''
row_default_height = NumericProperty(0)
'''Default minimum size to use for row.
.. versionadded:: 1.0.7
:attr:`row_default_height` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.
'''
col_force_default = BooleanProperty(False)
'''If True, ignore the width and size_hint_x of the child and use the
default column width.
.. versionadded:: 1.0.7
:attr:`col_force_default` is a :class:`~kivy.properties.BooleanProperty`
and defaults to False.
'''
row_force_default = BooleanProperty(False)
'''If True, ignore the height and size_hint_y of the child and use the
default row height.
.. versionadded:: 1.0.7
:attr:`row_force_default` is a :class:`~kivy.properties.BooleanProperty`
and defaults to False.
'''
cols_minimum = DictProperty({})
'''Dict of minimum width for each column. The dictionary keys are the
column numbers, e.g. 0, 1, 2...
.. versionadded:: 1.0.7
:attr:`cols_minimum` is a :class:`~kivy.properties.DictProperty` and
defaults to {}.
'''
rows_minimum = DictProperty({})
'''Dict of minimum height for each row. The dictionary keys are the
row numbers, e.g. 0, 1, 2...
.. versionadded:: 1.0.7
:attr:`rows_minimum` is a :class:`~kivy.properties.DictProperty` and
defaults to {}.
'''
minimum_width = NumericProperty(0)
'''Automatically computed minimum width needed to contain all children.
.. versionadded:: 1.0.8
:attr:`minimum_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0. It is read only.
'''
minimum_height = NumericProperty(0)
'''Automatically computed minimum height needed to contain all children.
.. versionadded:: 1.0.8
:attr:`minimum_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0. It is read only.
'''
minimum_size = ReferenceListProperty(minimum_width, minimum_height)
'''Automatically computed minimum size needed to contain all children.
.. versionadded:: 1.0.8
:attr:`minimum_size` is a
:class:`~kivy.properties.ReferenceListProperty` of
(:attr:`minimum_width`, :attr:`minimum_height`) properties. It is read
only.
'''
orientation = OptionProperty('lr-tb', options=(
'lr-tb', 'tb-lr', 'rl-tb', 'tb-rl', 'lr-bt', 'bt-lr', 'rl-bt',
'bt-rl'))
'''Orientation of the layout.
:attr:`orientation` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'lr-tb'.
Valid orientations are 'lr-tb', 'tb-lr', 'rl-tb', 'tb-rl', 'lr-bt',
'bt-lr', 'rl-bt' and 'bt-rl'.
.. versionadded:: 2.0.0
.. note::
'lr' means Left to Right.
'rl' means Right to Left.
'tb' means Top to Bottom.
'bt' means Bottom to Top.
'''
def __init__(self, **kwargs):
self._cols = self._rows = None
super(GridLayout, self).__init__(**kwargs)
fbind = self.fbind
update = self._trigger_layout
fbind('col_default_width', update)
fbind('row_default_height', update)
fbind('col_force_default', update)
fbind('row_force_default', update)
fbind('cols', update)
fbind('rows', update)
fbind('parent', update)
fbind('spacing', update)
fbind('padding', update)
fbind('children', update)
fbind('size', update)
fbind('pos', update)
fbind('orientation', update)
def get_max_widgets(self):
if self.cols and self.rows:
return self.rows * self.cols
else:
return None
def on_children(self, instance, value):
# if that makes impossible to construct things with deferred method,
# migrate this test in do_layout, and/or issue a warning.
smax = self.get_max_widgets()
if smax and len(value) > smax:
raise GridLayoutException(
'Too many children in GridLayout. Increase rows/cols!')
@property
def _fills_row_first(self):
return self.orientation[0] in 'lr'
@property
def _fills_from_left_to_right(self):
return 'lr' in self.orientation
@property
def _fills_from_top_to_bottom(self):
return 'tb' in self.orientation
def _init_rows_cols_sizes(self, count):
# the goal here is to calculate the minimum size of every cols/rows
# and determine if they have stretch or not
current_cols = self.cols
current_rows = self.rows
# if no cols or rows are set, we can't calculate minimum size.
# the grid must be constrained at least on one side
if not current_cols and not current_rows:
Logger.warning('%r have no cols or rows set, '
'layout is not triggered.' % self)
return
if current_cols is None:
current_cols = int(ceil(count / float(current_rows)))
elif current_rows is None:
current_rows = int(ceil(count / float(current_cols)))
current_cols = max(1, current_cols)
current_rows = max(1, current_rows)
self._has_hint_bound_x = False
self._has_hint_bound_y = False
self._cols_min_size_none = 0. # min size from all the None hint
self._rows_min_size_none = 0. # min size from all the None hint
self._cols = cols = [self.col_default_width] * current_cols
self._cols_sh = [None] * current_cols
self._cols_sh_min = [None] * current_cols
self._cols_sh_max = [None] * current_cols
self._rows = rows = [self.row_default_height] * current_rows
self._rows_sh = [None] * current_rows
self._rows_sh_min = [None] * current_rows
self._rows_sh_max = [None] * current_rows
# update minimum size from the dicts
items = (i for i in self.cols_minimum.items() if i[0] < len(cols))
for index, value in items:
cols[index] = max(value, cols[index])
items = (i for i in self.rows_minimum.items() if i[0] < len(rows))
for index, value in items:
rows[index] = max(value, rows[index])
return True
def _fill_rows_cols_sizes(self):
cols, rows = self._cols, self._rows
cols_sh, rows_sh = self._cols_sh, self._rows_sh
cols_sh_min, rows_sh_min = self._cols_sh_min, self._rows_sh_min
cols_sh_max, rows_sh_max = self._cols_sh_max, self._rows_sh_max
# calculate minimum size for each columns and rows
has_bound_y = has_bound_x = False
idx_iter = self._create_idx_iter(len(cols), len(rows))
for child, (col, row) in zip(reversed(self.children), idx_iter):
(shw, shh), (w, h) = child.size_hint, child.size
shw_min, shh_min = child.size_hint_min
shw_max, shh_max = child.size_hint_max
# compute minimum size / maximum stretch needed
if shw is None:
cols[col] = nmax(cols[col], w)
else:
cols_sh[col] = nmax(cols_sh[col], shw)
if shw_min is not None:
has_bound_x = True
cols_sh_min[col] = nmax(cols_sh_min[col], shw_min)
if shw_max is not None:
has_bound_x = True
cols_sh_max[col] = nmin(cols_sh_max[col], shw_max)
if shh is None:
rows[row] = nmax(rows[row], h)
else:
rows_sh[row] = nmax(rows_sh[row], shh)
if shh_min is not None:
has_bound_y = True
rows_sh_min[row] = nmax(rows_sh_min[row], shh_min)
if shh_max is not None:
has_bound_y = True
rows_sh_max[row] = nmin(rows_sh_max[row], shh_max)
self._has_hint_bound_x = has_bound_x
self._has_hint_bound_y = has_bound_y
def _update_minimum_size(self):
# calculate minimum width/height needed, starting from padding +
# spacing
l, t, r, b = self.padding
spacing_x, spacing_y = self.spacing
cols, rows = self._cols, self._rows
width = l + r + spacing_x * (len(cols) - 1)
self._cols_min_size_none = sum(cols) + width
# we need to subtract for the sh_max/min the already guaranteed size
# due to having a None in the col. So sh_min gets smaller by that size
# since it's already covered. Similarly for sh_max, because if we
# already exceeded the max, the subtracted max will be zero, so
# it won't get larger
if self._has_hint_bound_x:
cols_sh_min = self._cols_sh_min
cols_sh_max = self._cols_sh_max
for i, (c, sh_min, sh_max) in enumerate(
zip(cols, cols_sh_min, cols_sh_max)):
if sh_min is not None:
width += max(c, sh_min)
cols_sh_min[i] = max(0., sh_min - c)
else:
width += c
if sh_max is not None:
cols_sh_max[i] = max(0., sh_max - c)
else:
width = self._cols_min_size_none
height = t + b + spacing_y * (len(rows) - 1)
self._rows_min_size_none = sum(rows) + height
if self._has_hint_bound_y:
rows_sh_min = self._rows_sh_min
rows_sh_max = self._rows_sh_max
for i, (r, sh_min, sh_max) in enumerate(
zip(rows, rows_sh_min, rows_sh_max)):
if sh_min is not None:
height += max(r, sh_min)
rows_sh_min[i] = max(0., sh_min - r)
else:
height += r
if sh_max is not None:
rows_sh_max[i] = max(0., sh_max - r)
else:
height = self._rows_min_size_none
# finally, set the minimum size
self.minimum_size = (width, height)
def _finalize_rows_cols_sizes(self):
selfw = self.width
selfh = self.height
# resolve size for each column
if self.col_force_default:
cols = [self.col_default_width] * len(self._cols)
for index, value in self.cols_minimum.items():
cols[index] = value
self._cols = cols
else:
cols = self._cols
cols_sh = self._cols_sh
cols_sh_min = self._cols_sh_min
cols_weight = float(sum((x for x in cols_sh if x is not None)))
stretch_w = max(0., selfw - self._cols_min_size_none)
if stretch_w > 1e-9:
if self._has_hint_bound_x:
# fix the hints to be within bounds
self.layout_hint_with_bounds(
cols_weight, stretch_w,
sum((c for c in cols_sh_min if c is not None)),
cols_sh_min, self._cols_sh_max, cols_sh)
for index, col_stretch in enumerate(cols_sh):
# if the col don't have stretch information, nothing to do
if not col_stretch:
continue
# add to the min width whatever remains from size_hint
cols[index] += stretch_w * col_stretch / cols_weight
# same algo for rows
if self.row_force_default:
rows = [self.row_default_height] * len(self._rows)
for index, value in self.rows_minimum.items():
rows[index] = value
self._rows = rows
else:
rows = self._rows
rows_sh = self._rows_sh
rows_sh_min = self._rows_sh_min
rows_weight = float(sum((x for x in rows_sh if x is not None)))
stretch_h = max(0., selfh - self._rows_min_size_none)
if stretch_h > 1e-9:
if self._has_hint_bound_y:
# fix the hints to be within bounds
self.layout_hint_with_bounds(
rows_weight, stretch_h,
sum((r for r in rows_sh_min if r is not None)),
rows_sh_min, self._rows_sh_max, rows_sh)
for index, row_stretch in enumerate(rows_sh):
# if the row don't have stretch information, nothing to do
if not row_stretch:
continue
# add to the min height whatever remains from size_hint
rows[index] += stretch_h * row_stretch / rows_weight
def _iterate_layout(self, count):
orientation = self.orientation
padding = self.padding
spacing_x, spacing_y = self.spacing
cols = self._cols
if self._fills_from_left_to_right:
x_iter = accumulate(chain(
(self.x + padding[0], ),
(
col_width + spacing_x
for col_width in islice(cols, len(cols) - 1)
),
))
else:
x_iter = accumulate(chain(
(self.right - padding[2] - cols[-1], ),
(
col_width + spacing_x
for col_width in islice(reversed(cols), 1, None)
),
), sub)
cols = reversed(cols)
rows = self._rows
if self._fills_from_top_to_bottom:
y_iter = accumulate(chain(
(self.top - padding[1] - rows[0], ),
(
row_height + spacing_y
for row_height in islice(rows, 1, None)
),
), sub)
else:
y_iter = accumulate(chain(
(self.y + padding[3], ),
(
row_height + spacing_y
for row_height in islice(reversed(rows), len(rows) - 1)
),
))
rows = reversed(rows)
if self._fills_row_first:
for i, (y, x), (row_height, col_width) in zip(
reversed(range(count)),
product(y_iter, x_iter),
product(rows, cols)):
yield i, x, y, col_width, row_height
else:
for i, (x, y), (col_width, row_height) in zip(
reversed(range(count)),
product(x_iter, y_iter),
product(cols, rows)):
yield i, x, y, col_width, row_height
def do_layout(self, *largs):
children = self.children
if not children or not self._init_rows_cols_sizes(len(children)):
l, t, r, b = self.padding
self.minimum_size = l + r, t + b
return
self._fill_rows_cols_sizes()
self._update_minimum_size()
self._finalize_rows_cols_sizes()
for i, x, y, w, h in self._iterate_layout(len(children)):
c = children[i]
c.pos = x, y
shw, shh = c.size_hint
shw_min, shh_min = c.size_hint_min
shw_max, shh_max = c.size_hint_max
if shw_min is not None:
if shw_max is not None:
w = max(min(w, shw_max), shw_min)
else:
w = max(w, shw_min)
else:
if shw_max is not None:
w = min(w, shw_max)
if shh_min is not None:
if shh_max is not None:
h = max(min(h, shh_max), shh_min)
else:
h = max(h, shh_min)
else:
if shh_max is not None:
h = min(h, shh_max)
if shw is None:
if shh is not None:
c.height = h
else:
if shh is None:
c.width = w
else:
c.size = (w, h)
def _create_idx_iter(self, n_cols, n_rows):
col_indices = range(n_cols) if self._fills_from_left_to_right \
else range(n_cols - 1, -1, -1)
row_indices = range(n_rows) if self._fills_from_top_to_bottom \
else range(n_rows - 1, -1, -1)
if self._fills_row_first:
return (
(col_index, row_index)
for row_index, col_index in product(row_indices, col_indices))
else:
return product(col_indices, row_indices)
+528
View File
@@ -0,0 +1,528 @@
'''
Image
=====
The :class:`Image` widget is used to display an image::
Example in python::
wimg = Image(source='mylogo.png')
Kv Example::
Image:
source: 'mylogo.png'
size: self.texture_size
Asynchronous Loading
--------------------
To load an image asynchronously (for example from an external webserver), use
the :class:`AsyncImage` subclass::
aimg = AsyncImage(source='http://mywebsite.com/logo.png')
This can be useful as it prevents your application from waiting until the image
is loaded. If you want to display large images or retrieve them from URL's,
using :class:`AsyncImage` will allow these resources to be retrieved on a
background thread without blocking your application.
Alignment
---------
By default, the image is centered inside the widget bounding box.
Adjustment
----------
To control how the image should be adjusted to fit inside the widget box, you
should use the :attr:`~kivy.uix.image.Image.fit_mode` property. Available
options include:
- ``"scale-down"``: maintains aspect ratio without stretching.
- ``"fill"``: stretches to fill widget, may cause distortion.
- ``"contain"``: maintains aspect ratio and resizes to fit inside widget.
- ``"cover"``: maintains aspect ratio and stretches to fill widget, may clip
image.
For more details, refer to the :attr:`~kivy.uix.image.Image.fit_mode`.
You can also inherit from Image and create your own style. For example, if you
want your image to be greater than the size of your widget, you could do::
class FullImage(Image):
pass
And in your kivy language file::
<-FullImage>:
canvas:
Color:
rgb: (1, 1, 1)
Rectangle:
texture: self.texture
size: self.width + 20, self.height + 20
pos: self.x - 10, self.y - 10
'''
__all__ = ('Image', 'AsyncImage')
from kivy.uix.widget import Widget
from kivy.core.image import Image as CoreImage
from kivy.resources import resource_find
from kivy.properties import (
StringProperty,
ObjectProperty,
ListProperty,
AliasProperty,
BooleanProperty,
NumericProperty,
ColorProperty,
OptionProperty
)
from kivy.logger import Logger
# delayed imports
Loader = None
class Image(Widget):
'''Image class, see module documentation for more information.'''
source = StringProperty(None)
'''Filename / source of your image.
:attr:`source` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
texture = ObjectProperty(None, allownone=True)
'''Texture object of the image. The texture represents the original, loaded
image texture. It is stretched and positioned during rendering according to
the :attr:`fit_mode` property.
Depending of the texture creation, the value will be a
:class:`~kivy.graphics.texture.Texture` or a
:class:`~kivy.graphics.texture.TextureRegion` object.
:attr:`texture` is an :class:`~kivy.properties.ObjectProperty` and defaults
to None.
'''
texture_size = ListProperty([0, 0])
'''Texture size of the image. This represents the original, loaded image
texture size.
.. warning::
The texture size is set after the texture property. So if you listen to
the change on :attr:`texture`, the property texture_size will not be
up-to-date. Use self.texture.size instead.
'''
def get_image_ratio(self):
if self.texture:
return self.texture.width / float(self.texture.height)
return 1.0
mipmap = BooleanProperty(False)
'''Indicate if you want OpenGL mipmapping to be applied to the texture.
Read :ref:`mipmap` for more information.
.. versionadded:: 1.0.7
:attr:`mipmap` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
image_ratio = AliasProperty(get_image_ratio, bind=('texture',), cache=True)
'''Ratio of the image (width / float(height).
:attr:`image_ratio` is an :class:`~kivy.properties.AliasProperty` and is
read-only.
'''
color = ColorProperty([1, 1, 1, 1])
'''Image color, in the format (r, g, b, a). This attribute can be used to
'tint' an image. Be careful: if the source image is not gray/white, the
color will not really work as expected.
.. versionadded:: 1.0.6
:attr:`color` is a :class:`~kivy.properties.ColorProperty` and defaults to
[1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
allow_stretch = BooleanProperty(False, deprecated=True)
'''If True, the normalized image size will be maximized to fit in the image
box. Otherwise, if the box is too tall, the image will not be
stretched more than 1:1 pixels.
.. versionadded:: 1.0.7
.. deprecated:: 2.2.0
:attr:`allow_stretch` have been deprecated. Please use `fit_mode`
instead.
:attr:`allow_stretch` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
keep_ratio = BooleanProperty(True, deprecated=True)
'''If False along with allow_stretch being True, the normalized image
size will be maximized to fit in the image box and ignores the aspect
ratio of the image.
Otherwise, if the box is too tall, the image will not be stretched more
than 1:1 pixels.
.. versionadded:: 1.0.8
.. deprecated:: 2.2.0
:attr:`keep_ratio` have been deprecated. Please use `fit_mode`
instead.
:attr:`keep_ratio` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
fit_mode = OptionProperty(
"scale-down", options=["scale-down", "fill", "contain", "cover"]
)
'''If the size of the image is different than the size of the widget,
determine how the image should be resized to fit inside the widget box.
Available options:
- ``"scale-down"``: the image will be scaled down to fit inside the widget
box, **maintaining its aspect ratio and without stretching**. If the size
of the image is smaller than the widget, it will be displayed at its
original size. If the image has a different aspect ratio than the widget,
there will be blank areas on the widget box.
- ``"fill"``: the image is stretched to fill the widget, **regardless of
its aspect ratio or dimensions**. If the image has a different aspect ratio
than the widget, this option can lead to distortion of the image.
- ``"contain"``: the image is resized to fit inside the widget box,
**maintaining its aspect ratio**. If the image size is larger than the
widget size, the behavior will be similar to ``"scale-down"``. However, if
the size of the image is smaller than the widget size, unlike
``"scale-down``, the image will be resized to fit inside the widget.
If the image has a different aspect ratio than the widget, there will be
blank areas on the widget box.
- ``"cover"``: the image will be stretched horizontally or vertically to
fill the widget box, **maintaining its aspect ratio**. If the image has a
different aspect ratio than the widget, then the image will be clipped to
fit.
:attr:`fit_mode` is a :class:`~kivy.properties.OptionProperty` and
defaults to ``"scale-down"``.
'''
keep_data = BooleanProperty(False)
'''If True, the underlying _coreimage will store the raw image data.
This is useful when performing pixel based collision detection.
.. versionadded:: 1.3.0
:attr:`keep_data` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
anim_delay = NumericProperty(0.25)
'''Delay the animation if the image is sequenced (like an animated gif).
If anim_delay is set to -1, the animation will be stopped.
.. versionadded:: 1.0.8
:attr:`anim_delay` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.25 (4 FPS).
'''
anim_loop = NumericProperty(0)
'''Number of loops to play then stop animating. 0 means keep animating.
.. versionadded:: 1.9.0
:attr:`anim_loop` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
nocache = BooleanProperty(False)
'''If this property is set True, the image will not be added to the
internal cache. The cache will simply ignore any calls trying to
append the core image.
.. versionadded:: 1.6.0
:attr:`nocache` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
def get_norm_image_size(self):
if not self.texture:
return list(self.size)
ratio = self.image_ratio
w, h = self.size
tw, th = self.texture.size
if self.fit_mode == "cover":
widget_ratio = w / max(1, h)
if widget_ratio > ratio:
return [w, (w * th) / tw]
else:
return [(h * tw) / th, h]
elif self.fit_mode == "fill":
return [w, h]
elif self.fit_mode == "contain":
iw = w
else:
iw = min(w, tw)
# calculate the appropriate height
ih = iw / ratio
# if the height is too higher, take the height of the container
# and calculate appropriate width. no need to test further. :)
if ih > h:
if self.fit_mode == "contain":
ih = h
else:
ih = min(h, th)
iw = ih * ratio
return [iw, ih]
norm_image_size = AliasProperty(
get_norm_image_size,
bind=(
'texture',
'size',
'image_ratio',
'fit_mode',
),
cache=True,
)
'''Normalized image size within the widget box.
This size will always fit the widget size and will preserve the image
ratio.
:attr:`norm_image_size` is an :class:`~kivy.properties.AliasProperty` and
is read-only.
'''
def __init__(self, **kwargs):
self._coreimage = None
self._loops = 0
update = self.texture_update
fbind = self.fbind
fbind('source', update)
fbind('mipmap', update)
# NOTE: Compatibility code due to deprecated properties.
fbind('keep_ratio', self._update_fit_mode)
fbind('allow_stretch', self._update_fit_mode)
super().__init__(**kwargs)
def _update_fit_mode(self, *args):
keep_ratio = self.keep_ratio
allow_stretch = self.allow_stretch
if (
not keep_ratio and not allow_stretch
or keep_ratio and not allow_stretch
):
self.fit_mode = "scale-down"
elif not keep_ratio and allow_stretch:
self.fit_mode = "fill"
elif keep_ratio and allow_stretch:
self.fit_mode = "contain"
def texture_update(self, *largs):
self.set_texture_from_resource(self.source)
def set_texture_from_resource(self, resource):
if not resource:
self._clear_core_image()
return
source = resource_find(resource)
if not source:
Logger.error('Image: Not found <%s>' % resource)
self._clear_core_image()
return
if self._coreimage:
self._coreimage.unbind(on_texture=self._on_tex_change)
try:
self._coreimage = image = CoreImage(
source,
mipmap=self.mipmap,
anim_delay=self.anim_delay,
keep_data=self.keep_data,
nocache=self.nocache
)
except Exception:
Logger.error('Image: Error loading <%s>' % resource)
self._clear_core_image()
image = self._coreimage
if image:
image.bind(on_texture=self._on_tex_change)
self.texture = image.texture
def on_anim_delay(self, instance, value):
if self._coreimage is None:
return
self._coreimage.anim_delay = value
if value < 0:
self._coreimage.anim_reset(False)
def on_texture(self, instance, value):
self.texture_size = value.size if value else [0, 0]
def _clear_core_image(self):
if self._coreimage:
self._coreimage.unbind(on_texture=self._on_tex_change)
self.texture = None
self._coreimage = None
self._loops = 0
def _on_tex_change(self, *largs):
# update texture from core image
self.texture = self._coreimage.texture
ci = self._coreimage
if self.anim_loop and ci._anim_index == len(ci._image.textures) - 1:
self._loops += 1
if self.anim_loop == self._loops:
ci.anim_reset(False)
self._loops = 0
def reload(self):
'''Reload image from disk. This facilitates re-loading of
images from disk in case the image content changes.
.. versionadded:: 1.3.0
Usage::
im = Image(source = '1.jpg')
# -- do something --
im.reload()
# image will be re-loaded from disk
'''
self.remove_from_cache()
old_source = self.source
self.source = ''
self.source = old_source
def remove_from_cache(self):
'''Remove image from cache.
.. versionadded:: 2.0.0
'''
if self._coreimage:
self._coreimage.remove_from_cache()
def on_nocache(self, *args):
if self.nocache:
self.remove_from_cache()
if self._coreimage:
self._coreimage._nocache = True
class AsyncImage(Image):
'''Asynchronous Image class. See the module documentation for more
information.
.. note::
The AsyncImage is a specialized form of the Image class. You may
want to refer to the :mod:`~kivy.loader` documentation and in
particular, the :class:`~kivy.loader.ProxyImage` for more detail
on how to handle events around asynchronous image loading.
.. note::
AsyncImage currently does not support properties
:attr:`anim_loop` and :attr:`mipmap` and setting those properties will
have no effect.
'''
__events__ = ('on_error', 'on_load')
def __init__(self, **kwargs):
self._found_source = None
self._coreimage = None
global Loader
if not Loader:
from kivy.loader import Loader
self.fbind('source', self._load_source)
super().__init__(**kwargs)
def _load_source(self, *args):
source = self.source
if not source:
self._clear_core_image()
return
if not self.is_uri(source):
source = resource_find(source)
if not source:
Logger.error('AsyncImage: Not found <%s>' % self.source)
self._clear_core_image()
return
self._found_source = source
self._coreimage = image = Loader.image(
source,
nocache=self.nocache,
mipmap=self.mipmap,
anim_delay=self.anim_delay
)
image.bind(
on_load=self._on_source_load,
on_error=self._on_source_error,
on_texture=self._on_tex_change
)
self.texture = image.texture
def _on_source_load(self, value):
image = self._coreimage.image
if not image:
return
self.texture = image.texture
self.dispatch('on_load')
def _on_source_error(self, instance, error=None):
self.dispatch('on_error', error)
def on_error(self, error):
pass
def on_load(self, *args):
pass
def is_uri(self, filename):
proto = filename.split('://', 1)[0]
return proto in ('http', 'https', 'ftp', 'smb')
def _clear_core_image(self):
if self._coreimage:
self._coreimage.unbind(on_load=self._on_source_load)
super()._clear_core_image()
self._found_source = None
def _on_tex_change(self, *largs):
if self._coreimage:
self.texture = self._coreimage.texture
def texture_update(self, *largs):
pass
def remove_from_cache(self):
if self._found_source:
Loader.remove_from_cache(self._found_source)
super().remove_from_cache()
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
'''
Layout
======
Layouts are used to calculate and assign widget positions.
The :class:`Layout` class itself cannot be used directly.
You should use one of the following layout classes:
- Anchor layout: :class:`kivy.uix.anchorlayout.AnchorLayout`
- Box layout: :class:`kivy.uix.boxlayout.BoxLayout`
- Float layout: :class:`kivy.uix.floatlayout.FloatLayout`
- Grid layout: :class:`kivy.uix.gridlayout.GridLayout`
- Page Layout: :class:`kivy.uix.pagelayout.PageLayout`
- Relative layout: :class:`kivy.uix.relativelayout.RelativeLayout`
- Scatter layout: :class:`kivy.uix.scatterlayout.ScatterLayout`
- Stack layout: :class:`kivy.uix.stacklayout.StackLayout`
Understanding the `size_hint` Property in `Widget`
--------------------------------------------------
The :attr:`~kivy.uix.Widget.size_hint` is a tuple of values used by
layouts to manage the sizes of their children. It indicates the size
relative to the layout's size instead of an absolute size (in
pixels/points/cm/etc). The format is::
widget.size_hint = (width_proportion, height_proportion)
The proportions are specified as floating point numbers in the range 0-1. For
example, 0.5 represents 50%, 1 represents 100%.
If you want a widget's width to be half of the parent's width and the
height to be identical to the parent's height, you would do::
widget.size_hint = (0.5, 1.0)
If you don't want to use a size_hint for either the width or height, set the
value to None. For example, to make a widget that is 250px wide and 30%
of the parent's height, do::
widget.size_hint = (None, 0.3)
widget.width = 250
Being :class:`Kivy properties <kivy.properties>`, these can also be set via
constructor arguments::
widget = Widget(size_hint=(None, 0.3), width=250)
.. versionchanged:: 1.4.1
The `reposition_child` internal method (made public by mistake) has
been removed.
'''
__all__ = ('Layout', )
from kivy.clock import Clock
from kivy.uix.widget import Widget
from kivy.compat import isclose
class Layout(Widget):
'''Layout interface class, used to implement every layout. See module
documentation for more information.
'''
_trigger_layout = None
def __init__(self, **kwargs):
if self.__class__ == Layout:
raise Exception('The Layout class is abstract and \
cannot be used directly.')
if self._trigger_layout is None:
self._trigger_layout = Clock.create_trigger(self.do_layout, -1)
super(Layout, self).__init__(**kwargs)
def do_layout(self, *largs):
'''This function is called when a layout is called by a trigger.
If you are writing a new Layout subclass, don't call this function
directly but use :meth:`_trigger_layout` instead.
The function is by default called *before* the next frame, therefore
the layout isn't updated immediately. Anything depending on the
positions of e.g. children should be scheduled for the next frame.
.. versionadded:: 1.0.8
'''
raise NotImplementedError('Must be implemented in subclasses.')
def add_widget(self, widget, *args, **kwargs):
fbind = widget.fbind
fbind('size', self._trigger_layout)
fbind('size_hint', self._trigger_layout)
fbind('size_hint_max', self._trigger_layout)
fbind('size_hint_min', self._trigger_layout)
super(Layout, self).add_widget(widget, *args, **kwargs)
def remove_widget(self, widget, *args, **kwargs):
funbind = widget.funbind
funbind('size', self._trigger_layout)
funbind('size_hint', self._trigger_layout)
funbind('size_hint_max', self._trigger_layout)
funbind('size_hint_min', self._trigger_layout)
super(Layout, self).remove_widget(widget, *args, **kwargs)
def layout_hint_with_bounds(
self, sh_sum, available_space, min_bounded_size, sh_min_vals,
sh_max_vals, hint):
'''(internal) Computes the appropriate (size) hint for all the
widgets given (potential) min or max bounds on the widgets' size.
The ``hint`` list is updated with appropriate sizes.
It walks through the hints and for any widgets whose hint will result
in violating min or max constraints, it fixes the hint. Any remaining
or missing space after all the widgets are fixed get distributed
to the widgets making them smaller or larger according to their
size hint.
This algorithms knows nothing about the widgets other than what is
passed through the input params, so it's fairly generic for laying
things out according to constraints using size hints.
:Parameters:
`sh_sum`: float
The sum of the size hints (basically ``sum(size_hint)``).
`available_space`: float
The amount of pixels available for all the widgets
whose size hint is not None. Cannot be zero.
`min_bounded_size`: float
The minimum amount of space required according to the
`size_hint_min` of the widgets (basically
``sum(size_hint_min)``).
`sh_min_vals`: list or iterable
Items in the iterable are the size_hint_min for each widget.
Can be None. The length should be the same as ``hint``
`sh_max_vals`: list or iterable
Items in the iterable are the size_hint_max for each widget.
Can be None. The length should be the same as ``hint``
`hint`: list
A list whose size is the same as the length of ``sh_min_vals``
and ``sh_min_vals`` whose each element is the corresponding
size hint value of that element. This list is updated in place
with correct size hints that ensure the constraints are not
violated.
:returns:
Nothing. ``hint`` is updated in place.
'''
if not sh_sum:
return
# TODO: test when children have size_hint, max/min of zero
# all divs are float denominator ;)
stretch_ratio = sh_sum / float(available_space)
if available_space <= min_bounded_size or \
isclose(available_space, min_bounded_size):
# too small, just set to min
for i, (sh, sh_min) in enumerate(zip(hint, sh_min_vals)):
if sh is None:
continue
if sh_min is not None:
hint[i] = sh_min * stretch_ratio # set to min size
else:
hint[i] = 0. # everything else is zero
return
# these dicts take i (widget child) as key
not_mined_contrib = {} # all who's sh > min_sh or had no min_sh
not_maxed_contrib = {} # all who's sh < max_sh or had no max_sh
sh_mins_avail = {} # the sh amt removable until we hit sh_min
sh_maxs_avail = {} # the sh amt addable until we hit sh_max
oversize_amt = undersize_amt = 0
hint_orig = hint[:]
# first, for all the items, set them to be within their max/min
# size_hint bound, also find how much their size_hint can be reduced
# or increased
for i, (sh, sh_min, sh_max) in enumerate(
zip(hint, sh_min_vals, sh_max_vals)):
if sh is None:
continue
diff = 0
if sh_min is not None:
sh_min *= stretch_ratio
diff = sh_min - sh # how much we are under the min
if diff > 0:
hint[i] = sh_min
undersize_amt += diff
else:
not_mined_contrib[i] = None
sh_mins_avail[i] = hint[i] - sh_min
else:
not_mined_contrib[i] = None
sh_mins_avail[i] = hint[i]
if sh_max is not None:
sh_max *= stretch_ratio
diff = sh - sh_max
if diff > 0:
hint[i] = sh_max # how much we are over the max
oversize_amt += diff
else:
not_maxed_contrib[i] = None
sh_maxs_avail[i] = sh_max - hint[i]
else:
not_maxed_contrib[i] = None
sh_maxs_avail[i] = sh_sum - hint[i]
if i in not_mined_contrib:
not_mined_contrib[i] = max(0., diff) # how much got removed
if i in not_maxed_contrib:
not_maxed_contrib[i] = max(0., diff) # how much got added
# if margin is zero, the amount of the widgets that were made smaller
# magically equals the amount of the widgets that were made larger
# so we're all good
margin = oversize_amt - undersize_amt
if isclose(oversize_amt, undersize_amt, abs_tol=1e-15):
return
# we need to redistribute the margin among all widgets
# if margin is positive, then we have extra space because the widgets
# that were larger and were reduced contributed more, so increase
# the size hint for those that are allowed to be larger by the
# most allowed, proportionately to their size (or inverse size hint).
# similarly for the opposite case
if margin > 1e-15:
contrib_amt = not_maxed_contrib
sh_available = sh_maxs_avail
mult = 1.
contrib_proportion = hint_orig
elif margin < -1e-15:
margin *= -1.
contrib_amt = not_mined_contrib
sh_available = sh_mins_avail
mult = -1.
# when reducing the size of widgets proportionately, those with
# larger sh get reduced less, and those with smaller, more.
mn = min((h for h in hint_orig if h))
mx = max((h for h in hint_orig if h is not None))
hint_top = (2. * mn if mn else 1.) if mn == mx else mn + mx
contrib_proportion = [None if h is None else hint_top - h for
h in hint_orig]
# contrib_amt is all the widgets that are not their max/min and
# can afford to be made bigger/smaller
# We only use the contrib_amt indices from now on
contrib_prop_sum = float(
sum((contrib_proportion[i] for i in contrib_amt)))
if contrib_prop_sum < 1e-9:
assert mult == 1. # should only happen when all sh are zero
return
contrib_height = {
i: val / (contrib_proportion[i] / contrib_prop_sum) for
i, val in contrib_amt.items()}
items = sorted(
(i for i in contrib_amt),
key=lambda x: contrib_height[x])
j = items[0]
sum_i_contributed = contrib_amt[j]
last_height = contrib_height[j]
sh_available_i = {j: sh_available[j]}
contrib_prop_sum_i = contrib_proportion[j]
n = len(items) # check when n <= 1
i = 1
if 1 < n:
j = items[1]
curr_height = contrib_height[j]
done = False
while not done and i < n:
while i < n and last_height == curr_height:
j = items[i]
sum_i_contributed += contrib_amt[j]
contrib_prop_sum_i += contrib_proportion[j]
sh_available_i[j] = sh_available[j]
curr_height = contrib_height[j]
i += 1
last_height = curr_height
while not done:
margin_height = ((margin + sum_i_contributed) /
(contrib_prop_sum_i / contrib_prop_sum))
if margin_height - curr_height > 1e-9 and i < n:
break
done = True
for k, available_sh in list(sh_available_i.items()):
if margin_height - available_sh / (
contrib_proportion[k] / contrib_prop_sum) > 1e-9:
del sh_available_i[k]
sum_i_contributed -= contrib_amt[k]
contrib_prop_sum_i -= contrib_proportion[k]
margin -= available_sh
hint[k] += mult * available_sh
done = False
if not sh_available_i: # all were under the margin
break
if sh_available_i:
assert contrib_prop_sum_i and margin
margin_height = ((margin + sum_i_contributed) /
(contrib_prop_sum_i / contrib_prop_sum))
for i in sh_available_i:
hint[i] += mult * (
margin_height * contrib_proportion[i] / contrib_prop_sum -
contrib_amt[i])
@@ -0,0 +1,339 @@
"""
ModalView
=========
.. versionadded:: 1.4.0
The :class:`ModalView` widget is used to create modal views. By default, the
view will cover the whole "main" window.
Remember that the default size of a Widget is size_hint=(1, 1). If you don't
want your view to be fullscreen, either use size hints with values lower than
1 (for instance size_hint=(.8, .8)) or deactivate the size_hint and use fixed
size attributes.
Examples
--------
Example of a simple 400x400 Hello world view::
view = ModalView(size_hint=(None, None), size=(400, 400))
view.add_widget(Label(text='Hello world'))
By default, any click outside the view will dismiss it. If you don't
want that, you can set :attr:`ModalView.auto_dismiss` to False::
view = ModalView(auto_dismiss=False)
view.add_widget(Label(text='Hello world'))
view.open()
To manually dismiss/close the view, use the :meth:`ModalView.dismiss` method of
the ModalView instance::
view.dismiss()
Both :meth:`ModalView.open` and :meth:`ModalView.dismiss` are bind-able. That
means you can directly bind the function to an action, e.g. to a button's
on_press ::
# create content and add it to the view
content = Button(text='Close me!')
view = ModalView(auto_dismiss=False)
view.add_widget(content)
# bind the on_press event of the button to the dismiss function
content.bind(on_press=view.dismiss)
# open the view
view.open()
ModalView Events
----------------
There are four events available: `on_pre_open` and `on_open` which are raised
when the view is opening; `on_pre_dismiss` and `on_dismiss` which are raised
when the view is closed.
For `on_dismiss`, you can prevent the view from closing by explicitly
returning `True` from your callback::
def my_callback(instance):
print('ModalView', instance, 'is being dismissed, but is prevented!')
return True
view = ModalView()
view.add_widget(Label(text='Hello world'))
view.bind(on_dismiss=my_callback)
view.open()
.. versionchanged:: 1.5.0
The ModalView can be closed by hitting the escape key on the
keyboard if the :attr:`ModalView.auto_dismiss` property is True (the
default).
"""
__all__ = ('ModalView', )
from kivy.animation import Animation
from kivy.properties import (
StringProperty, BooleanProperty, ObjectProperty, NumericProperty,
ListProperty, ColorProperty)
from kivy.uix.anchorlayout import AnchorLayout
class ModalView(AnchorLayout):
"""ModalView class. See module documentation for more information.
:Events:
`on_pre_open`:
Fired before the ModalView is opened. When this event is fired
ModalView is not yet added to window.
`on_open`:
Fired when the ModalView is opened.
`on_pre_dismiss`:
Fired before the ModalView is closed.
`on_dismiss`:
Fired when the ModalView is closed. If the callback returns True,
the dismiss will be canceled.
.. versionchanged:: 1.11.0
Added events `on_pre_open` and `on_pre_dismiss`.
.. versionchanged:: 2.0.0
Added property 'overlay_color'.
.. versionchanged:: 2.1.0
Marked `attach_to` property as deprecated.
"""
# noinspection PyArgumentEqualDefault
auto_dismiss = BooleanProperty(True)
'''This property determines if the view is automatically
dismissed when the user clicks outside it.
:attr:`auto_dismiss` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
attach_to = ObjectProperty(None, deprecated=True)
'''If a widget is set on attach_to, the view will attach to the nearest
parent window of the widget. If none is found, it will attach to the
main/global Window.
:attr:`attach_to` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''Background color, in the format (r, g, b, a).
This acts as a *multiplier* to the texture color. The default
texture is grey, so just setting the background color will give
a darker result. To set a plain color, set the
:attr:`background_normal` to ``''``.
The :attr:`background_color` is a
:class:`~kivy.properties.ColorProperty` and defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed behavior to affect the background of the widget itself, not
the overlay dimming.
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
background = StringProperty(
'atlas://data/images/defaulttheme/modalview-background')
'''Background image of the view used for the view background.
:attr:`background` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/modalview-background'.
'''
border = ListProperty([16, 16, 16, 16])
'''Border used for :class:`~kivy.graphics.vertex_instructions.BorderImage`
graphics instruction. Used for the :attr:`background_normal` and the
:attr:`background_down` properties. Can be used when using custom
backgrounds.
It must be a list of four values: (bottom, right, top, left). Read the
BorderImage instructions for more information about how to use it.
:attr:`border` is a :class:`~kivy.properties.ListProperty` and defaults to
(16, 16, 16, 16).
'''
overlay_color = ColorProperty([0, 0, 0, .7])
'''Overlay color in the format (r, g, b, a).
Used for dimming the window behind the modal view.
:attr:`overlay_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [0, 0, 0, .7].
.. versionadded:: 2.0.0
'''
# Internals properties used for graphical representation.
_anim_alpha = NumericProperty(0)
_anim_duration = NumericProperty(.1)
_window = ObjectProperty(allownone=True, rebind=True)
_is_open = BooleanProperty(False)
_touch_started_inside = None
__events__ = ('on_pre_open', 'on_open', 'on_pre_dismiss', 'on_dismiss')
def __init__(self, **kwargs):
self._parent = None
super(ModalView, self).__init__(**kwargs)
def open(self, *_args, **kwargs):
"""Display the modal in the Window.
When the view is opened, it will be faded in with an animation. If you
don't want the animation, use::
view.open(animation=False)
"""
from kivy.core.window import Window
if self._is_open:
return
self._window = Window
self._is_open = True
self.dispatch('on_pre_open')
Window.add_widget(self)
Window.bind(
on_resize=self._align_center,
on_keyboard=self._handle_keyboard)
self.center = Window.center
self.fbind('center', self._align_center)
self.fbind('size', self._align_center)
if kwargs.get('animation', True):
ani = Animation(_anim_alpha=1., d=self._anim_duration)
ani.bind(on_complete=lambda *_args: self.dispatch('on_open'))
ani.start(self)
else:
self._anim_alpha = 1.
self.dispatch('on_open')
def dismiss(self, *_args, **kwargs):
""" Close the view if it is open.
If you really want to close the view, whatever the on_dismiss
event returns, you can use the *force* keyword argument::
view = ModalView()
view.dismiss(force=True)
When the view is dismissed, it will be faded out before being
removed from the parent. If you don't want this animation, use::
view.dismiss(animation=False)
"""
if not self._is_open:
return
self.dispatch('on_pre_dismiss')
if self.dispatch('on_dismiss') is True:
if kwargs.get('force', False) is not True:
return
if kwargs.get('animation', True):
Animation(_anim_alpha=0., d=self._anim_duration).start(self)
else:
self._anim_alpha = 0
self._real_remove_widget()
def _align_center(self, *_args):
if self._is_open:
self.center = self._window.center
def on_motion(self, etype, me):
super().on_motion(etype, me)
return True
def on_touch_down(self, touch):
""" touch down event handler. """
self._touch_started_inside = self.collide_point(*touch.pos)
if not self.auto_dismiss or self._touch_started_inside:
super().on_touch_down(touch)
return True
def on_touch_move(self, touch):
""" touch moved event handler. """
if not self.auto_dismiss or self._touch_started_inside:
super().on_touch_move(touch)
return True
def on_touch_up(self, touch):
""" touch up event handler. """
# Explicitly test for False as None occurs when shown by on_touch_down
if self.auto_dismiss and self._touch_started_inside is False:
self.dismiss()
else:
super().on_touch_up(touch)
self._touch_started_inside = None
return True
def on__anim_alpha(self, _instance, value):
""" animation progress callback. """
if value == 0 and self._is_open:
self._real_remove_widget()
def _real_remove_widget(self):
if not self._is_open:
return
self._window.remove_widget(self)
self._window.unbind(
on_resize=self._align_center,
on_keyboard=self._handle_keyboard)
self._is_open = False
self._window = None
def on_pre_open(self):
""" default pre-open event handler. """
def on_open(self):
""" default open event handler. """
def on_pre_dismiss(self):
""" default pre-dismiss event handler. """
def on_dismiss(self):
""" default dismiss event handler. """
def _handle_keyboard(self, _window, key, *_args):
if key == 27 and self.auto_dismiss:
self.dismiss()
return True
if __name__ == '__main__':
from kivy.base import runTouchApp
from kivy.uix.button import Button
from kivy.core.window import Window
from kivy.uix.label import Label
from kivy.uix.gridlayout import GridLayout
# add view
content = GridLayout(cols=1)
content.add_widget(Label(text='This is a hello world'))
view = ModalView(size_hint=(None, None), size=(256, 256))
view.add_widget(content)
layout = GridLayout(cols=3)
for x in range(9):
btn = Button(text=f"click me {x}")
btn.bind(on_release=view.open)
layout.add_widget(btn)
Window.add_widget(layout)
view.open()
runTouchApp()
@@ -0,0 +1,233 @@
"""
PageLayout
==========
.. image:: images/pagelayout.gif
:align: right
The :class:`PageLayout` class is used to create a simple multi-page
layout, in a way that allows easy flipping from one page to another using
borders.
:class:`PageLayout` does not currently honor the
:attr:`~kivy.uix.widget.Widget.size_hint`,
:attr:`~kivy.uix.widget.Widget.size_hint_min`,
:attr:`~kivy.uix.widget.Widget.size_hint_max`, or
:attr:`~kivy.uix.widget.Widget.pos_hint` properties.
.. versionadded:: 1.8.0
Example:
.. code-block:: kv
PageLayout:
Button:
text: 'page1'
Button:
text: 'page2'
Button:
text: 'page3'
Transitions from one page to the next are made by swiping in from the border
areas on the right or left hand side. If you wish to display multiple widgets
in a page, we suggest you use a containing layout. Ideally, each page should
consist of a single :mod:`~kivy.uix.layout` widget that contains the remaining
widgets on that page.
"""
__all__ = ('PageLayout', )
from kivy.uix.layout import Layout
from kivy.properties import NumericProperty, DictProperty
from kivy.animation import Animation
class PageLayout(Layout):
'''PageLayout class. See module documentation for more information.
'''
page = NumericProperty(0)
'''The currently displayed page.
:data:`page` is a :class:`~kivy.properties.NumericProperty` and defaults
to 0.
'''
border = NumericProperty('50dp')
'''The width of the border around the current page used to display
the previous/next page swipe areas when needed.
:data:`border` is a :class:`~kivy.properties.NumericProperty` and
defaults to 50dp.
'''
swipe_threshold = NumericProperty(.5)
'''The threshold used to trigger swipes as ratio of the widget
size.
:data:`swipe_threshold` is a :class:`~kivy.properties.NumericProperty`
and defaults to .5.
'''
anim_kwargs = DictProperty({'d': .5, 't': 'in_quad'})
'''The animation kwargs used to construct the animation
:data:`anim_kwargs` is a :class:`~kivy.properties.DictProperty`
and defaults to {'d': .5, 't': 'in_quad'}.
.. versionadded:: 1.11.0
'''
def __init__(self, **kwargs):
super(PageLayout, self).__init__(**kwargs)
trigger = self._trigger_layout
fbind = self.fbind
fbind('border', trigger)
fbind('page', trigger)
fbind('parent', trigger)
fbind('children', trigger)
fbind('size', trigger)
fbind('pos', trigger)
def do_layout(self, *largs):
l_children = len(self.children) - 1
h = self.height
x_parent, y_parent = self.pos
p = self.page
border = self.border
half_border = border / 2.
right = self.right
width = self.width - border
for i, c in enumerate(reversed(self.children)):
if i < p:
x = x_parent
elif i == p:
if not p: # it's first page
x = x_parent
elif p != l_children: # not first, but there are post pages
x = x_parent + half_border
else: # not first and there are no post pages
x = x_parent + border
elif i == p + 1:
if not p: # second page - no left margin
x = right - border
else: # there's already a left margin
x = right - half_border
else:
x = right
c.height = h
c.width = width
Animation(
x=x,
y=y_parent,
**self.anim_kwargs).start(c)
def on_touch_down(self, touch):
if (
self.disabled or
not self.collide_point(*touch.pos) or
not self.children
):
return
page = self.children[-self.page - 1]
if self.x <= touch.x < page.x:
touch.ud['page'] = 'previous'
touch.grab(self)
return True
elif page.right <= touch.x < self.right:
touch.ud['page'] = 'next'
touch.grab(self)
return True
return page.on_touch_down(touch)
def on_touch_move(self, touch):
if touch.grab_current != self:
return
p = self.page
border = self.border
half_border = border / 2.
page = self.children[-p - 1]
if touch.ud['page'] == 'previous':
# move next page up to right edge
if p < len(self.children) - 1:
self.children[-p - 2].x = min(
self.right - self.border * (1 - (touch.sx - touch.osx)),
self.right)
# move current page until edge hits the right border
if p >= 1:
b_right = half_border if p > 1 else border
b_left = half_border if p < len(self.children) - 1 else border
self.children[-p - 1].x = max(min(
self.x + b_left + (touch.x - touch.ox),
self.right - b_right),
self.x + b_left)
# move previous page left edge up to left border
if p > 1:
self.children[-p].x = min(
self.x + half_border * (touch.sx - touch.osx),
self.x + half_border)
elif touch.ud['page'] == 'next':
# move current page up to left edge
if p >= 1:
self.children[-p - 1].x = max(
self.x + half_border * (1 - (touch.osx - touch.sx)),
self.x)
# move next page until its edge hit the left border
if p < len(self.children) - 1:
b_right = half_border if p >= 1 else border
b_left = half_border if p < len(self.children) - 2 else border
self.children[-p - 2].x = min(max(
self.right - b_right + (touch.x - touch.ox),
self.x + b_left),
self.right - b_right)
# move second next page up to right border
if p < len(self.children) - 2:
self.children[-p - 3].x = max(
self.right + half_border * (touch.sx - touch.osx),
self.right - half_border)
return page.on_touch_move(touch)
def on_touch_up(self, touch):
if touch.grab_current == self:
if (
touch.ud['page'] == 'previous' and
abs(touch.x - touch.ox) / self.width > self.swipe_threshold
):
self.page -= 1
elif (
touch.ud['page'] == 'next' and
abs(touch.x - touch.ox) / self.width > self.swipe_threshold
):
self.page += 1
else:
self._trigger_layout()
touch.ungrab(self)
if len(self.children) > 1:
return self.children[-self.page + 1].on_touch_up(touch)
if __name__ == '__main__':
from kivy.base import runTouchApp
from kivy.uix.button import Button
pl = PageLayout()
for i in range(1, 4):
b = Button(text='page%s' % i)
pl.add_widget(b)
runTouchApp(pl)
+266
View File
@@ -0,0 +1,266 @@
'''
Popup
=====
.. versionadded:: 1.0.7
.. image:: images/popup.jpg
:align: right
The :class:`Popup` widget is used to create modal popups. By default, the popup
will cover the whole "parent" window. When you are creating a popup, you
must at least set a :attr:`Popup.title` and :attr:`Popup.content`.
Remember that the default size of a Widget is size_hint=(1, 1). If you don't
want your popup to be fullscreen, either use size hints with values less than 1
(for instance size_hint=(.8, .8)) or deactivate the size_hint and use
fixed size attributes.
.. versionchanged:: 1.4.0
The :class:`Popup` class now inherits from
:class:`~kivy.uix.modalview.ModalView`. The :class:`Popup` offers a default
layout with a title and a separation bar.
Examples
--------
Example of a simple 400x400 Hello world popup::
popup = Popup(title='Test popup',
content=Label(text='Hello world'),
size_hint=(None, None), size=(400, 400))
By default, any click outside the popup will dismiss/close it. If you don't
want that, you can set
:attr:`~kivy.uix.modalview.ModalView.auto_dismiss` to False::
popup = Popup(title='Test popup', content=Label(text='Hello world'),
auto_dismiss=False)
popup.open()
To manually dismiss/close the popup, use
:attr:`~kivy.uix.modalview.ModalView.dismiss`::
popup.dismiss()
Both :meth:`~kivy.uix.modalview.ModalView.open` and
:meth:`~kivy.uix.modalview.ModalView.dismiss` are bindable. That means you
can directly bind the function to an action, e.g. to a button's on_press::
# create content and add to the popup
content = Button(text='Close me!')
popup = Popup(content=content, auto_dismiss=False)
# bind the on_press event of the button to the dismiss function
content.bind(on_press=popup.dismiss)
# open the popup
popup.open()
Same thing in KV language only with :class:`Factory`:
.. code-block:: kv
#:import Factory kivy.factory.Factory
<MyPopup@Popup>:
auto_dismiss: False
Button:
text: 'Close me!'
on_release: root.dismiss()
Button:
text: 'Open popup'
on_release: Factory.MyPopup().open()
.. note::
Popup is a special widget. Don't try to add it as a child to any other
widget. If you do, Popup will be handled like an ordinary widget and
won't be created hidden in the background.
.. code-block:: kv
BoxLayout:
MyPopup: # bad!
Popup Events
------------
There are two events available: `on_open` which is raised when the popup is
opening, and `on_dismiss` which is raised when the popup is closed.
For `on_dismiss`, you can prevent the
popup from closing by explicitly returning True from your callback::
def my_callback(instance):
print('Popup', instance, 'is being dismissed but is prevented!')
return True
popup = Popup(content=Label(text='Hello world'))
popup.bind(on_dismiss=my_callback)
popup.open()
'''
__all__ = ('Popup', 'PopupException')
from kivy.core.text import DEFAULT_FONT
from kivy.uix.modalview import ModalView
from kivy.properties import (StringProperty, ObjectProperty, OptionProperty,
NumericProperty, ColorProperty)
class PopupException(Exception):
'''Popup exception, fired when multiple content widgets are added to the
popup.
.. versionadded:: 1.4.0
'''
class Popup(ModalView):
'''Popup class. See module documentation for more information.
:Events:
`on_open`:
Fired when the Popup is opened.
`on_dismiss`:
Fired when the Popup is closed. If the callback returns True, the
dismiss will be canceled.
'''
title = StringProperty('No title')
'''String that represents the title of the popup.
:attr:`title` is a :class:`~kivy.properties.StringProperty` and defaults to
'No title'.
'''
title_size = NumericProperty('14sp')
'''Represents the font size of the popup title.
.. versionadded:: 1.6.0
:attr:`title_size` is a :class:`~kivy.properties.NumericProperty` and
defaults to '14sp'.
'''
title_align = OptionProperty(
'left', options=['left', 'center', 'right', 'justify'])
'''Horizontal alignment of the title.
.. versionadded:: 1.9.0
:attr:`title_align` is a :class:`~kivy.properties.OptionProperty` and
defaults to 'left'. Available options are left, center, right and justify.
'''
title_font = StringProperty(DEFAULT_FONT)
'''Font used to render the title text.
.. versionadded:: 1.9.0
:attr:`title_font` is a :class:`~kivy.properties.StringProperty` and
defaults to 'Roboto'. This value is taken
from :class:`~kivy.config.Config`.
'''
content = ObjectProperty(None)
'''Content of the popup that is displayed just under the title.
:attr:`content` is an :class:`~kivy.properties.ObjectProperty` and defaults
to None.
'''
title_color = ColorProperty([1, 1, 1, 1])
'''Color used by the Title.
.. versionadded:: 1.8.0
:attr:`title_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
separator_color = ColorProperty([47 / 255., 167 / 255., 212 / 255., 1.])
'''Color used by the separator between title and content.
.. versionadded:: 1.1.0
:attr:`separator_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [47 / 255., 167 / 255., 212 / 255., 1.].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
separator_height = NumericProperty('2dp')
'''Height of the separator.
.. versionadded:: 1.1.0
:attr:`separator_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to 2dp.
'''
# Internal properties used for graphical representation.
_container = ObjectProperty(None)
def add_widget(self, widget, *args, **kwargs):
if self._container:
if self.content:
raise PopupException(
'Popup can have only one widget as content')
self.content = widget
else:
super(Popup, self).add_widget(widget, *args, **kwargs)
def on_content(self, instance, value):
if self._container:
self._container.clear_widgets()
self._container.add_widget(value)
def on__container(self, instance, value):
if value is None or self.content is None:
return
self._container.clear_widgets()
self._container.add_widget(self.content)
def on_touch_down(self, touch):
if self.disabled and self.collide_point(*touch.pos):
return True
return super(Popup, self).on_touch_down(touch)
if __name__ == '__main__':
from kivy.base import runTouchApp
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.gridlayout import GridLayout
from kivy.core.window import Window
# add popup
content = GridLayout(cols=1)
content_cancel = Button(text='Cancel', size_hint_y=None, height=40)
content.add_widget(Label(text='This is a hello world'))
content.add_widget(content_cancel)
popup = Popup(title='Test popup',
size_hint=(None, None), size=(256, 256),
content=content, disabled=True)
content_cancel.bind(on_release=popup.dismiss)
layout = GridLayout(cols=3)
for x in range(9):
btn = Button(text=str(x))
btn.bind(on_release=popup.open)
layout.add_widget(btn)
Window.add_widget(layout)
popup.open()
runTouchApp()
@@ -0,0 +1,95 @@
'''
Progress Bar
============
.. versionadded:: 1.0.8
.. image:: images/progressbar.jpg
:align: right
The :class:`ProgressBar` widget is used to visualize the progress of some task.
Only the horizontal mode is currently supported: the vertical mode is not
yet available.
The progress bar has no interactive elements and is a display-only widget.
To use it, simply assign a value to indicate the current progress::
from kivy.uix.progressbar import ProgressBar
pb = ProgressBar(max=1000)
# this will update the graphics automatically (75% done)
pb.value = 750
'''
__all__ = ('ProgressBar', )
from kivy.uix.widget import Widget
from kivy.properties import NumericProperty, AliasProperty
class ProgressBar(Widget):
'''Class for creating a progress bar widget.
See module documentation for more details.
'''
def __init__(self, **kwargs):
self._value = 0.
super(ProgressBar, self).__init__(**kwargs)
def _get_value(self):
return self._value
def _set_value(self, value):
value = max(0, min(self.max, value))
if value != self._value:
self._value = value
return True
value = AliasProperty(_get_value, _set_value)
'''Current value used for the slider.
:attr:`value` is an :class:`~kivy.properties.AliasProperty` that
returns the value of the progress bar. If the value is < 0 or >
:attr:`max`, it will be normalized to those boundaries.
.. versionchanged:: 1.6.0
The value is now limited to between 0 and :attr:`max`.
'''
def get_norm_value(self):
d = self.max
if d == 0:
return 0
return self.value / float(d)
def set_norm_value(self, value):
self.value = value * self.max
value_normalized = AliasProperty(get_norm_value, set_norm_value,
bind=('value', 'max'), cache=True)
'''Normalized value inside the range 0-1::
>>> pb = ProgressBar(value=50, max=100)
>>> pb.value
50
>>> pb.value_normalized
0.5
:attr:`value_normalized` is an :class:`~kivy.properties.AliasProperty`.
'''
max = NumericProperty(100.)
'''Maximum value allowed for :attr:`value`.
:attr:`max` is a :class:`~kivy.properties.NumericProperty` and defaults to
100.
'''
if __name__ == '__main__':
from kivy.base import runTouchApp
runTouchApp(ProgressBar(value=50))
@@ -0,0 +1,183 @@
"""
RecycleBoxLayout
================
.. versionadded:: 1.10.0
.. warning::
This module is highly experimental, its API may change in the future and
the documentation is not complete at this time.
The RecycleBoxLayout is designed to provide a
:class:`~kivy.uix.boxlayout.BoxLayout` type layout when used with the
:class:`~kivy.uix.recycleview.RecycleView` widget. Please refer to the
:mod:`~kivy.uix.recycleview` module documentation for more information.
"""
from kivy.uix.recyclelayout import RecycleLayout
from kivy.uix.boxlayout import BoxLayout
__all__ = ('RecycleBoxLayout', )
class RecycleBoxLayout(RecycleLayout, BoxLayout):
_rv_positions = None
def __init__(self, **kwargs):
super(RecycleBoxLayout, self).__init__(**kwargs)
self.funbind('children', self._trigger_layout)
def _update_sizes(self, changed):
horizontal = self.orientation == 'horizontal'
padding_left, padding_top, padding_right, padding_bottom = self.padding
padding_x = padding_left + padding_right
padding_y = padding_top + padding_bottom
selfw = self.width
selfh = self.height
layout_w = max(0, selfw - padding_x)
layout_h = max(0, selfh - padding_y)
cx = self.x + padding_left
cy = self.y + padding_bottom
view_opts = self.view_opts
remove_view = self.remove_view
for (index, widget, (w, h), (wn, hn), (shw, shh), (shnw, shnh),
(shw_min, shh_min), (shwn_min, shhn_min), (shw_max, shh_max),
(shwn_max, shhn_max), ph, phn) in changed:
if (horizontal and
(shw != shnw or w != wn or shw_min != shwn_min or
shw_max != shwn_max) or
not horizontal and
(shh != shnh or h != hn or shh_min != shhn_min or
shh_max != shhn_max)):
return True
remove_view(widget, index)
opt = view_opts[index]
if horizontal:
wo, ho = opt['size']
if shnh is not None:
_, h = opt['size'] = [wo, shnh * layout_h]
else:
h = ho
xo, yo = opt['pos']
for key, value in phn.items():
posy = value * layout_h
if key == 'y':
yo = posy + cy
elif key == 'top':
yo = posy - h
elif key == 'center_y':
yo = posy - (h / 2.)
opt['pos'] = [xo, yo]
else:
wo, ho = opt['size']
if shnw is not None:
w, _ = opt['size'] = [shnw * layout_w, ho]
else:
w = wo
xo, yo = opt['pos']
for key, value in phn.items():
posx = value * layout_w
if key == 'x':
xo = posx + cx
elif key == 'right':
xo = posx - w
elif key == 'center_x':
xo = posx - (w / 2.)
opt['pos'] = [xo, yo]
return False
def compute_layout(self, data, flags):
super(RecycleBoxLayout, self).compute_layout(data, flags)
changed = self._changed_views
if (changed is None or
changed and not self._update_sizes(changed)):
return
self.clear_layout()
self._rv_positions = None
if not data:
l, t, r, b = self.padding
self.minimum_size = l + r, t + b
return
view_opts = self.view_opts
n = len(view_opts)
for i, x, y, w, h in self._iterate_layout(
[(opt['size'], opt['size_hint'], opt['pos_hint'],
opt['size_hint_min'], opt['size_hint_max']) for
opt in reversed(view_opts)]):
opt = view_opts[n - i - 1]
shw, shh = opt['size_hint']
opt['pos'] = x, y
wo, ho = opt['size']
# layout won't/shouldn't change previous size if size_hint is None
# which is what w/h being None means.
opt['size'] = [(wo if shw is None else w),
(ho if shh is None else h)]
spacing = self.spacing
pos = self._rv_positions = [None, ] * len(data)
if self.orientation == 'horizontal':
pos[0] = self.x
last = pos[0] + self.padding[0] + view_opts[0]['size'][0] + \
spacing / 2.
for i, val in enumerate(view_opts[1:], 1):
pos[i] = last
last += val['size'][0] + spacing
else:
last = pos[-1] = \
self.y + self.height - self.padding[1] - \
view_opts[0]['size'][1] - spacing / 2.
n = len(view_opts)
for i, val in enumerate(view_opts[1:], 1):
last -= spacing + val['size'][1]
pos[n - 1 - i] = last
def get_view_index_at(self, pos):
calc_pos = self._rv_positions
if not calc_pos:
return 0
x, y = pos
if self.orientation == 'horizontal':
if x >= calc_pos[-1] or len(calc_pos) == 1:
return len(calc_pos) - 1
ix = 0
for val in calc_pos[1:]:
if x < val:
return ix
ix += 1
else:
if y >= calc_pos[-1] or len(calc_pos) == 1:
return 0
iy = 0
for val in calc_pos[1:]:
if y < val:
return len(calc_pos) - iy - 1
iy += 1
assert False
def compute_visible_views(self, data, viewport):
if self._rv_positions is None or not data:
return []
x, y, w, h = viewport
at_idx = self.get_view_index_at
if self.orientation == 'horizontal':
a, b = at_idx((x, y)), at_idx((x + w, y))
else:
a, b = at_idx((x, y + h)), at_idx((x, y))
return list(range(a, b + 1))
@@ -0,0 +1,255 @@
"""
RecycleGridLayout
=================
.. versionadded:: 1.10.0
.. warning::
This module is highly experimental, its API may change in the future and
the documentation is not complete at this time.
The RecycleGridLayout is designed to provide a
:class:`~kivy.uix.gridlayout.GridLayout` type layout when used with the
:class:`~kivy.uix.recycleview.RecycleView` widget. Please refer to the
:mod:`~kivy.uix.recycleview` module documentation for more information.
"""
import itertools
chain_from_iterable = itertools.chain.from_iterable
from kivy.uix.recyclelayout import RecycleLayout
from kivy.uix.gridlayout import GridLayout, GridLayoutException, nmax, nmin
from collections import defaultdict
__all__ = ('RecycleGridLayout', )
class RecycleGridLayout(RecycleLayout, GridLayout):
_cols_pos = None
_rows_pos = None
def __init__(self, **kwargs):
super(RecycleGridLayout, self).__init__(**kwargs)
self.funbind('children', self._trigger_layout)
def on_children(self, instance, value):
pass
def _fill_rows_cols_sizes(self):
cols, rows = self._cols, self._rows
cols_sh, rows_sh = self._cols_sh, self._rows_sh
cols_sh_min, rows_sh_min = self._cols_sh_min, self._rows_sh_min
cols_sh_max, rows_sh_max = self._cols_sh_max, self._rows_sh_max
self._cols_count = cols_count = [defaultdict(int) for _ in cols]
self._rows_count = rows_count = [defaultdict(int) for _ in rows]
# calculate minimum size for each columns and rows
idx_iter = self._create_idx_iter(len(cols), len(rows))
has_bound_y = has_bound_x = False
for opt, (col, row) in zip(self.view_opts, idx_iter):
(shw, shh), (w, h) = opt['size_hint'], opt['size']
shw_min, shh_min = opt['size_hint_min']
shw_max, shh_max = opt['size_hint_max']
if shw is None:
cols_count[col][w] += 1
if shh is None:
rows_count[row][h] += 1
# compute minimum size / maximum stretch needed
if shw is None:
cols[col] = nmax(cols[col], w)
else:
cols_sh[col] = nmax(cols_sh[col], shw)
if shw_min is not None:
has_bound_x = True
cols_sh_min[col] = nmax(cols_sh_min[col], shw_min)
if shw_max is not None:
has_bound_x = True
cols_sh_max[col] = nmin(cols_sh_max[col], shw_max)
if shh is None:
rows[row] = nmax(rows[row], h)
else:
rows_sh[row] = nmax(rows_sh[row], shh)
if shh_min is not None:
has_bound_y = True
rows_sh_min[row] = nmax(rows_sh_min[row], shh_min)
if shh_max is not None:
has_bound_y = True
rows_sh_max[row] = nmin(rows_sh_max[row], shh_max)
self._has_hint_bound_x = has_bound_x
self._has_hint_bound_y = has_bound_y
def _update_rows_cols_sizes(self, changed):
cols_count, rows_count = self._cols_count, self._rows_count
cols, rows = self._cols, self._rows
remove_view = self.remove_view
n_cols = len(cols)
n_rows = len(rows)
orientation = self.orientation
# this can be further improved to reduce re-comp, but whatever...
for index, widget, (w, h), (wn, hn), sh, shn, sh_min, shn_min, \
sh_max, shn_max, _, _ in changed:
if sh != shn or sh_min != shn_min or sh_max != shn_max:
return True
elif (sh[0] is not None and w != wn and
(h == hn or sh[1] is not None) or
sh[1] is not None and h != hn and
(w == wn or sh[0] is not None)):
remove_view(widget, index)
else: # size hint is None, so check if it can be resized inplace
col, row = self._calculate_idx_from_a_view_idx(
n_cols, n_rows, index)
if w != wn:
col_w = cols[col]
cols_count[col][w] -= 1
cols_count[col][wn] += 1
was_last_w = cols_count[col][w] <= 0
if was_last_w and col_w == w or wn > col_w:
return True
if was_last_w:
del cols_count[col][w]
if h != hn:
row_h = rows[row]
rows_count[row][h] -= 1
rows_count[row][hn] += 1
was_last_h = rows_count[row][h] <= 0
if was_last_h and row_h == h or hn > row_h:
return True
if was_last_h:
del rows_count[row][h]
return False
def compute_layout(self, data, flags):
super(RecycleGridLayout, self).compute_layout(data, flags)
n = len(data)
smax = self.get_max_widgets()
if smax and n > smax:
raise GridLayoutException(
'Too many children ({}) in GridLayout. Increase rows/cols!'.
format(n))
changed = self._changed_views
if (changed is None or
changed and not self._update_rows_cols_sizes(changed)):
return
self.clear_layout()
if not self._init_rows_cols_sizes(n):
self._cols_pos = None
l, t, r, b = self.padding
self.minimum_size = l + r, t + b
return
self._fill_rows_cols_sizes()
self._update_minimum_size()
self._finalize_rows_cols_sizes()
view_opts = self.view_opts
for widget, x, y, w, h in self._iterate_layout(n):
opt = view_opts[n - widget - 1]
shw, shh = opt['size_hint']
opt['pos'] = x, y
wo, ho = opt['size']
# layout won't/shouldn't change previous size if size_hint is None
# which is what w/h being None means.
opt['size'] = [(wo if shw is None else w),
(ho if shh is None else h)]
spacing_x, spacing_y = self.spacing
cols, rows = self._cols, self._rows
cols_pos = self._cols_pos = [None, ] * len(cols)
rows_pos = self._rows_pos = [None, ] * len(rows)
cols_pos[0] = self.x
last = cols_pos[0] + self.padding[0] + cols[0] + spacing_x / 2.
for i, val in enumerate(cols[1:], 1):
cols_pos[i] = last
last += val + spacing_x
last = rows_pos[-1] = \
self.y + self.height - self.padding[1] - rows[0] - spacing_y / 2.
n = len(rows)
for i, val in enumerate(rows[1:], 1):
last -= spacing_y + val
rows_pos[n - 1 - i] = last
def get_view_index_at(self, pos):
if self._cols_pos is None:
return 0
x, y = pos
col_pos = self._cols_pos
row_pos = self._rows_pos
cols, rows = self._cols, self._rows
if not col_pos or not row_pos:
return 0
if x >= col_pos[-1]:
ix = len(cols) - 1
else:
ix = 0
for val in col_pos[1:]:
if x < val:
break
ix += 1
if y >= row_pos[-1]:
iy = len(rows) - 1
else:
iy = 0
for val in row_pos[1:]:
if y < val:
break
iy += 1
if not self._fills_from_left_to_right:
ix = len(cols) - ix - 1
if self._fills_from_top_to_bottom:
iy = len(rows) - iy - 1
return (iy * len(cols) + ix) if self._fills_row_first else \
(ix * len(rows) + iy)
def compute_visible_views(self, data, viewport):
if self._cols_pos is None:
return []
x, y, w, h = viewport
right = x + w
top = y + h
at_idx = self.get_view_index_at
tl, tr, bl, br = sorted((
at_idx((x, y)),
at_idx((right, y)),
at_idx((x, top)),
at_idx((right, top)),
))
n = len(data)
if len({tl, tr, bl, br}) < 4:
# visible area is one row/column
return range(min(n, tl), min(n, br + 1))
indices = []
stride = len(self._cols) if self._fills_row_first else len(self._rows)
if stride:
x_slice = br - bl + 1
indices = chain_from_iterable(
range(min(s, n), min(n, s + x_slice))
for s in range(tl, bl + 1, stride))
return indices
def _calculate_idx_from_a_view_idx(self, n_cols, n_rows, view_idx):
'''returns a tuple of (column-index, row-index) from a view-index'''
if self._fills_row_first:
row_idx, col_idx = divmod(view_idx, n_cols)
else:
col_idx, row_idx = divmod(view_idx, n_rows)
if not self._fills_from_left_to_right:
col_idx = n_cols - col_idx - 1
if not self._fills_from_top_to_bottom:
row_idx = n_rows - row_idx - 1
return (col_idx, row_idx, )
@@ -0,0 +1,446 @@
"""
RecycleLayout
=============
.. versionadded:: 1.10.0
.. warning::
This module is highly experimental, its API may change in the future and
the documentation is not complete at this time.
"""
from kivy.uix.recycleview.layout import RecycleLayoutManagerBehavior
from kivy.uix.layout import Layout
from kivy.properties import (
ObjectProperty, StringProperty, ReferenceListProperty, NumericProperty
)
from kivy.factory import Factory
__all__ = ('RecycleLayout', )
class RecycleLayout(RecycleLayoutManagerBehavior, Layout):
"""
RecycleLayout provides the default layout for RecycleViews.
"""
default_width = NumericProperty(100, allownone=True)
'''Default width for items
:attr:`default_width` is a NumericProperty and default to 100
'''
default_height = NumericProperty(100, allownone=True)
'''Default height for items
:attr:`default_height` is a :class:`~kivy.properties.NumericProperty` and
default to 100.
'''
default_size = ReferenceListProperty(default_width, default_height)
'''size (width, height). Each value can be None.
:attr:`default_size` is an :class:`~kivy.properties.ReferenceListProperty`
to [:attr:`default_width`, :attr:`default_height`].
'''
default_size_hint_x = NumericProperty(None, allownone=True)
'''Default size_hint_x for items
:attr:`default_size_hint_x` is a :class:`~kivy.properties.NumericProperty`
and default to None.
'''
default_size_hint_y = NumericProperty(None, allownone=True)
'''Default size_hint_y for items
:attr:`default_size_hint_y` is a :class:`~kivy.properties.NumericProperty`
and default to None.
'''
default_size_hint = ReferenceListProperty(
default_size_hint_x, default_size_hint_y
)
'''size (width, height). Each value can be None.
:attr:`default_size_hint` is an
:class:`~kivy.properties.ReferenceListProperty` to
[:attr:`default_size_hint_x`, :attr:`default_size_hint_y`].
'''
key_size = StringProperty(None, allownone=True)
'''If set, which key in the dict should be used to set the size property of
the item.
:attr:`key_size` is a :class:`~kivy.properties.StringProperty` and defaults
to None.
'''
key_size_hint = StringProperty(None, allownone=True)
'''If set, which key in the dict should be used to set the size_hint
property of the item.
:attr:`key_size_hint` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
key_size_hint_min = StringProperty(None, allownone=True)
'''If set, which key in the dict should be used to set the size_hint_min
property of the item.
:attr:`key_size_hint_min` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
default_size_hint_x_min = NumericProperty(None, allownone=True)
'''Default value for size_hint_x_min of items
:attr:`default_pos_hint_x_min` is a
:class:`~kivy.properties.NumericProperty` and defaults to None.
'''
default_size_hint_y_min = NumericProperty(None, allownone=True)
'''Default value for size_hint_y_min of items
:attr:`default_pos_hint_y_min` is a
:class:`~kivy.properties.NumericProperty` and defaults to None.
'''
default_size_hint_min = ReferenceListProperty(
default_size_hint_x_min,
default_size_hint_y_min
)
'''Default value for size_hint_min of items
:attr:`default_size_min` is a
:class:`~kivy.properties.ReferenceListProperty` to
[:attr:`default_size_hint_x_min`, :attr:`default_size_hint_y_min`].
'''
key_size_hint_max = StringProperty(None, allownone=True)
'''If set, which key in the dict should be used to set the size_hint_max
property of the item.
:attr:`key_size_hint_max` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
default_size_hint_x_max = NumericProperty(None, allownone=True)
'''Default value for size_hint_x_max of items
:attr:`default_pos_hint_x_max` is a
:class:`~kivy.properties.NumericProperty` and defaults to None.
'''
default_size_hint_y_max = NumericProperty(None, allownone=True)
'''Default value for size_hint_y_max of items
:attr:`default_pos_hint_y_max` is a
:class:`~kivy.properties.NumericProperty` and defaults to None.
'''
default_size_hint_max = ReferenceListProperty(
default_size_hint_x_max,
default_size_hint_y_max
)
'''Default value for size_hint_max of items
:attr:`default_size_max` is a
:class:`~kivy.properties.ReferenceListProperty` to
[:attr:`default_size_hint_x_max`, :attr:`default_size_hint_y_max`].
'''
default_pos_hint = ObjectProperty({})
'''Default pos_hint value for items
:attr:`default_pos_hint` is a :class:`~kivy.properties.DictProperty` and
defaults to {}.
'''
key_pos_hint = StringProperty(None, allownone=True)
'''If set, which key in the dict should be used to set the pos_hint of
items.
:attr:`key_pos_hint` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
initial_width = NumericProperty(100)
'''Initial width for the items.
:attr:`initial_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 100.
'''
initial_height = NumericProperty(100)
'''Initial height for the items.
:attr:`initial_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to 100.
'''
initial_size = ReferenceListProperty(initial_width, initial_height)
'''Initial size of items
:attr:`initial_size` is a :class:`~kivy.properties.ReferenceListProperty`
to [:attr:`initial_width`, :attr:`initial_height`].
'''
view_opts = []
_size_needs_update = False
_changed_views = []
view_indices = {}
def __init__(self, **kwargs):
self.view_indices = {}
self._updated_views = []
self._trigger_layout = self._catch_layout_trigger
super(RecycleLayout, self).__init__(**kwargs)
def attach_recycleview(self, rv):
super(RecycleLayout, self).attach_recycleview(rv)
if rv:
fbind = self.fbind
fbind('default_size', rv.refresh_from_data)
fbind('key_size', rv.refresh_from_data)
fbind('default_size_hint', rv.refresh_from_data)
fbind('key_size_hint', rv.refresh_from_data)
fbind('default_size_hint_min', rv.refresh_from_data)
fbind('key_size_hint_min', rv.refresh_from_data)
fbind('default_size_hint_max', rv.refresh_from_data)
fbind('key_size_hint_max', rv.refresh_from_data)
fbind('default_pos_hint', rv.refresh_from_data)
fbind('key_pos_hint', rv.refresh_from_data)
def detach_recycleview(self):
rv = self.recycleview
if rv:
funbind = self.funbind
funbind('default_size', rv.refresh_from_data)
funbind('key_size', rv.refresh_from_data)
funbind('default_size_hint', rv.refresh_from_data)
funbind('key_size_hint', rv.refresh_from_data)
funbind('default_size_hint_min', rv.refresh_from_data)
funbind('key_size_hint_min', rv.refresh_from_data)
funbind('default_size_hint_max', rv.refresh_from_data)
funbind('key_size_hint_max', rv.refresh_from_data)
funbind('default_pos_hint', rv.refresh_from_data)
funbind('key_pos_hint', rv.refresh_from_data)
super(RecycleLayout, self).detach_recycleview()
def _catch_layout_trigger(self, instance=None, value=None):
rv = self.recycleview
if rv is None:
return
idx = self.view_indices.get(instance)
if idx is not None:
if self._size_needs_update:
return
opt = self.view_opts[idx]
if (instance.size == opt['size'] and
instance.size_hint == opt['size_hint'] and
instance.size_hint_min == opt['size_hint_min'] and
instance.size_hint_max == opt['size_hint_max'] and
instance.pos_hint == opt['pos_hint']):
return
self._size_needs_update = True
rv.refresh_from_layout(view_size=True)
else:
rv.refresh_from_layout()
def compute_sizes_from_data(self, data, flags):
if [f for f in flags if not f]:
# at least one changed data unpredictably
self.clear_layout()
opts = self.view_opts = [None for _ in data]
else:
opts = self.view_opts
changed = False
for flag in flags:
for k, v in flag.items():
changed = True
if k == 'removed':
del opts[v]
elif k == 'appended':
opts.extend([None, ] * (v.stop - v.start))
elif k == 'inserted':
opts.insert(v, None)
elif k == 'modified':
start, stop, step = v.start, v.stop, v.step
r = range(start, stop) if step is None else \
range(start, stop, step)
for i in r:
opts[i] = None
else:
raise Exception('Unrecognized data flag {}'.format(k))
if changed:
self.clear_layout()
assert len(data) == len(opts)
ph_key = self.key_pos_hint
ph_def = self.default_pos_hint
sh_key = self.key_size_hint
sh_def = self.default_size_hint
sh_min_key = self.key_size_hint_min
sh_min_def = self.default_size_hint_min
sh_max_key = self.key_size_hint_max
sh_max_def = self.default_size_hint_max
s_key = self.key_size
s_def = self.default_size
viewcls_def = self.viewclass
viewcls_key = self.key_viewclass
iw, ih = self.initial_size
sh = []
for i, item in enumerate(data):
if opts[i] is not None:
continue
ph = ph_def if ph_key is None else item.get(ph_key, ph_def)
ph = item.get('pos_hint', ph)
sh = sh_def if sh_key is None else item.get(sh_key, sh_def)
sh = item.get('size_hint', sh)
sh = [item.get('size_hint_x', sh[0]),
item.get('size_hint_y', sh[1])]
sh_min = sh_min_def if sh_min_key is None else item.get(sh_min_key,
sh_min_def)
sh_min = item.get('size_hint_min', sh_min)
sh_min = [item.get('size_hint_min_x', sh_min[0]),
item.get('size_hint_min_y', sh_min[1])]
sh_max = sh_max_def if sh_max_key is None else item.get(sh_max_key,
sh_max_def)
sh_max = item.get('size_hint_max', sh_max)
sh_max = [item.get('size_hint_max_x', sh_max[0]),
item.get('size_hint_max_y', sh_max[1])]
s = s_def if s_key is None else item.get(s_key, s_def)
s = item.get('size', s)
w, h = s = item.get('width', s[0]), item.get('height', s[1])
viewcls = None
if viewcls_key is not None:
viewcls = item.get(viewcls_key)
if viewcls is not None:
viewcls = getattr(Factory, viewcls)
if viewcls is None:
viewcls = viewcls_def
opts[i] = {
'size': [(iw if w is None else w), (ih if h is None else h)],
'size_hint': sh, 'size_hint_min': sh_min,
'size_hint_max': sh_max, 'pos': None, 'pos_hint': ph,
'viewclass': viewcls, 'width_none': w is None,
'height_none': h is None}
def compute_layout(self, data, flags):
self._size_needs_update = False
opts = self.view_opts
changed = []
for widget, index in self.view_indices.items():
opt = opts[index]
s = opt['size']
w, h = sn = list(widget.size)
sh = opt['size_hint']
shnw, shnh = shn = list(widget.size_hint)
sh_min = opt['size_hint_min']
shn_min = list(widget.size_hint_min)
sh_max = opt['size_hint_max']
shn_max = list(widget.size_hint_max)
ph = opt['pos_hint']
phn = dict(widget.pos_hint)
if s != sn or sh != shn or ph != phn or sh_min != shn_min or \
sh_max != shn_max:
changed.append((index, widget, s, sn, sh, shn, sh_min, shn_min,
sh_max, shn_max, ph, phn))
if shnw is None:
if shnh is None:
opt['size'] = sn
else:
opt['size'] = [w, s[1]]
elif shnh is None:
opt['size'] = [s[0], h]
opt['size_hint'] = shn
opt['size_hint_min'] = shn_min
opt['size_hint_max'] = shn_max
opt['pos_hint'] = phn
if [f for f in flags if not f]: # need to redo everything
self._changed_views = []
else:
self._changed_views = changed if changed else None
def do_layout(self, *largs):
assert False
def set_visible_views(self, indices, data, viewport):
view_opts = self.view_opts
new, remaining, old = self.recycleview.view_adapter.set_visible_views(
indices, data, view_opts)
remove = self.remove_widget
view_indices = self.view_indices
for _, widget in old:
remove(widget)
del view_indices[widget]
# first update the sizing info so that when we update the size
# the widgets are not bound and won't trigger a re-layout
refresh_view_layout = self.refresh_view_layout
for index, widget in new:
# make sure widget is added first so that any sizing updates
# will be recorded
opt = view_opts[index].copy()
del opt['width_none']
del opt['height_none']
refresh_view_layout(index, opt, widget, viewport)
# then add all the visible widgets, which binds size/size_hint
add = self.add_widget
for index, widget in new:
# add to the container if it's not already done
view_indices[widget] = index
if widget.parent is None:
add(widget)
# finally, make sure if the size has changed to cause a re-layout
changed = False
for index, widget in new:
opt = view_opts[index]
if (changed or widget.size == opt['size'] and
widget.size_hint == opt['size_hint'] and
widget.size_hint_min == opt['size_hint_min'] and
widget.size_hint_max == opt['size_hint_max'] and
widget.pos_hint == opt['pos_hint']):
continue
changed = True
if changed:
# we could use LayoutChangeException here, but refresh_views in rv
# needs to be updated to watch for it in the layout phase
self._size_needs_update = True
self.recycleview.refresh_from_layout(view_size=True)
def refresh_view_layout(self, index, layout, view, viewport):
opt = self.view_opts[index].copy()
width_none = opt.pop('width_none')
height_none = opt.pop('height_none')
opt.update(layout)
w, h = opt['size']
shw, shh = opt['size_hint']
if shw is None and width_none:
w = None
if shh is None and height_none:
h = None
opt['size'] = w, h
super(RecycleLayout, self).refresh_view_layout(
index, opt, view, viewport)
def remove_views(self):
super(RecycleLayout, self).remove_views()
self.clear_widgets()
self.view_indices = {}
def remove_view(self, view, index):
super(RecycleLayout, self).remove_view(view, index)
self.remove_widget(view)
del self.view_indices[view]
def clear_layout(self):
super(RecycleLayout, self).clear_layout()
self.clear_widgets()
self.view_indices = {}
self._size_needs_update = False
@@ -0,0 +1,624 @@
"""
RecycleView
===========
.. versionadded:: 1.10.0
The RecycleView provides a flexible model for viewing selected sections of
large data sets. It aims to prevent the performance degradation that can occur
when generating large numbers of widgets in order to display many data items.
.. warning::
Because :class:`RecycleView` reuses widgets, any state change to a single
widget will stay with that widget as it's reused, even if the
:attr:`~RecycleView.data` assigned to it by the :class:`RecycleView`
changes, unless the complete state is tracked in :attr:`~RecycleView.data`
(see below).
The view is generated by processing the :attr:`~RecycleView.data`, essentially
a list of dicts, and uses these dicts to generate instances of the
:attr:`~RecycleView.viewclass` as required. Its design is based on the
MVC (`Model-View-Controller
<https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller>`_)
pattern.
* Model: The model is formed by :attr:`~RecycleView.data` you pass in via a
list of dicts.
* View: The View is split across layout and views and implemented using
adapters.
* Controller: The controller determines the logical interaction and is
implemented by :class:`RecycleViewBehavior`.
These are abstract classes and cannot be used directly. The default concrete
implementations are the
:class:`~kivy.uix.recycleview.datamodel.RecycleDataModel` for the model, the
:class:`~kivy.uix.recyclelayout.RecycleLayout` for the view, and the
:class:`RecycleView` for the controller.
When a RecycleView is instantiated, it automatically creates the views and data
classes. However, one must manually create the layout classes and add them to
the RecycleView.
A layout manager is automatically created as a
:attr:`~RecycleViewBehavior.layout_manager` when added as the child of the
RecycleView. Similarly when removed. A requirement is that the layout manager
must be contained as a child somewhere within the RecycleView's widget tree so
the view port can be found.
A minimal example might look something like this::
from kivy.app import App
from kivy.lang import Builder
from kivy.uix.recycleview import RecycleView
Builder.load_string('''
<RV>:
viewclass: 'Label'
RecycleBoxLayout:
default_size: None, dp(56)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
''')
class RV(RecycleView):
def __init__(self, **kwargs):
super(RV, self).__init__(**kwargs)
self.data = [{'text': str(x)} for x in range(100)]
class TestApp(App):
def build(self):
return RV()
if __name__ == '__main__':
TestApp().run()
In order to support selection in the view, you can add the required behaviors
as follows::
from kivy.app import App
from kivy.lang import Builder
from kivy.uix.recycleview import RecycleView
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.label import Label
from kivy.properties import BooleanProperty
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
Builder.load_string('''
<SelectableLabel>:
# Draw a background to indicate selection
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0, 0, 0, 1)
Rectangle:
pos: self.pos
size: self.size
<RV>:
viewclass: 'SelectableLabel'
SelectableRecycleBoxLayout:
default_size: None, dp(56)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
multiselect: True
touch_multiselect: True
''')
class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
RecycleBoxLayout):
''' Adds selection and focus behavior to the view. '''
class SelectableLabel(RecycleDataViewBehavior, Label):
''' Add selection support to the Label '''
index = None
selected = BooleanProperty(False)
selectable = BooleanProperty(True)
def refresh_view_attrs(self, rv, index, data):
''' Catch and handle the view changes '''
self.index = index
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def on_touch_down(self, touch):
''' Add selection on touch down '''
if super(SelectableLabel, self).on_touch_down(touch):
return True
if self.collide_point(*touch.pos) and self.selectable:
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
''' Respond to the selection of items in the view. '''
self.selected = is_selected
if is_selected:
print("selection changed to {0}".format(rv.data[index]))
else:
print("selection removed for {0}".format(rv.data[index]))
class RV(RecycleView):
def __init__(self, **kwargs):
super(RV, self).__init__(**kwargs)
self.data = [{'text': str(x)} for x in range(100)]
class TestApp(App):
def build(self):
return RV()
if __name__ == '__main__':
TestApp().run()
Please see the `examples/widgets/recycleview/basic_data.py` file for a more
complete example.
Viewclass State
^^^^^^^^^^^^^^^
Because the viewclass widgets are reused or instantiated as needed by the
:class:`RecycleView`, the order and content of the widgets are mutable. So any
state change to a single widget will stay with that widget, even when the data
assigned to it from the :attr:`~RecycleView.data` dict changes, unless
:attr:`~RecycleView.data` tracks those changes or they are manually refreshed
when re-used.
There are two methods for managing state changes in viewclass widgets:
1. Store state in the RecycleView.data Model
2. Generate state changes on-the-fly by catching :attr:`~RecycleView.data`
updates and manually refreshing.
An example::
from kivy.app import App
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.recycleview import RecycleView
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.properties import BooleanProperty, StringProperty
Builder.load_string('''
<StatefulLabel>:
active: stored_state.active
CheckBox:
id: stored_state
active: root.active
on_release: root.store_checkbox_state()
Label:
text: root.text
Label:
id: generate_state
text: root.generated_state_text
<RV>:
viewclass: 'StatefulLabel'
RecycleBoxLayout:
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
''')
class StatefulLabel(RecycleDataViewBehavior, BoxLayout):
text = StringProperty()
generated_state_text = StringProperty()
active = BooleanProperty()
index = 0
'''
To change a viewclass' state as the data assigned to it changes,
overload the refresh_view_attrs function (inherited from
RecycleDataViewBehavior)
'''
def refresh_view_attrs(self, rv, index, data):
self.index = index
if data['text'] == '0':
self.generated_state_text = "is zero"
elif int(data['text']) % 2 == 1:
self.generated_state_text = "is odd"
else:
self.generated_state_text = "is even"
super(StatefulLabel, self).refresh_view_attrs(rv, index, data)
'''
To keep state changes in the viewclass with associated data,
they can be explicitly stored in the RecycleView's data object
'''
def store_checkbox_state(self):
rv = App.get_running_app().rv
rv.data[self.index]['active'] = self.active
class RV(RecycleView, App):
def __init__(self, **kwargs):
super(RV, self).__init__(**kwargs)
self.data = [{'text': str(x), 'active': False} for x in range(10)]
App.get_running_app().rv = self
def build(self):
return self
if __name__ == '__main__':
RV().run()
TODO:
- Method to clear cached class instances.
- Test when views cannot be found (e.g. viewclass is None).
- Fix selection goto.
.. warning::
When views are re-used they may not trigger if the data remains the same.
"""
__all__ = ('RecycleViewBehavior', 'RecycleView')
from copy import deepcopy
from kivy.uix.scrollview import ScrollView
from kivy.properties import AliasProperty
from kivy.clock import Clock
from kivy.uix.recycleview.layout import RecycleLayoutManagerBehavior, \
LayoutChangeException
from kivy.uix.recycleview.views import RecycleDataAdapter
from kivy.uix.recycleview.datamodel import RecycleDataModelBehavior, \
RecycleDataModel
class RecycleViewBehavior(object):
"""RecycleViewBehavior provides a behavioral model upon which the
:class:`RecycleView` is built. Together, they offer an extensible and
flexible way to produce views with limited windows over large data sets.
See the module documentation for more information.
"""
# internals
_view_adapter = None
_data_model = None
_layout_manager = None
_refresh_flags = {'data': [], 'layout': [], 'viewport': False}
_refresh_trigger = None
def __init__(self, **kwargs):
self._refresh_trigger = Clock.create_trigger(self.refresh_views, -1)
self._refresh_flags = deepcopy(self._refresh_flags)
super(RecycleViewBehavior, self).__init__(**kwargs)
def get_viewport(self):
pass
def save_viewport(self):
pass
def restore_viewport(self):
pass
def refresh_views(self, *largs):
lm = self.layout_manager
flags = self._refresh_flags
if lm is None or self.view_adapter is None or self.data_model is None:
return
data = self.data
f = flags['data']
if f:
self.save_viewport()
# lm.clear_layout()
flags['data'] = []
flags['layout'] = [{}]
lm.compute_sizes_from_data(data, f)
while flags['layout']:
# if `data` we were re-triggered so finish in the next call.
# Otherwise go until fully laid out.
self.save_viewport()
if flags['data']:
return
flags['viewport'] = True
f = flags['layout']
flags['layout'] = []
try:
lm.compute_layout(data, f)
except LayoutChangeException:
flags['layout'].append({})
continue
if flags['data']: # in case that happened meanwhile
return
# make sure if we were re-triggered in the loop that we won't be
# called needlessly later.
self._refresh_trigger.cancel()
self.restore_viewport()
if flags['viewport']:
# TODO: make this also listen to LayoutChangeException
flags['viewport'] = False
viewport = self.get_viewport()
indices = lm.compute_visible_views(data, viewport)
lm.set_visible_views(indices, data, viewport)
def refresh_from_data(self, *largs, **kwargs):
"""
This should be called when data changes. Data changes typically
indicate that everything should be recomputed since the source data
changed.
This method is automatically bound to the
:attr:`~RecycleDataModelBehavior.on_data_changed` method of the
:class:`~RecycleDataModelBehavior` class and
therefore responds to and accepts the keyword arguments of that event.
It can be called manually to trigger an update.
"""
self._refresh_flags['data'].append(kwargs)
self._refresh_trigger()
def refresh_from_layout(self, *largs, **kwargs):
"""
This should be called when the layout changes or needs to change. It is
typically called when a layout parameter has changed and therefore the
layout needs to be recomputed.
"""
self._refresh_flags['layout'].append(kwargs)
self._refresh_trigger()
def refresh_from_viewport(self, *largs):
"""
This should be called when the viewport changes and the displayed data
must be updated. Neither the data nor the layout will be recomputed.
"""
self._refresh_flags['viewport'] = True
self._refresh_trigger()
def _dispatch_prop_on_source(self, prop_name, *largs):
# Dispatches the prop of this class when the
# view_adapter/layout_manager property changes.
getattr(self.__class__, prop_name).dispatch(self)
def _get_data_model(self):
return self._data_model
def _set_data_model(self, value):
data_model = self._data_model
if value is data_model:
return
if data_model is not None:
self._data_model = None
data_model.detach_recycleview()
if value is None:
return True
if not isinstance(value, RecycleDataModelBehavior):
raise ValueError(
'Expected object based on RecycleDataModelBehavior, got {}'.
format(value.__class__))
self._data_model = value
value.attach_recycleview(self)
self.refresh_from_data()
return True
data_model = AliasProperty(_get_data_model, _set_data_model)
"""
The Data model responsible for maintaining the data set.
data_model is an :class:`~kivy.properties.AliasProperty` that gets and sets
the current data model.
"""
def _get_view_adapter(self):
return self._view_adapter
def _set_view_adapter(self, value):
view_adapter = self._view_adapter
if value is view_adapter:
return
if view_adapter is not None:
self._view_adapter = None
view_adapter.detach_recycleview()
if value is None:
return True
if not isinstance(value, RecycleDataAdapter):
raise ValueError(
'Expected object based on RecycleAdapter, got {}'.
format(value.__class__))
self._view_adapter = value
value.attach_recycleview(self)
self.refresh_from_layout()
return True
view_adapter = AliasProperty(_get_view_adapter, _set_view_adapter)
"""
The adapter responsible for providing views that represent items in a data
set.
view_adapter is an :class:`~kivy.properties.AliasProperty` that gets and
sets the current view adapter.
"""
def _get_layout_manager(self):
return self._layout_manager
def _set_layout_manager(self, value):
lm = self._layout_manager
if value is lm:
return
if lm is not None:
self._layout_manager = None
lm.detach_recycleview()
if value is None:
return True
if not isinstance(value, RecycleLayoutManagerBehavior):
raise ValueError(
'Expected object based on RecycleLayoutManagerBehavior, '
'got {}'.format(value.__class__))
self._layout_manager = value
value.attach_recycleview(self)
self.refresh_from_layout()
return True
layout_manager = AliasProperty(_get_layout_manager, _set_layout_manager)
"""
The Layout manager responsible for positioning views within the
:class:`RecycleView`.
layout_manager is an :class:`~kivy.properties.AliasProperty` that gets
and sets the layout_manger.
"""
class RecycleView(RecycleViewBehavior, ScrollView):
"""
RecycleView is a flexible view for providing a limited window
into a large data set.
See the module documentation for more information.
"""
def __init__(self, **kwargs):
if self.data_model is None:
kwargs.setdefault('data_model', RecycleDataModel())
if self.view_adapter is None:
kwargs.setdefault('view_adapter', RecycleDataAdapter())
super(RecycleView, self).__init__(**kwargs)
fbind = self.fbind
fbind('scroll_x', self.refresh_from_viewport)
fbind('scroll_y', self.refresh_from_viewport)
fbind('size', self.refresh_from_viewport)
self.refresh_from_data()
def _convert_sv_to_lm(self, x, y):
lm = self.layout_manager
tree = [lm]
parent = lm.parent
while parent is not None and parent is not self:
tree.append(parent)
parent = parent.parent
if parent is not self:
raise Exception(
'The layout manager must be a sub child of the recycleview. '
'Could not find {} in the parent tree of {}'.format(self, lm))
for widget in reversed(tree):
x, y = widget.to_local(x, y)
return x, y
def get_viewport(self):
lm = self.layout_manager
lm_w, lm_h = lm.size
w, h = self.size
scroll_y = min(1, max(self.scroll_y, 0))
scroll_x = min(1, max(self.scroll_x, 0))
if lm_h <= h:
bottom = 0
else:
above = (lm_h - h) * scroll_y
bottom = max(0, lm_h - above - h)
bottom = max(0, (lm_h - h) * scroll_y)
left = max(0, (lm_w - w) * scroll_x)
width = min(w, lm_w)
height = min(h, lm_h)
# now convert the sv coordinates into the coordinates of the lm. In
# case there's a relative layout type widget in the parent tree
# between the sv and the lm.
left, bottom = self._convert_sv_to_lm(left, bottom)
return left, bottom, width, height
def save_viewport(self):
pass
def restore_viewport(self):
pass
def add_widget(self, widget, *args, **kwargs):
super(RecycleView, self).add_widget(widget, *args, **kwargs)
if (isinstance(widget, RecycleLayoutManagerBehavior) and
not self.layout_manager):
self.layout_manager = widget
def remove_widget(self, widget, *args, **kwargs):
super(RecycleView, self).remove_widget(widget, *args, **kwargs)
if self.layout_manager == widget:
self.layout_manager = None
# or easier way to use
def _get_data(self):
d = self.data_model
return d and d.data
def _set_data(self, value):
d = self.data_model
if d is not None:
d.data = value
data = AliasProperty(_get_data, _set_data, bind=["data_model"])
"""
The data used by the current view adapter. This is a list of dicts whose
keys map to the corresponding property names of the
:attr:`~RecycleView.viewclass`.
data is an :class:`~kivy.properties.AliasProperty` that gets and sets the
data used to generate the views.
"""
def _get_viewclass(self):
a = self.layout_manager
return a and a.viewclass
def _set_viewclass(self, value):
a = self.layout_manager
if a:
a.viewclass = value
viewclass = AliasProperty(_get_viewclass, _set_viewclass,
bind=["layout_manager"])
"""
The viewclass used by the current layout_manager.
viewclass is an :class:`~kivy.properties.AliasProperty` that gets and sets
the class used to generate the individual items presented in the view.
"""
def _get_key_viewclass(self):
a = self.layout_manager
return a and a.key_viewclass
def _set_key_viewclass(self, value):
a = self.layout_manager
if a:
a.key_viewclass = value
key_viewclass = AliasProperty(_get_key_viewclass, _set_key_viewclass,
bind=["layout_manager"])
"""
key_viewclass is an :class:`~kivy.properties.AliasProperty` that gets and
sets the key viewclass for the current
:attr:`~kivy.uix.recycleview.layout_manager`.
"""
@@ -0,0 +1,194 @@
'''
RecycleView Data Model
======================
.. versionadded:: 1.10.0
The data model part of the RecycleView model-view-controller pattern.
It defines the models (classes) that store the data associated with a
:class:`~kivy.uix.recycleview.RecycleViewBehavior`. Each model (class)
determines how the data is stored and emits requests to the controller
(:class:`~kivy.uix.recycleview.RecycleViewBehavior`) when the data is
modified.
'''
from kivy.properties import ListProperty, ObservableDict, ObjectProperty
from kivy.event import EventDispatcher
from functools import partial
__all__ = ('RecycleDataModelBehavior', 'RecycleDataModel')
def recondition_slice_assign(val, last_len, new_len):
if not isinstance(val, slice):
return slice(val, val + 1)
diff = new_len - last_len
start, stop, step = val.start, val.stop, val.step
if stop <= start:
return slice(0, 0)
if step is not None and step != 1:
assert last_len == new_len
if stop < 0:
stop = max(0, last_len + stop)
stop = min(last_len, stop)
if start < 0:
start = max(0, last_len + start)
start = min(last_len, start)
return slice(start, stop, step)
if start < 0:
start = last_len + start
if stop < 0:
stop = last_len + stop
# whatever, too complicated don't try to compute it
if (start < 0 or stop < 0 or start > last_len or stop > last_len or
new_len != last_len):
return None
return slice(start, stop)
class RecycleDataModelBehavior(object):
""":class:`RecycleDataModelBehavior` is the base class for the models
that describes and provides the data for the
:class:`~kivy.uix.recycleview.RecycleViewBehavior`.
:Events:
`on_data_changed`:
Fired when the data changes. The event may dispatch
keyword arguments specific to each implementation of the data
model.
When dispatched, the event and keyword arguments are forwarded to
:meth:`~kivy.uix.recycleview.RecycleViewBehavior.\
refresh_from_data`.
"""
__events__ = ("on_data_changed", )
recycleview = ObjectProperty(None, allownone=True)
'''The
:class:`~kivy.uix.recycleview.RecycleViewBehavior` instance
associated with this data model.
'''
def attach_recycleview(self, rv):
'''Associates a
:class:`~kivy.uix.recycleview.RecycleViewBehavior` with
this data model.
'''
self.recycleview = rv
if rv:
self.fbind('on_data_changed', rv.refresh_from_data)
def detach_recycleview(self):
'''Removes the
:class:`~kivy.uix.recycleview.RecycleViewBehavior`
associated with this data model.
'''
rv = self.recycleview
if rv:
self.funbind('on_data_changed', rv.refresh_from_data)
self.recycleview = None
def on_data_changed(self, *largs, **kwargs):
pass
class RecycleDataModel(RecycleDataModelBehavior, EventDispatcher):
'''An implementation of :class:`RecycleDataModelBehavior` that keeps the
data in a indexable list. See :attr:`data`.
When data changes this class currently dispatches `on_data_changed` with
one of the following additional keyword arguments.
`none`: no keyword argument
With no additional argument it means a generic data change.
`removed`: a slice or integer
The value is a slice or integer indicating the indices removed.
`appended`: a slice
The slice in :attr:`data` indicating the first and last new items
(i.e. the slice pointing to the new items added at the end).
`inserted`: a integer
The index in :attr:`data` where a new data item was inserted.
`modified`: a slice
The slice with the indices where the data has been modified.
This currently does not allow changing of size etc.
'''
data = ListProperty([])
'''Stores the model's data using a list.
The data for a item at index `i` can also be accessed with
:class:`RecycleDataModel` `[i]`.
'''
_last_len = 0
def __init__(self, **kwargs):
self.fbind('data', self._on_data_callback)
super(RecycleDataModel, self).__init__(**kwargs)
def __getitem__(self, index):
return self.data[index]
@property
def observable_dict(self):
'''A dictionary instance, which when modified will trigger a `data` and
consequently an `on_data_changed` dispatch.
'''
return partial(ObservableDict, self.__class__.data, self)
def attach_recycleview(self, rv):
super(RecycleDataModel, self).attach_recycleview(rv)
if rv:
self.fbind('data', rv._dispatch_prop_on_source, 'data')
def detach_recycleview(self):
rv = self.recycleview
if rv:
self.funbind('data', rv._dispatch_prop_on_source, 'data')
super(RecycleDataModel, self).detach_recycleview()
def _on_data_callback(self, instance, value):
last_len = self._last_len
new_len = self._last_len = len(self.data)
op, val = value.last_op
if op == '__setitem__':
val = recondition_slice_assign(val, last_len, new_len)
if val is not None:
self.dispatch('on_data_changed', modified=val)
else:
self.dispatch('on_data_changed')
elif op == '__delitem__':
self.dispatch('on_data_changed', removed=val)
elif op == '__setslice__':
val = recondition_slice_assign(slice(*val), last_len, new_len)
if val is not None:
self.dispatch('on_data_changed', modified=val)
else:
self.dispatch('on_data_changed')
elif op == '__delslice__':
self.dispatch('on_data_changed', removed=slice(*val))
elif op == '__iadd__' or op == '__imul__':
self.dispatch('on_data_changed', appended=slice(last_len, new_len))
elif op == 'append':
self.dispatch('on_data_changed', appended=slice(last_len, new_len))
elif op == 'insert':
self.dispatch('on_data_changed', inserted=val)
elif op == 'pop':
if val:
self.dispatch('on_data_changed', removed=val[0])
else:
self.dispatch('on_data_changed', removed=last_len - 1)
elif op == 'extend':
self.dispatch('on_data_changed', appended=slice(last_len, new_len))
else:
self.dispatch('on_data_changed')
@@ -0,0 +1,253 @@
'''
RecycleView Layouts
===================
.. versionadded:: 1.10.0
The Layouts handle the presentation of views for the
:class:`~kivy.uix.recycleview.RecycleView`.
.. warning::
This module is highly experimental, its API may change in the future and
the documentation is not complete at this time.
'''
from kivy.compat import string_types
from kivy.factory import Factory
from kivy.properties import StringProperty, ObjectProperty
from kivy.uix.behaviors import CompoundSelectionBehavior
from kivy.uix.recycleview.views import RecycleDataViewBehavior, \
_view_base_cache
class LayoutChangeException(Exception):
pass
class LayoutSelectionBehavior(CompoundSelectionBehavior):
'''The :class:`LayoutSelectionBehavior` can be combined with
:class:`RecycleLayoutManagerBehavior` to allow its derived classes
selection behaviors similarly to how
:class:`~kivy.uix.behaviors.compoundselection.CompoundSelectionBehavior`
can be used to add selection behaviors to normal layout.
:class:`RecycleLayoutManagerBehavior` manages its children
differently than normal layouts or widgets so this class adapts
:class:`~kivy.uix.behaviors.compoundselection.CompoundSelectionBehavior`
based selection to work with :class:`RecycleLayoutManagerBehavior` as well.
Similarly to
:class:`~kivy.uix.behaviors.compoundselection.CompoundSelectionBehavior`,
one can select using the keyboard or touch, which calls :meth:`select_node`
or :meth:`deselect_node`, or one can call these methods directly. When a
item is selected or deselected :meth:`apply_selection` is called. See
:meth:`apply_selection`.
'''
key_selection = StringProperty(None, allownone=True)
'''The key used to check whether a view of a data item can be selected
with touch or the keyboard.
:attr:`key_selection` is the key in data, which if present and ``True``
will enable selection for this item from the keyboard or with a touch.
When None, the default, not item will be selectable.
:attr:`key_selection` is a :class:`StringProperty` and defaults to None.
.. note::
All data items can be selected directly using :meth:`select_node` or
:meth:`deselect_node`, even if :attr:`key_selection` is False.
'''
_selectable_nodes = []
_nodes_map = {}
def __init__(self, **kwargs):
self.nodes_order_reversed = False
super(LayoutSelectionBehavior, self).__init__(**kwargs)
def compute_sizes_from_data(self, data, flags):
# overwrite this method so that when data changes we update
# selectable nodes.
key = self.key_selection
if key is None:
nodes = self._selectable_nodes = []
else:
nodes = self._selectable_nodes = [
i for i, d in enumerate(data) if d.get(key)]
self._nodes_map = {v: k for k, v in enumerate(nodes)}
return super(LayoutSelectionBehavior, self).compute_sizes_from_data(
data, flags)
def get_selectable_nodes(self):
# the indices of the data is used as the nodes
return self._selectable_nodes
def get_index_of_node(self, node, selectable_nodes):
# the indices of the data is used as the nodes, so node
return self._nodes_map[node]
def goto_node(self, key, last_node, last_node_idx):
node, idx = super(LayoutSelectionBehavior, self).goto_node(
key, last_node, last_node_idx)
if node is not last_node:
self.goto_view(node)
return node, idx
def select_node(self, node):
if super(LayoutSelectionBehavior, self).select_node(node):
view = self.recycleview.view_adapter.get_visible_view(node)
if view is not None:
self.apply_selection(node, view, True)
def deselect_node(self, node):
if super(LayoutSelectionBehavior, self).deselect_node(node):
view = self.recycleview.view_adapter.get_visible_view(node)
if view is not None:
self.apply_selection(node, view, False)
def apply_selection(self, index, view, is_selected):
'''Applies the selection to the view. This is called internally when
a view is displayed and it needs to be shown as selected or as not
selected.
It is called when :meth:`select_node` or :meth:`deselect_node` is
called or when a view needs to be refreshed. Its function is purely to
update the view to reflect the selection state. So the function may be
called multiple times even if the selection state may not have changed.
If the view is a instance of
:class:`~kivy.uix.recycleview.views.RecycleDataViewBehavior`, its
:meth:`~kivy.uix.recycleview.views.RecycleDataViewBehavior.\
apply_selection` method will be called every time the view needs to refresh
the selection state. Otherwise, the this method is responsible
for applying the selection.
:Parameters:
`index`: int
The index of the data item that is associated with the view.
`view`: widget
The widget that is the view of this data item.
`is_selected`: bool
Whether the item is selected.
'''
viewclass = view.__class__
if viewclass not in _view_base_cache:
_view_base_cache[viewclass] = isinstance(view,
RecycleDataViewBehavior)
if _view_base_cache[viewclass]:
view.apply_selection(self.recycleview, index, is_selected)
def refresh_view_layout(self, index, layout, view, viewport):
super(LayoutSelectionBehavior, self).refresh_view_layout(
index, layout, view, viewport)
self.apply_selection(index, view, index in self.selected_nodes)
class RecycleLayoutManagerBehavior(object):
"""A RecycleLayoutManagerBehavior is responsible for positioning views into
the :attr:`RecycleView.data` within a :class:`RecycleView`. It adds new
views into the data when it becomes visible to the user, and removes them
when they leave the visible area.
"""
viewclass = ObjectProperty(None)
'''See :attr:`RecyclerView.viewclass`.
'''
key_viewclass = StringProperty(None)
'''See :attr:`RecyclerView.key_viewclass`.
'''
recycleview = ObjectProperty(None, allownone=True)
asked_sizes = None
def attach_recycleview(self, rv):
self.recycleview = rv
if rv:
fbind = self.fbind
# can be made more selective update than refresh_from_data which
# causes a full update. But this likely affects most of the data.
fbind('viewclass', rv.refresh_from_data)
fbind('key_viewclass', rv.refresh_from_data)
fbind('viewclass', rv._dispatch_prop_on_source, 'viewclass')
fbind('key_viewclass', rv._dispatch_prop_on_source,
'key_viewclass')
def detach_recycleview(self):
self.clear_layout()
rv = self.recycleview
if rv:
funbind = self.funbind
funbind('viewclass', rv.refresh_from_data)
funbind('key_viewclass', rv.refresh_from_data)
funbind('viewclass', rv._dispatch_prop_on_source, 'viewclass')
funbind('key_viewclass', rv._dispatch_prop_on_source,
'key_viewclass')
self.recycleview = None
def compute_sizes_from_data(self, data, flags):
pass
def compute_layout(self, data, flags):
pass
def compute_visible_views(self, data, viewport):
'''`viewport` is in coordinates of the layout manager.
'''
pass
def set_visible_views(self, indices, data, viewport):
'''`viewport` is in coordinates of the layout manager.
'''
pass
def refresh_view_layout(self, index, layout, view, viewport):
'''`See :meth:`~kivy.uix.recycleview.views.RecycleDataAdapter.\
refresh_view_layout`.
'''
self.recycleview.view_adapter.refresh_view_layout(
index, layout, view, viewport)
def get_view_index_at(self, pos):
"""Return the view `index` on which position, `pos`, falls.
`pos` is in coordinates of the layout manager.
"""
pass
def remove_views(self):
rv = self.recycleview
if rv:
adapter = rv.view_adapter
if adapter:
adapter.make_views_dirty()
def remove_view(self, view, index):
rv = self.recycleview
if rv:
adapter = rv.view_adapter
if adapter:
adapter.make_view_dirty(view, index)
def clear_layout(self):
rv = self.recycleview
if rv:
adapter = rv.view_adapter
if adapter:
adapter.invalidate()
def goto_view(self, index):
'''Moves the views so that the view corresponding to `index` is
visible.
'''
pass
def on_viewclass(self, instance, value):
# resolve the real class if it was a string.
if isinstance(value, string_types):
self.viewclass = getattr(Factory, value)
@@ -0,0 +1,423 @@
'''
RecycleView Views
=================
.. versionadded:: 1.10.0
The adapter part of the RecycleView which together with the layout is the
view part of the model-view-controller pattern.
The view module handles converting the data to a view using the adapter class
which is then displayed by the layout. A view can be any Widget based class.
However, inheriting from RecycleDataViewBehavior adds methods for converting
the data to a view.
TODO:
* Make view caches specific to each view class type.
'''
from kivy.properties import ObjectProperty
from kivy.event import EventDispatcher
from collections import defaultdict
__all__ = (
'RecycleDataViewBehavior', 'RecycleKVIDsDataViewBehavior',
'RecycleDataAdapter')
_view_base_cache = {}
'''Cache whose keys are classes and values is a boolean indicating whether the
class inherits from :class:`RecycleDataViewBehavior`.
'''
_cached_views = defaultdict(list)
'''A size limited cache that contains old views (instances) that are not used.
Each key is a class whose value is the list of the instances of that class.
'''
# current number of unused classes in the class cache
_cache_count = 0
# maximum number of items in the class cache
_max_cache_size = 1000
def _clean_cache():
'''Trims _cached_views cache to half the size of `_max_cache_size`.
'''
# all keys will be reduced to max_size.
max_size = (_max_cache_size // 2) // len(_cached_views)
global _cache_count
for cls, instances in _cached_views.items():
_cache_count -= max(0, len(instances) - max_size)
del instances[max_size:]
class RecycleDataViewBehavior(object):
'''A optional base class for data views (:attr:`RecycleView`.viewclass).
If a view inherits from this class, the class's functions will be called
when the view needs to be updated due to a data change or layout update.
'''
def refresh_view_attrs(self, rv, index, data):
'''Called by the :class:`RecycleAdapter` when the view is initially
populated with the values from the `data` dictionary for this item.
Any pos or size info should be removed because they are set
subsequently with :attr:`refresh_view_layout`.
:Parameters:
`rv`: :class:`RecycleView` instance
The :class:`RecycleView` that caused the update.
`data`: dict
The data dict used to populate this view.
'''
sizing_attrs = RecycleDataAdapter._sizing_attrs
for key, value in data.items():
if key not in sizing_attrs:
setattr(self, key, value)
def refresh_view_layout(self, rv, index, layout, viewport):
'''Called when the view's size is updated by the layout manager,
:class:`RecycleLayoutManagerBehavior`.
:Parameters:
`rv`: :class:`RecycleView` instance
The :class:`RecycleView` that caused the update.
`viewport`: 4-tuple
The coordinates of the bottom left and width height in layout
manager coordinates. This may be larger than this view item.
:raises:
`LayoutChangeException`: If the sizing or data changed during a
call to this method, raising a `LayoutChangeException` exception
will force a refresh. Useful when data changed and we don't want
to layout further since it'll be overwritten again soon.
'''
w, h = layout.pop('size')
if w is None:
if h is not None:
self.height = h
else:
if h is None:
self.width = w
else:
self.size = w, h
for name, value in layout.items():
setattr(self, name, value)
def apply_selection(self, rv, index, is_selected):
pass
class RecycleKVIDsDataViewBehavior(RecycleDataViewBehavior):
"""Similar to :class:`RecycleDataViewBehavior`, except that the data keys
can signify properties of an object named with an id in the root KV rule.
E.g. given a KV rule::
<MyRule@RecycleKVIDsDataViewBehavior+BoxLayout>:
Label:
id: name
Label:
id: value
Then setting the data list with
``rv.data = [{'name.text': 'Kivy user', 'value.text': '12'}]`` would
automatically set the corresponding labels.
So, if the key doesn't have a period, the named property of the root widget
will be set to the corresponding value. If there is a period, the named
property of the widget with the id listed before the period will be set to
the corresponding value.
.. versionadded:: 2.0.0
"""
def refresh_view_attrs(self, rv, index, data):
sizing_attrs = RecycleDataAdapter._sizing_attrs
for key, value in data.items():
if key not in sizing_attrs:
name, *ids = key.split('.')
if ids:
if len(ids) != 1:
raise ValueError(
f'Data key "{key}" has more than one period')
setattr(self.ids[name], ids[0], value)
else:
setattr(self, name, value)
class RecycleDataAdapter(EventDispatcher):
'''The class that converts data to a view.
--- Internal details ---
A view can have 3 states.
* It can be completely in sync with the data, which
occurs when the view is displayed. These are stored in :attr:`views`.
* It can be dirty, which occurs when the view is in sync with the data,
except for the size/pos parameters which is controlled by the layout.
This occurs when the view is not currently displayed but the data has
not changed. These views are stored in :attr:`dirty_views`.
* Finally the view can be dead which occurs when the data changes and
the view was not updated or when a view is just created. Such views
are typically added to the internal cache.
Typically what happens is that the layout manager lays out the data
and then asks for views, using :meth:`set_visible_views`, for some specific
data items that it displays.
These views are gotten from the current views, dirty or global cache. Then
depending on the view state :meth:`refresh_view_attrs` is called to bring
the view up to date with the data (except for sizing parameters). Finally,
the layout manager gets these views, updates their size and displays them.
'''
recycleview = ObjectProperty(None, allownone=True)
'''The :class:`~kivy.uix.recycleview.RecycleViewBehavior` associated
with this instance.
'''
# internals
views = {} # current displayed items
# items whose attrs, except for pos/size is still accurate
dirty_views = defaultdict(dict)
_sizing_attrs = {
'size', 'width', 'height', 'size_hint', 'size_hint_x', 'size_hint_y',
'pos', 'x', 'y', 'center', 'center_x', 'center_y', 'pos_hint',
'size_hint_min', 'size_hint_min_x', 'size_hint_min_y', 'size_hint_max',
'size_hint_max_x', 'size_hint_max_y'}
def __init__(self, **kwargs):
"""
Fix for issue https://github.com/kivy/kivy/issues/5913:
Scrolling RV A, then Scrolling RV B, content of A and B seemed
to be getting mixed up
"""
self.views = {}
self.dirty_views = defaultdict(dict)
super(RecycleDataAdapter, self).__init__(**kwargs)
def attach_recycleview(self, rv):
'''Associates a :class:`~kivy.uix.recycleview.RecycleViewBehavior`
with this instance. It is stored in :attr:`recycleview`.
'''
self.recycleview = rv
def detach_recycleview(self):
'''Removes the :class:`~kivy.uix.recycleview.RecycleViewBehavior`
associated with this instance and clears :attr:`recycleview`.
'''
self.recycleview = None
def create_view(self, index, data_item, viewclass):
'''(internal) Creates and initializes the view for the data at `index`.
The returned view is synced with the data, except for the pos/size
information.
'''
if viewclass is None:
return
view = viewclass()
self.refresh_view_attrs(index, data_item, view)
return view
def get_view(self, index, data_item, viewclass):
'''(internal) Returns a view instance for the data at `index`
It looks through the various caches and finally creates a view if it
doesn't exist. The returned view is synced with the data, except for
the pos/size information.
If found in the cache it's removed from the source
before returning. It doesn't check the current views.
'''
# is it in the dirtied views?
dirty_views = self.dirty_views
if viewclass is None:
return
stale = False
view = None
if viewclass in dirty_views: # get it first from dirty list
dirty_class = dirty_views[viewclass]
if index in dirty_class:
# we found ourself in the dirty list, no need to update data!
view = dirty_class.pop(index)
elif _cached_views[viewclass]:
# global cache has this class, update data
view, stale = _cached_views[viewclass].pop(), True
elif dirty_class:
# random any dirty view element - update data
view, stale = dirty_class.popitem()[1], True
elif _cached_views[viewclass]: # otherwise go directly to cache
# global cache has this class, update data
view, stale = _cached_views[viewclass].pop(), True
if view is None:
view = self.create_view(index, data_item, viewclass)
if view is None:
return
if stale:
self.refresh_view_attrs(index, data_item, view)
return view
def refresh_view_attrs(self, index, data_item, view):
'''(internal) Syncs the view and brings it up to date with the data.
This method calls :meth:`RecycleDataViewBehavior.refresh_view_attrs`
if the view inherits from :class:`RecycleDataViewBehavior`. See that
method for more details.
.. note::
Any sizing and position info is skipped when syncing with the data.
'''
viewclass = view.__class__
if viewclass not in _view_base_cache:
_view_base_cache[viewclass] = isinstance(view,
RecycleDataViewBehavior)
if _view_base_cache[viewclass]:
view.refresh_view_attrs(self.recycleview, index, data_item)
else:
sizing_attrs = RecycleDataAdapter._sizing_attrs
for key, value in data_item.items():
if key not in sizing_attrs:
setattr(view, key, value)
def refresh_view_layout(self, index, layout, view, viewport):
'''Updates the sizing information of the view.
viewport is in coordinates of the layout manager.
This method calls :meth:`RecycleDataViewBehavior.refresh_view_attrs`
if the view inherits from :class:`RecycleDataViewBehavior`. See that
method for more details.
.. note::
Any sizing and position info is skipped when syncing with the data.
'''
if view.__class__ not in _view_base_cache:
_view_base_cache[view.__class__] = isinstance(
view, RecycleDataViewBehavior)
if _view_base_cache[view.__class__]:
view.refresh_view_layout(
self.recycleview, index, layout, viewport)
else:
w, h = layout.pop('size')
if w is None:
if h is not None:
view.height = h
else:
if h is None:
view.width = w
else:
view.size = w, h
for name, value in layout.items():
setattr(view, name, value)
def make_view_dirty(self, view, index):
'''(internal) Used to flag this view as dirty, ready to be used for
others. See :meth:`make_views_dirty`.
'''
del self.views[index]
self.dirty_views[view.__class__][index] = view
def make_views_dirty(self):
'''Makes all the current views dirty.
Dirty views are still in sync with the corresponding data. However, the
size information may go out of sync. Therefore a dirty view can be
reused by the same index by just updating the sizing information.
Once the underlying data of this index changes, the view should be
removed from the dirty views and moved to the global cache with
:meth:`invalidate`.
This is typically called when the layout manager needs to re-layout all
the data.
'''
views = self.views
if not views:
return
dirty_views = self.dirty_views
for index, view in views.items():
dirty_views[view.__class__][index] = view
self.views = {}
def invalidate(self):
'''Moves all the current views into the global cache.
As opposed to making a view dirty where the view is in sync with the
data except for sizing information, this will completely disconnect the
view from the data, as it is assumed the data has gone out of sync with
the view.
This is typically called when the data changes.
'''
global _cache_count
for view in self.views.values():
_cached_views[view.__class__].append(view)
_cache_count += 1
for cls, views in self.dirty_views.items():
_cached_views[cls].extend(views.values())
_cache_count += len(views)
if _cache_count >= _max_cache_size:
_clean_cache()
self.views = {}
self.dirty_views.clear()
def set_visible_views(self, indices, data, viewclasses):
'''Gets a 3-tuple of the new, remaining, and old views for the current
viewport.
The new views are synced to the data except for the size/pos
properties.
The old views need to be removed from the layout, and the new views
added.
The new views are not necessarily *new*, but are all the currently
visible views.
'''
visible_views = {}
previous_views = self.views
ret_new = []
ret_remain = []
get_view = self.get_view
# iterate though the visible view
# add them into the container if not already done
for index in indices:
view = previous_views.pop(index, None)
if view is not None: # was current view
visible_views[index] = view
ret_remain.append((index, view))
else:
view = get_view(index, data[index],
viewclasses[index]['viewclass'])
if view is None:
continue
visible_views[index] = view
ret_new.append((index, view))
old_views = previous_views.items()
self.make_views_dirty()
self.views = visible_views
return ret_new, ret_remain, old_views
def get_visible_view(self, index):
'''Returns the currently visible view associated with ``index``.
If no view is currently displayed for ``index`` it returns ``None``.
'''
return self.views.get(index)
@@ -0,0 +1,324 @@
'''
Relative Layout
===============
.. versionadded:: 1.4.0
This layout allows you to set relative coordinates for children. If you want
absolute positioning, use the :class:`~kivy.uix.floatlayout.FloatLayout`.
The :class:`RelativeLayout` class behaves just like the regular
:class:`FloatLayout` except that its child widgets are positioned relative to
the layout.
When a widget with position = (0,0) is added to a RelativeLayout,
the child widget will also move when the position of the RelativeLayout
is changed. The child widgets coordinates remain (0,0) as they are
always relative to the parent layout.
Coordinate Systems
------------------
Window coordinates
~~~~~~~~~~~~~~~~~~
By default, there's only one coordinate system that defines the position of
widgets and touch events dispatched to them: the window coordinate system,
which places (0, 0) at the bottom left corner of the window.
Although there are other coordinate systems defined, e.g. local
and parent coordinates, these coordinate systems are identical to the window
coordinate system as long as a relative layout type widget is not in the
widget's parent stack. When widget.pos is read or a touch is received,
the coordinate values are in parent coordinates. But as mentioned, these are
identical to window coordinates, even in complex widget stacks as long as
there's no relative layout type widget in the widget's parent stack.
For example:
.. code-block:: kv
BoxLayout:
Label:
text: 'Left'
Button:
text: 'Middle'
on_touch_down: print('Middle: {}'.format(args[1].pos))
BoxLayout:
on_touch_down: print('Box: {}'.format(args[1].pos))
Button:
text: 'Right'
on_touch_down: print('Right: {}'.format(args[1].pos))
When the middle button is clicked and the touch propagates through the
different parent coordinate systems, it prints the following::
>>> Box: (430.0, 282.0)
>>> Right: (430.0, 282.0)
>>> Middle: (430.0, 282.0)
As claimed, the touch has identical coordinates to the window coordinates
in every coordinate system. :meth:`~kivy.uix.widget.Widget.collide_point`
for example, takes the point in window coordinates.
Parent coordinates
~~~~~~~~~~~~~~~~~~
Other :class:`RelativeLayout` type widgets are
:class:`~kivy.uix.scatter.Scatter`,
:class:`~kivy.uix.scatterlayout.ScatterLayout`,
and :class:`~kivy.uix.scrollview.ScrollView`. If such a special widget is in
the parent stack, only then does the parent and local coordinate system
diverge from the window coordinate system. For each such widget in the stack,
a coordinate system with (0, 0) of that coordinate system being at the bottom
left corner of that widget is created. **Position and touch coordinates
received and read by a widget are in the coordinate system of the most
recent special widget in its parent stack (not including itself) or in window
coordinates if there are none** (as in the first example). We call these
coordinates parent coordinates.
For example:
.. code-block:: kv
BoxLayout:
Label:
text: 'Left'
Button:
text: 'Middle'
on_touch_down: print('Middle: {}'.format(args[1].pos))
RelativeLayout:
on_touch_down: print('Relative: {}'.format(args[1].pos))
Button:
text: 'Right'
on_touch_down: print('Right: {}'.format(args[1].pos))
Clicking on the middle button prints::
>>> Relative: (396.0, 298.0)
>>> Right: (-137.33, 298.0)
>>> Middle: (396.0, 298.0)
As the touch propagates through the widgets, for each widget, the
touch is received in parent coordinates. Because both the relative and middle
widgets don't have these special widgets in their parent stack, the touch is
the same as window coordinates. Only the right widget, which has a
RelativeLayout in its parent stack, receives the touch in coordinates relative
to that RelativeLayout which is different than window coordinates.
Local and Widget coordinates
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When expressed in parent coordinates, the position is expressed in the
coordinates of the most recent special widget in its parent stack, not
including itself. When expressed in local or widget coordinates, the widgets
themselves are also included.
Changing the above example to transform the parent coordinates into local
coordinates:
.. code-block:: kv
BoxLayout:
Label:
text: 'Left'
Button:
text: 'Middle'
on_touch_down: print('Middle: {}'.format(\
self.to_local(*args[1].pos)))
RelativeLayout:
on_touch_down: print('Relative: {}'.format(\
self.to_local(*args[1].pos)))
Button:
text: 'Right'
on_touch_down: print('Right: {}'.format(\
self.to_local(*args[1].pos)))
Now, clicking on the middle button prints::
>>> Relative: (-135.33, 301.0)
>>> Right: (-135.33, 301.0)
>>> Middle: (398.0, 301.0)
This is because now the relative widget also expresses the coordinates
relative to itself.
.. note::
Although all widgets including :class:`RelativeLayout` receive their touch
events in ``on_touch_xxx`` in parent coordinates, these special widgets
will transform the touch position to be in local coordinates before it
calls ``super``. This may only be noticeable in a complex inheritance
class.
Coordinate transformations
~~~~~~~~~~~~~~~~~~~~~~~~~~
:class:`~kivy.uix.widget.Widget` provides 4 functions to transform coordinates
between the various coordinate systems. For now, we assume that the `relative`
keyword of these functions is `False`.
:meth:`~kivy.uix.widget.Widget.to_widget` takes the coordinates expressed in
window coordinates and returns them in local (widget) coordinates.
:meth:`~kivy.uix.widget.Widget.to_window` takes the coordinates expressed in
local coordinates and returns them in window coordinates.
:meth:`~kivy.uix.widget.Widget.to_parent` takes the coordinates expressed in
local coordinates and returns them in parent coordinates.
:meth:`~kivy.uix.widget.Widget.to_local` takes the coordinates expressed in
parent coordinates and returns them in local coordinates.
Each of the 4 transformation functions take a `relative` parameter. When the
relative parameter is True, the coordinates are returned or originate in
true relative coordinates - relative to a coordinate system with its (0, 0) at
the bottom left corner of the widget in question.
.. _kivy-uix-relativelayout-common-pitfalls:
Common Pitfalls
---------------
As all positions within a :class:`RelativeLayout` are relative to the position
of the layout itself, the position of the layout should never be used in
determining the position of sub-widgets or the layout's :attr:`canvas`.
Take the following kv code for example:
.. container:: align-right
.. figure:: images/relativelayout-fixedposition.png
:scale: 50%
expected result
.. figure:: images/relativelayout-doubleposition.png
:scale: 50%
actual result
.. code-block:: kv
FloatLayout:
Widget:
size_hint: None, None
size: 200, 200
pos: 200, 200
canvas:
Color:
rgba: 1, 1, 1, 1
Rectangle:
pos: self.pos
size: self.size
RelativeLayout:
size_hint: None, None
size: 200, 200
pos: 200, 200
canvas:
Color:
rgba: 1, 0, 0, 0.5
Rectangle:
pos: self.pos # incorrect
size: self.size
You might expect this to render a single pink rectangle; however, the content
of the :class:`RelativeLayout` is already transformed, so the use of
`pos: self.pos` will double that transformation. In this case, using
`pos: 0, 0` or omitting `pos` completely will provide the expected result.
This also applies to the position of sub-widgets. Instead of positioning a
:class:`~kivy.uix.widget.Widget` based on the layout's own position:
.. code-block:: kv
RelativeLayout:
Widget:
pos: self.parent.pos
Widget:
center: self.parent.center
use the :attr:`pos_hint` property:
.. code-block:: kv
RelativeLayout:
Widget:
pos_hint: {'x': 0, 'y': 0}
Widget:
pos_hint: {'center_x': 0.5, 'center_y': 0.5}
.. versionchanged:: 1.7.0
Prior to version 1.7.0, the :class:`RelativeLayout` was implemented as a
:class:`~kivy.uix.floatlayout.FloatLayout` inside a
:class:`~kivy.uix.scatter.Scatter`. This behavior/widget has
been renamed to `ScatterLayout`. The :class:`RelativeLayout` now only
supports relative positions (and can't be rotated, scaled or translated on
a multitouch system using two or more fingers). This was done so that the
implementation could be optimized and avoid the heavier calculations of
:class:`Scatter` (e.g. inverse matrix, recalculating multiple properties
etc.)
'''
__all__ = ('RelativeLayout', )
from kivy.uix.floatlayout import FloatLayout
class RelativeLayout(FloatLayout):
'''RelativeLayout class, see module documentation for more information.
'''
def __init__(self, **kw):
super(RelativeLayout, self).__init__(**kw)
funbind = self.funbind
trigger = self._trigger_layout
funbind('pos', trigger)
funbind('pos_hint', trigger)
def do_layout(self, *args):
super(RelativeLayout, self).do_layout(pos=(0, 0))
def to_parent(self, x, y, **k):
return (x + self.x, y + self.y)
def to_local(self, x, y, **k):
return (x - self.x, y - self.y)
def _apply_transform(self, m, pos=None):
m.translate(self.x, self.y, 0)
return super(RelativeLayout, self)._apply_transform(m, (0, 0))
def on_motion(self, etype, me):
if me.type_id in self.motion_filter and 'pos' in me.profile:
me.push()
me.apply_transform_2d(self.to_local)
ret = super().on_motion(etype, me)
me.pop()
return ret
return super().on_motion(etype, me)
def on_touch_down(self, touch):
x, y = touch.x, touch.y
touch.push()
touch.apply_transform_2d(self.to_local)
ret = super(RelativeLayout, self).on_touch_down(touch)
touch.pop()
return ret
def on_touch_move(self, touch):
x, y = touch.x, touch.y
touch.push()
touch.apply_transform_2d(self.to_local)
ret = super(RelativeLayout, self).on_touch_move(touch)
touch.pop()
return ret
def on_touch_up(self, touch):
x, y = touch.x, touch.y
touch.push()
touch.apply_transform_2d(self.to_local)
ret = super(RelativeLayout, self).on_touch_up(touch)
touch.pop()
return ret
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
'''
Sandbox
=======
.. versionadded:: 1.8.0
.. warning::
This is experimental and subject to change as long as this warning notice
is present.
This is a widget that runs itself and all of its children in a Sandbox. That
means if a child raises an Exception, it will be caught. The Sandbox
itself runs its own Clock, Cache, etc.
The SandBox widget is still experimental and required for the Kivy designer.
When the user designs their own widget, if they do something wrong (wrong size
value, invalid python code), it will be caught correctly without breaking
the whole application. Because it has been designed that way, we are still
enhancing this widget and the :mod:`kivy.context` module.
Don't use it unless you know what you are doing.
'''
__all__ = ('Sandbox', )
from functools import wraps
from kivy.context import Context
from kivy.base import ExceptionManagerBase
from kivy.clock import Clock
from kivy.uix.widget import Widget
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.relativelayout import RelativeLayout
from kivy.lang import Builder
def sandbox(f):
@wraps(f)
def _f2(self, *args, **kwargs):
ret = None
with self:
ret = f(self, *args, **kwargs)
return ret
return _f2
class SandboxExceptionManager(ExceptionManagerBase):
def __init__(self, sandbox):
ExceptionManagerBase.__init__(self)
self.sandbox = sandbox
def handle_exception(self, e):
if not self.sandbox.on_exception(e):
return ExceptionManagerBase.RAISE
return ExceptionManagerBase.PASS
class SandboxContent(RelativeLayout):
pass
class Sandbox(FloatLayout):
'''Sandbox widget, used to trap all the exceptions raised by child
widgets.
'''
def __init__(self, **kwargs):
self._context = Context(init=True)
self._context['ExceptionManager'] = SandboxExceptionManager(self)
self._context.sandbox = self
self._context.push()
self.on_context_created()
self._container = None
super(Sandbox, self).__init__(**kwargs)
self._container = SandboxContent(size=self.size, pos=self.pos)
super(Sandbox, self).add_widget(self._container)
self._context.pop()
# force SandboxClock's scheduling
Clock.schedule_interval(self._clock_sandbox, 0)
Clock.schedule_once(self._clock_sandbox_draw, -1)
self.main_clock = object.__getattribute__(Clock, '_obj')
def __enter__(self):
self._context.push()
def __exit__(self, _type, value, traceback):
self._context.pop()
if _type is not None:
return self.on_exception(value, _traceback=traceback)
def on_context_created(self):
'''Override this method in order to load your kv file or do anything
else with the newly created context.
'''
pass
def on_exception(self, exception, _traceback=None):
'''Override this method in order to catch all the exceptions from
children.
If you return True, it will not reraise the exception.
If you return False, the exception will be raised to the parent.
'''
import traceback
traceback.print_tb(_traceback)
return True
on_motion = sandbox(Widget.on_motion)
on_touch_down = sandbox(Widget.on_touch_down)
on_touch_move = sandbox(Widget.on_touch_move)
on_touch_up = sandbox(Widget.on_touch_up)
@sandbox
def add_widget(self, *args, **kwargs):
self._container.add_widget(*args, **kwargs)
@sandbox
def remove_widget(self, *args, **kwargs):
self._container.remove_widget(*args, **kwargs)
@sandbox
def clear_widgets(self, *args, **kwargs):
self._container.clear_widgets(*args, **kwargs)
@sandbox
def on_size(self, *args):
if self._container:
self._container.size = self.size
@sandbox
def on_pos(self, *args):
if self._container:
self._container.pos = self.pos
@sandbox
def _clock_sandbox(self, dt):
# import pdb; pdb.set_trace()
Clock.tick()
Builder.sync()
@sandbox
def _clock_sandbox_draw(self, dt):
Clock.tick_draw()
Builder.sync()
self.main_clock.schedule_once(self._call_draw, 0)
def _call_draw(self, dt):
self.main_clock.schedule_once(self._clock_sandbox_draw, -1)
if __name__ == '__main__':
from kivy.base import runTouchApp
from kivy.uix.button import Button
class TestButton(Button):
def on_touch_up(self, touch):
# raise Exception('fdfdfdfdfdfdfd')
return super(TestButton, self).on_touch_up(touch)
def on_touch_down(self, touch):
# raise Exception('')
return super(TestButton, self).on_touch_down(touch)
s = Sandbox()
with s:
Builder.load_string('''
<TestButton>:
canvas:
Color:
rgb: (.3, .2, 0) if self.state == 'normal' else (.7, .7, 0)
Rectangle:
pos: self.pos
size: self.size
Color:
rgb: 1, 1, 1
Rectangle:
size: self.texture_size
pos: self.center_x - self.texture_size[0] / 2.,\
self.center_y - self.texture_size[1] / 2.
texture: self.texture
# invalid... for testing.
# on_touch_up: root.d()
# on_touch_down: root.f()
on_release: root.args()
# on_press: root.args()
''')
b = TestButton(text='Hello World')
s.add_widget(b)
# this exception is within the "with" block, but will be ignored by
# default because the sandbox on_exception will return True
raise Exception('hello')
runTouchApp(s)
+645
View File
@@ -0,0 +1,645 @@
'''
Scatter
=======
.. image:: images/scatter.gif
:align: right
:class:`Scatter` is used to build interactive widgets that can be translated,
rotated and scaled with two or more fingers on a multitouch system.
Scatter has its own matrix transformation: the modelview matrix is changed
before the children are drawn and the previous matrix is restored when the
drawing is finished. That makes it possible to perform rotation, scaling and
translation over the entire children tree without changing any widget
properties. That specific behavior makes the scatter unique, but there are some
advantages / constraints that you should consider:
#. The children are positioned relative to the scatter similarly to a
:mod:`~kivy.uix.relativelayout.RelativeLayout`. So when dragging the
scatter, the position of the children don't change, only the position of
the scatter does.
#. The scatter size has no impact on the size of its children.
#. If you want to resize the scatter, use scale, not size (read #2). Scale
transforms both the scatter and its children, but does not change size.
#. The scatter is not a layout. You must manage the size of the children
yourself.
For touch events, the scatter converts from the parent matrix to the scatter
matrix automatically in on_touch_down/move/up events. If you are doing things
manually, you will need to use :meth:`~kivy.uix.widget.Widget.to_parent` and
:meth:`~kivy.uix.widget.Widget.to_local`.
Usage
-----
By default, the Scatter does not have a graphical representation: it is a
container only. The idea is to combine the Scatter with another widget, for
example an :class:`~kivy.uix.image.Image`::
scatter = Scatter()
image = Image(source='sun.jpg')
scatter.add_widget(image)
Control Interactions
--------------------
By default, all interactions are enabled. You can selectively disable
them using the do_rotation, do_translation and do_scale properties.
Disable rotation::
scatter = Scatter(do_rotation=False)
Allow only translation::
scatter = Scatter(do_rotation=False, do_scale=False)
Allow only translation on x axis::
scatter = Scatter(do_rotation=False, do_scale=False,
do_translation_y=False)
Automatic Bring to Front
------------------------
If the :attr:`Scatter.auto_bring_to_front` property is True, the scatter
widget will be removed and re-added to the parent when it is touched
(brought to front, above all other widgets in the parent). This is useful
when you are manipulating several scatter widgets and don't want the active
one to be partially hidden.
Scale Limitation
----------------
We are using a 32-bit matrix in double representation. That means we have
a limit for scaling. You cannot do infinite scaling down/up with our
implementation. Generally, you don't hit the minimum scale (because you don't
see it on the screen), but the maximum scale is 9.99506983235e+19 (2^66).
You can also limit the minimum and maximum scale allowed::
scatter = Scatter(scale_min=.5, scale_max=3.)
Behavior
--------
.. versionchanged:: 1.1.0
If no control interactions are enabled, then the touch handler will never
return True.
'''
__all__ = ('Scatter', 'ScatterPlane')
from math import radians
from kivy.properties import BooleanProperty, AliasProperty, \
NumericProperty, ObjectProperty, BoundedNumericProperty
from kivy.vector import Vector
from kivy.uix.widget import Widget
from kivy.graphics.transformation import Matrix
class Scatter(Widget):
'''Scatter class. See module documentation for more information.
:Events:
`on_transform_with_touch`:
Fired when the scatter has been transformed by user touch
or multitouch, such as panning or zooming.
`on_bring_to_front`:
Fired when the scatter is brought to the front.
.. versionchanged:: 1.9.0
Event `on_bring_to_front` added.
.. versionchanged:: 1.8.0
Event `on_transform_with_touch` added.
'''
__events__ = ('on_transform_with_touch', 'on_bring_to_front')
auto_bring_to_front = BooleanProperty(True)
'''If True, the widget will be automatically pushed on the top of parent
widget list for drawing.
:attr:`auto_bring_to_front` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True.
'''
do_translation_x = BooleanProperty(True)
'''Allow translation on the X axis.
:attr:`do_translation_x` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
do_translation_y = BooleanProperty(True)
'''Allow translation on Y axis.
:attr:`do_translation_y` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
def _get_do_translation(self):
return (self.do_translation_x, self.do_translation_y)
def _set_do_translation(self, value):
if type(value) in (list, tuple):
self.do_translation_x, self.do_translation_y = value
else:
self.do_translation_x = self.do_translation_y = bool(value)
do_translation = AliasProperty(_get_do_translation, _set_do_translation,
bind=('do_translation_x',
'do_translation_y'),
cache=True)
'''Allow translation on the X or Y axis.
:attr:`do_translation` is an :class:`~kivy.properties.AliasProperty` of
(:attr:`do_translation_x` + :attr:`do_translation_y`)
'''
translation_touches = BoundedNumericProperty(1, min=1)
'''Determine whether translation was triggered by a single or multiple
touches. This only has effect when :attr:`do_translation` = True.
:attr:`translation_touches` is a :class:`~kivy.properties.NumericProperty`
and defaults to 1.
.. versionadded:: 1.7.0
'''
do_rotation = BooleanProperty(True)
'''Allow rotation.
:attr:`do_rotation` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
do_scale = BooleanProperty(True)
'''Allow scaling.
:attr:`do_scale` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
do_collide_after_children = BooleanProperty(False)
'''If True, the collision detection for limiting the touch inside the
scatter will be done after dispaching the touch to the children.
You can put children outside the bounding box of the scatter and still be
able to touch them.
:attr:`do_collide_after_children` is a
:class:`~kivy.properties.BooleanProperty` and defaults to False.
.. versionadded:: 1.3.0
'''
scale_min = NumericProperty(0.01)
'''Minimum scaling factor allowed.
:attr:`scale_min` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.01.
'''
scale_max = NumericProperty(1e20)
'''Maximum scaling factor allowed.
:attr:`scale_max` is a :class:`~kivy.properties.NumericProperty` and
defaults to 1e20.
'''
transform = ObjectProperty(Matrix())
'''Transformation matrix.
:attr:`transform` is an :class:`~kivy.properties.ObjectProperty` and
defaults to the identity matrix.
.. note::
This matrix reflects the current state of the transformation matrix
but setting it directly will erase previously applied
transformations. To apply a transformation considering context,
please use the :attr:`~Scatter.apply_transform` method.
'''
transform_inv = ObjectProperty(Matrix())
'''Inverse of the transformation matrix.
:attr:`transform_inv` is an :class:`~kivy.properties.ObjectProperty` and
defaults to the identity matrix.
'''
def _get_bbox(self):
xmin, ymin = xmax, ymax = self.to_parent(0, 0)
for point in [(self.width, 0), (0, self.height), self.size]:
x, y = self.to_parent(*point)
if x < xmin:
xmin = x
if y < ymin:
ymin = y
if x > xmax:
xmax = x
if y > ymax:
ymax = y
return (xmin, ymin), (xmax - xmin, ymax - ymin)
bbox = AliasProperty(_get_bbox, bind=('transform', 'width', 'height'))
'''Bounding box of the widget in parent space::
((x, y), (w, h))
# x, y = lower left corner
:attr:`bbox` is an :class:`~kivy.properties.AliasProperty`.
'''
def _get_rotation(self):
v1 = Vector(0, 10)
tp = self.to_parent
v2 = Vector(*tp(*self.pos)) - tp(self.x, self.y + 10)
return -1.0 * (v1.angle(v2) + 180) % 360
def _set_rotation(self, rotation):
angle_change = self.rotation - rotation
r = Matrix().rotate(-radians(angle_change), 0, 0, 1)
self.apply_transform(r, post_multiply=True,
anchor=self.to_local(*self.center))
rotation = AliasProperty(_get_rotation, _set_rotation,
bind=('x', 'y', 'transform'))
'''Rotation value of the scatter in degrees moving in a counterclockwise
direction.
:attr:`rotation` is an :class:`~kivy.properties.AliasProperty` and defaults
to 0.0.
'''
def _get_scale(self):
p1 = Vector(*self.to_parent(0, 0))
p2 = Vector(*self.to_parent(1, 0))
scale = p1.distance(p2)
# XXX float calculation are not accurate, and then, scale can be
# thrown again even with only the position change. So to
# prevent anything wrong with scale, just avoid to dispatch it
# if the scale "visually" didn't change. #947
# Remove this ugly hack when we'll be Python 3 only.
if hasattr(self, '_scale_p'):
if str(scale) == str(self._scale_p):
return self._scale_p
self._scale_p = scale
return scale
def _set_scale(self, scale):
rescale = scale * 1.0 / self.scale
self.apply_transform(Matrix().scale(rescale, rescale, rescale),
post_multiply=True,
anchor=self.to_local(*self.center))
scale = AliasProperty(_get_scale, _set_scale, bind=('x', 'y', 'transform'))
'''Scale value of the scatter.
:attr:`scale` is an :class:`~kivy.properties.AliasProperty` and defaults to
1.0.
'''
def _get_center(self):
return (self.bbox[0][0] + self.bbox[1][0] / 2.0,
self.bbox[0][1] + self.bbox[1][1] / 2.0)
def _set_center(self, center):
if center == self.center:
return False
t = Vector(*center) - self.center
trans = Matrix().translate(t.x, t.y, 0)
self.apply_transform(trans)
center = AliasProperty(_get_center, _set_center, bind=('bbox',))
def _get_pos(self):
return self.bbox[0]
def _set_pos(self, pos):
_pos = self.bbox[0]
if pos == _pos:
return
t = Vector(*pos) - _pos
trans = Matrix().translate(t.x, t.y, 0)
self.apply_transform(trans)
pos = AliasProperty(_get_pos, _set_pos, bind=('bbox',))
def _get_x(self):
return self.bbox[0][0]
def _set_x(self, x):
if x == self.bbox[0][0]:
return False
self.pos = (x, self.y)
return True
x = AliasProperty(_get_x, _set_x, bind=('bbox',))
def _get_y(self):
return self.bbox[0][1]
def _set_y(self, y):
if y == self.bbox[0][1]:
return False
self.pos = (self.x, y)
return True
y = AliasProperty(_get_y, _set_y, bind=('bbox',))
def get_right(self):
return self.x + self.bbox[1][0]
def set_right(self, value):
self.x = value - self.bbox[1][0]
right = AliasProperty(get_right, set_right, bind=('x', 'bbox'))
def get_top(self):
return self.y + self.bbox[1][1]
def set_top(self, value):
self.y = value - self.bbox[1][1]
top = AliasProperty(get_top, set_top, bind=('y', 'bbox'))
def get_center_x(self):
return self.x + self.bbox[1][0] / 2.
def set_center_x(self, value):
self.x = value - self.bbox[1][0] / 2.
center_x = AliasProperty(get_center_x, set_center_x, bind=('x', 'bbox'))
def get_center_y(self):
return self.y + self.bbox[1][1] / 2.
def set_center_y(self, value):
self.y = value - self.bbox[1][1] / 2.
center_y = AliasProperty(get_center_y, set_center_y, bind=('y', 'bbox'))
def __init__(self, **kwargs):
self._touches = []
self._last_touch_pos = {}
super(Scatter, self).__init__(**kwargs)
def on_transform(self, instance, value):
self.transform_inv = value.inverse()
def collide_point(self, x, y):
x, y = self.to_local(x, y)
return 0 <= x <= self.width and 0 <= y <= self.height
def to_parent(self, x, y, **k):
p = self.transform.transform_point(x, y, 0)
return (p[0], p[1])
def to_local(self, x, y, **k):
p = self.transform_inv.transform_point(x, y, 0)
return (p[0], p[1])
def _apply_transform(self, m, pos=None):
m = self.transform.multiply(m)
return super(Scatter, self)._apply_transform(m, (0, 0))
def apply_transform(self, trans, post_multiply=False, anchor=(0, 0)):
'''
Transforms the scatter by applying the "trans" transformation
matrix (on top of its current transformation state). The resultant
matrix can be found in the :attr:`~Scatter.transform` property.
:Parameters:
`trans`: :class:`~kivy.graphics.transformation.Matrix`.
Transformation matrix to be applied to the scatter widget.
`anchor`: tuple, defaults to (0, 0).
The point to use as the origin of the transformation
(uses local widget space).
`post_multiply`: bool, defaults to False.
If True, the transform matrix is post multiplied
(as if applied before the current transform).
Usage example::
from kivy.graphics.transformation import Matrix
mat = Matrix().scale(3, 3, 3)
scatter_instance.apply_transform(mat)
'''
t = Matrix().translate(anchor[0], anchor[1], 0)
t = t.multiply(trans)
t = t.multiply(Matrix().translate(-anchor[0], -anchor[1], 0))
if post_multiply:
self.transform = self.transform.multiply(t)
else:
self.transform = t.multiply(self.transform)
def transform_with_touch(self, touch):
# just do a simple one finger drag
changed = False
if len(self._touches) == self.translation_touches:
# _last_touch_pos has last pos in correct parent space,
# just like incoming touch
dx = (touch.x - self._last_touch_pos[touch][0]) \
* self.do_translation_x
dy = (touch.y - self._last_touch_pos[touch][1]) \
* self.do_translation_y
dx = dx / self.translation_touches
dy = dy / self.translation_touches
self.apply_transform(Matrix().translate(dx, dy, 0))
changed = True
if len(self._touches) == 1:
return changed
# we have more than one touch... list of last known pos
points = [Vector(self._last_touch_pos[t]) for t in self._touches
if t is not touch]
# add current touch last
points.append(Vector(touch.pos))
# we only want to transform if the touch is part of the two touches
# farthest apart! So first we find anchor, the point to transform
# around as another touch farthest away from current touch's pos
anchor = max(points[:-1], key=lambda p: p.distance(touch.pos))
# now we find the touch farthest away from anchor, if its not the
# same as touch. Touch is not one of the two touches used to transform
farthest = max(points, key=anchor.distance)
if farthest is not points[-1]:
return changed
# ok, so we have touch, and anchor, so we can actually compute the
# transformation
old_line = Vector(*touch.ppos) - anchor
new_line = Vector(*touch.pos) - anchor
if not old_line.length(): # div by zero
return changed
angle = radians(new_line.angle(old_line)) * self.do_rotation
if angle:
changed = True
self.apply_transform(Matrix().rotate(angle, 0, 0, 1), anchor=anchor)
if self.do_scale:
scale = new_line.length() / old_line.length()
new_scale = scale * self.scale
if new_scale < self.scale_min:
scale = self.scale_min / self.scale
elif new_scale > self.scale_max:
scale = self.scale_max / self.scale
self.apply_transform(Matrix().scale(scale, scale, scale),
anchor=anchor)
changed = True
return changed
def _bring_to_front(self, touch):
# auto bring to front
if self.auto_bring_to_front and self.parent:
parent = self.parent
if parent.children[0] is self:
return
parent.remove_widget(self)
parent.add_widget(self)
self.dispatch('on_bring_to_front', touch)
def on_motion(self, etype, me):
if me.type_id in self.motion_filter and 'pos' in me.profile:
me.push()
me.apply_transform_2d(self.to_local)
ret = super().on_motion(etype, me)
me.pop()
return ret
return super().on_motion(etype, me)
def on_touch_down(self, touch):
x, y = touch.x, touch.y
# if the touch isn't on the widget we do nothing
if not self.do_collide_after_children:
if not self.collide_point(x, y):
return False
# let the child widgets handle the event if they want
touch.push()
touch.apply_transform_2d(self.to_local)
if super(Scatter, self).on_touch_down(touch):
touch.pop()
self._bring_to_front(touch)
return True
touch.pop()
# if our child didn't do anything, and if we don't have any active
# interaction control, then don't accept the touch.
if not self.do_translation_x and \
not self.do_translation_y and \
not self.do_rotation and \
not self.do_scale:
return False
if self.do_collide_after_children:
if not self.collide_point(x, y):
return False
if 'multitouch_sim' in touch.profile:
touch.multitouch_sim = True
# grab the touch so we get all it later move events for sure
self._bring_to_front(touch)
touch.grab(self)
self._touches.append(touch)
self._last_touch_pos[touch] = touch.pos
return True
def on_touch_move(self, touch):
x, y = touch.x, touch.y
# let the child widgets handle the event if they want
if self.collide_point(x, y) and not touch.grab_current == self:
touch.push()
touch.apply_transform_2d(self.to_local)
if super(Scatter, self).on_touch_move(touch):
touch.pop()
return True
touch.pop()
# rotate/scale/translate
if touch in self._touches and touch.grab_current == self:
if self.transform_with_touch(touch):
self.dispatch('on_transform_with_touch', touch)
self._last_touch_pos[touch] = touch.pos
# stop propagating if its within our bounds
if self.collide_point(x, y):
return True
def on_transform_with_touch(self, touch):
'''
Called when a touch event has transformed the scatter widget.
By default this does nothing, but can be overridden by derived
classes that need to react to transformations caused by user
input.
:Parameters:
`touch`:
The touch object which triggered the transformation.
.. versionadded:: 1.8.0
'''
pass
def on_bring_to_front(self, touch):
'''
Called when a touch event causes the scatter to be brought to the
front of the parent (only if :attr:`auto_bring_to_front` is True)
:Parameters:
`touch`:
The touch object which brought the scatter to front.
.. versionadded:: 1.9.0
'''
pass
def on_touch_up(self, touch):
x, y = touch.x, touch.y
# if the touch isn't on the widget we do nothing, just try children
if not touch.grab_current == self:
touch.push()
touch.apply_transform_2d(self.to_local)
if super(Scatter, self).on_touch_up(touch):
touch.pop()
return True
touch.pop()
# remove it from our saved touches
if touch in self._touches and touch.grab_state:
touch.ungrab(self)
del self._last_touch_pos[touch]
self._touches.remove(touch)
# stop propagating if its within our bounds
if self.collide_point(x, y):
return True
class ScatterPlane(Scatter):
'''This is essentially an unbounded Scatter widget. It's a convenience
class to make it easier to handle infinite planes.
'''
def __init__(self, **kwargs):
if 'auto_bring_to_front' not in kwargs:
self.auto_bring_to_front = False
super(ScatterPlane, self).__init__(**kwargs)
def collide_point(self, x, y):
return True
@@ -0,0 +1,93 @@
'''
Scatter Layout
===============
.. versionadded:: 1.6.0
This layout behaves just like a
:class:`~kivy.uix.relativelayout.RelativeLayout`.
When a widget is added with position = (0,0) to a :class:`ScatterLayout`,
the child widget will also move when you change the position of the
:class:`ScatterLayout`. The child widget's coordinates remain
(0,0) as they are relative to the parent layout.
However, since :class:`ScatterLayout` is implemented using a
:class:`~kivy.uix.scatter.Scatter`
widget, you can also translate, rotate and scale the layout using touches
or clicks, just like in the case of a normal Scatter widget, and the child
widgets will behave as expected.
In contrast to a Scatter, the Layout favors 'hint' properties, such as
size_hint, size_hint_x, size_hint_y and pos_hint.
.. note::
The :class:`ScatterLayout` is implemented as a
:class:`~kivy.uix.floatlayout.FloatLayout`
inside a :class:`~kivy.uix.scatter.Scatter`.
.. warning::
Since the actual :class:`ScatterLayout` is a
:class:`~kivy.uix.scatter.Scatter`, its
add_widget and remove_widget functions are overridden to add children
to the embedded :class:`~kivy.uix.floatlayout.FloatLayout` (accessible as
the `content` property of :class:`~kivy.uix.scatter.Scatter`)
automatically. So if you want to access the added child elements,
you need self.content.children instead of self.children.
.. warning::
The :class:`ScatterLayout` was introduced in 1.7.0 and was called
:class:`~kivy.uix.relativelayout.RelativeLayout` in prior versions.
The :class:`~kivy.uix.relativelayout.RelativeLayout` is now an optimized
implementation that uses only a positional transform to avoid some of the
heavier calculation involved for :class:`~kivy.uix.scatter.Scatter`.
'''
__all__ = ('ScatterLayout', 'ScatterPlaneLayout')
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.scatter import Scatter, ScatterPlane
from kivy.properties import ObjectProperty
class ScatterLayout(Scatter):
'''ScatterLayout class, see module documentation for more information.
'''
content = ObjectProperty()
def __init__(self, **kw):
self.content = FloatLayout()
super(ScatterLayout, self).__init__(**kw)
if self.content.size != self.size:
self.content.size = self.size
super(ScatterLayout, self).add_widget(self.content)
self.fbind('size', self.update_size)
def update_size(self, instance, size):
self.content.size = size
def add_widget(self, *args, **kwargs):
self.content.add_widget(*args, **kwargs)
def remove_widget(self, *args, **kwargs):
self.content.remove_widget(*args, **kwargs)
def clear_widgets(self, *args, **kwargs):
self.content.clear_widgets(*args, **kwargs)
class ScatterPlaneLayout(ScatterPlane):
'''ScatterPlaneLayout class, see module documentation for more information.
Similar to ScatterLayout, but based on ScatterPlane - so the input is not
bounded.
.. versionadded:: 1.9.0
'''
def collide_point(self, x, y):
return True
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+421
View File
@@ -0,0 +1,421 @@
"""
Slider
======
.. image:: images/slider.jpg
The :class:`Slider` widget looks like a scrollbar. It supports horizontal and
vertical orientations, min/max values and a default value.
To create a slider from -100 to 100 starting from 25::
from kivy.uix.slider import Slider
s = Slider(min=-100, max=100, value=25)
To create a vertical slider::
from kivy.uix.slider import Slider
s = Slider(orientation='vertical')
To create a slider with a red line tracking the value::
from kivy.uix.slider import Slider
s = Slider(value_track=True, value_track_color=[1, 0, 0, 1])
Kv Example::
BoxLayout:
Slider:
id: slider
min: 0
max: 100
step: 1
orientation: 'vertical'
Label:
text: str(slider.value)
"""
__all__ = ('Slider', )
from kivy.uix.widget import Widget
from kivy.properties import (NumericProperty, AliasProperty, OptionProperty,
ReferenceListProperty, BoundedNumericProperty,
StringProperty, ListProperty, BooleanProperty,
ColorProperty)
class Slider(Widget):
"""Class for creating a Slider widget.
Check module documentation for more details.
"""
value = NumericProperty(0.)
'''Current value used for the slider.
:attr:`value` is a :class:`~kivy.properties.NumericProperty` and defaults
to 0.'''
min = NumericProperty(0.)
'''Minimum value allowed for :attr:`value`.
:attr:`min` is a :class:`~kivy.properties.NumericProperty` and defaults to
0.'''
max = NumericProperty(100.)
'''Maximum value allowed for :attr:`value`.
:attr:`max` is a :class:`~kivy.properties.NumericProperty` and defaults to
100.'''
padding = NumericProperty('16sp')
'''Padding of the slider. The padding is used for graphical representation
and interaction. It prevents the cursor from going out of the bounds of the
slider bounding box.
By default, padding is 16sp. The range of the slider is reduced from
padding \\*2 on the screen. It allows drawing the default cursor of 32sp
width without having the cursor go out of the widget.
:attr:`padding` is a :class:`~kivy.properties.NumericProperty` and defaults
to 16sp.'''
orientation = OptionProperty('horizontal', options=(
'vertical', 'horizontal'))
'''Orientation of the slider.
:attr:`orientation` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'horizontal'. Can take a value of 'vertical' or 'horizontal'.
'''
range = ReferenceListProperty(min, max)
'''Range of the slider in the format (minimum value, maximum value)::
>>> slider = Slider(min=10, max=80)
>>> slider.range
[10, 80]
>>> slider.range = (20, 100)
>>> slider.min
20
>>> slider.max
100
:attr:`range` is a :class:`~kivy.properties.ReferenceListProperty` of
(:attr:`min`, :attr:`max`) properties.
'''
step = BoundedNumericProperty(0, min=0)
'''Step size of the slider.
.. versionadded:: 1.4.0
Determines the size of each interval or step the slider takes between
:attr:`min` and :attr:`max`. If the value range can't be evenly
divisible by step the last step will be capped by slider.max.
A zero value will result in the smallest possible intervals/steps,
calculated from the (pixel) position of the slider.
:attr:`step` is a :class:`~kivy.properties.NumericProperty` and defaults
to 0.'''
background_horizontal = StringProperty(
'atlas://data/images/defaulttheme/sliderh_background')
"""Background of the slider used in the horizontal orientation.
.. versionadded:: 1.10.0
:attr:`background_horizontal` is a :class:`~kivy.properties.StringProperty`
and defaults to `atlas://data/images/defaulttheme/sliderh_background`.
"""
background_disabled_horizontal = StringProperty(
'atlas://data/images/defaulttheme/sliderh_background_disabled')
"""Background of the disabled slider used in the horizontal orientation.
.. versionadded:: 1.10.0
:attr:`background_disabled_horizontal` is a
:class:`~kivy.properties.StringProperty` and defaults to
`atlas://data/images/defaulttheme/sliderh_background_disabled`.
"""
background_vertical = StringProperty(
'atlas://data/images/defaulttheme/sliderv_background')
"""Background of the slider used in the vertical orientation.
.. versionadded:: 1.10.0
:attr:`background_vertical` is a :class:`~kivy.properties.StringProperty`
and defaults to `atlas://data/images/defaulttheme/sliderv_background`.
"""
background_disabled_vertical = StringProperty(
'atlas://data/images/defaulttheme/sliderv_background_disabled')
"""Background of the disabled slider used in the vertical orientation.
.. versionadded:: 1.10.0
:attr:`background_disabled_vertical` is a
:class:`~kivy.properties.StringProperty` and defaults to
`atlas://data/images/defaulttheme/sliderv_background_disabled`.
"""
background_width = NumericProperty('36sp')
"""Slider's background's width (thickness), used in both horizontal
and vertical orientations.
.. versionadded 1.10.0
:attr:`background_width` is a
:class:`~kivy.properties.NumericProperty` and defaults to 36sp.
"""
cursor_image = StringProperty(
'atlas://data/images/defaulttheme/slider_cursor')
"""Path of the image used to draw the slider cursor.
.. versionadded 1.10.0
:attr:`cursor_image` is a :class:`~kivy.properties.StringProperty`
and defaults to `atlas://data/images/defaulttheme/slider_cursor`.
"""
cursor_disabled_image = StringProperty(
'atlas://data/images/defaulttheme/slider_cursor_disabled')
"""Path of the image used to draw the disabled slider cursor.
.. versionadded 1.10.0
:attr:`cursor_image` is a :class:`~kivy.properties.StringProperty`
and defaults to `atlas://data/images/defaulttheme/slider_cursor_disabled`.
"""
cursor_width = NumericProperty('32sp')
"""Width of the cursor image.
.. versionadded 1.10.0
:attr:`cursor_width` is a :class:`~kivy.properties.NumericProperty`
and defaults to 32sp.
"""
cursor_height = NumericProperty('32sp')
"""Height of the cursor image.
.. versionadded 1.10.0
:attr:`cursor_height` is a :class:`~kivy.properties.NumericProperty`
and defaults to 32sp.
"""
cursor_size = ReferenceListProperty(cursor_width, cursor_height)
"""Size of the cursor image.
.. versionadded 1.10.0
:attr:`cursor_size` is a :class:`~kivy.properties.ReferenceListProperty`
of (:attr:`cursor_width`, :attr:`cursor_height`) properties.
"""
border_horizontal = ListProperty([0, 18, 0, 18])
"""Border used to draw the slider background in horizontal orientation.
.. versionadded 1.10.0
:attr:`border_horizontal` is a :class:`~kivy.properties.ListProperty`
and defaults to [0, 18, 0, 18].
"""
border_vertical = ListProperty([18, 0, 18, 0])
"""Border used to draw the slider background in vertical orientation.
.. versionadded 1.10.0
:attr:`border_horizontal` is a :class:`~kivy.properties.ListProperty`
and defaults to [18, 0, 18, 0].
"""
value_track = BooleanProperty(False)
"""Decides if slider should draw the line indicating the
space between :attr:`min` and :attr:`value` properties values.
.. versionadded 1.10.0
:attr:`value_track` is a :class:`~kivy.properties.BooleanProperty`
and defaults to False.
"""
value_track_color = ColorProperty([1, 1, 1, 1])
"""Color of the :attr:`value_line` in rgba format.
.. versionadded 1.10.0
:attr:`value_track_color` is a :class:`~kivy.properties.ColorProperty`
and defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
"""
value_track_width = NumericProperty('3dp')
"""Width of the track line.
.. versionadded 1.10.0
:attr:`value_track_width` is a :class:`~kivy.properties.NumericProperty`
and defaults to 3dp.
"""
sensitivity = OptionProperty('all', options=('all', 'handle'))
"""Whether the touch collides with the whole body of the widget
or with the slider handle part only.
.. versionadded:: 1.10.1
:attr:`sensitivity` is a :class:`~kivy.properties.OptionProperty`
and defaults to 'all'. Can take a value of 'all' or 'handle'.
"""
# The following two methods constrain the slider's value
# to range(min,max). Otherwise it may happen that self.value < self.min
# at init.
def on_min(self, *largs):
self.value = min(self.max, max(self.min, self.value))
def on_max(self, *largs):
self.value = min(self.max, max(self.min, self.value))
def get_norm_value(self):
vmin = self.min
d = self.max - vmin
if d == 0:
return 0
return (self.value - vmin) / float(d)
def set_norm_value(self, value):
vmin = self.min
vmax = self.max
step = self.step
val = min(value * (vmax - vmin) + vmin, vmax)
if step == 0:
self.value = val
else:
self.value = min(round((val - vmin) / step) * step + vmin,
vmax)
value_normalized = AliasProperty(get_norm_value, set_norm_value,
bind=('value', 'min', 'max'),
cache=True)
'''Normalized value inside the :attr:`range` (min/max) to 0-1 range::
>>> slider = Slider(value=50, min=0, max=100)
>>> slider.value
50
>>> slider.value_normalized
0.5
>>> slider.value = 0
>>> slider.value_normalized
0
>>> slider.value = 100
>>> slider.value_normalized
1
You can also use it for setting the real value without knowing the minimum
and maximum::
>>> slider = Slider(min=0, max=200)
>>> slider.value_normalized = .5
>>> slider.value
100
>>> slider.value_normalized = 1.
>>> slider.value
200
:attr:`value_normalized` is an :class:`~kivy.properties.AliasProperty`.
'''
def get_value_pos(self):
padding = self.padding
x = self.x
y = self.y
nval = self.value_normalized
if self.orientation == 'horizontal':
return (x + padding + nval * (self.width - 2 * padding), y)
else:
return (x, y + padding + nval * (self.height - 2 * padding))
def set_value_pos(self, pos):
padding = self.padding
x = min(self.right - padding, max(pos[0], self.x + padding))
y = min(self.top - padding, max(pos[1], self.y + padding))
if self.orientation == 'horizontal':
if self.width == 0:
self.value_normalized = 0
else:
self.value_normalized = (x - self.x - padding
) / float(self.width - 2 * padding)
else:
if self.height == 0:
self.value_normalized = 0
else:
self.value_normalized = (y - self.y - padding
) / float(self.height - 2 * padding)
value_pos = AliasProperty(get_value_pos, set_value_pos,
bind=('pos', 'size', 'min', 'max', 'padding',
'value_normalized', 'orientation'),
cache=True)
'''Position of the internal cursor, based on the normalized value.
:attr:`value_pos` is an :class:`~kivy.properties.AliasProperty`.
'''
def on_touch_down(self, touch):
if self.disabled or not self.collide_point(*touch.pos):
return
if touch.is_mouse_scrolling:
if 'down' in touch.button or 'left' in touch.button:
if self.step:
self.value = min(self.max, self.value + self.step)
else:
self.value = min(
self.max,
self.value + (self.max - self.min) / 20)
if 'up' in touch.button or 'right' in touch.button:
if self.step:
self.value = max(self.min, self.value - self.step)
else:
self.value = max(
self.min,
self.value - (self.max - self.min) / 20)
elif self.sensitivity == 'handle':
if self.children[0].collide_point(*touch.pos):
touch.grab(self)
else:
touch.grab(self)
self.value_pos = touch.pos
return True
def on_touch_move(self, touch):
if touch.grab_current == self:
self.value_pos = touch.pos
return True
def on_touch_up(self, touch):
if touch.grab_current == self:
self.value_pos = touch.pos
return True
if __name__ == '__main__':
from kivy.app import App
class SliderApp(App):
def build(self):
return Slider(padding=25)
SliderApp().run()
+221
View File
@@ -0,0 +1,221 @@
'''
Spinner
=======
.. versionadded:: 1.4.0
.. image:: images/spinner.jpg
:align: right
Spinner is a widget that provides a quick way to select one value from a set.
In the default state, a spinner shows its currently selected value.
Touching the spinner displays a dropdown menu with all the other available
values from which the user can select a new one.
Example::
from kivy.base import runTouchApp
from kivy.uix.spinner import Spinner
spinner = Spinner(
# default value shown
text='Home',
# available values
values=('Home', 'Work', 'Other', 'Custom'),
# just for positioning in our example
size_hint=(None, None),
size=(100, 44),
pos_hint={'center_x': .5, 'center_y': .5})
def show_selected_value(spinner, text):
print('The spinner', spinner, 'has text', text)
spinner.bind(text=show_selected_value)
runTouchApp(spinner)
Kv Example::
FloatLayout:
Spinner:
size_hint: None, None
size: 100, 44
pos_hint: {'center': (.5, .5)}
text: 'Home'
values: 'Home', 'Work', 'Other', 'Custom'
on_text:
print("The spinner {} has text {}".format(self, self.text))
'''
__all__ = ('Spinner', 'SpinnerOption')
from kivy.compat import string_types
from kivy.factory import Factory
from kivy.properties import ListProperty, ObjectProperty, BooleanProperty
from kivy.uix.button import Button
from kivy.uix.dropdown import DropDown
class SpinnerOption(Button):
'''Special button used in the :class:`Spinner` dropdown list. By default,
this is just a :class:`~kivy.uix.button.Button` with a size_hint_y of None
and a height of :meth:`48dp <kivy.metrics.dp>`.
'''
pass
class Spinner(Button):
'''Spinner class, see module documentation for more information.
'''
values = ListProperty()
'''Values that can be selected by the user. It must be a list of strings.
:attr:`values` is a :class:`~kivy.properties.ListProperty` and defaults to
[].
'''
text_autoupdate = BooleanProperty(False)
'''Indicates if the spinner's :attr:`text` should be automatically
updated with the first value of the :attr:`values` property.
Setting it to True will cause the spinner to update its :attr:`text`
property every time attr:`values` are changed.
.. versionadded:: 1.10.0
:attr:`text_autoupdate` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
option_cls = ObjectProperty(SpinnerOption)
'''Class used to display the options within the dropdown list displayed
under the Spinner. The `text` property of the class will be used to
represent the value.
The option class requires:
- a `text` property, used to display the value.
- an `on_release` event, used to trigger the option when pressed/touched.
- a :attr:`~kivy.uix.widget.Widget.size_hint_y` of None.
- the :attr:`~kivy.uix.widget.Widget.height` to be set.
:attr:`option_cls` is an :class:`~kivy.properties.ObjectProperty` and
defaults to :class:`SpinnerOption`.
.. versionchanged:: 1.8.0
If you set a string, the :class:`~kivy.factory.Factory` will be used to
resolve the class.
'''
dropdown_cls = ObjectProperty(DropDown)
'''Class used to display the dropdown list when the Spinner is pressed.
:attr:`dropdown_cls` is an :class:`~kivy.properties.ObjectProperty` and
defaults to :class:`~kivy.uix.dropdown.DropDown`.
.. versionchanged:: 1.8.0
If set to a string, the :class:`~kivy.factory.Factory` will be used to
resolve the class name.
'''
is_open = BooleanProperty(False)
'''By default, the spinner is not open. Set to True to open it.
:attr:`is_open` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
.. versionadded:: 1.4.0
'''
sync_height = BooleanProperty(False)
'''Each element in a dropdown list uses a default/user-supplied height.
Set to True to propagate the Spinner's height value to each dropdown
list element.
.. versionadded:: 1.10.0
:attr:`sync_height` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
def __init__(self, **kwargs):
self._dropdown = None
super(Spinner, self).__init__(**kwargs)
fbind = self.fbind
build_dropdown = self._build_dropdown
fbind('on_release', self._toggle_dropdown)
fbind('dropdown_cls', build_dropdown)
fbind('option_cls', build_dropdown)
fbind('values', self._update_dropdown)
fbind('size', self._update_dropdown_size)
fbind('text_autoupdate', self._update_dropdown)
build_dropdown()
def _build_dropdown(self, *largs):
if self._dropdown:
self._dropdown.unbind(on_select=self._on_dropdown_select)
self._dropdown.unbind(on_dismiss=self._close_dropdown)
self._dropdown.dismiss()
self._dropdown = None
cls = self.dropdown_cls
if isinstance(cls, string_types):
cls = Factory.get(cls)
self._dropdown = cls()
self._dropdown.bind(on_select=self._on_dropdown_select)
self._dropdown.bind(on_dismiss=self._close_dropdown)
self._update_dropdown()
def _update_dropdown_size(self, *largs):
if not self.sync_height:
return
dp = self._dropdown
if not dp:
return
container = dp.container
if not container:
return
h = self.height
for item in container.children[:]:
item.height = h
def _update_dropdown(self, *largs):
dp = self._dropdown
cls = self.option_cls
values = self.values
text_autoupdate = self.text_autoupdate
if isinstance(cls, string_types):
cls = Factory.get(cls)
dp.clear_widgets()
for value in values:
item = cls(text=value)
item.height = self.height if self.sync_height else item.height
item.bind(on_release=lambda option: dp.select(option.text))
dp.add_widget(item)
if text_autoupdate:
if values:
if not self.text or self.text not in values:
self.text = values[0]
else:
self.text = ''
def _toggle_dropdown(self, *largs):
if self.values:
self.is_open = not self.is_open
def _close_dropdown(self, *largs):
self.is_open = False
def _on_dropdown_select(self, instance, data, *largs):
self.text = data
self.is_open = False
def on_is_open(self, instance, value):
if value:
self._dropdown.open(self)
else:
if self._dropdown.attach_to:
self._dropdown.dismiss()
@@ -0,0 +1,430 @@
'''Splitter
======
.. versionadded:: 1.5.0
.. image:: images/splitter.jpg
:align: right
The :class:`Splitter` is a widget that helps you re-size its child
widget/layout by letting you re-size it via dragging the boundary or
double tapping the boundary. This widget is similar to the
:class:`~kivy.uix.scrollview.ScrollView` in that it allows only one
child widget.
Usage::
splitter = Splitter(sizable_from = 'right')
splitter.add_widget(layout_or_widget_instance)
splitter.min_size = 100
splitter.max_size = 250
To change the size of the strip/border used for resizing::
splitter.strip_size = '10pt'
To change its appearance::
splitter.strip_cls = your_custom_class
You can also change the appearance of the `strip_cls`, which defaults to
:class:`SplitterStrip`, by overriding the `kv` rule in your app:
.. code-block:: kv
<SplitterStrip>:
horizontal: True if self.parent and self.parent.sizable_from[0] \
in ('t', 'b') else False
background_normal: 'path to normal horizontal image' \
if self.horizontal else 'path to vertical normal image'
background_down: 'path to pressed horizontal image' \
if self.horizontal else 'path to vertical pressed image'
'''
__all__ = ('Splitter', )
from kivy.factory import Factory
from kivy.uix.button import Button
from kivy.properties import (OptionProperty, NumericProperty, ObjectProperty,
ListProperty, BooleanProperty)
from kivy.uix.boxlayout import BoxLayout
class SplitterStrip(Button):
'''Class used for the graphical representation of a
:class:`kivy.uix.splitter.SplitterStripe`.
'''
pass
class Splitter(BoxLayout):
'''See module documentation.
:Events:
`on_press`:
Fired when the splitter is pressed.
`on_release`:
Fired when the splitter is released.
.. versionchanged:: 1.6.0
Added `on_press` and `on_release` events.
'''
border = ListProperty([4, 4, 4, 4])
'''Border used for the
:class:`~kivy.graphics.vertex_instructions.BorderImage`
graphics instruction.
This must be a list of four values: (bottom, right, top, left).
Read the BorderImage instructions for more information about how
to use it.
:attr:`border` is a :class:`~kivy.properties.ListProperty` and
defaults to (4, 4, 4, 4).
'''
strip_cls = ObjectProperty(SplitterStrip)
'''Specifies the class of the resize Strip.
:attr:`strip_cls` is an :class:`kivy.properties.ObjectProperty` and
defaults to :class:`~kivy.uix.splitter.SplitterStrip`, which is of type
:class:`~kivy.uix.button.Button`.
.. versionchanged:: 1.8.0
If you set a string, the :class:`~kivy.factory.Factory` will be used to
resolve the class.
'''
sizable_from = OptionProperty('left', options=(
'left', 'right', 'top', 'bottom'))
'''Specifies whether the widget is resizable. Options are:
`left`, `right`, `top` or `bottom`
:attr:`sizable_from` is an :class:`~kivy.properties.OptionProperty`
and defaults to `left`.
'''
strip_size = NumericProperty('10pt')
'''Specifies the size of resize strip
:attr:`strp_size` is a :class:`~kivy.properties.NumericProperty`
defaults to `10pt`
'''
min_size = NumericProperty('100pt')
'''Specifies the minimum size beyond which the widget is not resizable.
:attr:`min_size` is a :class:`~kivy.properties.NumericProperty` and
defaults to `100pt`.
'''
max_size = NumericProperty('500pt')
'''Specifies the maximum size beyond which the widget is not resizable.
:attr:`max_size` is a :class:`~kivy.properties.NumericProperty`
and defaults to `500pt`.
'''
_parent_proportion = NumericProperty(0.)
'''(internal) Specifies the distance that the slider has travelled
across its parent, used to automatically maintain a sensible
position if the parent is resized.
:attr:`_parent_proportion` is a
:class:`~kivy.properties.NumericProperty` and defaults to 0.
.. versionadded:: 1.9.0
'''
_bound_parent = ObjectProperty(None, allownone=True)
'''(internal) References the widget whose size is currently being
tracked by :attr:`_parent_proportion`.
:attr:`_bound_parent` is a
:class:`~kivy.properties.ObjectProperty` and defaults to None.
.. versionadded:: 1.9.0
'''
keep_within_parent = BooleanProperty(False)
'''If True, will limit the splitter to stay within its parent widget.
:attr:`keep_within_parent` is a
:class:`~kivy.properties.BooleanProperty` and defaults to False.
.. versionadded:: 1.9.0
'''
rescale_with_parent = BooleanProperty(False)
'''If True, will automatically change size to take up the same
proportion of the parent widget when it is resized, while
staying within :attr:`min_size` and :attr:`max_size`. As long as
these attributes can be satisfied, this stops the
:class:`Splitter` from exceeding the parent size during rescaling.
:attr:`rescale_with_parent` is a
:class:`~kivy.properties.BooleanProperty` and defaults to False.
.. versionadded:: 1.9.0
'''
__events__ = ('on_press', 'on_release')
def __init__(self, **kwargs):
self._container = None
self._strip = None
super(Splitter, self).__init__(**kwargs)
do_size = self._do_size
fbind = self.fbind
fbind('max_size', do_size)
fbind('min_size', do_size)
fbind('parent', self._rebind_parent)
def on_sizable_from(self, instance, sizable_from):
if not instance._container:
return
sup = super(Splitter, instance)
_strp = instance._strip
if _strp:
# remove any previous binds
_strp.unbind(on_touch_down=instance.strip_down)
_strp.unbind(on_touch_move=instance.strip_move)
_strp.unbind(on_touch_up=instance.strip_up)
self.unbind(disabled=_strp.setter('disabled'))
sup.remove_widget(instance._strip)
cls = instance.strip_cls
if not isinstance(_strp, cls):
if isinstance(cls, str):
cls = Factory.get(cls)
instance._strip = _strp = cls()
sz_frm = instance.sizable_from[0]
if sz_frm in ('l', 'r'):
_strp.size_hint = None, 1
_strp.width = instance.strip_size
instance.orientation = 'horizontal'
instance.unbind(strip_size=_strp.setter('width'))
instance.bind(strip_size=_strp.setter('width'))
else:
_strp.size_hint = 1, None
_strp.height = instance.strip_size
instance.orientation = 'vertical'
instance.unbind(strip_size=_strp.setter('height'))
instance.bind(strip_size=_strp.setter('height'))
index = 1
if sz_frm in ('r', 'b'):
index = 0
sup.add_widget(_strp, index)
_strp.bind(on_touch_down=instance.strip_down)
_strp.bind(on_touch_move=instance.strip_move)
_strp.bind(on_touch_up=instance.strip_up)
_strp.disabled = self.disabled
self.bind(disabled=_strp.setter('disabled'))
def add_widget(self, widget, index=0, *args, **kwargs):
if self._container or not widget:
return Exception('Splitter accepts only one Child')
self._container = widget
sz_frm = self.sizable_from[0]
if sz_frm in ('l', 'r'):
widget.size_hint_x = 1
else:
widget.size_hint_y = 1
index = 0
if sz_frm in ('r', 'b'):
index = 1
super(Splitter, self).add_widget(widget, index, *args, **kwargs)
self.on_sizable_from(self, self.sizable_from)
def remove_widget(self, widget, *args, **kwargs):
super(Splitter, self).remove_widget(widget, *args, **kwargs)
if widget == self._container:
self._container = None
def clear_widgets(self, *args, **kwargs):
self.remove_widget(self._container)
def strip_down(self, instance, touch):
if not instance.collide_point(*touch.pos):
return False
touch.grab(self)
self.dispatch('on_press')
def on_press(self):
pass
def _rebind_parent(self, instance, new_parent):
if self._bound_parent is not None:
self._bound_parent.unbind(size=self.rescale_parent_proportion)
if self.parent is not None:
new_parent.bind(size=self.rescale_parent_proportion)
self._bound_parent = new_parent
self.rescale_parent_proportion()
def rescale_parent_proportion(self, *args):
if not self.parent:
return
if self.rescale_with_parent:
parent_proportion = self._parent_proportion
if self.sizable_from in ('top', 'bottom'):
new_height = parent_proportion * self.parent.height
self.height = max(self.min_size,
min(new_height, self.max_size))
else:
new_width = parent_proportion * self.parent.width
self.width = max(self.min_size, min(new_width, self.max_size))
def _do_size(self, instance, value):
if self.sizable_from[0] in ('l', 'r'):
self.width = max(self.min_size, min(self.width, self.max_size))
else:
self.height = max(self.min_size, min(self.height, self.max_size))
@staticmethod
def _is_moving(sz_frm, diff, pos, minpos, maxpos):
if sz_frm in ('l', 'b'):
cmp = minpos
else:
cmp = maxpos
if diff == 0:
return False
elif diff > 0 and pos <= cmp:
return False
elif diff < 0 and pos >= cmp:
return False
return True
def strip_move(self, instance, touch):
if touch.grab_current is not instance:
return False
max_size = self.max_size
min_size = self.min_size
sz_frm = self.sizable_from[0]
if sz_frm in ('t', 'b'):
diff_y = (touch.dy)
self_y = self.y
self_top = self.top
if not self._is_moving(sz_frm, diff_y, touch.y, self_y, self_top):
return
if self.keep_within_parent:
if sz_frm == 't' and (self_top + diff_y) > self.parent.top:
diff_y = self.parent.top - self_top
elif sz_frm == 'b' and (self_y + diff_y) < self.parent.y:
diff_y = self.parent.y - self_y
if sz_frm == 'b':
diff_y *= -1
if self.size_hint_y:
self.size_hint_y = None
if self.height > 0:
self.height += diff_y
else:
self.height = 1
height = self.height
self.height = max(min_size, min(height, max_size))
self._parent_proportion = self.height / self.parent.height
else:
diff_x = (touch.dx)
self_x = self.x
self_right = self.right
if not self._is_moving(sz_frm, diff_x, touch.x, self_x, self_right):
return
if self.keep_within_parent:
if sz_frm == 'l' and (self_x + diff_x) < self.parent.x:
diff_x = self.parent.x - self_x
elif (sz_frm == 'r' and
(self_right + diff_x) > self.parent.right):
diff_x = self.parent.right - self_right
if sz_frm == 'l':
diff_x *= -1
if self.size_hint_x:
self.size_hint_x = None
if self.width > 0:
self.width += diff_x
else:
self.width = 1
width = self.width
self.width = max(min_size, min(width, max_size))
self._parent_proportion = self.width / self.parent.width
def strip_up(self, instance, touch):
if touch.grab_current is not instance:
return
if touch.is_double_tap:
max_size = self.max_size
min_size = self.min_size
sz_frm = self.sizable_from[0]
s = self.size
if sz_frm in ('t', 'b'):
if self.size_hint_y:
self.size_hint_y = None
if s[1] - min_size <= max_size - s[1]:
self.height = max_size
else:
self.height = min_size
else:
if self.size_hint_x:
self.size_hint_x = None
if s[0] - min_size <= max_size - s[0]:
self.width = max_size
else:
self.width = min_size
touch.ungrab(instance)
self.dispatch('on_release')
def on_release(self):
pass
if __name__ == '__main__':
from kivy.app import App
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout
class SplitterApp(App):
def build(self):
root = FloatLayout()
bx = BoxLayout()
bx.add_widget(Button())
bx.add_widget(Button())
bx2 = BoxLayout()
bx2.add_widget(Button())
bx2.add_widget(Button())
bx2.add_widget(Button())
spl = Splitter(
size_hint=(1, .25),
pos_hint={'top': 1},
sizable_from='bottom')
spl1 = Splitter(
sizable_from='left',
size_hint=(None, 1), width=90)
spl1.add_widget(Button())
bx.add_widget(spl1)
spl.add_widget(bx)
spl2 = Splitter(size_hint=(.25, 1))
spl2.add_widget(bx2)
spl2.sizable_from = 'right'
root.add_widget(spl)
root.add_widget(spl2)
return root
SplitterApp().run()
@@ -0,0 +1,331 @@
'''
Stack Layout
============
.. only:: html
.. image:: images/stacklayout.gif
:align: right
.. only:: latex
.. image:: images/stacklayout.png
:align: right
.. versionadded:: 1.0.5
The :class:`StackLayout` arranges children vertically or horizontally, as many
as the layout can fit. The size of the individual children widgets do not
have to be uniform.
For example, to display widgets that get progressively larger in width::
root = StackLayout()
for i in range(25):
btn = Button(text=str(i), width=40 + i * 5, size_hint=(None, 0.15))
root.add_widget(btn)
.. image:: images/stacklayout_sizing.png
:align: left
'''
__all__ = ('StackLayout', )
from kivy.uix.layout import Layout
from kivy.properties import NumericProperty, OptionProperty, \
ReferenceListProperty, VariableListProperty
def _compute_size(c, available_size, idx):
sh_min = c.size_hint_min[idx]
sh_max = c.size_hint_max[idx]
val = c.size_hint[idx] * available_size
if sh_min is not None:
if sh_max is not None:
return max(min(sh_max, val), sh_min)
return max(val, sh_min)
if sh_max is not None:
return min(sh_max, val)
return val
class StackLayout(Layout):
'''Stack layout class. See module documentation for more information.
'''
spacing = VariableListProperty([0, 0], length=2)
'''Spacing between children: [spacing_horizontal, spacing_vertical].
spacing also accepts a single argument form [spacing].
:attr:`spacing` is a
:class:`~kivy.properties.VariableListProperty` and defaults to [0, 0].
'''
padding = VariableListProperty([0, 0, 0, 0])
'''Padding between the layout box and it's children: [padding_left,
padding_top, padding_right, padding_bottom].
padding also accepts a two argument form [padding_horizontal,
padding_vertical] and a single argument form [padding].
.. versionchanged:: 1.7.0
Replaced the NumericProperty with a VariableListProperty.
:attr:`padding` is a
:class:`~kivy.properties.VariableListProperty` and defaults to
[0, 0, 0, 0].
'''
orientation = OptionProperty('lr-tb', options=(
'lr-tb', 'tb-lr', 'rl-tb', 'tb-rl', 'lr-bt', 'bt-lr', 'rl-bt',
'bt-rl'))
'''Orientation of the layout.
:attr:`orientation` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'lr-tb'.
Valid orientations are 'lr-tb', 'tb-lr', 'rl-tb', 'tb-rl', 'lr-bt',
'bt-lr', 'rl-bt' and 'bt-rl'.
.. versionchanged:: 1.5.0
:attr:`orientation` now correctly handles all valid combinations of
'lr','rl','tb','bt'. Before this version only 'lr-tb' and
'tb-lr' were supported, and 'tb-lr' was misnamed and placed
widgets from bottom to top and from right to left (reversed compared
to what was expected).
.. note::
'lr' means Left to Right.
'rl' means Right to Left.
'tb' means Top to Bottom.
'bt' means Bottom to Top.
'''
minimum_width = NumericProperty(0)
'''Minimum width needed to contain all children. It is automatically set
by the layout.
.. versionadded:: 1.0.8
:attr:`minimum_width` is a :class:`kivy.properties.NumericProperty` and
defaults to 0.
'''
minimum_height = NumericProperty(0)
'''Minimum height needed to contain all children. It is automatically set
by the layout.
.. versionadded:: 1.0.8
:attr:`minimum_height` is a :class:`kivy.properties.NumericProperty` and
defaults to 0.
'''
minimum_size = ReferenceListProperty(minimum_width, minimum_height)
'''Minimum size needed to contain all children. It is automatically set
by the layout.
.. versionadded:: 1.0.8
:attr:`minimum_size` is a
:class:`~kivy.properties.ReferenceListProperty` of
(:attr:`minimum_width`, :attr:`minimum_height`) properties.
'''
def __init__(self, **kwargs):
super(StackLayout, self).__init__(**kwargs)
trigger = self._trigger_layout
fbind = self.fbind
fbind('padding', trigger)
fbind('spacing', trigger)
fbind('children', trigger)
fbind('orientation', trigger)
fbind('size', trigger)
fbind('pos', trigger)
def do_layout(self, *largs):
if not self.children:
self.minimum_size = (0., 0.)
return
# optimize layout by preventing looking at the same attribute in a loop
selfpos = self.pos
selfsize = self.size
orientation = self.orientation.split('-')
padding_left = self.padding[0]
padding_top = self.padding[1]
padding_right = self.padding[2]
padding_bottom = self.padding[3]
padding_x = padding_left + padding_right
padding_y = padding_top + padding_bottom
spacing_x, spacing_y = self.spacing
# Determine which direction and in what order to place the widgets
posattr = [0] * 2
posdelta = [0] * 2
posstart = [0] * 2
for i in (0, 1):
posattr[i] = 1 * (orientation[i] in ('tb', 'bt'))
k = posattr[i]
if orientation[i] == 'lr':
# left to right
posdelta[i] = 1
posstart[i] = selfpos[k] + padding_left
elif orientation[i] == 'bt':
# bottom to top
posdelta[i] = 1
posstart[i] = selfpos[k] + padding_bottom
elif orientation[i] == 'rl':
# right to left
posdelta[i] = -1
posstart[i] = selfpos[k] + selfsize[k] - padding_right
else:
# top to bottom
posdelta[i] = -1
posstart[i] = selfpos[k] + selfsize[k] - padding_top
innerattr, outerattr = posattr
ustart, vstart = posstart
deltau, deltav = posdelta
del posattr, posdelta, posstart
u = ustart # inner loop position variable
v = vstart # outer loop position variable
# space calculation, used for determining when a row or column is full
if orientation[0] in ('lr', 'rl'):
sv = padding_y # size in v-direction, for minimum_size property
su = padding_x # size in h-direction
spacing_u = spacing_x
spacing_v = spacing_y
padding_u = padding_x
padding_v = padding_y
else:
sv = padding_x # size in v-direction, for minimum_size property
su = padding_y # size in h-direction
spacing_u = spacing_y
spacing_v = spacing_x
padding_u = padding_y
padding_v = padding_x
# space calculation, row height or column width, for arranging widgets
lv = 0
urev = (deltau < 0)
vrev = (deltav < 0)
firstchild = self.children[0]
sizes = []
lc = []
for c in reversed(self.children):
if c.size_hint[outerattr] is not None:
c.size[outerattr] = max(
1, _compute_size(c, selfsize[outerattr] - padding_v,
outerattr))
# does the widget fit in the row/column?
ccount = len(lc)
totalsize = availsize = max(
0, selfsize[innerattr] - padding_u - spacing_u * ccount)
if not lc:
if c.size_hint[innerattr] is not None:
childsize = max(1, _compute_size(c, totalsize, innerattr))
else:
childsize = max(0, c.size[innerattr])
availsize = selfsize[innerattr] - padding_u - childsize
testsizes = [childsize]
else:
testsizes = [0] * (ccount + 1)
for i, child in enumerate(lc):
if availsize <= 0:
# no space left but we're trying to add another widget.
availsize = -1
break
if child.size_hint[innerattr] is not None:
testsizes[i] = childsize = max(
1, _compute_size(child, totalsize, innerattr))
else:
childsize = max(0, child.size[innerattr])
testsizes[i] = childsize
availsize -= childsize
if c.size_hint[innerattr] is not None:
testsizes[-1] = max(
1, _compute_size(c, totalsize, innerattr))
else:
testsizes[-1] = max(0, c.size[innerattr])
availsize -= testsizes[-1]
# Tiny value added in order to avoid issues with float precision
# causing unexpected children reordering when parent resizes.
# e.g. if size is 101 and children size_hint_x is 1./5
# 5 children would not fit in one line because 101*(1./5) > 101/5
if (availsize + 1e-10) >= 0 or not lc:
# even if there's no space, we always add one widget to a row
lc.append(c)
sizes = testsizes
lv = max(lv, c.size[outerattr])
continue
# apply the sizes
for i, child in enumerate(lc):
if child.size_hint[innerattr] is not None:
child.size[innerattr] = sizes[i]
# push the line
sv += lv + spacing_v
for c2 in lc:
if urev:
u -= c2.size[innerattr]
c2.pos[innerattr] = u
pos_outer = v
if vrev:
# v position is actually the top/right side of the widget
# when going from high to low coordinate values,
# we need to subtract the height/width from the position.
pos_outer -= c2.size[outerattr]
c2.pos[outerattr] = pos_outer
if urev:
u -= spacing_u
else:
u += c2.size[innerattr] + spacing_u
v += deltav * lv
v += deltav * spacing_v
lc = [c]
lv = c.size[outerattr]
if c.size_hint[innerattr] is not None:
sizes = [
max(1, _compute_size(c, selfsize[innerattr] - padding_u,
innerattr))]
else:
sizes = [max(0, c.size[innerattr])]
u = ustart
if lc:
# apply the sizes
for i, child in enumerate(lc):
if child.size_hint[innerattr] is not None:
child.size[innerattr] = sizes[i]
# push the last (incomplete) line
sv += lv + spacing_v
for c2 in lc:
if urev:
u -= c2.size[innerattr]
c2.pos[innerattr] = u
pos_outer = v
if vrev:
pos_outer -= c2.size[outerattr]
c2.pos[outerattr] = pos_outer
if urev:
u -= spacing_u
else:
u += c2.size[innerattr] + spacing_u
self.minimum_size[outerattr] = sv
@@ -0,0 +1,40 @@
'''
Stencil View
============
.. image:: images/stencilview.gif
:align: right
.. versionadded:: 1.0.4
:class:`StencilView` limits the drawing of child widgets to the StencilView's
bounding box. Any drawing outside the bounding box will be clipped (trashed).
The StencilView uses the stencil graphics instructions under the hood. It
provides an efficient way to clip the drawing area of children.
.. note::
As with the stencil graphics instructions, you cannot stack more than 128
stencil-aware widgets.
.. note::
StencilView is not a layout. Consequently, you have to manage the size and
position of its children directly. You can combine (subclass both)
a StencilView and a Layout in order to achieve a layout's behavior.
For example::
class BoxStencil(BoxLayout, StencilView):
pass
'''
__all__ = ('StencilView', )
from kivy.uix.widget import Widget
class StencilView(Widget):
'''StencilView class. See module documentation for more information.
'''
pass
+122
View File
@@ -0,0 +1,122 @@
'''
Switch
======
.. versionadded:: 1.0.7
.. image:: images/switch-on.jpg
:align: right
.. image:: images/switch-off.jpg
:align: right
The :class:`Switch` widget is active or inactive, like a mechanical light
switch. The user can swipe to the left/right to activate/deactivate it::
switch = Switch(active=True)
To attach a callback that listens to the activation state::
def callback(instance, value):
print('the switch', instance, 'is', value)
switch = Switch()
switch.bind(active=callback)
By default, the representation of the widget is static. The minimum size
required is 83x32 pixels (defined by the background image). The image is
centered within the widget.
The entire widget is active, not just the part with graphics. As long as you
swipe over the widget's bounding box, it will work.
.. note::
If you want to control the state with a single touch instead of a swipe,
use the :class:`ToggleButton` instead.
Kv Example::
BoxLayout:
Label:
text: 'power up'
Switch:
id: switch
Label:
text: 'woooooooooooh' if switch.active else ''
'''
from kivy.uix.widget import Widget
from kivy.animation import Animation
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty
class Switch(Widget):
'''Switch class. See module documentation for more information.
'''
active = BooleanProperty(False)
'''Indicate whether the switch is active or inactive.
:attr:`active` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
touch_control = ObjectProperty(None, allownone=True)
'''(internal) Contains the touch that currently interacts with the switch.
:attr:`touch_control` is an :class:`~kivy.properties.ObjectProperty`
and defaults to None.
'''
touch_distance = NumericProperty(0)
'''(internal) Contains the distance between the initial position of the
touch and the current position to determine if the swipe is from the left
or right.
:attr:`touch_distance` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.
'''
active_norm_pos = NumericProperty(0)
'''(internal) Contains the normalized position of the movable element
inside the switch, in the 0-1 range.
:attr:`active_norm_pos` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.
'''
def on_touch_down(self, touch):
if self.disabled or self.touch_control is not None:
return
if not self.collide_point(*touch.pos):
return
touch.grab(self)
self.touch_distance = 0
self.touch_control = touch
return True
def on_touch_move(self, touch):
if touch.grab_current is not self:
return
self.touch_distance = touch.x - touch.ox
return True
def on_touch_up(self, touch):
if touch.grab_current is not self:
return
touch.ungrab(self)
# depending of the distance, activate by norm pos or invert
if abs(touch.ox - touch.x) < 5:
self.active = not self.active
else:
self.active = self.active_norm_pos > 0.5
Animation(active_norm_pos=int(self.active), t='out_quad',
d=.2).start(self)
self.touch_control = None
return True
if __name__ == '__main__':
from kivy.base import runTouchApp
runTouchApp(Switch())
@@ -0,0 +1,880 @@
'''
TabbedPanel
===========
.. image:: images/tabbed_panel.jpg
:align: right
.. versionadded:: 1.3.0
The `TabbedPanel` widget manages different widgets in tabs, with a header area
for the actual tab buttons and a content area for showing the current tab
content.
The :class:`TabbedPanel` provides one default tab.
Simple example
--------------
.. include:: ../../examples/widgets/tabbedpanel.py
:literal:
.. note::
A new class :class:`TabbedPanelItem` has been introduced in 1.5.0 for
convenience. So now one can simply add a :class:`TabbedPanelItem` to a
:class:`TabbedPanel` and `content` to the :class:`TabbedPanelItem`
as in the example provided above.
Customize the Tabbed Panel
--------------------------
You can choose the position in which the tabs are displayed::
tab_pos = 'top_mid'
An individual tab is called a TabbedPanelHeader. It is a special button
containing a `content` property. You add the TabbedPanelHeader first, and set
its `content` property separately::
tp = TabbedPanel()
th = TabbedPanelHeader(text='Tab2')
tp.add_widget(th)
An individual tab, represented by a TabbedPanelHeader, needs its content set.
This content can be any widget. It could be a layout with a deep
hierarchy of widgets, or it could be an individual widget, such as a label or a
button::
th.content = your_content_instance
There is one "shared" main content area active at any given time, for all
the tabs. Your app is responsible for adding the content of individual tabs
and for managing them, but it's not responsible for content switching. The
tabbed panel handles switching of the main content object as per user action.
There is a default tab added when the tabbed panel is instantiated.
Tabs that you add individually as above, are added in addition to the default
tab. Thus, depending on your needs and design, you will want to customize the
default tab::
tp.default_tab_text = 'Something Specific To Your Use'
The default tab machinery requires special consideration and management.
Accordingly, an `on_default_tab` event is provided for associating a callback::
tp.bind(default_tab = my_default_tab_callback)
It's important to note that by default, :attr:`default_tab_cls` is of type
:class:`TabbedPanelHeader` and thus has the same properties as other tabs.
Since 1.5.0, it is now possible to disable the creation of the
:attr:`default_tab` by setting :attr:`do_default_tab` to False.
Tabs and content can be removed in several ways::
tp.remove_widget(widget/tabbed_panel_header)
or
tp.clear_widgets() # to clear all the widgets in the content area
or
tp.clear_tabs() # to remove the TabbedPanelHeaders
To access the children of the tabbed panel, use content.children::
tp.content.children
To access the list of tabs::
tp.tab_list
To change the appearance of the main tabbed panel content::
background_color = (1, 0, 0, .5) #50% translucent red
border = [0, 0, 0, 0]
background_image = 'path/to/background/image'
To change the background of a individual tab, use these two properties::
tab_header_instance.background_normal = 'path/to/tab_head/img'
tab_header_instance.background_down = 'path/to/tab_head/img_pressed'
A TabbedPanelStrip contains the individual tab headers. To change the
appearance of this tab strip, override the canvas of TabbedPanelStrip.
For example, in the kv language:
.. code-block:: kv
<TabbedPanelStrip>
canvas:
Color:
rgba: (0, 1, 0, 1) # green
Rectangle:
size: self.size
pos: self.pos
By default the tabbed panel strip takes its background image and color from the
tabbed panel's background_image and background_color.
'''
__all__ = ('StripLayout', 'TabbedPanel', 'TabbedPanelContent',
'TabbedPanelHeader', 'TabbedPanelItem', 'TabbedPanelStrip',
'TabbedPanelException')
from functools import partial
from kivy.clock import Clock
from kivy.compat import string_types
from kivy.factory import Factory
from kivy.uix.togglebutton import ToggleButton
from kivy.uix.widget import Widget
from kivy.uix.scatter import Scatter
from kivy.uix.scrollview import ScrollView
from kivy.uix.gridlayout import GridLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.logger import Logger
from kivy.metrics import dp
from kivy.properties import ObjectProperty, StringProperty, OptionProperty, \
ListProperty, NumericProperty, AliasProperty, BooleanProperty, \
ColorProperty
class TabbedPanelException(Exception):
'''The TabbedPanelException class.
'''
pass
class TabbedPanelHeader(ToggleButton):
'''A Base for implementing a Tabbed Panel Head. A button intended to be
used as a Heading/Tab for a TabbedPanel widget.
You can use this TabbedPanelHeader widget to add a new tab to a
TabbedPanel.
'''
content = ObjectProperty(None, allownone=True)
'''Content to be loaded when this tab header is selected.
:attr:`content` is an :class:`~kivy.properties.ObjectProperty` and defaults
to None.
'''
# only allow selecting the tab if not already selected
def on_touch_down(self, touch):
if self.state == 'down':
# dispatch to children, not to self
for child in self.children:
child.dispatch('on_touch_down', touch)
return
else:
super(TabbedPanelHeader, self).on_touch_down(touch)
def on_release(self, *largs):
# Tabbed panel header is a child of tab_strib which has a
# `tabbed_panel` property
if self.parent:
self.parent.tabbed_panel.switch_to(self)
else:
# tab removed before we could switch to it. Switch back to
# previous tab
self.panel.switch_to(self.panel.current_tab)
class TabbedPanelItem(TabbedPanelHeader):
'''This is a convenience class that provides a header of type
TabbedPanelHeader and links it with the content automatically. Thus
facilitating you to simply do the following in kv language:
.. code-block:: kv
<TabbedPanel>:
# ...other settings
TabbedPanelItem:
BoxLayout:
Label:
text: 'Second tab content area'
Button:
text: 'Button that does nothing'
.. versionadded:: 1.5.0
'''
def add_widget(self, widget, *args, **kwargs):
self.content = widget
if not self.parent:
return
panel = self.parent.tabbed_panel
if panel.current_tab == self:
panel.switch_to(self)
def remove_widget(self, *args, **kwargs):
self.content = None
if not self.parent:
return
panel = self.parent.tabbed_panel
if panel.current_tab == self:
panel.remove_widget(*args, **kwargs)
class TabbedPanelStrip(GridLayout):
'''A strip intended to be used as background for Heading/Tab.
This does not cover the blank areas in case the tabs don't cover
the entire width/height of the TabbedPanel(use :class:`StripLayout`
for that).
'''
tabbed_panel = ObjectProperty(None)
'''Link to the panel that the tab strip is a part of.
:attr:`tabbed_panel` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None .
'''
class StripLayout(GridLayout):
''' The main layout that is used to house the entire tabbedpanel strip
including the blank areas in case the tabs don't cover the entire
width/height.
.. versionadded:: 1.8.0
'''
border = ListProperty([4, 4, 4, 4])
'''Border property for the :attr:`background_image`.
:attr:`border` is a :class:`~kivy.properties.ListProperty` and defaults
to [4, 4, 4, 4]
'''
background_image = StringProperty(
'atlas://data/images/defaulttheme/action_view')
'''Background image to be used for the Strip layout of the TabbedPanel.
:attr:`background_image` is a :class:`~kivy.properties.StringProperty` and
defaults to a transparent image.
'''
class TabbedPanelContent(FloatLayout):
'''The TabbedPanelContent class.
'''
pass
class TabbedPanel(GridLayout):
'''The TabbedPanel class. See module documentation for more information.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''Background color, in the format (r, g, b, a).
:attr:`background_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
border = ListProperty([16, 16, 16, 16])
'''Border used for :class:`~kivy.graphics.vertex_instructions.BorderImage`
graphics instruction, used itself for :attr:`background_image`.
Can be changed for a custom background.
It must be a list of four values: (bottom, right, top, left). Read the
BorderImage instructions for more information.
:attr:`border` is a :class:`~kivy.properties.ListProperty` and
defaults to (16, 16, 16, 16)
'''
background_image = StringProperty('atlas://data/images/defaulttheme/tab')
'''Background image of the main shared content object.
:attr:`background_image` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/tab'.
'''
background_disabled_image = StringProperty(
'atlas://data/images/defaulttheme/tab_disabled')
'''Background image of the main shared content object when disabled.
.. versionadded:: 1.8.0
:attr:`background_disabled_image` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/tab'.
'''
strip_image = StringProperty(
'atlas://data/images/defaulttheme/action_view')
'''Background image of the tabbed strip.
.. versionadded:: 1.8.0
:attr:`strip_image` is a :class:`~kivy.properties.StringProperty`
and defaults to a empty image.
'''
strip_border = ListProperty([4, 4, 4, 4])
'''Border to be used on :attr:`strip_image`.
.. versionadded:: 1.8.0
:attr:`strip_border` is a :class:`~kivy.properties.ListProperty` and
defaults to [4, 4, 4, 4].
'''
_current_tab = ObjectProperty(None)
def get_current_tab(self):
return self._current_tab
current_tab = AliasProperty(get_current_tab, None, bind=('_current_tab', ))
'''Links to the currently selected or active tab.
.. versionadded:: 1.4.0
:attr:`current_tab` is an :class:`~kivy.AliasProperty`, read-only.
'''
tab_pos = OptionProperty(
'top_left',
options=('left_top', 'left_mid', 'left_bottom', 'top_left',
'top_mid', 'top_right', 'right_top', 'right_mid',
'right_bottom', 'bottom_left', 'bottom_mid', 'bottom_right'))
'''Specifies the position of the tabs relative to the content.
Can be one of: `left_top`, `left_mid`, `left_bottom`, `top_left`,
`top_mid`, `top_right`, `right_top`, `right_mid`, `right_bottom`,
`bottom_left`, `bottom_mid`, `bottom_right`.
:attr:`tab_pos` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'top_left'.
'''
tab_height = NumericProperty('40dp')
'''Specifies the height of the tab header.
:attr:`tab_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to 40.
'''
tab_width = NumericProperty('100dp', allownone=True)
'''Specifies the width of the tab header.
:attr:`tab_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 100.
'''
bar_width = NumericProperty('2dp')
'''Width of the horizontal scroll bar. The width is interpreted
as a height for the horizontal bar.
.. versionadded:: 2.2.0
:attr:`bar_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 2.
'''
scroll_type = OptionProperty(['content'], options=(['content'], ['bars'],
['bars', 'content'], ['content', 'bars']))
'''Sets the type of scrolling to use for the content of the scrollview.
Available options are: ['content'], ['bars'], ['bars', 'content'].
.. versionadded:: 2.2.0
:attr:`scroll_type` is an :class:`~kivy.properties.OptionProperty` and
defaults to ['content'].
'''
do_default_tab = BooleanProperty(True)
'''Specifies whether a default_tab head is provided.
.. versionadded:: 1.5.0
:attr:`do_default_tab` is a :class:`~kivy.properties.BooleanProperty` and
defaults to 'True'.
'''
default_tab_text = StringProperty('Default tab')
'''Specifies the text displayed on the default tab header.
:attr:`default_tab_text` is a :class:`~kivy.properties.StringProperty` and
defaults to 'default tab'.
'''
default_tab_cls = ObjectProperty(TabbedPanelHeader)
'''Specifies the class to use for the styling of the default tab.
.. versionadded:: 1.4.0
.. warning::
`default_tab_cls` should be subclassed from `TabbedPanelHeader`
:attr:`default_tab_cls` is an :class:`~kivy.properties.ObjectProperty`
and defaults to `TabbedPanelHeader`. If you set a string, the
:class:`~kivy.factory.Factory` will be used to resolve the class.
.. versionchanged:: 1.8.0
The :class:`~kivy.factory.Factory` will resolve the class if a string
is set.
'''
def get_tab_list(self):
if self._tab_strip:
return self._tab_strip.children
return 1.
tab_list = AliasProperty(get_tab_list, None)
'''List of all the tab headers.
:attr:`tab_list` is an :class:`~kivy.properties.AliasProperty` and is
read-only.
'''
content = ObjectProperty(None)
'''This is the object holding (current_tab's content is added to this)
the content of the current tab. To Listen to the changes in the content
of the current tab, you should bind to current_tabs `content` property.
:attr:`content` is an :class:`~kivy.properties.ObjectProperty` and
defaults to 'None'.
'''
_default_tab = ObjectProperty(None, allow_none=True)
def get_def_tab(self):
return self._default_tab
def set_def_tab(self, new_tab):
if not issubclass(new_tab.__class__, TabbedPanelHeader):
raise TabbedPanelException('`default_tab_class` should be\
subclassed from `TabbedPanelHeader`')
if self._default_tab == new_tab:
return
oltab = self._default_tab
self._default_tab = new_tab
self.remove_widget(oltab)
self._original_tab = None
self.switch_to(new_tab)
new_tab.state = 'down'
default_tab = AliasProperty(get_def_tab, set_def_tab,
bind=('_default_tab', ))
'''Holds the default tab.
.. Note:: For convenience, the automatically provided default tab is
deleted when you change default_tab to something else.
As of 1.5.0, this behavior has been extended to every
`default_tab` for consistency and not just the automatically
provided one.
:attr:`default_tab` is an :class:`~kivy.properties.AliasProperty`.
'''
def get_def_tab_content(self):
return self.default_tab.content
def set_def_tab_content(self, *l):
self.default_tab.content = l[0]
default_tab_content = AliasProperty(get_def_tab_content,
set_def_tab_content)
'''Holds the default tab content.
:attr:`default_tab_content` is an :class:`~kivy.properties.AliasProperty`.
'''
_update_top_ev = _update_tab_ev = _update_tabs_ev = None
def __init__(self, **kwargs):
# these variables need to be initialized before the kv lang is
# processed setup the base layout for the tabbed panel
self._childrens = []
self._tab_layout = StripLayout(rows=1)
self.rows = 1
self._tab_strip = TabbedPanelStrip(
tabbed_panel=self,
rows=1, size_hint=(None, None),
height=self.tab_height, width=self.tab_width)
self._partial_update_scrollview = None
self.content = TabbedPanelContent()
self._current_tab = self._original_tab \
= self._default_tab = TabbedPanelHeader()
super(TabbedPanel, self).__init__(**kwargs)
self.fbind('size', self._reposition_tabs)
if not self.do_default_tab:
Clock.schedule_once(self._switch_to_first_tab)
return
self._setup_default_tab()
self.switch_to(self.default_tab)
def switch_to(self, header, do_scroll=False):
'''Switch to a specific panel header.
.. versionchanged:: 1.10.0
If used with `do_scroll=True`, it scrolls
to the header's tab too.
:meth:`switch_to` cannot be called from within the
:class:`TabbedPanel` or its subclass' ``__init__`` method.
If that is required, use the ``Clock`` to schedule it. See `discussion
<https://github.com/kivy/kivy/issues/3493#issuecomment-121567969>`_
for full example.
'''
header_content = header.content
self._current_tab.state = 'normal'
header.state = 'down'
self._current_tab = header
self.clear_widgets()
if header_content is None:
return
# if content has a previous parent remove it from that parent
parent = header_content.parent
if parent:
parent.remove_widget(header_content)
self.add_widget(header_content)
if do_scroll:
tabs = self._tab_strip
tabs.parent.scroll_to(header)
def clear_tabs(self, *l):
self_tabs = self._tab_strip
self_tabs.clear_widgets()
if self.do_default_tab:
self_default_tab = self._default_tab
self_tabs.add_widget(self_default_tab)
self_tabs.width = self_default_tab.width
self._reposition_tabs()
def add_widget(self, widget, *args, **kwargs):
content = self.content
if content is None:
return
parent = widget.parent
if parent:
parent.remove_widget(widget)
if widget in (content, self._tab_layout):
super(TabbedPanel, self).add_widget(widget, *args, **kwargs)
elif isinstance(widget, TabbedPanelHeader):
self_tabs = self._tab_strip
self_tabs.add_widget(widget, *args, **kwargs)
widget.group = '__tab%r__' % self_tabs.uid
self.on_tab_width()
else:
widget.pos_hint = {'x': 0, 'top': 1}
self._childrens.append(widget)
content.disabled = self.current_tab.disabled
content.add_widget(widget, *args, **kwargs)
def remove_widget(self, widget, *args, **kwargs):
content = self.content
if content is None:
return
if widget in (content, self._tab_layout):
super(TabbedPanel, self).remove_widget(widget, *args, **kwargs)
elif isinstance(widget, TabbedPanelHeader):
if not (self.do_default_tab and widget is self._default_tab):
self_tabs = self._tab_strip
self_tabs.width -= widget.width
self_tabs.remove_widget(widget)
if widget.state == 'down' and self.do_default_tab:
self._default_tab.on_release()
self._reposition_tabs()
else:
Logger.info('TabbedPanel: default tab! can\'t be removed.\n' +
'Change `default_tab` to a different tab.')
else:
if widget in self._childrens:
self._childrens.remove(widget)
if widget in content.children:
content.remove_widget(widget, *args, **kwargs)
def clear_widgets(self, *args, **kwargs):
if self.content:
self.content.clear_widgets(*args, **kwargs)
def on_strip_image(self, instance, value):
if not self._tab_layout:
return
self._tab_layout.background_image = value
def on_strip_border(self, instance, value):
if not self._tab_layout:
return
self._tab_layout.border = value
def on_do_default_tab(self, instance, value):
if not value:
dft = self.default_tab
if dft in self.tab_list:
self.remove_widget(dft)
self._switch_to_first_tab()
self._default_tab = self._current_tab
else:
self._current_tab.state = 'normal'
self._setup_default_tab()
def on_default_tab_text(self, *args):
self._default_tab.text = self.default_tab_text
def on_tab_width(self, *l):
ev = self._update_tab_ev
if ev is None:
ev = self._update_tab_ev = Clock.create_trigger(
self._update_tab_width, 0)
ev()
def on_tab_height(self, *l):
self._tab_layout.height = self._tab_strip.height = self.tab_height
self._reposition_tabs()
def on_tab_pos(self, *l):
# ensure canvas
self._reposition_tabs()
def _setup_default_tab(self):
if self._default_tab in self.tab_list:
return
content = self._default_tab.content
_tabs = self._tab_strip
cls = self.default_tab_cls
if isinstance(cls, string_types):
cls = Factory.get(cls)
if not issubclass(cls, TabbedPanelHeader):
raise TabbedPanelException('`default_tab_class` should be\
subclassed from `TabbedPanelHeader`')
# no need to instantiate if class is TabbedPanelHeader
if cls != TabbedPanelHeader:
self._current_tab = self._original_tab = self._default_tab = cls()
default_tab = self.default_tab
if self._original_tab == self.default_tab:
default_tab.text = self.default_tab_text
default_tab.height = self.tab_height
default_tab.group = '__tab%r__' % _tabs.uid
default_tab.state = 'down'
default_tab.width = self.tab_width if self.tab_width else 100
default_tab.content = content
tl = self.tab_list
if default_tab not in tl:
_tabs.add_widget(default_tab, len(tl))
if default_tab.content:
self.clear_widgets()
self.add_widget(self.default_tab.content)
else:
Clock.schedule_once(self._load_default_tab_content)
self._current_tab = default_tab
def _switch_to_first_tab(self, *l):
ltl = len(self.tab_list) - 1
if ltl > -1:
self._current_tab = dt = self._original_tab \
= self.tab_list[ltl]
self.switch_to(dt)
def _load_default_tab_content(self, dt):
if self.default_tab:
self.switch_to(self.default_tab)
def _reposition_tabs(self, *l):
ev = self._update_tabs_ev
if ev is None:
ev = self._update_tabs_ev = Clock.create_trigger(
self._update_tabs, 0)
ev()
def _update_tabs(self, *l):
self_content = self.content
if not self_content:
return
# cache variables for faster access
tab_pos = self.tab_pos
tab_layout = self._tab_layout
tab_layout.clear_widgets()
scrl_v = ScrollView(size_hint=(None, 1), always_overscroll=False,
bar_width=self.bar_width,
scroll_type=self.scroll_type)
tabs = self._tab_strip
parent = tabs.parent
if parent:
parent.remove_widget(tabs)
scrl_v.add_widget(tabs)
scrl_v.pos = (0, 0)
self_update_scrollview = self._update_scrollview
# update scrlv width when tab width changes depends on tab_pos
if self._partial_update_scrollview is not None:
tabs.unbind(width=self._partial_update_scrollview)
self._partial_update_scrollview = partial(
self_update_scrollview, scrl_v)
tabs.bind(width=self._partial_update_scrollview)
# remove all widgets from the tab_strip
super(TabbedPanel, self).clear_widgets()
tab_height = self.tab_height
widget_list = []
tab_list = []
pos_letter = tab_pos[0]
if pos_letter == 'b' or pos_letter == 't':
# bottom or top positions
# one col containing the tab_strip and the content
self.cols = 1
self.rows = 2
# tab_layout contains the scrollview containing tabs and two blank
# dummy widgets for spacing
tab_layout.rows = 1
tab_layout.cols = 3
tab_layout.size_hint = (1, None)
tab_layout.height = (tab_height + tab_layout.padding[1] +
tab_layout.padding[3] + dp(2))
self_update_scrollview(scrl_v)
if pos_letter == 'b':
# bottom
if tab_pos == 'bottom_mid':
tab_list = (Widget(), scrl_v, Widget())
widget_list = (self_content, tab_layout)
else:
if tab_pos == 'bottom_left':
tab_list = (scrl_v, Widget(), Widget())
elif tab_pos == 'bottom_right':
# add two dummy widgets
tab_list = (Widget(), Widget(), scrl_v)
widget_list = (self_content, tab_layout)
else:
# top
if tab_pos == 'top_mid':
tab_list = (Widget(), scrl_v, Widget())
elif tab_pos == 'top_left':
tab_list = (scrl_v, Widget(), Widget())
elif tab_pos == 'top_right':
tab_list = (Widget(), Widget(), scrl_v)
widget_list = (tab_layout, self_content)
elif pos_letter == 'l' or pos_letter == 'r':
# left or right positions
# one row containing the tab_strip and the content
self.cols = 2
self.rows = 1
# tab_layout contains two blank dummy widgets for spacing
# "vertically" and the scatter containing scrollview
# containing tabs
tab_layout.rows = 3
tab_layout.cols = 1
tab_layout.size_hint = (None, 1)
tab_layout.width = tab_height
scrl_v.height = tab_height
self_update_scrollview(scrl_v)
# rotate the scatter for vertical positions
rotation = 90 if tab_pos[0] == 'l' else -90
sctr = Scatter(do_translation=False,
rotation=rotation,
do_rotation=False,
do_scale=False,
size_hint=(None, None),
auto_bring_to_front=False,
size=scrl_v.size)
sctr.add_widget(scrl_v)
lentab_pos = len(tab_pos)
# Update scatter's top when its pos changes.
# Needed for repositioning scatter to the correct place after its
# added to the parent. Use clock_schedule_once to ensure top is
# calculated after the parent's pos on canvas has been calculated.
# This is needed for when tab_pos changes to correctly position
# scatter. Without clock.schedule_once the positions would look
# fine but touch won't translate to the correct position
if tab_pos[lentab_pos - 4:] == '_top':
# on positions 'left_top' and 'right_top'
sctr.bind(pos=partial(self._update_top, sctr, 'top', None))
tab_list = (sctr, )
elif tab_pos[lentab_pos - 4:] == '_mid':
# calculate top of scatter
sctr.bind(pos=partial(self._update_top, sctr, 'mid',
scrl_v.width))
tab_list = (Widget(), sctr, Widget())
elif tab_pos[lentab_pos - 7:] == '_bottom':
tab_list = (Widget(), Widget(), sctr)
if pos_letter == 'l':
widget_list = (tab_layout, self_content)
else:
widget_list = (self_content, tab_layout)
# add widgets to tab_layout
add = tab_layout.add_widget
for widg in tab_list:
add(widg)
# add widgets to self
add = self.add_widget
for widg in widget_list:
add(widg)
def _update_tab_width(self, *l):
if self.tab_width:
for tab in self.tab_list:
tab.size_hint_x = 1
tsw = self.tab_width * len(self._tab_strip.children)
else:
# tab_width = None
tsw = 0
for tab in self.tab_list:
if tab.size_hint_x:
# size_hint_x: x/.xyz
tab.size_hint_x = 1
# drop to default tab_width
tsw += 100
else:
# size_hint_x: None
tsw += tab.width
self._tab_strip.width = tsw
self._reposition_tabs()
def _update_top(self, *args):
sctr, top, scrl_v_width, x, y = args
ev = self._update_top_ev
if ev is not None:
ev.cancel()
ev = self._update_top_ev = Clock.schedule_once(
partial(self._updt_top, sctr, top, scrl_v_width), 0)
def _updt_top(self, sctr, top, scrl_v_width, *args):
if top[0] == 't':
sctr.top = self.top
else:
sctr.top = self.top - (self.height - scrl_v_width) / 2
def _update_scrollview(self, scrl_v, *l):
self_tab_pos = self.tab_pos
self_tabs = self._tab_strip
if self_tab_pos[0] == 'b' or self_tab_pos[0] == 't':
# bottom or top
scrl_v.width = min(self.width, self_tabs.width)
# required for situations when scrl_v's pos is calculated
# when it has no parent
scrl_v.top += 1
scrl_v.top -= 1
else:
# left or right
scrl_v.width = min(self.height, self_tabs.width)
self_tabs.pos = (0, 0)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
'''
Toggle button
=============
.. image:: images/togglebutton.jpg
:align: right
The :class:`ToggleButton` widget acts like a checkbox. When you touch or click
it, the state toggles between 'normal' and 'down' (as opposed to a
:class:`Button` that is only 'down' as long as it is pressed).
Toggle buttons can also be grouped to make radio buttons - only one button in
a group can be in a 'down' state. The group name can be a string or any other
hashable Python object::
btn1 = ToggleButton(text='Male', group='sex',)
btn2 = ToggleButton(text='Female', group='sex', state='down')
btn3 = ToggleButton(text='Mixed', group='sex')
Only one of the buttons can be 'down'/checked at the same time.
To configure the ToggleButton, you can use the same properties that you can use
for a :class:`~kivy.uix.button.Button` class.
'''
__all__ = ('ToggleButton', )
from kivy.uix.button import Button
from kivy.uix.behaviors import ToggleButtonBehavior
class ToggleButton(ToggleButtonBehavior, Button):
'''Toggle button class, see module documentation for more information.
'''
pass
@@ -0,0 +1,665 @@
'''
Tree View
=========
.. image:: images/treeview.png
:align: right
.. versionadded:: 1.0.4
:class:`TreeView` is a widget used to represent a tree structure. It is
currently very basic, supporting a minimal feature set.
Introduction
------------
A :class:`TreeView` is populated with :class:`TreeViewNode` instances, but you
cannot use a :class:`TreeViewNode` directly. You must combine it with another
widget, such as :class:`~kivy.uix.label.Label`,
:class:`~kivy.uix.button.Button` or even your own widget. The TreeView
always creates a default root node, based on :class:`TreeViewLabel`.
:class:`TreeViewNode` is a class object containing needed properties for
serving as a tree node. Extend :class:`TreeViewNode` to create custom node
types for use with a :class:`TreeView`.
For constructing your own subclass, follow the pattern of TreeViewLabel which
combines a Label and a TreeViewNode, producing a :class:`TreeViewLabel` for
direct use in a TreeView instance.
To use the TreeViewLabel class, you could create two nodes directly attached
to root::
tv = TreeView()
tv.add_node(TreeViewLabel(text='My first item'))
tv.add_node(TreeViewLabel(text='My second item'))
Or, create two nodes attached to a first::
tv = TreeView()
n1 = tv.add_node(TreeViewLabel(text='Item 1'))
tv.add_node(TreeViewLabel(text='SubItem 1'), n1)
tv.add_node(TreeViewLabel(text='SubItem 2'), n1)
If you have a large tree structure, perhaps you would need a utility function
to populate the tree view::
def populate_tree_view(tree_view, parent, node):
if parent is None:
tree_node = tree_view.add_node(TreeViewLabel(text=node['node_id'],
is_open=True))
else:
tree_node = tree_view.add_node(TreeViewLabel(text=node['node_id'],
is_open=True), parent)
for child_node in node['children']:
populate_tree_view(tree_view, tree_node, child_node)
tree = {'node_id': '1',
'children': [{'node_id': '1.1',
'children': [{'node_id': '1.1.1',
'children': [{'node_id': '1.1.1.1',
'children': []}]},
{'node_id': '1.1.2',
'children': []},
{'node_id': '1.1.3',
'children': []}]},
{'node_id': '1.2',
'children': []}]}
class TreeWidget(FloatLayout):
def __init__(self, **kwargs):
super(TreeWidget, self).__init__(**kwargs)
tv = TreeView(root_options=dict(text='Tree One'),
hide_root=False,
indent_level=4)
populate_tree_view(tv, None, tree)
self.add_widget(tv)
The root widget in the tree view is opened by default and has text set as
'Root'. If you want to change that, you can use the
:attr:`TreeView.root_options`
property. This will pass options to the root widget::
tv = TreeView(root_options=dict(text='My root label'))
Creating Your Own Node Widget
-----------------------------
For a button node type, combine a :class:`~kivy.uix.button.Button` and a
:class:`TreeViewNode` as follows::
class TreeViewButton(Button, TreeViewNode):
pass
You must know that, for a given node, only the
:attr:`~kivy.uix.widget.Widget.size_hint_x` will be honored. The allocated
width for the node will depend of the current width of the TreeView and the
level of the node. For example, if a node is at level 4, the width
allocated will be:
treeview.width - treeview.indent_start - treeview.indent_level * node.level
You might have some trouble with that. It is the developer's responsibility to
correctly handle adapting the graphical representation nodes, if needed.
'''
from kivy.clock import Clock
from kivy.uix.label import Label
from kivy.uix.widget import Widget
from kivy.properties import BooleanProperty, ListProperty, ObjectProperty, \
AliasProperty, NumericProperty, ReferenceListProperty, ColorProperty
class TreeViewException(Exception):
'''Exception for errors in the :class:`TreeView`.
'''
pass
class TreeViewNode(object):
'''TreeViewNode class, used to build a node class for a TreeView object.
'''
def __init__(self, **kwargs):
if self.__class__ is TreeViewNode:
raise TreeViewException('You cannot use directly TreeViewNode.')
super(TreeViewNode, self).__init__(**kwargs)
is_leaf = BooleanProperty(True)
'''Boolean to indicate whether this node is a leaf or not. Used to adjust
the graphical representation.
:attr:`is_leaf` is a :class:`~kivy.properties.BooleanProperty` and defaults
to True. It is automatically set to False when child is added.
'''
is_open = BooleanProperty(False)
'''Boolean to indicate whether this node is opened or not, in case there
are child nodes. This is used to adjust the graphical representation.
.. warning::
This property is automatically set by the :class:`TreeView`. You can
read but not write it.
:attr:`is_open` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
is_loaded = BooleanProperty(False)
'''Boolean to indicate whether this node is already loaded or not. This
property is used only if the :class:`TreeView` uses asynchronous loading.
:attr:`is_loaded` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
is_selected = BooleanProperty(False)
'''Boolean to indicate whether this node is selected or not. This is used
adjust the graphical representation.
.. warning::
This property is automatically set by the :class:`TreeView`. You can
read but not write it.
:attr:`is_selected` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
no_selection = BooleanProperty(False)
'''Boolean used to indicate whether selection of the node is allowed or
not.
:attr:`no_selection` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
nodes = ListProperty([])
'''List of nodes. The nodes list is different than the children list. A
node in the nodes list represents a node on the tree. An item in the
children list represents the widget associated with the node.
.. warning::
This property is automatically set by the :class:`TreeView`. You can
read but not write it.
:attr:`nodes` is a :class:`~kivy.properties.ListProperty` and defaults to
[].
'''
parent_node = ObjectProperty(None, allownone=True)
'''Parent node. This attribute is needed because the :attr:`parent` can be
None when the node is not displayed.
.. versionadded:: 1.0.7
:attr:`parent_node` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None.
'''
level = NumericProperty(-1)
'''Level of the node.
:attr:`level` is a :class:`~kivy.properties.NumericProperty` and defaults
to -1.
'''
color_selected = ColorProperty([.3, .3, .3, 1.])
'''Background color of the node when the node is selected.
:attr:`color_selected` is a :class:`~kivy.properties.ColorProperty` and
defaults to [.1, .1, .1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
odd = BooleanProperty(False)
'''
This property is set by the TreeView widget automatically and is read-only.
:attr:`odd` is a :class:`~kivy.properties.BooleanProperty` and defaults to
False.
'''
odd_color = ColorProperty([1., 1., 1., .0])
'''Background color of odd nodes when the node is not selected.
:attr:`odd_color` is a :class:`~kivy.properties.ColorProperty` and defaults
to [1., 1., 1., 0.].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
even_color = ColorProperty([0.5, 0.5, 0.5, 0.1])
'''Background color of even nodes when the node is not selected.
:attr:`bg_color` is a :class:`~kivy.properties.ColorProperty` and defaults
to [.5, .5, .5, .1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
class TreeViewLabel(Label, TreeViewNode):
'''Combines a :class:`~kivy.uix.label.Label` and a :class:`TreeViewNode` to
create a :class:`TreeViewLabel` that can be used as a text node in the
tree.
See module documentation for more information.
'''
class TreeView(Widget):
'''TreeView class. See module documentation for more information.
:Events:
`on_node_expand`: (node, )
Fired when a node is being expanded
`on_node_collapse`: (node, )
Fired when a node is being collapsed
'''
__events__ = ('on_node_expand', 'on_node_collapse')
def __init__(self, **kwargs):
self._trigger_layout = Clock.create_trigger(self._do_layout, -1)
super(TreeView, self).__init__(**kwargs)
tvlabel = TreeViewLabel(text='Root', is_open=True, level=0)
for key, value in self.root_options.items():
setattr(tvlabel, key, value)
self._root = self.add_node(tvlabel, None)
trigger = self._trigger_layout
fbind = self.fbind
fbind('pos', trigger)
fbind('size', trigger)
fbind('indent_level', trigger)
fbind('indent_start', trigger)
trigger()
def add_node(self, node, parent=None):
'''Add a new node to the tree.
:Parameters:
`node`: instance of a :class:`TreeViewNode`
Node to add into the tree
`parent`: instance of a :class:`TreeViewNode`, defaults to None
Parent node to attach the new node. If `None`, it is added to
the :attr:`root` node.
:returns:
the node `node`.
'''
# check if the widget is "ok" for a node
if not isinstance(node, TreeViewNode):
raise TreeViewException(
'The node must be a subclass of TreeViewNode')
# create node
if parent is None and self._root:
parent = self._root
if parent:
parent.is_leaf = False
parent.nodes.append(node)
node.parent_node = parent
node.level = parent.level + 1
node.fbind('size', self._trigger_layout)
self._trigger_layout()
return node
def remove_node(self, node):
'''Removes a node from the tree.
.. versionadded:: 1.0.7
:Parameters:
`node`: instance of a :class:`TreeViewNode`
Node to remove from the tree. If `node` is :attr:`root`, it is
not removed.
'''
# check if the widget is "ok" for a node
if not isinstance(node, TreeViewNode):
raise TreeViewException(
'The node must be a subclass of TreeViewNode')
parent = node.parent_node
if parent is not None:
if node == self._selected_node:
node.is_selected = False
self._selected_node = None
nodes = parent.nodes
if node in nodes:
nodes.remove(node)
parent.is_leaf = not bool(len(nodes))
node.parent_node = None
node.funbind('size', self._trigger_layout)
self._trigger_layout()
def on_node_expand(self, node):
pass
def on_node_collapse(self, node):
pass
def select_node(self, node):
'''Select a node in the tree.
'''
if node.no_selection:
return
if self._selected_node:
self._selected_node.is_selected = False
node.is_selected = True
self._selected_node = node
def deselect_node(self, *args):
'''Deselect any selected node.
.. versionadded:: 1.10.0
'''
if self._selected_node:
self._selected_node.is_selected = False
self._selected_node = None
def toggle_node(self, node):
'''Toggle the state of the node (open/collapsed).
'''
node.is_open = not node.is_open
if node.is_open:
if self.load_func and not node.is_loaded:
self._do_node_load(node)
self.dispatch('on_node_expand', node)
else:
self.dispatch('on_node_collapse', node)
self._trigger_layout()
def get_node_at_pos(self, pos):
'''Get the node at the position (x, y).
'''
x, y = pos
for node in self.iterate_open_nodes(self.root):
if self.x <= x <= self.right and \
node.y <= y <= node.top:
return node
def iterate_open_nodes(self, node=None):
'''Generator to iterate over all the expended nodes starting from
`node` and down. If `node` is `None`, the generator start with
:attr:`root`.
To get all the open nodes::
treeview = TreeView()
# ... add nodes ...
for node in treeview.iterate_open_nodes():
print(node)
'''
if not node:
node = self.root
if self.hide_root and node is self.root:
pass
else:
yield node
if not node.is_open:
return
f = self.iterate_open_nodes
for cnode in node.nodes:
for ynode in f(cnode):
yield ynode
def iterate_all_nodes(self, node=None):
'''Generator to iterate over all nodes from `node` and down whether
expanded or not. If `node` is `None`, the generator start with
:attr:`root`.
'''
if not node:
node = self.root
yield node
f = self.iterate_all_nodes
for cnode in node.nodes:
for ynode in f(cnode):
yield ynode
#
# Private
#
def on_load_func(self, instance, value):
if value:
Clock.schedule_once(self._do_initial_load)
def _do_initial_load(self, *largs):
if not self.load_func:
return
self._do_node_load(None)
def _do_node_load(self, node):
gen = self.load_func(self, node)
if node:
node.is_loaded = True
if not gen:
return
for cnode in gen:
self.add_node(cnode, node)
def on_root_options(self, instance, value):
if not self.root:
return
for key, value in value.items():
setattr(self.root, key, value)
def _do_layout(self, *largs):
self.clear_widgets()
# display only the one who are is_open
self._do_open_node(self.root)
# now do layout
self._do_layout_node(self.root, 0, self.top)
# now iterate for calculating minimum size
min_width = min_height = 0
for count, node in enumerate(self.iterate_open_nodes(self.root)):
node.odd = False if count % 2 else True
min_width = max(min_width, node.right - self.x)
min_height += node.height
self.minimum_size = (min_width, min_height)
def _do_open_node(self, node):
if self.hide_root and node is self.root:
height = 0
else:
self.add_widget(node)
height = node.height
if not node.is_open:
return height
for cnode in node.nodes:
height += self._do_open_node(cnode)
return height
def _do_layout_node(self, node, level, y):
if self.hide_root and node is self.root:
level -= 1
else:
node.x = self.x + self.indent_start + level * self.indent_level
node.top = y
if node.size_hint_x:
node.width = (self.width - (node.x - self.x)) \
* node.size_hint_x
y -= node.height
if not node.is_open:
return y
for cnode in node.nodes:
y = self._do_layout_node(cnode, level + 1, y)
return y
def on_touch_down(self, touch):
node = self.get_node_at_pos(touch.pos)
if not node:
return
if node.disabled:
return
# toggle node or selection ?
if node.x - self.indent_start <= touch.x < node.x:
self.toggle_node(node)
elif node.x <= touch.x:
self.select_node(node)
node.dispatch('on_touch_down', touch)
return True
#
# Private properties
#
_root = ObjectProperty(None)
_selected_node = ObjectProperty(None, allownone=True)
#
# Properties
#
minimum_width = NumericProperty(0)
'''Minimum width needed to contain all children.
.. versionadded:: 1.0.9
:attr:`minimum_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
minimum_height = NumericProperty(0)
'''Minimum height needed to contain all children.
.. versionadded:: 1.0.9
:attr:`minimum_height` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
minimum_size = ReferenceListProperty(minimum_width, minimum_height)
'''Minimum size needed to contain all children.
.. versionadded:: 1.0.9
:attr:`minimum_size` is a :class:`~kivy.properties.ReferenceListProperty`
of (:attr:`minimum_width`, :attr:`minimum_height`) properties.
'''
indent_level = NumericProperty('16dp')
'''Width used for the indentation of each level except the first level.
Computation of indent for each level of the tree is::
indent = indent_start + level * indent_level
:attr:`indent_level` is a :class:`~kivy.properties.NumericProperty` and
defaults to 16.
'''
indent_start = NumericProperty('24dp')
'''Indentation width of the level 0 / root node. This is mostly the initial
size to accommodate a tree icon (collapsed / expanded). See
:attr:`indent_level` for more information about the computation of level
indentation.
:attr:`indent_start` is a :class:`~kivy.properties.NumericProperty` and
defaults to 24.
'''
hide_root = BooleanProperty(False)
'''Use this property to show/hide the initial root node. If True, the root
node will be appear as a closed node.
:attr:`hide_root` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
def get_selected_node(self):
return self._selected_node
selected_node = AliasProperty(get_selected_node, None,
bind=('_selected_node', ))
'''Node selected by :meth:`TreeView.select_node` or by touch.
:attr:`selected_node` is a :class:`~kivy.properties.AliasProperty` and
defaults to None. It is read-only.
'''
def get_root(self):
return self._root
root = AliasProperty(get_root, None, bind=('_root', ))
'''Root node.
By default, the root node widget is a :class:`TreeViewLabel` with text
'Root'. If you want to change the default options passed to the widget
creation, use the :attr:`root_options` property::
treeview = TreeView(root_options={
'text': 'Root directory',
'font_size': 15})
:attr:`root_options` will change the properties of the
:class:`TreeViewLabel` instance. However, you cannot change the class used
for root node yet.
:attr:`root` is an :class:`~kivy.properties.AliasProperty` and defaults to
None. It is read-only. However, the content of the widget can be changed.
'''
root_options = ObjectProperty({})
'''Default root options to pass for root widget. See :attr:`root` property
for more information about the usage of root_options.
:attr:`root_options` is an :class:`~kivy.properties.ObjectProperty` and
defaults to {}.
'''
load_func = ObjectProperty(None)
'''Callback to use for asynchronous loading. If set, asynchronous loading
will be automatically done. The callback must act as a Python generator
function, using yield to send data back to the treeview.
The callback should be in the format::
def callback(treeview, node):
for name in ('Item 1', 'Item 2'):
yield TreeViewLabel(text=name)
:attr:`load_func` is a :class:`~kivy.properties.ObjectProperty` and
defaults to None.
'''
if __name__ == '__main__':
from kivy.app import App
class TestApp(App):
def build(self):
tv = TreeView(hide_root=True)
add = tv.add_node
root = add(TreeViewLabel(text='Level 1, entry 1', is_open=True))
for x in range(5):
add(TreeViewLabel(text='Element %d' % x), root)
root2 = add(TreeViewLabel(text='Level 1, entry 2', is_open=False))
for x in range(24):
add(TreeViewLabel(text='Element %d' % x), root2)
for x in range(5):
add(TreeViewLabel(text='Element %d' % x), root)
root2 = add(TreeViewLabel(text='Element childs 2', is_open=False),
root)
for x in range(24):
add(TreeViewLabel(text='Element %d' % x), root2)
return tv
TestApp().run()
+302
View File
@@ -0,0 +1,302 @@
'''
Video
=====
The :class:`Video` widget is used to display video files and streams.
Depending on your Video core provider, platform, and plugins, you will
be able to play different formats. For example, the pygame video
provider only supports MPEG1 on Linux and OSX. GStreamer is more
versatile, and can read many video containers and codecs such as MKV,
OGV, AVI, MOV, FLV (if the correct gstreamer plugins are installed). Our
:class:`~kivy.core.video.VideoBase` implementation is used under the
hood.
Video loading is asynchronous - many properties are not available until
the video is loaded (when the texture is created)::
def on_position_change(instance, value):
print('The position in the video is', value)
def on_duration_change(instance, value):
print('The duration of the video is', value)
video = Video(source='PandaSneezes.avi')
video.bind(
position=on_position_change,
duration=on_duration_change
)
One can define a preview image which gets displayed until the video is
started/loaded by passing ``preview`` to the constructor::
video = Video(
source='PandaSneezes.avi',
preview='PandaSneezes_preview.png'
)
One can display the placeholder image when the video stops by reacting on eos::
def on_eos_change(self, inst, val):
if val and self.preview:
self.set_texture_from_resource(self.preview)
video.bind(eos=on_eos_change)
'''
__all__ = ('Video', )
from kivy.clock import Clock
from kivy.uix.image import Image
from kivy.core.video import Video as CoreVideo
from kivy.resources import resource_find
from kivy.properties import (BooleanProperty, NumericProperty, ObjectProperty,
OptionProperty, StringProperty)
class Video(Image):
'''Video class. See module documentation for more information.
'''
preview = StringProperty(None, allownone=True)
'''Filename / source of a preview image displayed before video starts.
:attr:`preview` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
If set, it gets displayed until the video is loaded/started.
.. versionadded:: 2.1.0
'''
state = OptionProperty('stop', options=('play', 'pause', 'stop'))
'''String, indicates whether to play, pause, or stop the video::
# start playing the video at creation
video = Video(source='movie.mkv', state='play')
# create the video, and start later
video = Video(source='movie.mkv')
# and later
video.state = 'play'
:attr:`state` is an :class:`~kivy.properties.OptionProperty` and defaults
to 'stop'.
'''
play = BooleanProperty(False, deprecated=True)
'''
.. deprecated:: 1.4.0
Use :attr:`state` instead.
Boolean, indicates whether the video is playing or not.
You can start/stop the video by setting this property::
# start playing the video at creation
video = Video(source='movie.mkv', play=True)
# create the video, and start later
video = Video(source='movie.mkv')
# and later
video.play = True
:attr:`play` is a :class:`~kivy.properties.BooleanProperty` and defaults to
False.
.. deprecated:: 1.4.0
Use :attr:`state` instead.
'''
eos = BooleanProperty(False)
'''Boolean, indicates whether the video has finished playing or not
(reached the end of the stream).
:attr:`eos` is a :class:`~kivy.properties.BooleanProperty` and defaults to
False.
'''
loaded = BooleanProperty(False)
'''Boolean, indicates whether the video is loaded and ready for playback
or not.
.. versionadded:: 1.6.0
:attr:`loaded` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
position = NumericProperty(-1)
'''Position of the video between 0 and :attr:`duration`. The position
defaults to -1 and is set to a real position when the video is loaded.
:attr:`position` is a :class:`~kivy.properties.NumericProperty` and
defaults to -1.
'''
duration = NumericProperty(-1)
'''Duration of the video. The duration defaults to -1, and is set to a real
duration when the video is loaded.
:attr:`duration` is a :class:`~kivy.properties.NumericProperty` and
defaults to -1.
'''
volume = NumericProperty(1.)
'''Volume of the video, in the range 0-1. 1 means full volume, 0
means mute.
:attr:`volume` is a :class:`~kivy.properties.NumericProperty` and defaults
to 1.
'''
options = ObjectProperty({})
'''Options to pass at Video core object creation.
.. versionadded:: 1.0.4
:attr:`options` is an :class:`kivy.properties.ObjectProperty` and defaults
to {}.
'''
_video_load_event = None
def __init__(self, **kwargs):
self._video = None
super(Video, self).__init__(**kwargs)
self.fbind('source', self._trigger_video_load)
if "eos" in kwargs:
self.options["eos"] = kwargs["eos"]
if self.source:
self._trigger_video_load()
def texture_update(self, *largs):
if self.preview:
self.set_texture_from_resource(self.preview)
else:
self.set_texture_from_resource(self.source)
def seek(self, percent, precise=True):
'''Change the position to a percentage (strictly, a proportion)
of duration.
:Parameters:
`percent`: float or int
Position to seek as a proportion of the total duration,
must be between 0-1.
`precise`: bool, defaults to True
Precise seeking is slower, but seeks to exact requested
percent.
.. warning::
Calling seek() before the video is loaded has no effect.
.. versionadded:: 1.2.0
.. versionchanged:: 1.10.1
The `precise` keyword argument has been added.
'''
if self._video is None:
raise Exception('Video not loaded.')
self._video.seek(percent, precise=precise)
def _trigger_video_load(self, *largs):
ev = self._video_load_event
if ev is None:
ev = self._video_load_event = Clock.schedule_once(
self._do_video_load, -1)
ev()
def _do_video_load(self, *largs):
if CoreVideo is None:
return
self.unload()
if not self.source:
self._video = None
self.texture = None
else:
filename = self.source
# Check if filename is not url
if '://' not in filename:
filename = resource_find(filename)
self._video = CoreVideo(filename=filename, **self.options)
self._video.volume = self.volume
self._video.bind(on_load=self._on_load,
on_frame=self._on_video_frame,
on_eos=self._on_eos)
if self.state == 'play' or self.play:
self._video.play()
self.duration = 1.
self.position = 0.
def on_play(self, instance, value):
value = 'play' if value else 'stop'
return self.on_state(instance, value)
def on_state(self, instance, value):
if not self._video:
return
if value == 'play':
if self.eos:
self._video.stop()
self._video.position = 0.
self.eos = False
self._video.play()
elif value == 'pause':
self._video.pause()
else:
self._video.stop()
self._video.position = 0
def _on_video_frame(self, *largs):
video = self._video
if not video:
return
self.duration = video.duration
self.position = video.position
self.texture = video.texture
self.canvas.ask_update()
def _on_eos(self, *largs):
if not self._video or self._video.eos != 'loop':
self.state = 'stop'
self.eos = True
def _on_load(self, *largs):
self.loaded = True
self._on_video_frame(largs)
def on_volume(self, instance, value):
if self._video:
self._video.volume = value
def unload(self):
'''Unload the video. The playback will be stopped.
.. versionadded:: 1.8.0
'''
if self._video:
self._video.stop()
self._video.unload()
self._video = None
self.loaded = False
if __name__ == '__main__':
from kivy.app import App
import sys
if len(sys.argv) != 2:
print("usage: %s file" % sys.argv[0])
sys.exit(1)
class VideoApp(App):
def build(self):
self.v = Video(source=sys.argv[1], state='play')
self.v.bind(state=self.replay)
return self.v
def replay(self, *args):
if self.v.state == 'stop':
self.v.state = 'play'
VideoApp().run()
@@ -0,0 +1,697 @@
'''
Video player
============
.. versionadded:: 1.2.0
The video player widget can be used to play video and let the user control the
play/pausing, volume and position. The widget cannot be customized much because
of the complex assembly of numerous base widgets.
.. image:: images/videoplayer.jpg
:align: center
Annotations
-----------
If you want to display text at a specific time and for a certain duration,
consider annotations. An annotation file has a ".jsa" extension. The player
will automatically load the associated annotation file if it exists.
An annotation file is JSON-based, providing a list of label dictionary items.
The key and value must match one of the :class:`VideoPlayerAnnotation` items.
For example, here is a short version of a jsa file that you can find in
`examples/widgets/cityCC0.jsa`::
[
{"start": 0, "duration": 2,
"text": "This is an example of annotation"},
{"start": 2, "duration": 2,
"bgcolor": [0.5, 0.2, 0.4, 0.5],
"text": "You can change the background color"}
]
For our cityCC0.mpg example, the result will be:
.. image:: images/videoplayer-annotation.jpg
:align: center
If you want to experiment with annotation files, test with::
python -m kivy.uix.videoplayer examples/widgets/cityCC0.mpg
Fullscreen
----------
The video player can play the video in fullscreen, if
:attr:`VideoPlayer.allow_fullscreen` is activated by a double-tap on
the video. By default, if the video is smaller than the Window, it will be not
stretched.
You can allow stretching by passing custom options to a
:class:`VideoPlayer` instance::
player = VideoPlayer(source='myvideo.avi', state='play',
options={'fit_mode': 'contain'})
End-of-stream behavior
----------------------
You can specify what happens when the video has finished playing by passing an
`eos` (end of stream) directive to the underlying
:class:`~kivy.core.video.VideoBase` class. `eos` can be one of 'stop', 'pause'
or 'loop' and defaults to 'stop'. For example, in order to loop the video::
player = VideoPlayer(source='myvideo.avi', state='play',
options={'eos': 'loop'})
.. note::
The `eos` property of the VideoBase class is a string specifying the
end-of-stream behavior. This property differs from the `eos`
properties of the :class:`VideoPlayer` and
:class:`~kivy.uix.video.Video` classes, whose `eos`
property is simply a boolean indicating that the end of the file has
been reached.
'''
__all__ = ('VideoPlayer', 'VideoPlayerAnnotation')
from json import load
from os.path import exists
from kivy.properties import ObjectProperty, StringProperty, BooleanProperty, \
NumericProperty, DictProperty, OptionProperty
from kivy.animation import Animation
from kivy.uix.gridlayout import GridLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.progressbar import ProgressBar
from kivy.uix.label import Label
from kivy.uix.video import Video
from kivy.uix.video import Image
from kivy.factory import Factory
from kivy.logger import Logger
from kivy.clock import Clock
class VideoPlayerVolume(Image):
video = ObjectProperty(None)
def on_touch_down(self, touch):
if not self.collide_point(*touch.pos):
return False
touch.grab(self)
# save the current volume and delta to it
touch.ud[self.uid] = [self.video.volume, 0]
return True
def on_touch_move(self, touch):
if touch.grab_current is not self:
return
# calculate delta
dy = abs(touch.y - touch.oy)
if dy > 10:
dy = min(dy - 10, 100)
touch.ud[self.uid][1] = dy
self.video.volume = dy / 100.
return True
def on_touch_up(self, touch):
if touch.grab_current is not self:
return
touch.ungrab(self)
dy = abs(touch.y - touch.oy)
if dy < 10:
if self.video.volume > 0:
self.video.volume = 0
else:
self.video.volume = 1.
class VideoPlayerPlayPause(Image):
video = ObjectProperty(None)
def on_touch_down(self, touch):
'''.. versionchanged:: 1.4.0'''
if self.collide_point(*touch.pos):
if self.video.state == 'play':
self.video.state = 'pause'
else:
self.video.state = 'play'
return True
class VideoPlayerStop(Image):
video = ObjectProperty(None)
def on_touch_down(self, touch):
if self.collide_point(*touch.pos):
self.video.state = 'stop'
self.video.position = 0
return True
class VideoPlayerProgressBar(ProgressBar):
video = ObjectProperty(None)
seek = NumericProperty(None, allownone=True)
alpha = NumericProperty(1.)
def __init__(self, **kwargs):
super(VideoPlayerProgressBar, self).__init__(**kwargs)
self.bubble = Factory.Bubble(size=(50, 44))
self.bubble_label = Factory.Label(text='0:00')
self.bubble.add_widget(self.bubble_label)
self.add_widget(self.bubble)
update = self._update_bubble
fbind = self.fbind
fbind('pos', update)
fbind('size', update)
fbind('seek', update)
def on_video(self, instance, value):
self.video.bind(position=self._update_bubble,
state=self._showhide_bubble)
def on_touch_down(self, touch):
if not self.collide_point(*touch.pos):
return
self._show_bubble()
touch.grab(self)
self._update_seek(touch.x)
return True
def on_touch_move(self, touch):
if touch.grab_current is not self:
return
self._update_seek(touch.x)
return True
def on_touch_up(self, touch):
if touch.grab_current is not self:
return
touch.ungrab(self)
if self.seek:
self.video.seek(self.seek)
self.seek = None
self._hide_bubble()
return True
def _update_seek(self, x):
if self.width == 0:
return
x = max(self.x, min(self.right, x)) - self.x
self.seek = x / float(self.width)
def _show_bubble(self):
self.alpha = 1
Animation.stop_all(self, 'alpha')
def _hide_bubble(self):
self.alpha = 1.
Animation(alpha=0, d=4, t='in_out_expo').start(self)
def on_alpha(self, instance, value):
self.bubble.background_color = (1, 1, 1, value)
self.bubble_label.color = (1, 1, 1, value)
def _update_bubble(self, *l):
seek = self.seek
if self.seek is None:
if self.video.duration == 0:
seek = 0
else:
seek = self.video.position / self.video.duration
# convert to minutes:seconds
d = self.video.duration * seek
minutes = int(d / 60)
seconds = int(d - (minutes * 60))
# fix bubble label & position
self.bubble_label.text = '%d:%02d' % (minutes, seconds)
self.bubble.center_x = self.x + seek * self.width
self.bubble.y = self.top
def _showhide_bubble(self, instance, value):
if value == 'play':
self._hide_bubble()
else:
self._show_bubble()
class VideoPlayerPreview(FloatLayout):
source = ObjectProperty(None)
video = ObjectProperty(None)
click_done = BooleanProperty(False)
def on_touch_down(self, touch):
if self.collide_point(*touch.pos) and not self.click_done:
self.click_done = True
self.video.state = 'play'
return True
class VideoPlayerAnnotation(Label):
'''Annotation class used for creating annotation labels.
Additional keys are available:
* bgcolor: [r, g, b, a] - background color of the text box
* bgsource: 'filename' - background image used for the background text box
* border: (n, e, s, w) - border used for the background image
'''
start = NumericProperty(0)
'''Start time of the annotation.
:attr:`start` is a :class:`~kivy.properties.NumericProperty` and defaults
to 0.
'''
duration = NumericProperty(1)
'''Duration of the annotation.
:attr:`duration` is a :class:`~kivy.properties.NumericProperty` and
defaults to 1.
'''
annotation = DictProperty({})
def on_annotation(self, instance, ann):
for key, value in ann.items():
setattr(self, key, value)
class VideoPlayer(GridLayout):
'''VideoPlayer class. See module documentation for more information.
'''
source = StringProperty('')
'''Source of the video to read.
:attr:`source` is a :class:`~kivy.properties.StringProperty` and
defaults to ''.
.. versionchanged:: 1.4.0
'''
thumbnail = StringProperty('')
'''Thumbnail of the video to show. If None, VideoPlayer will try to find
the thumbnail from the :attr:`source` + '.png'.
:attr:`thumbnail` a :class:`~kivy.properties.StringProperty` and defaults
to ''.
.. versionchanged:: 1.4.0
'''
duration = NumericProperty(-1)
'''Duration of the video. The duration defaults to -1 and is set to the
real duration when the video is loaded.
:attr:`duration` is a :class:`~kivy.properties.NumericProperty` and
defaults to -1.
'''
position = NumericProperty(0)
'''Position of the video between 0 and :attr:`duration`. The position
defaults to -1 and is set to the real position when the video is loaded.
:attr:`position` is a :class:`~kivy.properties.NumericProperty` and
defaults to -1.
'''
volume = NumericProperty(1.0)
'''Volume of the video in the range 0-1. 1 means full volume and 0 means
mute.
:attr:`volume` is a :class:`~kivy.properties.NumericProperty` and defaults
to 1.
'''
state = OptionProperty('stop', options=('play', 'pause', 'stop'))
'''String, indicates whether to play, pause, or stop the video::
# start playing the video at creation
video = VideoPlayer(source='movie.mkv', state='play')
# create the video, and start later
video = VideoPlayer(source='movie.mkv')
# and later
video.state = 'play'
:attr:`state` is an :class:`~kivy.properties.OptionProperty` and defaults
to 'stop'.
'''
play = BooleanProperty(False, deprecated=True)
'''
.. deprecated:: 1.4.0
Use :attr:`state` instead.
Boolean, indicates whether the video is playing or not. You can start/stop
the video by setting this property::
# start playing the video at creation
video = VideoPlayer(source='movie.mkv', play=True)
# create the video, and start later
video = VideoPlayer(source='movie.mkv')
# and later
video.play = True
:attr:`play` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
image_overlay_play = StringProperty(
'atlas://data/images/defaulttheme/player-play-overlay')
'''Image filename used to show a "play" overlay when the video has not yet
started.
:attr:`image_overlay_play` is a
:class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/player-play-overlay'.
'''
image_loading = StringProperty('data/images/image-loading.zip')
'''Image filename used when the video is loading.
:attr:`image_loading` is a :class:`~kivy.properties.StringProperty` and
defaults to 'data/images/image-loading.zip'.
'''
image_play = StringProperty(
'atlas://data/images/defaulttheme/media-playback-start')
'''Image filename used for the "Play" button.
:attr:`image_play` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/media-playback-start'.
'''
image_stop = StringProperty(
'atlas://data/images/defaulttheme/media-playback-stop')
'''Image filename used for the "Stop" button.
:attr:`image_stop` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/media-playback-stop'.
'''
image_pause = StringProperty(
'atlas://data/images/defaulttheme/media-playback-pause')
'''Image filename used for the "Pause" button.
:attr:`image_pause` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/media-playback-pause'.
'''
image_volumehigh = StringProperty(
'atlas://data/images/defaulttheme/audio-volume-high')
'''Image filename used for the volume icon when the volume is high.
:attr:`image_volumehigh` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/audio-volume-high'.
'''
image_volumemedium = StringProperty(
'atlas://data/images/defaulttheme/audio-volume-medium')
'''Image filename used for the volume icon when the volume is medium.
:attr:`image_volumemedium` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/audio-volume-medium'.
'''
image_volumelow = StringProperty(
'atlas://data/images/defaulttheme/audio-volume-low')
'''Image filename used for the volume icon when the volume is low.
:attr:`image_volumelow` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/audio-volume-low'.
'''
image_volumemuted = StringProperty(
'atlas://data/images/defaulttheme/audio-volume-muted')
'''Image filename used for the volume icon when the volume is muted.
:attr:`image_volumemuted` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/audio-volume-muted'.
'''
annotations = StringProperty('')
'''If set, it will be used for reading annotations box.
:attr:`annotations` is a :class:`~kivy.properties.StringProperty`
and defaults to ''.
'''
fullscreen = BooleanProperty(False)
'''Switch to fullscreen view. This should be used with care. When
activated, the widget will remove itself from its parent, remove all
children from the window and will add itself to it. When fullscreen is
unset, all the previous children are restored and the widget is restored to
its previous parent.
.. warning::
The re-add operation doesn't care about the index position of its
children within the parent.
:attr:`fullscreen` is a :class:`~kivy.properties.BooleanProperty`
and defaults to False.
'''
allow_fullscreen = BooleanProperty(True)
'''By default, you can double-tap on the video to make it fullscreen. Set
this property to False to prevent this behavior.
:attr:`allow_fullscreen` is a :class:`~kivy.properties.BooleanProperty`
defaults to True.
'''
options = DictProperty({})
'''Optional parameters can be passed to a :class:`~kivy.uix.video.Video`
instance with this property.
:attr:`options` a :class:`~kivy.properties.DictProperty` and
defaults to {}.
'''
# internals
container = ObjectProperty(None)
_video_load_ev = None
def __init__(self, **kwargs):
self._video = None
self._image = None
self._annotations = ''
self._annotations_labels = []
super(VideoPlayer, self).__init__(**kwargs)
update_thumbnail = self._update_thumbnail
update_annotations = self._update_annotations
fbind = self.fbind
fbind('thumbnail', update_thumbnail)
fbind('annotations', update_annotations)
if self.source:
self._trigger_video_load()
def _trigger_video_load(self, *largs):
ev = self._video_load_ev
if ev is None:
ev = self._video_load_ev = Clock.schedule_once(self._do_video_load,
-1)
ev()
def _try_load_default_thumbnail(self, *largs):
if not self.thumbnail:
filename = self.source.rsplit('.', 1)
thumbnail = filename[0] + '.png'
if exists(thumbnail):
self._load_thumbnail(thumbnail)
def _try_load_default_annotations(self, *largs):
if not self.annotations:
filename = self.source.rsplit('.', 1)
annotations = filename[0] + '.jsa'
if exists(annotations):
self._load_annotations(annotations)
def on_source(self, instance, value):
# By default, VideoPlayer should look for thumbnail and annotations
# with the same filename (except extension) of the source (video) file.
Clock.schedule_once(self._try_load_default_thumbnail, -1)
Clock.schedule_once(self._try_load_default_annotations, -1)
if self._video is not None:
self._video.unload()
self._video = None
if value:
self._trigger_video_load()
def _update_thumbnail(self, *largs):
self._load_thumbnail(self.thumbnail)
def _update_annotations(self, *largs):
self._load_annotations(self.annotations)
def on_image_overlay_play(self, instance, value):
self._image.image_overlay_play = value
def on_image_loading(self, instance, value):
self._image.image_loading = value
def _load_thumbnail(self, thumbnail):
if not self.container:
return
self.container.clear_widgets()
if thumbnail:
self._image = VideoPlayerPreview(source=thumbnail, video=self)
self.container.add_widget(self._image)
def _load_annotations(self, annotations):
if not self.container:
return
self._annotations_labels = []
if annotations:
with open(annotations, 'r') as fd:
self._annotations = load(fd)
if self._annotations:
for ann in self._annotations:
self._annotations_labels.append(
VideoPlayerAnnotation(annotation=ann))
def on_state(self, instance, value):
if self._video is not None:
self._video.state = value
def _set_state(self, instance, value):
self.state = value
def _do_video_load(self, *largs):
self._video = Video(source=self.source, state=self.state,
volume=self.volume, pos_hint={'x': 0, 'y': 0},
**self.options)
self._video.bind(texture=self._play_started,
duration=self.setter('duration'),
position=self.setter('position'),
volume=self.setter('volume'),
state=self._set_state)
def on_play(self, instance, value):
value = 'play' if value else 'stop'
return self.on_state(instance, value)
def on_volume(self, instance, value):
if not self._video:
return
self._video.volume = value
def on_position(self, instance, value):
labels = self._annotations_labels
if not labels:
return
for label in labels:
start = label.start
duration = label.duration
if start > value or (start + duration) < value:
if label.parent:
label.parent.remove_widget(label)
elif label.parent is None:
self.container.add_widget(label)
def seek(self, percent, precise=True):
'''Change the position to a percentage (strictly, a proportion)
of duration.
:Parameters:
`percent`: float or int
Position to seek as a proportion of total duration, must
be between 0-1.
`precise`: bool, defaults to True
Precise seeking is slower, but seeks to exact requested
percent.
.. warning::
Calling seek() before the video is loaded has no effect.
.. versionadded:: 1.2.0
.. versionchanged:: 1.10.1
The `precise` keyword argument has been added.
'''
if not self._video:
return
self._video.seek(percent, precise=precise)
def _play_started(self, instance, value):
self.container.clear_widgets()
self.container.add_widget(self._video)
def on_touch_down(self, touch):
if not self.collide_point(*touch.pos):
return False
if touch.is_double_tap and self.allow_fullscreen:
self.fullscreen = not self.fullscreen
return True
return super(VideoPlayer, self).on_touch_down(touch)
def on_fullscreen(self, instance, value):
window = self.get_parent_window()
if not window:
Logger.warning('VideoPlayer: Cannot switch to fullscreen, '
'window not found.')
if value:
self.fullscreen = False
return
if not self.parent:
Logger.warning('VideoPlayer: Cannot switch to fullscreen, '
'no parent.')
if value:
self.fullscreen = False
return
if value:
self._fullscreen_state = state = {
'parent': self.parent,
'pos': self.pos,
'size': self.size,
'pos_hint': self.pos_hint,
'size_hint': self.size_hint,
'window_children': window.children[:]}
# remove all window children
for child in window.children[:]:
window.remove_widget(child)
# put the video in fullscreen
if state['parent'] is not window:
state['parent'].remove_widget(self)
window.add_widget(self)
# ensure the video widget is in 0, 0, and the size will be
# readjusted
self.pos = (0, 0)
self.size = (100, 100)
self.pos_hint = {}
self.size_hint = (1, 1)
else:
state = self._fullscreen_state
window.remove_widget(self)
for child in state['window_children']:
window.add_widget(child)
self.pos_hint = state['pos_hint']
self.size_hint = state['size_hint']
self.pos = state['pos']
self.size = state['size']
if state['parent'] is not window:
state['parent'].add_widget(self)
if __name__ == '__main__':
import sys
from kivy.base import runTouchApp
player = VideoPlayer(source=sys.argv[1])
runTouchApp(player)
if player:
player.state = 'stop'
@@ -0,0 +1,879 @@
'''
VKeyboard
=========
.. image:: images/vkeyboard.jpg
:align: right
.. versionadded:: 1.0.8
VKeyboard is an onscreen keyboard for Kivy. Its operation is intended to be
transparent to the user. Using the widget directly is NOT recommended. Read the
section `Request keyboard`_ first.
Modes
-----
This virtual keyboard has a docked and free mode:
* docked mode (:attr:`VKeyboard.docked` = True)
Generally used when only one person is using the computer, like a tablet or
personal computer etc.
* free mode: (:attr:`VKeyboard.docked` = False)
Mostly for multitouch surfaces. This mode allows multiple virtual
keyboards to be used on the screen.
If the docked mode changes, you need to manually call
:meth:`VKeyboard.setup_mode` otherwise the change will have no impact.
During that call, the VKeyboard, implemented on top of a
:class:`~kivy.uix.scatter.Scatter`, will change the
behavior of the scatter and position the keyboard near the target (if target
and docked mode is set).
Layouts
-------
The virtual keyboard is able to load a custom layout. If you create a new
layout and put the JSON in :file:`<kivy_data_dir>/keyboards/<layoutid>.json`,
you can load it by setting :attr:`VKeyboard.layout` to your layoutid.
The JSON must be structured like this::
{
"title": "Title of your layout",
"description": "Description of your layout",
"cols": 15,
"rows": 5,
...
}
Then, you need to describe the keys in each row, for either a "normal",
"shift" or a "special" (added in version 1.9.0) mode. Keys for this row
data must be named `normal_<row>`, `shift_<row>` and `special_<row>`.
Replace `row` with the row number.
Inside each row, you will describe the key. A key is a 4 element list in
the format::
[ <text displayed on the keyboard>, <text to put when the key is pressed>,
<text that represents the keycode>, <size of cols> ]
Here are example keys::
# f key
["f", "f", "f", 1]
# capslock
["\u21B9", "\t", "tab", 1.5]
Finally, complete the JSON::
{
...
"normal_1": [
["`", "`", "`", 1], ["1", "1", "1", 1], ["2", "2", "2", 1],
["3", "3", "3", 1], ["4", "4", "4", 1], ["5", "5", "5", 1],
["6", "6", "6", 1], ["7", "7", "7", 1], ["8", "8", "8", 1],
["9", "9", "9", 1], ["0", "0", "0", 1], ["+", "+", "+", 1],
["=", "=", "=", 1], ["\u232b", null, "backspace", 2]
],
"shift_1": [ ... ],
"normal_2": [ ... ],
"special_2": [ ... ],
...
}
Request Keyboard
----------------
The instantiation of the virtual keyboard is controlled by the configuration.
Check `keyboard_mode` and `keyboard_layout` in the :doc:`api-kivy.config`.
If you intend to create a widget that requires a keyboard, do not use the
virtual keyboard directly, but prefer to use the best method available on
the platform. Check the :meth:`~kivy.core.window.WindowBase.request_keyboard`
method in the :doc:`api-kivy.core.window`.
If you want a specific layout when you request the keyboard, you should write
something like this (from 1.8.0, numeric.json can be in the same directory as
your main.py)::
keyboard = Window.request_keyboard(
self._keyboard_close, self)
if keyboard.widget:
vkeyboard = self._keyboard.widget
vkeyboard.layout = 'numeric.json'
'''
__all__ = ('VKeyboard', )
from kivy import kivy_data_dir
from kivy.vector import Vector
from kivy.config import Config
from kivy.uix.scatter import Scatter
from kivy.uix.label import Label
from kivy.properties import ObjectProperty, NumericProperty, StringProperty, \
BooleanProperty, DictProperty, OptionProperty, ListProperty, ColorProperty
from kivy.logger import Logger
from kivy.graphics import Color, BorderImage, Canvas
from kivy.core.image import Image
from kivy.resources import resource_find
from kivy.clock import Clock
from io import open
from os.path import join, splitext, basename
from os import listdir
from json import loads
default_layout_path = join(kivy_data_dir, 'keyboards')
class VKeyboard(Scatter):
'''
VKeyboard is an onscreen keyboard with multitouch support.
Its layout is entirely customizable and you can switch between available
layouts using a button in the bottom right of the widget.
:Events:
`on_key_down`: keycode, internal, modifiers
Fired when the keyboard received a key down event (key press).
`on_key_up`: keycode, internal, modifiers
Fired when the keyboard received a key up event (key release).
'''
target = ObjectProperty(None, allownone=True)
'''Target widget associated with the VKeyboard. If set, it will be used to
send keyboard events. If the VKeyboard mode is "free", it will also be used
to set the initial position.
:attr:`target` is an :class:`~kivy.properties.ObjectProperty` instance and
defaults to None.
'''
callback = ObjectProperty(None, allownone=True)
'''Callback can be set to a function that will be called if the
VKeyboard is closed by the user.
:attr:`target` is an :class:`~kivy.properties.ObjectProperty` instance and
defaults to None.
'''
layout = StringProperty(None)
'''Layout to use for the VKeyboard. By default, it will be the
layout set in the configuration, according to the `keyboard_layout`
in `[kivy]` section.
.. versionchanged:: 1.8.0
If layout is a .json filename, it will loaded and added to the
available_layouts.
:attr:`layout` is a :class:`~kivy.properties.StringProperty` and defaults
to None.
'''
layout_path = StringProperty(default_layout_path)
'''Path from which layouts are read.
:attr:`layout` is a :class:`~kivy.properties.StringProperty` and
defaults to :file:`<kivy_data_dir>/keyboards/`
'''
available_layouts = DictProperty({})
'''Dictionary of all available layouts. Keys are the layout ID, and the
value is the JSON (translated into a Python object).
:attr:`available_layouts` is a :class:`~kivy.properties.DictProperty` and
defaults to {}.
'''
docked = BooleanProperty(False)
'''Indicate whether the VKeyboard is docked on the screen or not. If you
change it, you must manually call :meth:`setup_mode` otherwise it will have
no impact. If the VKeyboard is created by the Window, the docked mode will
be automatically set by the configuration, using the `keyboard_mode` token
in `[kivy]` section.
:attr:`docked` is a :class:`~kivy.properties.BooleanProperty` and defaults
to False.
'''
margin_hint = ListProperty([.05, .06, .05, .06])
'''Margin hint, used as spacing between keyboard background and keys
content. The margin is composed of four values, between 0 and 1::
margin_hint = [top, right, bottom, left]
The margin hints will be multiplied by width and height, according to their
position.
:attr:`margin_hint` is a :class:`~kivy.properties.ListProperty` and
defaults to [.05, .06, .05, .06]
'''
key_margin = ListProperty([2, 2, 2, 2])
'''Key margin, used to create space between keys. The margin is composed of
four values, in pixels::
key_margin = [top, right, bottom, left]
:attr:`key_margin` is a :class:`~kivy.properties.ListProperty` and defaults
to [2, 2, 2, 2]
'''
font_size = NumericProperty(20.)
'''font_size, specifies the size of the text on the virtual keyboard keys.
It should be kept within limits to ensure the text does not extend beyond
the bounds of the key or become too small to read.
.. versionadded:: 1.10.0
:attr:`font_size` is a :class:`~kivy.properties.NumericProperty` and
defaults to 20.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''Background color, in the format (r, g, b, a). If a background is
set, the color will be combined with the background texture.
:attr:`background_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
background = StringProperty(
'atlas://data/images/defaulttheme/vkeyboard_background')
'''Filename of the background image.
:attr:`background` is a :class:`~kivy.properties.StringProperty` and
defaults to :file:`atlas://data/images/defaulttheme/vkeyboard_background`.
'''
background_disabled = StringProperty(
'atlas://data/images/defaulttheme/vkeyboard_disabled_background')
'''Filename of the background image when the vkeyboard is disabled.
.. versionadded:: 1.8.0
:attr:`background_disabled` is a
:class:`~kivy.properties.StringProperty` and defaults to
:file:`atlas://data/images/defaulttheme/vkeyboard__disabled_background`.
'''
key_background_color = ColorProperty([1, 1, 1, 1])
'''Key background color, in the format (r, g, b, a). If a key background is
set, the color will be combined with the key background texture.
:attr:`key_background_color` is a :class:`~kivy.properties.ColorProperty`
and defaults to [1, 1, 1, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
key_background_normal = StringProperty(
'atlas://data/images/defaulttheme/vkeyboard_key_normal')
'''Filename of the key background image for use when no touches are active
on the widget.
:attr:`key_background_normal` is a :class:`~kivy.properties.StringProperty`
and defaults to
:file:`atlas://data/images/defaulttheme/vkeyboard_key_normal`.
'''
key_disabled_background_normal = StringProperty(
'atlas://data/images/defaulttheme/vkeyboard_key_normal')
'''Filename of the key background image for use when no touches are active
on the widget and vkeyboard is disabled.
.. versionadded:: 1.8.0
:attr:`key_disabled_background_normal` is a
:class:`~kivy.properties.StringProperty` and defaults to
:file:`atlas://data/images/defaulttheme/vkeyboard_disabled_key_normal`.
'''
key_background_down = StringProperty(
'atlas://data/images/defaulttheme/vkeyboard_key_down')
'''Filename of the key background image for use when a touch is active
on the widget.
:attr:`key_background_down` is a :class:`~kivy.properties.StringProperty`
and defaults to
:file:`atlas://data/images/defaulttheme/vkeyboard_key_down`.
'''
background_border = ListProperty([16, 16, 16, 16])
'''Background image border. Used for controlling the
:attr:`~kivy.graphics.vertex_instructions.BorderImage.border` property of
the background.
:attr:`background_border` is a :class:`~kivy.properties.ListProperty` and
defaults to [16, 16, 16, 16]
'''
key_border = ListProperty([8, 8, 8, 8])
'''Key image border. Used for controlling the
:attr:`~kivy.graphics.vertex_instructions.BorderImage.border` property of
the key.
:attr:`key_border` is a :class:`~kivy.properties.ListProperty` and
defaults to [16, 16, 16, 16]
'''
# XXX internal variables
layout_mode = OptionProperty('normal',
options=('normal', 'shift', 'special'))
layout_geometry = DictProperty({})
have_capslock = BooleanProperty(False)
have_shift = BooleanProperty(False)
have_special = BooleanProperty(False)
active_keys = DictProperty({})
font_name = StringProperty('data/fonts/DejaVuSans.ttf')
repeat_touch = ObjectProperty(allownone=True)
_start_repeat_key_ev = None
_repeat_key_ev = None
__events__ = ('on_key_down', 'on_key_up', 'on_textinput')
def __init__(self, **kwargs):
# XXX move to style.kv
if 'size_hint' not in kwargs:
if 'size_hint_x' not in kwargs:
self.size_hint_x = None
if 'size_hint_y' not in kwargs:
self.size_hint_y = None
if 'size' not in kwargs:
if 'width' not in kwargs:
self.width = 700
if 'height' not in kwargs:
self.height = 200
if 'scale_min' not in kwargs:
self.scale_min = .4
if 'scale_max' not in kwargs:
self.scale_max = 1.6
if 'docked' not in kwargs:
self.docked = False
layout_mode = self._trigger_update_layout_mode = Clock.create_trigger(
self._update_layout_mode)
layouts = self._trigger_load_layouts = Clock.create_trigger(
self._load_layouts)
layout = self._trigger_load_layout = Clock.create_trigger(
self._load_layout)
fbind = self.fbind
fbind('docked', self.setup_mode)
fbind('have_shift', layout_mode)
fbind('have_capslock', layout_mode)
fbind('have_special', layout_mode)
fbind('layout_path', layouts)
fbind('layout', layout)
super(VKeyboard, self).__init__(**kwargs)
# load all the layouts found in the layout_path directory
self._load_layouts()
# ensure we have default layouts
available_layouts = self.available_layouts
if not available_layouts:
Logger.critical('VKeyboard: unable to load default layouts')
# load the default layout from configuration
if self.layout is None:
self.layout = Config.get('kivy', 'keyboard_layout')
else:
# ensure the current layout is found on the available layout
self._trigger_load_layout()
# update layout mode (shift or normal)
self._trigger_update_layout_mode()
# create a top layer to draw active keys on
with self.canvas:
self.background_key_layer = Canvas()
self.active_keys_layer = Canvas()
def on_disabled(self, instance, value):
self.refresh_keys()
def _update_layout_mode(self, *l):
# update mode according to capslock and shift key
mode = self.have_capslock != self.have_shift
mode = 'shift' if mode else 'normal'
if self.have_special:
mode = "special"
if mode != self.layout_mode:
self.layout_mode = mode
self.refresh(False)
def _load_layout(self, *largs):
# ensure new layouts are loaded first
if self._trigger_load_layouts.is_triggered:
self._load_layouts()
self._trigger_load_layouts.cancel()
value = self.layout
available_layouts = self.available_layouts
# it's a filename, try to load it directly
if self.layout[-5:] == '.json':
if value not in available_layouts:
fn = resource_find(self.layout)
self._load_layout_fn(fn, self.layout)
if not available_layouts:
return
if value not in available_layouts and value != 'qwerty':
Logger.error(
'Vkeyboard: <%s> keyboard layout mentioned in '
'conf file was not found, fallback on qwerty' %
value)
self.layout = 'qwerty'
self.refresh(True)
def _load_layouts(self, *largs):
# first load available layouts from json files
# XXX fix to be able to reload layout when path is changing
value = self.layout_path
for fn in listdir(value):
self._load_layout_fn(join(value, fn),
basename(splitext(fn)[0]))
def _load_layout_fn(self, fn, name):
available_layouts = self.available_layouts
if fn[-5:] != '.json':
return
with open(fn, 'r', encoding='utf-8') as fd:
json_content = fd.read()
layout = loads(json_content)
available_layouts[name] = layout
def setup_mode(self, *largs):
'''Call this method when you want to readjust the keyboard according to
options: :attr:`docked` or not, with attached :attr:`target` or not:
* If :attr:`docked` is True, it will call :meth:`setup_mode_dock`
* If :attr:`docked` is False, it will call :meth:`setup_mode_free`
Feel free to overload these methods to create new
positioning behavior.
'''
if self.docked:
self.setup_mode_dock()
else:
self.setup_mode_free()
def setup_mode_dock(self, *largs):
'''Setup the keyboard in docked mode.
Dock mode will reset the rotation, disable translation, rotation and
scale. Scale and position will be automatically adjusted to attach the
keyboard to the bottom of the screen.
.. note::
Don't call this method directly, use :meth:`setup_mode` instead.
'''
self.do_translation = False
self.do_rotation = False
self.do_scale = False
self.rotation = 0
win = self.get_parent_window()
scale = win.width / float(self.width)
self.scale = scale
self.pos = 0, 0
win.bind(on_resize=self._update_dock_mode)
def _update_dock_mode(self, win, *largs):
scale = win.width / float(self.width)
self.scale = scale
self.pos = 0, 0
def setup_mode_free(self):
'''Setup the keyboard in free mode.
Free mode is designed to let the user control the position and
orientation of the keyboard. The only real usage is for a multiuser
environment, but you might found other ways to use it.
If a :attr:`target` is set, it will place the vkeyboard under the
target.
.. note::
Don't call this method directly, use :meth:`setup_mode` instead.
'''
self.do_translation = True
self.do_rotation = True
self.do_scale = True
target = self.target
if not target:
return
# NOTE all math will be done in window point of view
# determine rotation of the target
a = Vector(1, 0)
b = Vector(target.to_window(0, 0))
c = Vector(target.to_window(1, 0)) - b
self.rotation = -a.angle(c)
# determine the position of center/top of the keyboard
dpos = Vector(self.to_window(self.width / 2., self.height))
# determine the position of center/bottom of the target
cpos = Vector(target.to_window(target.center_x, target.y))
# the goal now is to map both point, calculate the diff between them
diff = dpos - cpos
# we still have an issue, self.pos represent the bounding box,
# not the 0,0 coordinate of the scatter. we need to apply also
# the diff between them (inside and outside coordinate matrix).
# It's hard to explain, but do a scheme on a paper, write all
# the vector i'm calculating, and you'll understand. :)
diff2 = Vector(self.x + self.width / 2., self.y + self.height) - \
Vector(self.to_parent(self.width / 2., self.height))
diff -= diff2
# now we have a good "diff", set it as a pos.
self.pos = -diff
def change_layout(self):
# XXX implement popup with all available layouts
pass
def refresh(self, force=False):
'''(internal) Recreate the entire widget and graphics according to the
selected layout.
'''
self.clear_widgets()
if force:
self.refresh_keys_hint()
self.refresh_keys()
self.refresh_active_keys_layer()
def refresh_active_keys_layer(self):
self.active_keys_layer.clear()
active_keys = self.active_keys
layout_geometry = self.layout_geometry
background = resource_find(self.key_background_down)
texture = Image(background, mipmap=True).texture
with self.active_keys_layer:
Color(*self.key_background_color)
for line_nb, index in active_keys.values():
pos, size = layout_geometry['LINE_%d' % line_nb][index]
BorderImage(texture=texture, pos=pos, size=size,
border=self.key_border)
def refresh_keys_hint(self):
layout = self.available_layouts[self.layout]
layout_cols = layout['cols']
layout_rows = layout['rows']
layout_geometry = self.layout_geometry
mtop, mright, mbottom, mleft = self.margin_hint
# get relative EFFICIENT surface of the layout without external margins
el_hint = 1. - mleft - mright
eh_hint = 1. - mtop - mbottom
ex_hint = 0 + mleft
ey_hint = 0 + mbottom
# get relative unit surface
uw_hint = (1. / layout_cols) * el_hint
uh_hint = (1. / layout_rows) * eh_hint
layout_geometry['U_HINT'] = (uw_hint, uh_hint)
# calculate individual key RELATIVE surface and pos (without key
# margin)
current_y_hint = ey_hint + eh_hint
for line_nb in range(1, layout_rows + 1):
current_y_hint -= uh_hint
# get line_name
line_name = '%s_%d' % (self.layout_mode, line_nb)
line_hint = 'LINE_HINT_%d' % line_nb
layout_geometry[line_hint] = []
current_x_hint = ex_hint
# go through the list of keys (tuples of 4)
for key in layout[line_name]:
# calculate relative pos, size
layout_geometry[line_hint].append([
(current_x_hint, current_y_hint),
(key[3] * uw_hint, uh_hint)])
current_x_hint += key[3] * uw_hint
self.layout_geometry = layout_geometry
def refresh_keys(self):
layout = self.available_layouts[self.layout]
layout_rows = layout['rows']
layout_geometry = self.layout_geometry
w, h = self.size
kmtop, kmright, kmbottom, kmleft = self.key_margin
uw_hint, uh_hint = layout_geometry['U_HINT']
for line_nb in range(1, layout_rows + 1):
llg = layout_geometry['LINE_%d' % line_nb] = []
llg_append = llg.append
for key in layout_geometry['LINE_HINT_%d' % line_nb]:
x_hint, y_hint = key[0]
w_hint, h_hint = key[1]
kx = x_hint * w
ky = y_hint * h
kw = w_hint * w
kh = h_hint * h
# now adjust, considering the key margin
kx = int(kx + kmleft)
ky = int(ky + kmbottom)
kw = int(kw - kmleft - kmright)
kh = int(kh - kmbottom - kmtop)
pos = (kx, ky)
size = (kw, kh)
llg_append((pos, size))
self.layout_geometry = layout_geometry
self.draw_keys()
def draw_keys(self):
layout = self.available_layouts[self.layout]
layout_rows = layout['rows']
layout_geometry = self.layout_geometry
layout_mode = self.layout_mode
# draw background
background = resource_find(self.background_disabled
if self.disabled else
self.background)
texture = Image(background, mipmap=True).texture
self.background_key_layer.clear()
with self.background_key_layer:
Color(*self.background_color)
BorderImage(texture=texture, size=self.size,
border=self.background_border)
# XXX separate drawing the keys and the fonts to avoid
# XXX reloading the texture each time
# first draw keys without the font
key_normal = resource_find(self.key_background_disabled_normal
if self.disabled else
self.key_background_normal)
texture = Image(key_normal, mipmap=True).texture
with self.background_key_layer:
Color(*self.key_background_color)
for line_nb in range(1, layout_rows + 1):
for pos, size in layout_geometry['LINE_%d' % line_nb]:
BorderImage(texture=texture, pos=pos, size=size,
border=self.key_border)
# then draw the text
for line_nb in range(1, layout_rows + 1):
key_nb = 0
for pos, size in layout_geometry['LINE_%d' % line_nb]:
# retrieve the relative text
text = layout[layout_mode + '_' + str(line_nb)][key_nb][0]
z = Label(text=text, font_size=self.font_size, pos=pos,
size=size, font_name=self.font_name)
self.add_widget(z)
key_nb += 1
def on_key_down(self, *largs):
pass
def on_key_up(self, *largs):
pass
def on_textinput(self, *largs):
pass
def get_key_at_pos(self, x, y):
w, h = self.size
x_hint = x / w
# focus on the surface without margins
layout_geometry = self.layout_geometry
layout = self.available_layouts[self.layout]
layout_rows = layout['rows']
mtop, mright, mbottom, mleft = self.margin_hint
# get the line of the layout
e_height = h - (mbottom + mtop) * h # efficient height in pixels
line_height = e_height / layout_rows # line height in px
y = y - mbottom * h
line_nb = layout_rows - int(y / line_height)
if line_nb > layout_rows:
line_nb = layout_rows
if line_nb < 1:
line_nb = 1
# get the key within the line
key_index = ''
current_key_index = 0
for key in layout_geometry['LINE_HINT_%d' % line_nb]:
if x_hint >= key[0][0] and x_hint < key[0][0] + key[1][0]:
key_index = current_key_index
break
else:
current_key_index += 1
if key_index == '':
return None
# get the full character
key = layout['%s_%d' % (self.layout_mode, line_nb)][key_index]
return [key, (line_nb, key_index)]
def collide_margin(self, x, y):
'''Do a collision test, and return True if the (x, y) is inside the
vkeyboard margin.
'''
mtop, mright, mbottom, mleft = self.margin_hint
x_hint = x / self.width
y_hint = y / self.height
if x_hint > mleft and x_hint < 1. - mright \
and y_hint > mbottom and y_hint < 1. - mtop:
return False
return True
def process_key_on(self, touch):
if not touch:
return
x, y = self.to_local(*touch.pos)
key = self.get_key_at_pos(x, y)
if not key:
return
key_data = key[0]
displayed_char, internal, special_char, size = key_data
line_nb, key_index = key[1]
# save pressed key on the touch
ud = touch.ud[self.uid] = {}
ud['key'] = key
# for caps lock or shift only:
uid = touch.uid
if special_char is not None:
# Do not repeat special keys
if special_char in ('capslock', 'shift', 'layout', 'special'):
if self._start_repeat_key_ev is not None:
self._start_repeat_key_ev.cancel()
self._start_repeat_key_ev = None
self.repeat_touch = None
if special_char == 'capslock':
self.have_capslock = not self.have_capslock
uid = -1
elif special_char == 'shift':
self.have_shift = True
elif special_char == 'special':
self.have_special = True
elif special_char == 'layout':
self.change_layout()
# send info to the bus
b_keycode = special_char
b_modifiers = self._get_modifiers()
if self.get_parent_window().__class__.__module__ == \
'kivy.core.window.window_sdl2' and internal:
self.dispatch('on_textinput', internal)
else:
self.dispatch('on_key_down', b_keycode, internal, b_modifiers)
# save key as an active key for drawing
self.active_keys[uid] = key[1]
self.refresh_active_keys_layer()
def process_key_up(self, touch):
uid = touch.uid
if self.uid not in touch.ud:
return
# save pressed key on the touch
key_data, key = touch.ud[self.uid]['key']
displayed_char, internal, special_char, size = key_data
# send info to the bus
b_keycode = special_char
b_modifiers = self._get_modifiers()
self.dispatch('on_key_up', b_keycode, internal, b_modifiers)
if special_char == 'capslock':
uid = -1
if uid in self.active_keys:
self.active_keys.pop(uid, None)
if special_char == 'shift':
self.have_shift = False
elif special_char == 'special':
self.have_special = False
if special_char == 'capslock' and self.have_capslock:
self.active_keys[-1] = key
self.refresh_active_keys_layer()
def _get_modifiers(self):
ret = []
if self.have_shift:
ret.append('shift')
if self.have_capslock:
ret.append('capslock')
return ret
def _start_repeat_key(self, *kwargs):
self._repeat_key_ev = Clock.schedule_interval(self._repeat_key, 0.05)
def _repeat_key(self, *kwargs):
self.process_key_on(self.repeat_touch)
def on_touch_down(self, touch):
x, y = touch.pos
if not self.collide_point(x, y):
return
if self.disabled:
return True
x, y = self.to_local(x, y)
if not self.collide_margin(x, y):
if self.repeat_touch is None:
self._start_repeat_key_ev = Clock.schedule_once(
self._start_repeat_key, 0.5)
self.repeat_touch = touch
self.process_key_on(touch)
touch.grab(self, exclusive=True)
else:
super(VKeyboard, self).on_touch_down(touch)
return True
def on_touch_up(self, touch):
if touch.grab_current is self:
self.process_key_up(touch)
if self._start_repeat_key_ev is not None:
self._start_repeat_key_ev.cancel()
self._start_repeat_key_ev = None
if touch == self.repeat_touch:
if self._repeat_key_ev is not None:
self._repeat_key_ev.cancel()
self._repeat_key_ev = None
self.repeat_touch = None
return super(VKeyboard, self).on_touch_up(touch)
if __name__ == '__main__':
from kivy.base import runTouchApp
vk = VKeyboard(layout='azerty')
runTouchApp(vk)
File diff suppressed because it is too large Load Diff