# $Id: ScrolledList.py,v 1.61.2.5 2007/03/06 10:07:16 marcusva Exp $
#
# Copyright (c) 2004-2007, Marcus von Appen
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#  * Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""A scrollable widget, which contains list elements."""

from pygame import KMOD_SHIFT, KMOD_CTRL, K_UP, K_DOWN, K_HOME, K_END, K_SPACE
from pygame import K_a, key
from ocempgui.widgets.components import ListItemCollection
from ScrolledWindow import ScrolledWindow
from ListViewPort import ListViewPort
from Constants import *
from StyleInformation import StyleInformation
import base

class ScrolledList (ScrolledWindow):
    """ScrolledList (width, height, collection=None) -> ScrolledList

    A widget class, that populates elements in a list-style manner.

    The ScrolledList displays data in a listed form and allows to browse
    through it using horizontal and vertical scrolling. Single or
    multiple items of the list can be selected or - dependant on the
    ListItem object - be edited, etc.

    The ScrolledList's child is a ListViewPort object, which takes care
    of drawing the attached list items. You can supply your own
    ListViewPort object through the 'child' attribute as described in
    the Bin class documentation.

    To set or reset an already created collection of items, the 'items'
    attribute and set_items() method can be used. The collection to set
    needs to be a ListItemCollection object.

    myitems = ListItemCollection()
    myitems.append (TextListItem ('First')
    scrolledlist.items = myitems
    scrolledlist.set_items (myitems)

    The ScrolledList allows different types of selections by modifying
    the 'selectionmode' attribute. Dependant on the set selection mode,
    you can then select multiple items at once (SELECTION_MULTIPLE),
    only one item at a time (SELECTION_SINGLE) or nothing at all
    (SELECTION_NONE). Modifying the selection mode does not have any
    effect on a currently made selection. See the selection constants
    section in the Constants documentation for more details.

    scrolledlist.selectionmode = SELECTION_NONE
    scrolledlist.set_selectionmode (SELECTION_SINGLE)

    To improve the appearance and selection behaviour of the single list
    items, the ScrolledList support additional spacing to place between
    them. It can be read and set through the 'spacing' attribute and
    set_spacing() method.

    scrolledlist.spacing = 4
    scrolledlist.set_spacing (0)
    
    Default action (invoked by activate()):
    See the ScrolledWindow class.
    
    Mnemonic action (invoked by activate_mnemonic()):
    None
    
    Signals:
    SIG_SELECTCHANGED - Invoked, when the item selection changed.
    SIG_LISTCHANGED   - Invoked, when the underlying item list changed.
    
    Attributes:
    items         - Item list of the ScrolledList.
    selectionmode - The selection mode for the ScrolledList. Default is
                    SELECTION_MULTIPLE.
    spacing       - Spacing to place between the list items. Default is 0.
    cursor        - The currently focused item.
    """
    def __init__ (self, width, height, collection=None):
        # Temporary placeholder for kwargs of the update() method.
        self.__lastargs = None

        ScrolledWindow.__init__ (self, width, height)

        self._spacing = 0

        # The item cursor position within the list.
        self._cursor = None

        # Used for selections.
        self._last_direction = None

        self._signals[SIG_LISTCHANGED] = []
        self._signals[SIG_SELECTCHANGED] = []

        # Items and selection.
        self._itemcollection = None
        self._selectionmode = SELECTION_MULTIPLE
        if collection:
            self.set_items (collection)
        else:
            self._itemcollection = ListItemCollection ()
            self._itemcollection.list_changed = self._list_has_changed
            self._itemcollection.item_changed = self._item_has_changed
        self.child = ListViewPort (self)

    def _list_has_changed (self, collection):
        """S._list_has_changed (...) -> None

        Update method for list_changed () notifications.
        """
        if not self.locked:
            self.child.update_items ()
            self.dirty = True
        self.run_signal_handlers (SIG_LISTCHANGED)

    def _item_has_changed (self, item):
        """S._item_has_changed (...) -> None

        Update method for item_changed() notifications.
        """
        if not self.locked:
            self.child.update_items ()

    def set_items (self, items):
        """S.set_items (...) -> None

        Sets a collection of items to display.
        """
        old = self._itemcollection
        self.vscrollbar.value = 0
        self.hscrollbar.value = 0
        if isinstance (items, ListItemCollection):
            self._itemcollection = items
        else:
            self._itemcollection = ListItemCollection (items)
        self._itemcollection.list_changed = self._list_has_changed
        self._itemcollection.item_changed = self._item_has_changed
        old.list_changed = None
        old.item_changed = None
        del old

        if len (self._itemcollection) > 0:
            self._cursor = self._itemcollection[0]
        else:
            self._cursor = None
        
        self._list_has_changed (self._itemcollection)

    def set_focus (self, focus=True):
        """S.set_focus (...) -> bool

        Sets the input and action focus of the ScrolledList.
        
        Sets the input and action focus of the ScrolledList and returns
        True upon success or False, if the focus could not be set.
        """
        if focus != self.focus:
            self.lock ()
            if focus and not self._cursor and (len (self.items) > 0):
                self._cursor = self.items[0]
                self._cursor.selected = True
            ScrolledWindow.set_focus (self, focus)
            self.unlock ()
        return self.focus
    
    def set_spacing (self, spacing):
        """S.set_spacing (...) -> None

        Sets the spacing to place between the list items of the ScrolledList.

        The spacing value is the amount of pixels to place between the
        items of the ScrolledList.

        Raises a TypeError, if the passed argument is not a positive
        integer.
        """
        if (type (spacing) != int) or (spacing < 0):
            raise TypeError ("spacing must be a positive integer")
        self._spacing = spacing
        self.child.update_items ()

    def set_selectionmode (self, mode):
        """S.set_selectionmode (...) -> None

        Sets the selection mode for the ScrolledList.

        The selection mode can be one of the SELECTION_TYPES list.
        SELECTION_NONE disables selecting any list item,
        SELECTION_SINGLE allows to select only one item from the list and 
        SELECTION_MULTIPLE allows to select multiple items from the list.

        Raises a ValueError, if the passed argument is not a value of
        the SELECTION_TYPES tuple.
        """
        if mode not in SELECTION_TYPES:
            raise ValueError ("mode must be a value from SELECTION_TYPES")
        self._selectionmode = mode

    def select (self, *items):
        """S.select (...) -> None

        Selects one or more specific items of the ScrolledList.

        Dependant on the set selection mode selecting an item has
        specific side effects. If the selection mode is set to
        SELECTION_SINGLE, selecting an item causes any other item to
        become deselected. As a counterpart SELECTION_MULTIPLE causes
        the items to get selected while leaving any other item untouched.
        The method causes the SIG_SELECTCHANGED event to be emitted,
        whenever the selection changes.

        Raises a LookupError, if the passed argument could not be
        found in the items attribute.
        """
        self.__select (*items)
        self.run_signal_handlers (SIG_SELECTCHANGED)

    def select_all (self):
        """S.select_all () -> None

        Selects all items of the ScrolledList.

        Selects all items of the ScrolledList, if the selection mode is
        set to SELECTION_MULTIPLE
        """
        if self.selectionmode == SELECTION_MULTIPLE:
            notselected = filter (lambda x: x.selected == False, self.items)
            for i in notselected:
                i.selected = True
            if len (notselected) > 0:
                self.run_signal_handlers (SIG_SELECTCHANGED)
    
    def deselect (self, *items):
        """S.deselect (...) -> None
        
        Deselects the specified items in the ScrolledList.

        The method causes the SIG_SELECTCHANGED event to be emitted, when
        the selection changes.

        Raises a LookupError, if the passed argument could not be
        found in the items attribute.
        """
        self.__deselect (*items)
        self.run_signal_handlers (SIG_SELECTCHANGED)

    def __select (self, *items):
        """S.__select (...) -> None
        
        Selects one or more specific items of the ScrolledList.
        """
        if self.selectionmode == SELECTION_NONE:
            # Do nothing here, maybe implement a specific event action
            # or s.th. like that...
            return

        self.lock ()
        if self.selectionmode == SELECTION_SINGLE:
            # Only the last item.
            if items[-1] not in self.items:
                raise LookupError ("item could not be found in list")
            items[-1].selected = True

        elif self.selectionmode == SELECTION_MULTIPLE:
            for item in items:
                if item not in self.items:
                    raise LookupError ("item could not be found in list")
                item.selected = True
        self.unlock ()

    def __deselect (self, *items):
        """S.__deselect (..:) -> None

        Deselects the specified items in the ScrolledList
        """
        self.lock ()
        for item in items:
            if item not in self.items:
                raise LookupError ("item could not be found in list")
            item.selected = False
            self.child.update_items ()
        self.unlock ()

    def get_selected (self):
        """S.get_selected () -> list

        Returns a list cotaining the selected items.
        """
        if self.items.length != 0:
            return [item for item in self.items if item.selected]
        return []

    def _set_cursor (self, item, selected):
        """S._set_cursor (...) -> None

        Sets the cursor index to the desired item.
        """
        self._cursor = item
        self._scroll_to_cursor ()
        if item.selected == selected:
            self.child.dirty = True # Render the cursor
        else:
            item.selected = selected
            self.run_signal_handlers (SIG_SELECTCHANGED)

    def _cursor_next (self, selected):
        """S._cursor_next () -> None

        Advances the cursor to the next item in the list.
        """
        if not self._cursor:
            if self.items.length > 0:
                self._cursor = self.items[0]
            return
        index = self.items.index (self._cursor)
        if index < self.items.length - 1:
            self._set_cursor (self.items[index + 1], selected)

    def _cursor_prev (self, selected):
        """S._cursor_prev () -> None

        Advances the cursor to the previous item in the list.
        """
        if not self._cursor:
            if self.items.length > 0:
                self._cursor = self.items[0]
            return
        index = self.items.index (self._cursor)
        if index > 0:
            self._set_cursor (self.items[index - 1], selected)
        
    def _scroll_to_cursor (self):
        """S._scroll_to_cursor () -> None

        Scrolls the list to the cursor.
        """
        if not self.cursor or not self.child.images.has_key (self.cursor):
            return

        border = base.GlobalStyle.get_border_size \
                     (self.child.__class__, self.child.style,
                      StyleInformation.get ("ACTIVE_BORDER")) * 2

        x, y = self.child.get_item_position (self.cursor)
        w, h = self.child.width, self.child.height
        height = self.child.images[self.cursor][1].height + border + \
                 self.spacing
        # Bottom edge of the item.
        py = y + height

        if (self.vscrollbar.value - y > 0): # Scrolling up.
            self.vscrollbar.value = max (0, y - border)
        elif (py > self.vscrollbar.value + h):
            self.vscrollbar.value = min (abs (py - h + border),
                                         self.vscrollbar.maximum)
        
    def _navigate (self, key, mod):
        """S._navigate (key) -> None

        Deals with keyboard navigation.
        """
        multiselect = self.selectionmode == SELECTION_MULTIPLE
        selected = not mod & KMOD_CTRL and self.selectionmode != SELECTION_NONE
        
        if key == K_UP:
            if (mod & KMOD_SHIFT) and multiselect:
                if self._last_direction == K_DOWN:
                    if len (self.get_selected ()) > 1:
                        self._cursor.selected = False
                        self._cursor_prev (True)
                        return
                self._last_direction = key
                self._cursor_prev (True)
                return
            self.__deselect (*self.get_selected ())
            self._cursor_prev (selected)
        
        elif key == K_DOWN:
            if (mod & KMOD_SHIFT) and multiselect:
                if self._last_direction == K_UP:
                    if len (self.get_selected ()) > 1:
                        self._cursor.selected = False
                        self._cursor_next (True)
                        return
                self._last_direction = key
                self._cursor_next (True)
                return
            self.__deselect (*self.get_selected ())
            self._cursor_next (selected)

        elif key == K_END:
            if (mod & KMOD_SHIFT) and multiselect:
                start = self.items.index (self._cursor)
                items = self.get_selected ()
                self.__deselect (*items)
                if self._last_direction == K_UP:
                    if len (items) > 0:
                        start = self.items.index (items[-1])
                end = len (self.items) - 1
                self.__select (*self.items[start:end])
            else:
                self.__deselect (*self.get_selected ())
            self._last_direction = K_DOWN
            self._set_cursor (self.items[-1], selected)
            
        elif key == K_HOME:
            if (mod & KMOD_SHIFT) and multiselect:
                end = self.items.index (self._cursor)
                items = self.get_selected ()
                self.__deselect (*items)
                if self._last_direction == K_DOWN:
                    if len (items) > 0:
                        end = self.items.index (items[0])
                self.__select (*self.items[0:end])
            else:
                self.__deselect (*self.get_selected ())
            self._last_direction = K_UP
            self._set_cursor (self.items[0], selected)
        
        elif key == K_SPACE:
            if self._cursor.selected:
                self._set_cursor (self._cursor, False)
            else:
                self._set_cursor (self._cursor, True)

        elif (key == K_a) and mod & KMOD_CTRL and \
                 multiselect:
            self._last_direction = K_DOWN
            self.lock ()
            notselected = filter (lambda x: x.selected == False, self.items)
            for i in notselected:
                i.selected = True
            self.unlock ()
            self._set_cursor (self.items[-1], True)

    def _click (self, position):
        """S._click (...) -> None

        Deals with mouse clicks.
        """
        # Get the item and toggle the selection.
        item = self.child.get_item_at_pos (position)
        mods = key.get_mods ()
        if not item:
            return

        if self.selectionmode != SELECTION_MULTIPLE:
            self._last_direction = None
            selection = self.get_selected ()
            allowed = self.selectionmode != SELECTION_NONE
            if mods & KMOD_CTRL:
                if selection and item in selection:
                    selection.remove (item)
                self.__deselect (*selection)
                self._set_cursor (item, not item.selected and allowed)
            else:
                self.__deselect (*selection)
                self._set_cursor (item, allowed)
            return
        
        if item.selected:
            if mods & KMOD_CTRL:
                self._set_cursor (item, False)
            elif mods & KMOD_SHIFT:
                # The item usually should be somewhere in that selection.
                # Get its index and crop the selection according to that
                # index.
                selection = self.get_selected ()
                if len (selection) > 1:
                    start = self.items.index (selection[0])
                    end = self.items.index (selection[-1])
                    index = selection.index (item)
                    if self._last_direction == K_UP:
                        if index > 0:
                            self.__deselect (*selection[0:index])
                    elif self._last_direction == K_DOWN:
                        if index > 0:
                            self.__deselect (*selection[index:end + 1])
                    self._set_cursor (item, True)
                else:
                    self._set_cursor (selection[0], True)
            else:
                self._last_direction = None
                self.__deselect (*self.get_selected ())
                self._set_cursor (item, True)
        else:
            if mods & KMOD_CTRL:
                self._set_cursor (item, True)
            elif mods & KMOD_SHIFT:
                # No click on an existing selection. Expand the current.
                selection = self.get_selected ()
                start = self.items.index (self.cursor)
                index = self.items.index (item)
                if len (selection) != 0:
                    if self._last_direction == K_DOWN:
                        start = self.items.index (selection[0])
                        end = self.items.index (selection[-1])
                        if index < start:
                            self._last_direction = K_UP
                            self.__deselect (*selection)
                            self.__select (*self.items[index:start])
                        elif index > end:
                            self.__select (*self.items[end:index])
                        
                    elif self._last_direction == K_UP:
                        start = self.items.index (selection[0])
                        end = self.items.index (selection[-1])
                        if index > end:
                            self._last_direction = K_DOWN
                            self.__deselect (*selection)
                            self.__select (*self.items[end + 1:index])
                        elif index < start:
                            self.__select (*self.items[index:start])

                    else:
                        start = self.items.index (selection[0])
                        if index > start:
                            self.__select (*self.items[start:index])
                            self._last_direction = K_DOWN
                        else:
                            self.__select (*self.items[index:start])
                            self._last_direction = K_UP
            
                self._set_cursor (item, True)
            else:
                self._last_direction = None
                self.__deselect (*self.get_selected ())
                self._set_cursor (item, True)
    
    def notify (self, event):
        """S.notify (...) -> None

        Notifies the ScrolledList about an event.
        """
        if not self.sensitive:
            return

        if self.focus and (event.signal == SIG_KEYDOWN):
            if len (self.items) > 0:
                self._navigate (event.data.key, event.data.mod)
                event.handled = True
        elif event.signal == SIG_MOUSEDOWN:
            eventarea = self.rect_to_client ()
            if eventarea.collidepoint (event.data.pos):
                for c in self.controls:
                    c.notify (event)
                if not event.handled:
                    self.focus = True
                    self.run_signal_handlers (SIG_MOUSEDOWN, event.data)
                    if event.data.button == 1:
                        self._click (event.data.pos)
                    else:
                        ScrolledWindow.notify (self, event)
                event.handled = True
        else:
            ScrolledWindow.notify (self, event)

    selectionmode = property (lambda self: self._selectionmode,
                              lambda self, var: self.set_selectionmode (var),
                              doc = "The selection mode for the ScrolledList.")
    spacing = property (lambda self: self._spacing,
                        lambda self, var: self.set_spacing (var),
                        doc = "Additional spacing to place between the items.")
    items = property (lambda self: self._itemcollection,
                      lambda self, var: self.set_items (var),
                      doc = "The item collection of the ScrolledList.")
    cursor = property (lambda self: self._cursor,
                       doc = "The item, which is currently focused.")
