view cagou/core/widgets_handler.py @ 171:27b4ceb977c7

widgets handler: keep proportion on resize
author Goffi <goffi@goffi.org>
date Sun, 29 Apr 2018 13:10:14 +0200
parents f4522b7c3318
children 9835cafbd909
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


from sat.core import log as logging
log = logging.getLogger(__name__)
from sat.core import exceptions
from sat_frontends.quick_frontend import quick_widgets
from kivy.graphics import Color, Ellipse
from kivy.uix.layout import Layout
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.stencilview import StencilView
from kivy.metrics import dp
from kivy import properties
from cagou import G


REMOVE_WID_LIMIT = dp(10)
MIN_WIDTH = MIN_HEIGHT = dp(50)


class WHWrapper(BoxLayout):
    carousel = properties.ObjectProperty(None)
    split_size = properties.NumericProperty(dp(1))
    split_margin = properties.NumericProperty(dp(2))
    split_color = properties.ListProperty([0.8, 0.8, 0.8, 1])
    split_color_move = properties.ListProperty([0.0, 0.8, 0.8, 1])
    split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1])
    # sp stands for "split point"
    sp_size = properties.NumericProperty(dp(1))
    sp_space = properties.NumericProperty(dp(4))
    sp_zone = properties.NumericProperty(dp(30))
    _split = properties.OptionProperty('None', options=['None', 'left', 'top'])
    _split_del = properties.BooleanProperty(False)

    def __init__(self, **kwargs):
        idx = kwargs.pop('_wid_idx')
        self._wid_idx = idx
        super(WHWrapper, self).__init__(**kwargs)
        self._former_slide = None
        self.carousel.bind(current_slide=self.onSlideChange)
        self._slides_update_lock = False
        self._left_wids = set()
        self._top_wids = set()
        self._right_wids = set()
        self._bottom_wids = set()

    def __repr__(self):
        return "WHWrapper_{idx}".format(idx=self._wid_idx)

    def _main_wid(self, wid_list):
        """return main widget of a side list

        main widget is either the widget currently splitter
        or any widget if none is split
        @return (WHWrapper, None): main widget or None
            if there is not widget
        """
        if not wid_list:
            return None
        for wid in wid_list:
            if wid._split != 'None':
                return wid
        return next(iter(wid_list))

    @property
    def _left_wid(self):
        return self._main_wid(self._left_wids)

    @property
    def _top_wid(self):
        return self._main_wid(self._top_wids)

    @property
    def _right_wid(self):
        return self._main_wid(self._right_wids)

    @property
    def _bottom_wid(self):
        return self._main_wid(self._bottom_wids)

    @property
    def current_slide(self):
        return self.carousel.current_slide

    def _draw_ellipse(self):
        """draw split ellipse"""
        color = self.split_color_del if self._split_del else self.split_color_move
        try:
            self.canvas.after.remove(self.ellipse)
        except AttributeError:
            pass
        if self._split == "top":
            with self.canvas.after:
                Color(*color)
                self.ellipse = Ellipse(angle_start=90, angle_end=270,
                               pos=(self.x + self.width/2 - self.sp_zone/2,
                                    self.y + self.height - self.sp_zone/2),
                               size=(self.sp_zone, self.sp_zone))
        elif self._split == "left":
            with self.canvas.after:
                Color(*color)
                self.ellipse = Ellipse(angle_end=180,
                               pos=(self.x + -self.sp_zone/2,
                                    self.y + self.height/2 - self.sp_zone/2),
                               size = (self.sp_zone, self.sp_zone))
        else:
            raise exceptions.InternalError('unexpected split value')

    def on_touch_down(self, touch):
        """activate split if touch is on a split zone"""
        if not self.collide_point(*touch.pos):
            return
        log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format(
            self._wid_idx,
            'None' if not self._left_wids else [w._wid_idx for w in self._left_wids],
            'None' if not self._top_wids else [w._wid_idx for w in self._top_wids],
            'None' if not self._right_wids else [w._wid_idx for w in self._right_wids],
            'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids],
            self.pos,
            self.size,
            ))
        touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True)
        if (touch_ry <= self.height and
            touch_ry >= self.height - self.split_size - self.split_margin or
            touch_ry <= self.height and
            touch_ry >= self.height - self.sp_zone and
            touch_rx >= self.width/2 - self.sp_zone/2 and
            touch_rx <= self.width/2 + self.sp_zone/2):
            # split area is touched, we activate top split mode
            self._split = "top"
            self._draw_ellipse()
        elif (touch_rx >= 0 and
              touch_rx <= self.split_size + self.split_margin or
              touch_rx >= 0 and
              touch_rx <= self.sp_zone and
              touch_ry >= self.height/2 - self.sp_zone/2 and
              touch_ry <= self.height/2 + self.sp_zone/2):
            # split area is touched, we activate left split mode
            self._split = "left"
            touch.ud['ori_width'] = self.width
            self._draw_ellipse()
        else:
            if len(self.carousel.slides) == 1:
                # we don't want swipe if there is only one slide
                return StencilView.on_touch_down(self.carousel, touch)
            else:
                return super(WHWrapper, self).on_touch_down(touch)

    def on_touch_move(self, touch):
        """handle size change and widget creation on split"""
        if self._split == 'None':
            return super(WHWrapper, self).on_touch_move(touch)

        elif self._split == 'top':
            new_height = touch.y - self.y

            if new_height < MIN_HEIGHT:
                return

            # we must not pass the top widget/border
            if self._top_wids:
                top = next(iter(self._top_wids))
                y_limit = top.y + top.height

                if top.height <= REMOVE_WID_LIMIT:
                    # we are in remove zone, we add visual hint for that
                    if not self._split_del and self._top_wids:
                        self._split_del = True
                        self._draw_ellipse()
                else:
                    if self._split_del:
                        self._split_del = False
                        self._draw_ellipse()
            else:
                y_limit = self.y + self.height

            if touch.y >= y_limit:
                return

            # all right, we can change size
            self.height = new_height
            self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2)

            if not self._top_wids:
                # we are the last widget on the top
                # so we create a new widget
                new_wid = self.parent.add_widget()
                self._top_wids.add(new_wid)
                new_wid._bottom_wids.add(self)
                for w in self._right_wids:
                    new_wid._right_wids.add(w)
                    w._left_wids.add(new_wid)
                for w in self._left_wids:
                    new_wid._left_wids.add(w)
                    w._right_wids.add(new_wid)

        elif self._split == 'left':
            ori_width = touch.ud['ori_width']
            new_x = touch.x
            new_width = ori_width - (touch.x - touch.ox)

            if new_width < MIN_WIDTH:
                return

            # we must not pass the left widget/border
            if self._left_wids:
                left = next(iter(self._left_wids))
                x_limit = left.x

                if left.width <= REMOVE_WID_LIMIT:
                    # we are in remove zone, we add visual hint for that
                    if not self._split_del and self._left_wids:
                        self._split_del = True
                        self._draw_ellipse()
                else:
                    if self._split_del:
                        self._split_del = False
                        self._draw_ellipse()
            else:
                x_limit = self.x

            if new_x <= x_limit:
                return

            # all right, we can change position/size
            self.x = new_x
            self.width = new_width
            self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1])

            if not self._left_wids:
                # we are the last widget on the left
                # so we create a new widget
                new_wid = self.parent.add_widget()
                self._left_wids.add(new_wid)
                new_wid._right_wids.add(self)
                for w in self._top_wids:
                    new_wid._top_wids.add(w)
                    w._bottom_wids.add(new_wid)
                for w in self._bottom_wids:
                    new_wid._bottom_wids.add(w)
                    w._top_wids.add(new_wid)

        else:
            raise Exception.InternalError('invalid _split value')

    def on_touch_up(self, touch):
        if self._split == 'None':
            return super(WHWrapper, self).on_touch_up(touch)
        if self._split == 'top':
            # we remove all top widgets in delete zone,
            # and update there side widgets list
            for top in self._top_wids.copy():
                if top.height <= REMOVE_WID_LIMIT:
                    for w in top._top_wids:
                        w._bottom_wids.remove(top)
                        w._bottom_wids.update(top._bottom_wids)
                    for w in top._bottom_wids:
                        w._top_wids.remove(top)
                        w._top_wids.update(top._top_wids)
                    for w in top._left_wids:
                        w._right_wids.remove(top)
                    for w in top._right_wids:
                        w._left_wids.remove(top)
                    self.parent.remove_widget(top)
        elif self._split == 'left':
            # we remove all left widgets in delete zone,
            # and update there side widgets list
            for left in self._left_wids.copy():
                if left.width <= REMOVE_WID_LIMIT:
                    for w in left._left_wids:
                        w._right_wids.remove(left)
                        w._right_wids.update(left._right_wids)
                    for w in left._right_wids:
                        w._left_wids.remove(left)
                        w._left_wids.update(left._left_wids)
                    for w in left._top_wids:
                        w._bottom_wids.remove(left)
                    for w in left._bottom_wids:
                        w._top_wids.remove(left)
                    self.parent.remove_widget(left)
        self._split = 'None'
        self.canvas.after.remove(self.ellipse)
        del self.ellipse

    def set_widget(self, wid, index=0):
        self.carousel.add_widget(wid, index)

    def changeWidget(self, new_widget):
        """Change currently displayed widget

        slides widgets will be updated
        """
        # slides update need to be blocked to avoid the update in onSlideChange
        # which would mess the removal of current widgets
        self._slides_update_lock = True
        current = self.carousel.current_slide
        for w in self.carousel.slides:
            if w == current or w == new_widget:
                continue
            if isinstance(w, quick_widgets.QuickWidget):
                G.host.widgets.deleteWidget(w)
        self.carousel.clear_widgets()
        self.carousel.add_widget(new_widget)
        self._slides_update_lock = False
        self.updateHiddenSlides()

    def onSlideChange(self, handler, new_slide):
        if isinstance(self._former_slide, quick_widgets.QuickWidget):
            G.host.removeVisibleWidget(self._former_slide)
        self._former_slide = new_slide
        if isinstance(new_slide, quick_widgets.QuickWidget):
            G.host.addVisibleWidget(new_slide)
            self.updateHiddenSlides()

    def hiddenList(self, visible_list):
        """return widgets of same class as holded one which are hidden

        @param visible_list(list[QuickWidget]): widgets visible
        @return (iter[QuickWidget]): widgets hidden
        """
        added = [(w.targets, w.profiles) for w in visible_list]  # we want to avoid recreated widgets
        for w in G.host.widgets.getWidgets(self.current_slide.__class__, profiles=self.current_slide.profiles):
            if w in visible_list or (w.targets, w.profiles) in added:
                continue
            yield w

    def widgets_sort(self, widget):
        """method used as key to sort the widgets

        order of the widgets when changing slide is affected
        @param widget(QuickWidget): widget to sort
        @return: a value which will be used for sorting
        """
        try:
            return unicode(widget.target).lower()
        except AttributeError:
            return unicode(list(widget.targets)[0]).lower()

    def updateHiddenSlides(self):
        """adjust carousel slides according to visible widgets"""
        if self._slides_update_lock:
            return
        if not isinstance(self.carousel.current_slide, quick_widgets.QuickWidget):
            return
        # lock must be used here to avoid recursions
        self._slides_update_lock = True
        visible_list = G.host.getVisibleList(self.current_slide.__class__)
        hidden = list(self.hiddenList(visible_list))
        slides_sorted =  sorted(hidden + [self.carousel.current_slide], key=self.widgets_sort)
        to_remove = set(self.carousel.slides).difference({self.carousel.current_slide})
        for w in to_remove:
            self.carousel.remove_widget(w)
        if hidden:
            # no need to add more than two widgets (next and previous),
            # as the list will be updated on each new visible widget
            current_idx = slides_sorted.index(self.current_slide)
            try:
                next_slide = slides_sorted[current_idx+1]
            except IndexError:
                next_slide = slides_sorted[0]
            self.carousel.add_widget(G.host.getOrClone(next_slide))
            if len(hidden)>1:
                previous_slide = slides_sorted[current_idx-1]
                self.carousel.add_widget(G.host.getOrClone(previous_slide))

        self._slides_update_lock = False


class WidgetsHandlerLayout(Layout):
    count = 0

    def __init__(self, **kwargs):
        super(WidgetsHandlerLayout, self).__init__(**kwargs)
        self._layout_size = None  # size used for the last layout
        fbind = self.fbind
        update = self._trigger_layout
        fbind('children', update)
        fbind('parent', update)
        fbind('size', self.adjust_prop)
        fbind('pos', update)

    @property
    def default_widget(self):
        return G.host.default_wid['factory'](G.host.default_wid, None, None)

    def adjust_prop(self, handler, new_size):
        """Adjust children proportion

        useful when this widget is resized (e.g. when going to fullscreen)
        """
        if len(self.children) > 1:
            old_width, old_height = self._layout_size
            if not old_width or not old_height:
                # we don't want division by zero
                return self._trigger_layout(handler, new_size)
            width_factor = float(self.width) / old_width
            height_factor = float(self.height) / old_height
            for child in self.children:
                child.width *= width_factor
                child.height *= height_factor
                child.x *= width_factor
                child.y *= height_factor
        self._trigger_layout(handler, new_size)

    def do_layout(self, *args):
        self._layout_size = self.size[:]
        for child in self.children:
            # XXX: left must be calculated before right and bottom before top
            #      because they are the pos, and are used to caculate size (right and top)
            # left
            left = child._left_wid
            left_end_x = self.x-1 if left is None else left.right
            if child.x != left_end_x + 1 and child._split == "None":
                child.x = left_end_x + 1
            # right
            right = child._right_wid
            right_x = self.right + 1 if right is None else right.x
            if child.right != right_x - 1:
                child.width = right_x - child.x - 1
            # bottom
            bottom = child._bottom_wid
            if bottom is None:
                if child.y != self.y:
                    child.y = self.y
            else:
                if child.y != bottom.top + 1:
                    child.y = bottom.top + 1
            # top
            top = child._top_wid
            top_y = self.top+1 if top is None else top.y
            if child.top != top_y - 1:
                if child._split == "None":
                    child.height = top_y - child.y - 1

    def remove_widget(self, wid):
        super(WidgetsHandlerLayout, self).remove_widget(wid)
        log.debug("widget deleted ({})".format(wid._wid_idx))

    def add_widget(self, wid=None, index=0):
        WidgetsHandlerLayout.count += 1
        if wid is None:
            wid = self.default_widget
        wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count)
        log.debug("WHWrapper created ({})".format(wrapper._wid_idx))
        wrapper.set_widget(wid)
        super(WidgetsHandlerLayout, self).add_widget(wrapper, index)
        return wrapper


class WidgetsHandler(WidgetsHandlerLayout):

    def __init__(self, **kw):
        super(WidgetsHandler, self).__init__(**kw)
        self.wrapper = self.add_widget()

    @property
    def cagou_widget(self):
        """get holded CagouWidget"""
        return self.wrapper.current_slide