User:Walter/favoriteslayout.py

From Sugar Labs
Jump to navigation Jump to search
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.

/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