Source code for tagit.controller.browser

"""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 scroll_up(self): """Scroll Up Cursor: No change View: Jump one row up """ self.offset -= self.cols self.offset = max(self.offset, 0) self.redraw()
[docs] def scroll_down(self): """Scroll Down Cursor: No change View: Jump one row down """ step = self.cols page_size = self.page_size() if self.offset + step + page_size < self.n_results + self.cols: # Cannot scroll below last full page # Remove the *page_size* and *self.cols* terms to weaken this property. self.offset += step 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 ##