"""Display a grid filled with images.
.. NOTE::
Always test scroll / selection behaviour with several
grid sizes, e.g.
1xN 1x5, 5x1
NxN 1x1, 3x3, 5x5, 2x2
NxM 1x2, 3x4, 2x3
Part of the tagit module.
A copy of the license is provided with the project.
Author: Matthias Baumgartner, 2016
"""
# imports
from os.path import join, dirname, exists
from math import floor, ceil
from kivy.lang import Builder
from kivy.uix.image import AsyncImage
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.logger import Logger
from kivy.uix.behaviors import ButtonBehavior
from kivy.uix.image import Image
from kivy.graphics.instructions import InstructionGroup
from kivy.graphics import Rectangle, Color, Line, PushMatrix, PopMatrix, Rotate, Scale
from kivy.resources import resource_find
# inner-module imports
from dialogues import TextInputDialogue
from ..controller.browser import CBrowser
# exports
__all__ = ('VBrowser', )
# Load kv
Builder.load_file(join(dirname(__file__), 'browser.kv'))
[docs]class VBrowser(BoxLayout):
"""Image browser.
Guaranteed childs (needed for Sideboxes):
* cursor Current image (*VImage* instance)
* selection Selected images
* get_displayed Displayed images
.. todo::
BUGFIX: When scrolling over a page or by row, the cursor sometimes multiplies (check cursor update messages)
FEATURE: Configurable orientation; Currently we only have top-down (vertical) layout.
"""
__events__ = ('on_cursor_change', 'on_selection_change')
# Prototypes
def on_cursor_change(self, cursor): pass
def on_selection_change(self, selection): pass
[docs] def on_parent(self, *args):
"""Set the controller factory.
Reinitializes parts of the browser.
"""
self.controller = self.get_controller(CBrowser)
if self.controller is not None:
# Initialize some variables
# FIXME: Currently to be able to set img_{cols|rows} to None
self.set_grid_size(self.controller.cols, self.controller.rows)
super(VBrowser, self).on_parent(*args)
[docs] def on_touch_down(self, touch):
"""Scroll in browser."""
if self.collide_point(*touch.pos):
if touch.button == 'scrollup':
return self.controller.request_scrollup()
elif touch.button == 'scrolldown':
return self.controller.request_scrolldown()
return super(VBrowser, self).on_touch_down(touch)
[docs] def set_grid_size(self, num_cols, num_rows):
"""Set the grid size (cols and rows) according to proposed values."""
# Note: No need to set both values. Leaving one as None results
# in valid and different behaviour. However, at least one should be set.
self.cols = num_cols
self.rows = num_rows
[docs] def redraw(self, images, offset, n_results, page_size):
"""Update the displayed images."""
self.navigation_info = "{0} - {1} of {2}".format(
offset + 1, # first on page
min(offset + page_size, n_results), # last on page
n_results # total results
)
widgets = self.image_canvas.draw(self.controller, images)
for wx in widgets:
# bindings
self.bind(on_cursor_change=wx.on_cursor_change)
self.bind(on_selection_change=wx.on_selection_change)
[docs] def redraw_selection(self, selected_images):
"""Redraw selection decoration."""
self.dispatch('on_selection_change', selected_images)
[docs] def redraw_cursor(self, cursor):
"""Redraw cursor decoration."""
self.dispatch('on_cursor_change', cursor)
[docs] def update_status(self, status):
"""Update the status line."""
self.status_info = status
[docs] def displayed_images(self):
"""Return the images currently displayed."""
images = [img.image_ref for img in self.image_canvas.children]
images.reverse()
return images
def first_displayed_image(self):
try:
return self.image_canvas.children[-1].image_ref
except IndexError:
return None
def last_displayed_image(self):
try:
return self.image_canvas.children[0].image_ref
except IndexError:
return None
def tag_dialogue(self, callback, text=''):
keyctrl = self.controller.get_root().widget
dlg = TextInputDialogue(text=text, keyboard_ctrl=keyctrl)
dlg.bind(on_ok=lambda c: callback(c.text))
dlg.open()
[docs] def next_page(self):
"""Click on the next page button."""
self.controller.request_next_page()
[docs] def previous_page(self):
"""Click on the previous page button."""
self.controller.request_previous_page()
[docs] def go_first(self):
"""Click on the go first button."""
self.controller.request_first()
[docs] def go_last(self):
"""Click on the go last button."""
self.controller.request_last()
class ImageSpace(GridLayout):
"""Representation of a grid of images.
Both axes are flexible, i.e. can be set freely or not be defined (FIXME: May not be true).
The grid (3x4) may look like this:
+-+-+-+-+
|0|1|2| |
+-+-+-+-+
| | | | |
+-+-+-+-+
| | | | |
+-+-+-+-O
Kivy defines the origin at the bottom right corner (O). The coordinate grid is built
up accordingly. However, the image order starts with 0 at the top left corner.
"""
def draw(self, controller, images):
"""Draw the image widgets into the allocated space."""
# redraw widgets
# FIXME: Instead of creating new images, we can change the *source* property of the existing images
self.clear_widgets()
if len(images) > 0:
n_cols, n_rows = self.cols, self.rows
if n_cols is None and n_rows is None:
n_cols = n_rows = 1
elif n_cols is None:
n_cols = ceil(1.0 * len(images) / n_rows)
elif n_rows is None:
n_rows = ceil(1.0 * len(images) / n_cols)
res_w = 1.0 * self.width / min(len(images), n_cols)
res_h = 1.0 * self.height / min(len(images), n_rows)
for img in images:
src = controller.model.get_image(img, (res_w, res_h))
ori = controller.model.attributes.orientation(img)
if not exists(src):
src = resource_find('image-missing.png')
wx = VImage(
source_obj=img
, source=src
, orientation=ori
, touch_clbk=lambda arg: self.browser.controller.select(arg.image_ref, self.browser.controller.SELECT_MOUSE) # FIXME: Hacky
)
self.add_widget(wx)
return self.children
class VImage(FloatLayout):
"""Just an image.
Parameter *touch_clbk* is executed whenever a touch inside the image is registered.
Parameter *source_obj* holds a copy to the database reference object.
"""
def __init__(self, source_obj, orientation, touch_clbk, **kwargs):
source = kwargs.pop('source')
super(VImage, self).__init__(**kwargs)
self.image_ref = source_obj
self.image_orientation = orientation
self._touch_clbk = touch_clbk
self._selectBox = None
self._cursorBox = None
self.source = source # Somehow this assignment is broken in the default constructor for the very first VImage created.
def on_touch_down(self, touch):
"""Click on image."""
if self.collide_point(*touch.pos):
if touch.button == 'left':
# Set cursor
Logger.debug('Touchdown in VImage ' + self.source)
self._touch_clbk(self)
return True
return super(VImage, self).on_touch_down(touch)
def on_size(self, *args):
"""Redraw the canvas."""
self._redraw()
def on_texture(self, *args):
"""Redraw the canvas."""
self._redraw()
def on_cursor_change(self, instance, image):
"""Act on cursor change.
Changes the image effect.
"""
if image is not None and image == self.image_ref:
if self._cursorBox is None:
#Logger.debug('Cursor changed to ' + self.image_ref)
self._cursorBox = InstructionGroup()
self._cursorBox.add(Color(1, 1, 1, 1.0))
self._cursorBox.add(Line(width=5, rectangle=(
self.x,
self.y,
self.width,
self.height
)))
#Logger.debug('Draw box at ({0},{1}) x ({2},{3})'.format(self.x, self.y, self.width, self.height))
self.canvas.after.add(self._cursorBox)
elif self._cursorBox is not None:
#Logger.debug('Curser removed from ' + self.image_ref)
self.canvas.after.remove(self._cursorBox)
self._cursorBox = None
def on_selection_change(self, instance, selection):
"""Act on selection change.
Changes the image effect.
"""
if self.image_ref in selection:
if self._selectBox is None:
#Logger.debug('Selected ' + self.image_ref)
self._selectBox = InstructionGroup()
self._selectBox.add(Color(0, 0, 1, 0.5))
self._selectBox.add(Rectangle(
pos=(self.x, self.center_y - int(self.height / 2.0)),
size=(self.width, self.height),
))
self.canvas.after.add(self._selectBox)
else:
self.on_texture()
elif self._selectBox is not None:
#Logger.debug('Unselected ' + self.image_ref)
self.canvas.after.remove(self._selectBox)
self._selectBox= None
def _redraw(self):
"""Redraw the cursor and selection highlights."""
# Fix orientation
width, height = self.size
if self.image_orientation in (2, 4, 5, 7): # Mirror
self.image.mirror = True
if self.image_orientation in (3, 4): # Rotate 180deg
self.image.angle = 180
if self.image_orientation in (5, 6): # Rotate clockwise, 90 deg
self.image.angle = -90
width, height = height, width
if self.image_orientation in (7, 8): # Rotate counter-clockwise, 90 deg
self.image.angle = 90
width, height = height, width
self.image.size = (width, height)
self.image.center = self.center
# Redraw the cursor
if self._cursorBox is not None:
self._cursorBox.children[-1].rectangle = (
self.x,
self.center_y - int(self.height / 2.0),
self.width,
self.height
)
# Redraw the select box
if self._selectBox is not None:
# redraw
self._selectBox.children[-1].pos = (self.x, self.center_y - int(self.height / 2.0))
self._selectBox.children[-1].size = (self.width, self.height)
"""
orientation == 1:
> Normal camera pose. (Top/Left)
No op
orientation == 2:
> Mirrored image. (Top/Right)
Mirror on vertical axis
orientation == 3:
> Camera was upside-down. (Bottom/Right)
Rotate 180deg
orientation == 4:
> Horizontally mirrored image. (Bottom/Left)
Rotate 180deg
Mirror on vertical axis
Shortcut: Mirror on horizontal axis
orientation == 5:
> Camera tilt to the right, Mirrored (Left/Top)
Rotate clockwise, 90deg
Mirror on vertical axis
orientation == 6:
> Camera tilt to the right (Right/Top)
Rotate clockwise, 90deg
orientation == 7:
> Camera tilt to the left, Mirrored (Right/Bottom)
Rotate counter-clockwise, 90deg
Mirror on vertical axis
orientation == 8:
> Camera tilt to the left (Left/Bottom)
Rotate counter-clockwise, 90deg
"""
class VBrowserStatus_Button(ButtonBehavior, Image): pass
## EOF ##