""" Part of the tagit module. A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports import math import os # kivy imports from kivy.lang import Builder import kivy.properties as kp # tagit imports from tagit import config, dialogues from tagit.utils import clamp from tagit.widgets import Binding # inner-module imports from .action import Action # exports __all__ = [] ## code ## # load kv Builder.load_file(os.path.join(os.path.dirname(__file__), 'browser.kv')) # classes class NextPage(Action): """Scroll one page downwards without moving the cursor.""" text = kp.StringProperty('Next page') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'page_next')) def apply(self): with self.root.browser as browser: browser.offset = clamp(browser.offset + browser.page_size, browser.max_offset) class PreviousPage(Action): """Scroll one page upwards without moving the cursor.""" text = kp.StringProperty('Previous page') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'page_prev')) def apply(self): with self.root.browser as browser: browser.offset = max(browser.offset - browser.page_size, 0) class ScrollUp(Action): """Scroll one row up without moving the cursor.""" text = kp.StringProperty('Scroll up') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'scroll_up')) def on_touch_down(self, touch): scrollcfg = self.cfg('ui', 'standalone', 'browser', 'scroll') scrolldir = 'scrolldown' if scrollcfg == 'mouse' else 'scrollup' if self.root.browser.collide_point(*touch.pos) \ and not self.root.keys.ctrl_pressed: if touch.button == scrolldir: self.apply() return super(ScrollUp, self).on_touch_down(touch) def apply(self): with self.root.browser as browser: browser.offset = clamp(browser.offset - browser.cols, browser.max_offset) class ScrollDown(Action): """Scroll one row down without moving the cursor.""" text = kp.StringProperty('Scroll down') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'scroll_down')) def on_touch_down(self, touch): scrollcfg = self.cfg('ui', 'standalone', 'browser', 'scroll') scrolldir = 'scrollup' if scrollcfg == 'mouse' else 'scrolldown' if self.root.browser.collide_point(*touch.pos) \ and not self.root.keys.ctrl_pressed: if touch.button == scrolldir: self.apply() return super(ScrollDown, self).on_touch_down(touch) def apply(self): with self.root.browser as browser: browser.offset = clamp(browser.offset + browser.cols, browser.max_offset) class JumpToPage(Action): """Jump to a specified offset.""" text = kp.StringProperty('Go to page') def apply(self, offset=None): if offset is None: browser = self.root.browser dlg = dialogues.NumericInput(lo=0, hi=browser.max_offset, init_value=browser.offset) dlg.bind(on_ok=lambda wx: self.set_offset(wx.value)) dlg.open() else: self.set_offset(offset) def set_offset(self, offset): with self.root.browser as browser: browser.offset = clamp(offset, browser.max_offset) class ZoomIn(Action): """Decrease the grid size.""" text = kp.StringProperty('Zoom in') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'zoom_in')) # TODO: zoom by gesture def on_touch_down(self, touch): # not triggered (but ScrollDown is!) if self.root.browser.collide_point(*touch.pos) \ and self.root.keys.ctrl_pressed: if touch.button == 'scrolldown': self.apply() return super(ZoomIn, self).on_touch_down(touch) def apply(self): with self.root.browser as browser: step = self.cfg('ui', 'standalone', 'browser', 'zoom_step') if browser.gridmode == browser.GRIDMODE_LIST: cols = browser.cols else: cols = max(1, browser.cols - step) rows = max(1, browser.rows - step) # TODO: Zoom to center? (adjust offset) if cols != browser.cols or rows != browser.rows: # clear widgets first, otherwise GridLayout will # complain about too many childrens. browser.clear_widgets() # adjust the grid size browser.cols = cols browser.rows = rows class ZoomOut(Action): """Increase the grid size.""" text = kp.StringProperty('Zoom out') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'zoom_out')) # TODO: zoom by gesture def on_touch_down(self, touch): if self.root.browser.collide_point(*touch.pos) \ and self.root.keys.ctrl_pressed: if touch.button == 'scrollup': self.apply() return super(ZoomOut, self).on_touch_down(touch) def apply(self): with self.root.browser as browser: # TODO: Zoom from center? (adjust offset) step = self.cfg('ui', 'standalone', 'browser', 'zoom_step') # get maxcols maxcols = self.cfg('ui', 'standalone', 'browser', 'maxcols') maxcols = float('inf') if maxcols <= 0 else maxcols # get maxrows maxrows = self.cfg('ui', 'standalone', 'browser', 'maxrows') maxrows = float('inf') if maxrows <= 0 else maxrows # set cols/rows if browser.gridmode != browser.GRIDMODE_LIST: browser.cols = min(browser.cols + step, maxcols) browser.rows = min(browser.rows + step, maxrows) # adjust offset to ensure that one full page is visible browser.offset = clamp(browser.offset, browser.max_offset) class JumpToCursor(Action): """Focus the field of view at the cursor.""" text = kp.StringProperty('Find cursor') def apply(self): with self.root.browser as browser: if browser.cursor is None: # cursor not set, nothing to do pass else: idx = browser.items.index(browser.cursor) if idx < browser.offset: # cursor is above view, scroll up such that the cursor # is in the first row. offset = math.floor(idx / browser.cols) * browser.cols browser.offset = clamp(offset, browser.max_offset) elif browser.offset + browser.page_size <= idx: # cursor is below view, scroll down such that the cursor # is in the last row. offset = math.floor(idx / browser.cols) * browser.cols offset -= (browser.page_size - browser.cols) browser.offset = clamp(offset, browser.max_offset) else: # cursor is visible, nothing to do pass class SetCursor(Action): """Set the cursor to a specific item.""" text = kp.StringProperty('Set cursor') def apply(self, obj): with self.root.browser as browser: browser.cursor = obj self.root.trigger('JumpToCursor') # is invoked via mouse click only, hence # the item selection should always toggle self.root.trigger('Select', browser.cursor) class MoveCursorFirst(Action): """Set the cursor to the first item.""" text = kp.StringProperty('First') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'go_first')) def apply(self): with self.root.browser as browser: if browser.n_items == 0: # browser is empty, nothing to do pass else: # set cursor to first item old = browser.cursor browser.cursor = browser.items[0] # scroll to first page if need be self.root.trigger('JumpToCursor') # fix selection if browser.select_mode != browser.SELECT_MULTI and \ (browser.select_mode != browser.SELECT_SINGLE or old != browser.cursor): self.root.trigger('Select', browser.cursor) class MoveCursorLast(Action): """Set the cursor to the last item.""" text = kp.StringProperty('Last') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'go_last')) def apply(self): with self.root.browser as browser: if browser.n_items == 0: # browser is empty, nothing to do pass else: # set cursor to last item old = browser.cursor browser.cursor = browser.items[-1] # scroll to last page if need be self.root.trigger('JumpToCursor') # fix selection if browser.select_mode != browser.SELECT_MULTI and \ (browser.select_mode != browser.SELECT_SINGLE or old != browser.cursor): self.root.trigger('Select', browser.cursor) class MoveCursorUp(Action): """Move the cursor one item upwards. Scroll if needbe.""" text = kp.StringProperty('Cursor up') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_up')) def apply(self): with self.root.browser as browser: if browser.n_items == 0: # browser is empty, nothing to do pass elif browser.cursor is None: # cursor wasn't set before. Set to last item self.root.trigger('MoveCursorLast') else: # move cursor one row up old = browser.items.index(browser.cursor) # check if the cursor is in the first row already if old < browser.cols: return # first row already # move cursor up new = clamp(old - browser.cols, browser.n_items - 1) browser.cursor = browser.items[new] # fix field of view self.root.trigger('JumpToCursor') # fix selection if browser.select_mode != browser.SELECT_MULTI and \ (browser.select_mode != browser.SELECT_SINGLE or old != new): self.root.trigger('Select', browser.cursor) class MoveCursorDown(Action): """Move the cursor one item downwards. Scroll if needbe.""" text = kp.StringProperty('Cursor down') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_down')) def apply(self): with self.root.browser as browser: if browser.n_items == 0: # browser is empty, nothing to do pass elif browser.cursor is None: # cursor wasn't set before. Set to first item self.root.trigger('MoveCursorFirst') else: # move cursor one row down old = browser.items.index(browser.cursor) # check if the cursor is in the last row already last_row = browser.n_items % browser.cols last_row = last_row if last_row > 0 else browser.cols if old >= browser.n_items - last_row: return # last row already # move cursor down new = clamp(old + browser.cols, browser.n_items - 1) browser.cursor = browser.items[new] # fix field of view self.root.trigger('JumpToCursor') # fix selection if browser.select_mode != browser.SELECT_MULTI and \ (browser.select_mode != browser.SELECT_SINGLE or old != new): self.root.trigger('Select', browser.cursor) class MoveCursorLeft(Action): """Move the cursor to the previous item.""" text = kp.StringProperty('Cursor left') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_left')) def apply(self): with self.root.browser as browser: if browser.n_items == 0: # browser is empty, nothing to do pass elif browser.cursor is None: # cursor wasn't set before. Set to the last item self.root.trigger('MoveCursorLast') else: # move cursor one position to the left old = browser.items.index(browser.cursor) new = clamp(old - 1, browser.n_items - 1) browser.cursor = browser.items[new] self.root.trigger('JumpToCursor') # fix selection if browser.select_mode != browser.SELECT_MULTI and \ (browser.select_mode != browser.SELECT_SINGLE or old != new): self.root.trigger('Select', browser.cursor) class MoveCursorRight(Action): """Move the cursor to the next item.""" text = kp.StringProperty('Cursor right') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_right')) def apply(self): with self.root.browser as browser: if browser.n_items == 0: # browser is empty, nothing to do pass elif browser.cursor is None: # cursor wasn't set before. Set to the last item self.root.trigger('MoveCursorFirst') else: # move cursor one position to the right old = browser.items.index(browser.cursor) new = clamp(old + 1, browser.n_items - 1) browser.cursor = browser.items[new] self.root.trigger('JumpToCursor') # fix selection if browser.select_mode != browser.SELECT_MULTI and \ (browser.select_mode != browser.SELECT_SINGLE or old != new): self.root.trigger('Select', browser.cursor) class SelectAll(Action): """Select all items.""" text = kp.StringProperty('Select all') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'select_all')) def apply(self): with self.root.browser as browser: browser.selection = browser.items.as_set().copy() class SelectNone(Action): """Clear the selection.""" text = kp.StringProperty('Clear selection') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'select_none')) def apply(self): with self.root.browser as browser: browser.selection = set() class SelectInvert(Action): """Invert the selection.""" text = kp.StringProperty('Invert selection') def apply(self): with self.root.browser as browser: browser.selection = browser.items.as_set() - browser.selection class SelectSingle(Action): """Select only the cursor.""" text = kp.StringProperty('Select one') def apply(self): with self.root.browser as browser: browser.select_mode = browser.SELECT_SINGLE class SelectAdditive(Action): """Set the selection mode to additive select.""" text = kp.StringProperty('Always select') def apply(self): with self.root.browser as browser: browser.select_mode = browser.SELECT_ADDITIVE class SelectSubtractive(Action): """Set the selection mode to subtractive select.""" text = kp.StringProperty('Always deselect') def apply(self): with self.root.browser as browser: browser.select_mode = browser.SELECT_SUBTRACTIVE class SelectMulti(Action): """Set the selection mode to random access.""" text = kp.StringProperty('Select many') browser = kp.ObjectProperty(None, allownone=True) def ktrigger(self, evt): key, _, _ = evt if key in self.root.keys.modemap[self.root.keys.MODIFIERS_CTRL]: self.browser = self.root.browser self.apply() def on_root(self, wx, root): super(SelectMulti, self).on_root(wx, root) root.keys.bind(on_release=self.on_key_up) def on_key_up(self, wx, key): if key in self.root.keys.modemap[self.root.keys.MODIFIERS_CTRL]: if self.browser is not None: with self.browser as browser: if browser.select_mode & browser.SELECT_MULTI: browser.select_mode -= browser.SELECT_MULTI def apply(self): with self.root.browser as browser: browser.select_mode |= browser.SELECT_MULTI class SelectRange(Action): """Set the selection mode to range select.""" text = kp.StringProperty('Select range') browser = kp.ObjectProperty(None, allownone=True) def ktrigger(self, evt): key, _, _ = evt if key in self.root.keys.modemap[self.root.keys.MODIFIERS_SHIFT]: self.browser = self.root.browser self.apply() def on_root(self, wx, root): super(SelectRange, self).on_root(wx, root) root.keys.bind(on_release=self.on_key_up) def on_key_up(self, wx, key): if key in self.root.keys.modemap[self.root.keys.MODIFIERS_SHIFT]: if self.browser is not None: with self.browser as browser: if browser.select_mode & browser.SELECT_RANGE: browser.select_mode -= browser.SELECT_RANGE browser.range_base = set() browser.range_origin = None def apply(self): with self.root.browser as browser: browser.select_mode |= browser.SELECT_RANGE browser.range_base = browser.selection.copy() idx = None if browser.cursor is None else browser.items.index(browser.cursor) browser.range_origin = idx class Select(Action): """Select or deselect an item. How the selection changes depends on the selection mode.""" text = kp.StringProperty('Select') def ktrigger(self, evt): return Binding.check(evt, self.cfg('bindings', 'browser', 'select')) def apply(self, obj=None): with self.root.browser as browser: obj = obj if obj is not None else browser.cursor if obj is None: # nothing to do pass elif browser.select_mode & browser.SELECT_ADDITIVE: browser.selection.add(obj) elif browser.select_mode & browser.SELECT_SUBTRACTIVE: if obj in browser.selection: browser.selection.remove(obj) elif browser.select_mode & browser.SELECT_RANGE: idx = browser.items.index(obj) lo = min(idx, browser.range_origin) hi = max(idx, browser.range_origin) browser.selection = browser.range_base | set(browser.items[lo:hi+1]) elif browser.select_mode & browser.SELECT_MULTI: # Toggle if obj in browser.selection: browser.selection.remove(obj) else: browser.selection.add(obj) elif browser.select_mode == 0: #elif browser.select_mode & browser.SELECT_SINGLE: # Toggle if obj in browser.selection: browser.selection = set() else: browser.selection = {obj} ## config ## config.declare(('ui', 'standalone', 'browser', 'maxcols'), config.Unsigned(), 0, __name__, 'Column maximum', 'Maximal number of columns. This guards against aggressive zooming, because the application may become unresponsive if too many preview images are shown on the same page. A value of Infinity means that no limit is enforced.') config.declare(('ui', 'standalone', 'browser', 'maxrows'), config.Unsigned(), 0, __name__, 'Row maximum', 'Maximal number of rows. This guards against aggressive zooming, because the application may become unresponsive if too many preview images are shown on the same page. A value of Infinity means that no limit is enforced.') config.declare(('ui', 'standalone', 'browser', 'zoom_step'), config.Unsigned(), 1, __name__, 'Zoom step', 'Controls by how much the gridsize is increased or decreased when zoomed in or out. Affects both dimensions (cols/rows) in grid mode.') config.declare(('ui', 'standalone', 'browser', 'scroll'), config.Enum('mouse', 'touch'), 'mouse', __name__, 'Inverted scroll', 'To scroll downwards, one can either move the fingers in an upward direction (touch) or use the scroll wheel in a downward direction (mouse).') # keybindings config.declare(('bindings', 'browser', 'page_next'), config.Keybind(), Binding.simple(Binding.PGDN), __name__, NextPage.text.defaultvalue, NextPage.__doc__) config.declare(('bindings', 'browser', 'page_prev'), config.Keybind(), Binding.simple(Binding.PGUP), __name__, PreviousPage.text.defaultvalue, PreviousPage.__doc__) config.declare(('bindings', 'browser', 'scroll_up'), config.Keybind(), Binding.simple('k', None, Binding.mALL), __name__, ScrollUp.text.defaultvalue, ScrollUp.__doc__) config.declare(('bindings', 'browser', 'scroll_down'), config.Keybind(), Binding.simple('j', None, Binding.mALL), __name__, ScrollDown.text.defaultvalue, ScrollDown.__doc__) config.declare(('bindings', 'browser', 'zoom_in'), config.Keybind(), Binding.simple('+'), __name__, ZoomIn.text.defaultvalue, ZoomIn.__doc__) config.declare(('bindings', 'browser', 'zoom_out'), config.Keybind(), Binding.simple('-'), __name__, ZoomOut.text.defaultvalue, ZoomOut.__doc__) config.declare(('bindings', 'browser', 'go_first'), config.Keybind(), Binding.simple(Binding.HOME), __name__, MoveCursorFirst.text.defaultvalue, MoveCursorFirst.__doc__) config.declare(('bindings', 'browser', 'go_last'), config.Keybind(), Binding.simple(Binding.END), __name__, MoveCursorLast.text.defaultvalue, MoveCursorLast.__doc__) config.declare(('bindings', 'browser', 'cursor_up'), config.Keybind(), Binding.simple(Binding.UP), __name__, MoveCursorUp.text.defaultvalue, MoveCursorUp.__doc__) config.declare(('bindings', 'browser', 'cursor_down'), config.Keybind(), Binding.simple(Binding.DOWN), __name__, MoveCursorDown.text.defaultvalue, MoveCursorDown.__doc__) config.declare(('bindings', 'browser', 'cursor_left'), config.Keybind(), Binding.simple(Binding.LEFT), __name__, MoveCursorLeft.text.defaultvalue, MoveCursorLeft.__doc__) config.declare(('bindings', 'browser', 'cursor_right'), config.Keybind(), Binding.simple(Binding.RIGHT), __name__, MoveCursorRight.text.defaultvalue, MoveCursorRight.__doc__) config.declare(('bindings', 'browser', 'select_all'), config.Keybind(), Binding.simple('a', Binding.mCTRL, Binding.mREST), __name__, SelectAll.text.defaultvalue, SelectAll.__doc__) config.declare(('bindings', 'browser', 'select_none'), config.Keybind(), Binding.simple('a', (Binding.mCTRL, Binding.mSHIFT), Binding.mREST), __name__, SelectNone.text.defaultvalue, SelectNone.__doc__) config.declare(('bindings', 'browser', 'select'), config.Keybind(), Binding.simple(Binding.SPACEBAR), __name__, Select.text.defaultvalue, Select.__doc__) ## EOF ##