"""Browser controller.
Part of the tagit module.
A copy of the license is provided with the project.
Author: Matthias Baumgartner, 2016
"""
# IMPORTS
# INNER-MODULE IMPORTS
from controller import DataController
from ..basics import intersection, unique, truncate_dir
from ..bindings import Binding
# CONSTANTS
TAGS_SEPERATOR = ','
# EXPORTS
__all__ = ('CBrowser', 'TAGS_SEPERATOR')
## CODE ##
[docs]class CBrowser(DataController):
"""
.. TODO::
redraw_cursor is called twice when moved by key press (e.g. up/down).
Once from cursor_down, once from redraw
"""
# Config constants
MODIFIERS_NONE = 0b00 # Single select
MODIFIERS_CTRL = 0b01 # Multiple select
MODIFIERS_SHIFT = 0b10 # Range select
SELECT_MOUSE = 'mouse'
SELECT_KEYBOARD = 'key'
ORI_VERTICAL = 'vertical'
ORI_HORIZONTAL = 'horizontal'
def __init__(self, widget, model, settings, parent=None):
super(CBrowser, self).__init__(widget, model, settings, parent)
# Keyboard bindings
self.get_root().bind(on_key_down=self.on_key_down)
self.get_root().bind(on_key_up=self.on_key_up)
self.get_root().bind(on_keyboard=self.on_keyboard)
# Results bindings
self.parent.bind(on_results_change=self.on_results_changed)
self.parent.bind(status=self.on_status_update)
# Page setup
self.cols = self.settings.trace('view', 'columns', 3)
self.rows = self.settings.trace('view', 'rows', 3)
orientation = self.settings.trace('view', 'orientation', 'vertical') # FIXME: Use!
# Page control
self.offset = 0
self.images, self.n_results = [], 0
self._range_origin, self._range_base = 0, []
self.n_tokens = 0
# Highlight control
self.mode = self.MODIFIERS_NONE
self.selection = []
self.cursor = None
def __del__(self):
# Release bindings
self.get_root().unbind(on_key_down=self.on_key_down)
self.get_root().unbind(on_key_up=self.on_key_up)
self.get_root().unbind(on_keyboard=self.on_keyboard)
self.parent.unbind(on_results_change=self.on_results_changed)
self.parent.unbind(status=self.on_status_update)
def on_status_update(self, status):
self.widget.update_status(str(status))
[docs] def on_results_changed(self, filter_, images):
"""Called when the set of images to be displayed was changed.
"""
self.images = images
self.n_results = len(images)
n_tokens = filter_.num_tokens()
if n_tokens >= self.n_tokens: # Filter was added or changed
self.offset = 0 # Always
if self.cursor is None or self.cursor not in self.images: # Cursor removed
self.set_cursor_first()
else: # Filter was removed
# Cursor is available and left unchanged
# TODO: We could check if the cursor wasn't visible and then give priority to the
# current offset...
if self.cursor is not None:
try:
self.offset = self.images.index(self.cursor)
self.offset = min(self.n_results - self.page_size(), self.offset)
self.offset = max(0, self.offset)
except ValueError:
self.set_cursor_first()
else:
self.set_cursor_first()
self.n_tokens = filter_.num_tokens()
self.selection = filter(lambda img: img in self.images, self.selection) # Keep what we have
self.redraw()
return False # Don't prevent other listeners to run.
[docs] def redraw(self):
"""Update the widget.
"""
page_size = self.page_size()
lo = self.offset
hi = lo + page_size
self.widget.redraw(self.images[lo:hi], self.offset, self.n_results, page_size)
self.redraw_selection()
self.redraw_cursor()
def redraw_selection(self):
#disp = self.widget.displayed_images()
#selected = filter(lambda img: img in disp, self.selection)
self.widget.redraw_selection(self.selection)
self.parent.dispatch('on_selection', self, self.selection)
def redraw_cursor(self):
self.widget.redraw_cursor(self.cursor)
if self.cursor is not None:
self.parent.dispatch('status', truncate_dir(self.cursor))
self.parent.dispatch('on_cursor', self, self.cursor)
def page_size(self):
return self.cols * self.rows
###########################################################################
# NAVIGATION #
###########################################################################
[docs] def next_page(self):
"""Page Down
Cursor: No change
View: Jump by N*M images
"""
page_size = self.page_size()
if self.offset + page_size < self.n_results:
self.offset += page_size
self.redraw()
[docs] def previous_page(self):
"""Page Up
Cursor: No change
View: Jump by N*M images
"""
self.offset -= self.cols * self.rows
self.offset = max(self.offset, 0)
self.redraw()
[docs] def first_image(self):
"""Home
Cursor: Jump to first image
View: Jump to first page
"""
self.offset = 0
self.set_cursor_first()
self.redraw()
[docs] def last_image(self):
"""End
Cursor: Jump to last image
View: Jump to last page
"""
page_size = self.page_size()
row_size = self.cols
#self.offset = self.n_results - 1 # Pick last image
# ceil(n_results / page_size) -> number of (full) pages
# page size * number of (full) pages -> number of images to fill up all pages
#self.offset = int(page_size * ceil(1.0 * self.n_results / page_size) - page_size) # Pick last page
# n_results % row_size -> images on last row
# row_size - (images on last row) % row_size -> missing images on last row
self.offset = self.n_results + ((row_size - (self.n_results % row_size)) % row_size) - page_size # Pick last row
self.set_cursor_last()
self.redraw()
[docs] def zoom_in(self):
"""Decrease grid size.
"""
self.cols = max(1, self.cols - 1)
self.rows = max(1, self.rows - 1)
self.widget.set_grid_size(self.cols, self.rows)
if self.cursor in self.widget.displayed_images(): # Zoom to cursor
c_idx = self.images.index(self.cursor)
pg_size = self.cols * self.rows
if c_idx >= self.offset + pg_size:
self.offset = max(0, c_idx - pg_size + 1)
self.redraw()
[docs] def zoom_out(self):
"""Increase grid size.
"""
self.cols = self.cols + 1
self.rows = self.rows + 1
self.widget.set_grid_size(self.cols, self.rows)
self.redraw()
###########################################################################
# HIGHLIGHTING #
###########################################################################
[docs] def select(self, image, input_method=SELECT_MOUSE):
"""Set the selection.
*image* is the selected (clicked) image.
"""
if self.n_results == 0 or (image is None and input_method == self.SELECT_KEYBOARD):
self.selection = []
return
elif image is None:
return
if self.mode == self.MODIFIERS_NONE:
self.cursor = image
if self.selection == [image]:
if input_method == self.SELECT_MOUSE:
self.selection = []
else:
# Also picks cases where we return from multi-selection.
self.selection = [image]
if self.mode & self.MODIFIERS_CTRL:
self.cursor = image
if input_method == self.SELECT_MOUSE:
if image in self.selection:
# Remove all occurrences. Shouldn't be necessary but do it as a safety measure.
while image in self.selection:
self.selection.remove(image)
else:
self.selection.append(image)
if self.mode & self.MODIFIERS_SHIFT:
idx_image = self.images.index(image)
lo = min(idx_image, self._range_origin)
hi = max(idx_image, self._range_origin)
selection = self._range_base[:]
for i in range(lo, hi + 1):
selection.append(self.images[i])
self.selection = list(set(selection)) # Removes double occurrences.
self.redraw_cursor()
self.redraw_selection()
def set_cursor_first(self):
if self.n_results > 0:
self.cursor = self.images[0]
else:
self.cursor = None
self.select(self.cursor, self.SELECT_KEYBOARD)
def set_cursor_last(self):
if self.n_results > 0:
self.cursor = self.images[-1]
else:
self.cursor = None
self.select(self.cursor, self.SELECT_KEYBOARD)
[docs] def cursor_left(self):
"""Left
Cursor: One column left
View: No change
"""
if self.n_results == 0:
self.cursor = None
return
if self.cursor not in self.widget.displayed_images():
self.cursor = self.widget.first_displayed_image()
else:
num_displayed = len(self.widget.displayed_images())
cidx = self.images.index(self.cursor)
#if cidx > 0 and cidx % self.cols != 0: # range and left grid border
# # FIXME: When zooming, then scrolling, the offset can become shifted w.r.t. cols.
# # This can be seen as self.offset % self.cols != 0
# # Then, the cursor becomes misaligned with the actual page borders
# cidx -= 1
# Alternative: Allow over-the-border move and scroll if need be
if cidx > 0:cidx -= 1
if self.images[cidx] not in self.widget.displayed_images():
# cursor not visible anymore, scroll down one row
self.scroll_up()
self.cursor = self.images[cidx]
self.select(self.cursor, self.SELECT_KEYBOARD)
self.redraw_cursor()
self.redraw_selection()
[docs] def cursor_right(self):
"""Right
Cursor: One column right
View: No change
"""
if self.n_results == 0:
self.cursor = None
return
if self.cursor not in self.widget.displayed_images():
self.cursor = self.widget.first_displayed_image()
else:
num_displayed = len(self.widget.displayed_images())
cidx = self.images.index(self.cursor)
#if cidx + 1 < self.n_results and (cidx + 1) % self.cols != 0: # range and right grid border
# # FIXME: When zooming, then scrolling, the offset can become shifted w.r.t. cols.
# # This can be seen as self.offset % self.cols != 0
# # Then, the cursor becomes misaligned with the actual page borders
# cidx += 1
# Alternative: Allow over-the-border move and scroll if need be
if cidx + 1 < self.n_results: cidx += 1
if self.images[cidx] not in self.widget.displayed_images():
# cursor not visible anymore, scroll down one row
self.scroll_down()
self.cursor = self.images[cidx]
self.select(self.cursor, self.SELECT_KEYBOARD)
self.redraw_cursor()
self.redraw_selection()
[docs] def cursor_up(self):
"""Down
Cursor: One row down
View: If cursor was at bottom row, jump by one row
"""
if self.n_results == 0:
self.cursor = None
return
if self.cursor not in self.widget.displayed_images():
# Cursor not in view; Reset to first.
self.cursor = self.widget.first_displayed_image() # Can be None
else:
# Move the cursor down one row
cidx = self.images.index(self.cursor)
if cidx - self.cols >= 0: # Don't change in case of excess.
cidx -= self.cols
if self.images[cidx] not in self.widget.displayed_images():
# cursor not visible anymore, scroll up one row
self.scroll_up()
self.cursor = self.images[cidx]
self.select(self.cursor, self.SELECT_KEYBOARD)
self.redraw_cursor()
self.redraw_selection()
[docs] def cursor_down(self):
"""Down
Cursor: One row down
View: If cursor was at bottom row, jump by one row
"""
if self.n_results == 0:
self.cursor = None
return
if self.cursor not in self.widget.displayed_images():
# Cursor not in view; Reset to last.
self.cursor = self.widget.last_displayed_image() # Can be None
else:
# Move the cursor down one row
cidx = self.images.index(self.cursor)
if cidx < (self.n_results - (self.n_results % self.cols)): # rhs is index of first image on the last row.
cidx = min(cidx + self.cols, self.n_results - 1) # Set to last image in case of excess.
# else: Don't move if on the last row.
if self.images[cidx] not in self.widget.displayed_images():
# cursor not visible anymore, scroll down one row
self.scroll_down()
self.cursor = self.images[cidx]
self.select(self.cursor, self.SELECT_KEYBOARD)
self.redraw_cursor()
self.redraw_selection()
###########################################################################
# ACTIONS #
###########################################################################
[docs] def add_tag(self, text):
"""Add tags to images.
*text* is a string of tags, split at *TAGS_SEPERATOR*.
"""
tags = filter(lambda s: s != '', map(lambda s: s.strip(), text.split(TAGS_SEPERATOR)))
self.model.tags.add(self.selection, tags)
[docs] def edit_tag(self, original, modified):
"""Remove tags from images.
Original and modified are strings, split at *TAGS_SEPERATOR*. Tags are added and removed
with respect to the difference between those two variables.
"""
original = filter(lambda s: s != '', map(lambda s: s.strip(), original.split(TAGS_SEPERATOR)))
modified = filter(lambda s: s != '', map(lambda s: s.strip(), modified.split(TAGS_SEPERATOR)))
added = filter(lambda itm: itm not in original, modified)
removed = filter(lambda itm: itm not in modified, original)
self.model.tags.add(self.selection, added)
self.model.tags.remove(self.selection, removed)
###########################################################################
# KEYBINDINGS #
###########################################################################
[docs] def on_key_down(self, wx, (code, key), modifiers):
"""Watch out for those modifiers.
"""
if code in (303, 304): # Shift
self.mode |= self.MODIFIERS_SHIFT
self._range_base = self.selection[:]
if self.cursor in self.images:
self._range_origin = self.images.index(self.cursor)
elif code in (305, 306): # Ctrl
self.mode |= self.MODIFIERS_CTRL
# Ignore other keys
[docs] def on_key_up(self, wx, (code, key)):
"""Watch out for those modifiers.
Can receive some ups when modifier pressed at app start
"""
if code in (303, 304):
self.mode -= self.mode & self.MODIFIERS_SHIFT
self._range_base = []
self._range_origin = None
elif code in (305, 306):
self.mode -= self.mode & self.MODIFIERS_CTRL
# Ignore other keys
[docs] def on_keyboard(self, wx, evt):
"""The *CBrowser* portion of key press handling.
"""
if Binding.check(evt, self.settings.trace('bindings', 'browser', 'cursor_right' , Binding.simple(Binding.RIGHT, None, Binding.ALT))):
self.cursor_right()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'cursor_left' , Binding.simple(Binding.LEFT, None, Binding.ALT))): # The combination with 'alt' is used up by the filter.
self.cursor_left()
return True
# FIXME: Returning True clears the event for further processing.
# Meaning we won't receive it in filter (with the 'alt' modifier)
# and can't trigger the 'go back' reaction.
# Options are returning False (event is further processed)
# or filter for the 'alt' modifier.
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'cursor_up' , Binding.simple(Binding.UP))):
self.cursor_up()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'cursor_down' , Binding.simple(Binding.DOWN))):
self.cursor_down()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'select' , Binding.simple(Binding.SPACEBAR))): # Ctrl + whitespace, Shift + whitespace, whitespace
self.select(self.cursor, self.SELECT_MOUSE)
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'go_first' , Binding.simple(Binding.HOME))):
self.first_image()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'go_last' , Binding.simple(Binding.END))):
self.last_image()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'scroll_down' , Binding.simple(106, None, Binding.ALL))): # j
self.scroll_down()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'scroll_up' , Binding.simple(107, None, Binding.ALL))): # k
self.scroll_up()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'page_next' , Binding.simple(Binding.PGDOWN))):
self.next_page()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'page_prev' , Binding.simple(Binding.PGUP))):
self.previous_page()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'select_all' , Binding.simple(97, Binding.CTRL, Binding.REST))): # Ctrl + a
self.selection = self.images[:]
self.redraw_selection()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'select_none' , Binding.multi((Binding.ESC, ), (97, (Binding.CTRL, Binding.SHIFT), Binding.REST)))): # ESC or Ctrl + Shfit + a
self.selection = []
self.redraw_selection()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'add_tag' , Binding.simple(116, Binding.CTRL))): # Ctrl + t
self.widget.tag_dialogue(self.add_tag)
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'edit_tag' , Binding.simple(101, Binding.CTRL))): # Ctrl + e
if len(self.selection) > 0:
tags = [self.model.tags.get(img) for img in self.selection]
tags = reduce(intersection, tags[1:], tags[0]) # works for len(tags) == 0 as well.
tags = (TAGS_SEPERATOR + ' ').join(tags)
self.widget.tag_dialogue(lambda t: self.edit_tag(tags, t), tags)
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'exclusive' , Binding.simple(Binding.ENTER, Binding.CTRL, Binding.REST))):
self.parent.dispatch('on_show_selected', self.selection)
# Selection is kept constant over filter changes, which we don't want here.
self.selection = [self.cursor]
self.redraw_selection()
return True
elif Binding.check(evt, self.settings.trace('bindings', 'browser', 'remove' , Binding.simple(Binding.DEL, None, Binding.ALL))):
if len(self.selection) == 0: return True
new_offset = self.offset
new_cursor = self.cursor
if self.cursor in self.selection:
# set cursor to nearest neighbor
cur_idx = self.images.index(self.cursor)
sel_idx = map(self.images.index, self.selection)
# Find available images
n_right = filter(lambda idx: idx < self.n_results and idx not in sel_idx, map(lambda idx: idx + 1, sel_idx))
n_left = filter(lambda idx: idx >= 0 and idx not in sel_idx, map(lambda idx: idx - 1, sel_idx))
cand = sorted(unique(n_left + n_right))
# Find closes to cursor
c_dist = map(lambda idx: abs(idx - cur_idx), cand)
if len(c_dist) == 0:
new_cursor = None
else:
# Set cursor to image at candidate with minimum distance to cursor
new_cursor = self.images[cand[c_dist.index(min(c_dist))]]
self.parent.dispatch('on_remove_selected', self.selection)
self.cursor = new_cursor
self.selection = self.cursor is not None and [self.cursor] or []
self.offset = new_offset # Keep offset
self.redraw()
return True
return False
###########################################################################
# MOUSE EVENTS #
###########################################################################
def request_previous_page(self):
self.previous_page()
return True
def request_next_page(self):
self.next_page()
return True
def request_scrollup(self):
if self.mode & self.MODIFIERS_CTRL:
self.zoom_out()
else:
self.scroll_down()
return True
def request_scrolldown(self):
if self.mode & self.MODIFIERS_CTRL:
self.zoom_in()
else:
self.scroll_up()
return True
def request_last(self):
self.last_image()
def request_first(self):
self.first_image()
## EOF ##