User:Walter/favoriteslayout.py

/usr/share/sugar/shell/view/home/favoriteslayout.py

# Copyright (C) 2008 One Laptop Per Child
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

import logging
import math
import hashlib
from gettext import gettext as _

import gobject
import gtk
import hippo

from sugar.graphics import style
from sugar import activity

from view.home.grid import Grid

_logger = logging.getLogger('FavoritesLayout')

_CELL_SIZE = 4
_BASE_SCALE = 1000

class FavoritesLayout(gobject.GObject, hippo.CanvasLayout):
    """Base class of the different layout types."""

    __gtype_name__ = 'FavoritesLayout'

    def __init__(self):
        gobject.GObject.__init__(self)
        self.box = None
        self.fixed_positions = {}

    def do_set_box(self, box):
        self.box = box

    def do_get_height_request(self, for_width):
        return 0, gtk.gdk.screen_height() - style.GRID_CELL_SIZE

    def do_get_width_request(self):
        return 0, gtk.gdk.screen_width()

    def compare_activities(self, icon_a, icon_b):
        return 0

    def append(self, icon, locked=False):
        self.box.insert_sorted(icon, 0, self.compare_activities)
        if hasattr(icon, 'fixed_position'):
            relative_x, relative_y = icon.fixed_position
            if relative_x >= 0 and relative_y >= 0:
                min_width_, width = self.box.get_width_request()
                min_height_, height = self.box.get_height_request(width)
                self.fixed_positions[icon] = \
                        (int(relative_x * _BASE_SCALE / float(width)),
                         int(relative_y * _BASE_SCALE / float(height)))

    def remove(self, icon):
        if icon in self.fixed_positions:
            del self.fixed_positions[icon]
        self.box.remove(icon)

    def move_icon(self, icon, x, y, locked=False):
        if icon not in self.box.get_children():
            raise ValueError('Child not in box.')

        if hasattr(icon, 'get_bundle_id') and hasattr(icon, 'get_version'):
            min_width_, width = self.box.get_width_request()
            min_height_, height = self.box.get_height_request(width)
            registry = activity.get_registry()
            registry.set_activity_position(
                    icon.get_bundle_id(), icon.get_version(),
                    x * width / float(_BASE_SCALE),
                    y * height / float(_BASE_SCALE))
            self.fixed_positions[icon] = (x, y)

    def do_allocate(self, x, y, width, height, req_width, req_height,
                    origin_changed):
        raise NotImplementedError()

    def allow_dnd(self):
        return False

class RandomLayout(FavoritesLayout):
    """Lay out icons randomly; try to nudge them around to resolve overlaps."""

    __gtype_name__ = 'RandomLayout'

    icon_name = 'view-freeform'
    """Name of icon used in home view dropdown palette."""

    profile_key = 'random-layout'
    """String used in profile to represent this view."""

    def __init__(self):
        FavoritesLayout.__init__(self)

        min_width_, width = self.do_get_width_request()
        min_height_, height = self.do_get_height_request(width)

        self._grid = Grid(width / _CELL_SIZE, height / _CELL_SIZE)
        self._grid.connect('child-changed', self.__grid_child_changed_cb)

    def __grid_child_changed_cb(self, grid, child):
        child.emit_request_changed()

    def append(self, icon, locked=False):
        FavoritesLayout.append(self, icon, locked)

        min_width_, child_width = icon.get_width_request()
        min_height_, child_height = icon.get_height_request(child_width)
        min_width_, width = self.box.get_width_request()
        min_height_, height = self.box.get_height_request(width)

        if icon in self.fixed_positions:
            x, y = self.fixed_positions[icon]
            x = min(x, width - child_width)
            y = min(y, height - child_height)
        elif hasattr(icon, 'get_bundle_id'):
            name_hash = hashlib.md5(icon.get_bundle_id())
            x = int(name_hash.hexdigest()[:5], 16) % (width - child_width)
            y = int(name_hash.hexdigest()[-5:], 16) % (height - child_height)
        else:
            x = None
            y = None

        if x is None or y is None:
            self._grid.add(icon,
                           child_width / _CELL_SIZE, child_height / _CELL_SIZE)
        else:
            self._grid.add(icon,
                           child_width / _CELL_SIZE, child_height / _CELL_SIZE,
                           x / _CELL_SIZE, y / _CELL_SIZE)

    def remove(self, icon):
        self._grid.remove(icon)
        FavoritesLayout.remove(self, icon)

    def move_icon(self, icon, x, y, locked=False):
        self._grid.move(icon, x / _CELL_SIZE, y / _CELL_SIZE, locked)
        FavoritesLayout.move_icon(self, icon, x, y, locked)

    def do_allocate(self, x, y, width, height, req_width, req_height,
                    origin_changed):
        for child in self.box.get_layout_children():
            # We need to always get requests to not confuse hippo
            min_w_, child_width = child.get_width_request()
            min_h_, child_height = child.get_height_request(child_width)

            rect = self._grid.get_child_rect(child.item)
            child.allocate(rect.x * _CELL_SIZE,
                           rect.y * _CELL_SIZE,
                           child_width,
                           child_height,
                           origin_changed)

    def allow_dnd(self):
        return True

_MINIMUM_RADIUS = style.XLARGE_ICON_SIZE / 2 + style.DEFAULT_SPACING + \
        style.STANDARD_ICON_SIZE * 2
_MAXIMUM_RADIUS = (gtk.gdk.screen_height() - style.GRID_CELL_SIZE) / 2 - \
        style.STANDARD_ICON_SIZE - style.DEFAULT_SPACING

class RingLayout(FavoritesLayout):
    """Lay out icons in a ring around the XO man."""

    __gtype_name__ = 'RingLayout'
    icon_name = 'view-radial'
    """Name of icon used in home view dropdown palette."""
    profile_key = 'ring-layout'
    """String used in profile to represent this view."""

    def __init__(self):
        FavoritesLayout.__init__(self)
        self._locked_children = {}

    def append(self, icon, locked=False):
        FavoritesLayout.append(self, icon, locked)
        if locked:
            child = self.box.find_box_child(icon)
            self._locked_children[child] = (0, 0)

    def remove(self, icon):
        child = self.box.find_box_child(icon)
        if child in self._locked_children:
            del self._locked_children[child]
        FavoritesLayout.remove(self, icon)

    def move_icon(self, icon, x, y, locked=False):
        FavoritesLayout.move_icon(self, icon, x, y, locked)
        if locked:
            child = self.box.find_box_child(icon)
            self._locked_children[child] = (x, y)

    def _calculate_radius_and_icon_size(self, children_count):
        # what's the radius required without downscaling?
        distance = style.STANDARD_ICON_SIZE + style.DEFAULT_SPACING
        icon_size = style.STANDARD_ICON_SIZE
        # circumference is 2*pi*r; we want this to be at least
        # 'children_count * distance'
        radius = children_count * distance / (2 * math.pi)
        # limit computed radius to reasonable bounds.
        radius = max(radius, _MINIMUM_RADIUS)
        radius = min(radius, _MAXIMUM_RADIUS)
        # recompute icon size from limited radius
        if children_count > 0:
            icon_size = (2 * math.pi * radius / children_count) \
                        - style.DEFAULT_SPACING
        # limit adjusted icon size.
        icon_size = max(icon_size, style.SMALL_ICON_SIZE)
        icon_size = min(icon_size, style.MEDIUM_ICON_SIZE)
        return radius, icon_size

    def _calculate_position(self, radius, icon_size, index, children_count,
                            sin=math.sin, cos=math.cos):
        width, height = self.box.get_allocation()
        angle = index * (2 * math.pi / children_count) - math.pi / 2
        x = radius * cos(angle) + (width - icon_size) / 2
        y = radius * sin(angle) + (height - icon_size -
                                   (style.GRID_CELL_SIZE/2) ) / 2
        return x, y

    def _get_children_in_ring(self):
        children_in_ring = [child for child in self.box.get_layout_children() \
                if child not in self._locked_children]
        return children_in_ring

    def _update_icon_sizes(self):
        # XXX: THIS METHOD IS NEVER CALLED
        children_in_ring = self._get_children_in_ring()
        radius_, icon_size = \
                self._calculate_radius_and_icon_size(len(children_in_ring))

        for child in children_in_ring:
            child.item.props.size = icon_size

    def do_allocate(self, x, y, width, height, req_width, req_height,
                    origin_changed):
        children_in_ring = self._get_children_in_ring()
        if children_in_ring:
            radius, icon_size = \
                    self._calculate_radius_and_icon_size(len(children_in_ring))

            for n in range(len(children_in_ring)):
                child = children_in_ring[n]

                x, y = self._calculate_position(radius, icon_size, n,
                                                len(children_in_ring))

                # We need to always get requests to not confuse hippo
                min_w_, child_width = child.get_width_request()
                min_h_, child_height = child.get_height_request(child_width)

                child.allocate(int(x), int(y), child_width, child_height,
                               origin_changed)
                child.item.props.size = icon_size

        for child in self._locked_children.keys():
            x, y = self._locked_children[child]

            # We need to always get requests to not confuse hippo
            min_w_, child_width = child.get_width_request()
            min_h_, child_height = child.get_height_request(child_width)

            child.allocate(int(x), int(y), child_width, child_height,
                            origin_changed)

    def compare_activities(self, icon_a, icon_b):
        if hasattr(icon_a, 'installation_time') and \
                hasattr(icon_b, 'installation_time'):
            return icon_b.installation_time - icon_a.installation_time
        else:
            return 0

_SUNFLOWER_CONSTANT = style.STANDARD_ICON_SIZE * .75
"""Chose a constant such that STANDARD_ICON_SIZE icons are nicely spaced."""

_SUNFLOWER_OFFSET = \
    math.pow((style.XLARGE_ICON_SIZE / 2 + style.STANDARD_ICON_SIZE) /
             _SUNFLOWER_CONSTANT, 2)
"""
Compute a starting index for the `SunflowerLayout` which leaves space for
the XO man in the center.  Since r = _SUNFLOWER_CONSTANT * sqrt(n),
solve for n when r is (XLARGE_ICON_SIZE + STANDARD_ICON_SIZE)/2.
"""

_GOLDEN_RATIO = 1.6180339887498949
"""
Golden ratio: http://en.wikipedia.org/wiki/Golden_ratio
Calculation: (math.sqrt(5) + 1) / 2
"""

_SUNFLOWER_ANGLE = 2.3999632297286531
"""
The sunflower angle is approximately 137.5 degrees.
This is the golden angle: http://en.wikipedia.org/wiki/Golden_angle
Calculation: math.radians(360) / ( _GOLDEN_RATIO * _GOLDEN_RATIO )
"""

class SunflowerLayout(RingLayout):
    """Spiral layout based on Fibonacci ratio in phyllotaxis.

    See http://algorithmicbotany.org/papers/abop/abop-ch4.pdf
    for details of Vogel's model of florets in a sunflower head."""

    __gtype_name__ = 'SunflowerLayout'

    icon_name = 'view-spiral'
    """Name of icon used in home view dropdown palette."""

    profile_key = 'spiral-layout'
    """String used in profile to represent this view."""

    def __init__(self):
        RingLayout.__init__(self)
        self.skipped_indices = []

    def _calculate_radius_and_icon_size(self, children_count):
        """Stub out this method; not used in `SunflowerLayout`."""
        return None, style.STANDARD_ICON_SIZE

    def adjust_index(self, i):
        """Skip floret indices which end up outside the desired bounding box."""
        for idx in self.skipped_indices:
            if i < idx: break
            i += 1
        return i

    def _calculate_position(self, radius, icon_size, oindex, children_count):
        """Calculate the position of sunflower floret number 'oindex'.
        If the result is outside the bounding box, use the next index which
        is inside the bounding box."""

        width, height = self.box.get_allocation()

        while True:

            index = self.adjust_index(oindex)

            # tweak phi to get a nice gap lined up where the "active activity"
            # icon is, below the central XO man.
            phi = index * _SUNFLOWER_ANGLE + math.radians(-130)

            # we offset index when computing r to make space for the XO man.
            r = _SUNFLOWER_CONSTANT * math.sqrt(index + _SUNFLOWER_OFFSET)

            # x,y are the top-left corner of the icon, so remove icon_size
            # from width/height to compensate.  y has an extra GRID_CELL_SIZE/2
            # removed to make room for the "active activity" icon.
            x = r * math.cos(phi) + (width - icon_size) / 2
            y = r * math.sin(phi) + (height - icon_size - \
                                     (style.GRID_CELL_SIZE / 2) ) / 2

            # skip allocations outside the allocation box.
            # give up once we can't fit
            if r < math.hypot(width / 2, height / 2):
                if y < 0 or y > (height - icon_size) or \
                       x < 0 or x > (width - icon_size):
                    self.skipped_indices.append(index)
                    continue # try again

            return x, y

class BoxLayout(RingLayout):
    """Lay out icons in a square around the XO man."""

    __gtype_name__ = 'BoxLayout'

    icon_name = 'view-box'
    """Name of icon used in home view dropdown palette."""

    profile_key = 'box-layout'
    """String used in profile to represent this view."""

    def __init__(self):
        RingLayout.__init__(self)

    def _calculate_position(self, radius, icon_size, index, children_count):

        # use "orthogonal" versions of cos and sin in order to square the
        # circle and turn the 'ring view' into a 'box view'
        def cos_d(d):
            while d < 0:
                d += 360
            if d < 45: return 1
            if d < 135: return (90 - d) / 45.
            if d < 225: return -1
            return cos_d(360 - d) # mirror around 180

        cos = lambda r: cos_d(math.degrees(r))
        sin = lambda r: cos_d(math.degrees(r) - 90)

        return RingLayout._calculate_position\
               (self, radius, icon_size, index, children_count,
                sin=sin, cos=cos)

class TriangleLayout(RingLayout):
    """Lay out icons in a triangle around the XO man."""

    __gtype_name__ = 'TriangleLayout'

    icon_name = 'view-triangle'
    """Name of icon used in home view dropdown palette."""

    profile_key = 'triangle-layout'
    """String used in profile to represent this view."""

    def __init__(self):
        RingLayout.__init__(self)

    def _calculate_radius_and_icon_size(self, children_count):
        # use slightly larger minimum radius than parent, because sides
        # of triangle come awful close to the center.
        radius, icon_size = \
            RingLayout._calculate_radius_and_icon_size(self, children_count)
        return max(radius, _MINIMUM_RADIUS + style.MEDIUM_ICON_SIZE), icon_size

    def _calculate_position(self, radius, icon_size, index, children_count):
        # tweak cos and sin in order to make the 'ring' into an equilateral
        # triangle.

        def cos_d(d):
            while d < -90:
                d += 360
            if d <= 30: return (d + 90) / 120.
            if d <= 90: return (90 - d) / 60.
            return -cos_d(180 - d) # mirror around 90

        sqrt_3 = math.sqrt(3)

        def sin_d(d):
            while d < -90:
                d += 360
            if d <= 30: return ((d + 90) / 120.) * sqrt_3 - 1
            if d <= 90: return sqrt_3 - 1
            return sin_d(180 - d) # mirror around 90

        cos = lambda r: cos_d(math.degrees(r))
        sin = lambda r: sin_d(math.degrees(r))

        return RingLayout._calculate_position\
               (self, radius, icon_size, index, children_count,
                sin=sin, cos=cos)

class MyLayout(RingLayout):
    """Spiral layout based on Archimedean spiral: r = a + b*theta."""

    __gtype_name__ = 'MyLayout'

    icon_name = 'view-mylayout'
    """Name of icon used in home view dropdown palette."""

    profile_key = 'my-layout'
    """String used in profile to represent this view."""

    def __init__(self):
        RingLayout.__init__(self)

    def _calculate_radius_and_icon_size(self, children_count):
        """Stub out this method; not used in `My Layout`."""
        return None, style.STANDARD_ICON_SIZE

    def _calculate_position(self, radius, icon_size, index, children_count):
        """ Increment the radius as you go; decrease the angle of rotation
        as the radius increases to keep the distance between icons constant."""
        width, height = self.box.get_allocation()
        # angle decreases as the radius increases
        angle = index * (2 * math.pi / (12.0 + index / 6.0)) - math.pi / 2
        # radius is proportional to index/children_count
        myminimum = _MINIMUM_RADIUS * .67
        newradius = ((_MAXIMUM_RADIUS - myminimum) * (index * 1.1) / children_count) + myminimum
        x = newradius * math.cos(angle) + (width - icon_size) / 2
        y = newradius * math.sin(angle) + (height - icon_size - style.GRID_CELL_SIZE) / 2
        return x, y