+ def bookmark(self, positive=True):
+ """Set self.bookmark to positive, and update CSS class mark."""
+ self.bookmarked = positive
+ self.slot.mark('bookmarked', positive)
+
+
+class GallerySlot(Gtk.Button):
+ """Slot in Gallery representing a FileItem."""
+
+ def __init__(self, item, on_click_file):
+ super().__init__()
+ self.add_css_class('slot')
+ self.set_hexpand(True)
+ self.item = item
+ self.item.slot = self
+ self.connect('clicked', on_click_file)
+
+ def mark(self, css_class, do_add=True):
+ """Add or remove css_class from self."""
+ if do_add:
+ self.add_css_class(css_class)
+ else:
+ self.remove_css_class(css_class)
+
+ def update_widget(self, slot_size, margin, is_in_vp):
+ """(Un-)Load content if (not) is_in_vp, update geometry, CSS classes"""
+ new_content = None
+ if self.get_child() is None and isinstance(self.item, DirItem):
+ new_content = Gtk.Label(label=self.item.name)
+ elif isinstance(self.item, ImgItem):
+ if is_in_vp and not isinstance(self.item, Gtk.Image):
+ new_content = Gtk.Image.new_from_file(self.item.full_path)
+ elif (not is_in_vp) and not isinstance(self.item, Gtk.Label):
+ new_content = Gtk.Label(label='?')
+ if new_content:
+ self.set_child(new_content)
+ side_margin = margin // 2
+ if side_margin:
+ for s in ('bottom', 'top', 'start', 'end'):
+ setattr(self.get_child().props, f'margin_{s}', side_margin)
+ self.get_child().set_size_request(slot_size, slot_size)
+ if isinstance(self.item, ImgItem):
+ self.mark('bookmarked', self.item.bookmarked)
+
+
+class Gallery:
+ """Representation of FileItems below a directory."""
+
+ def __init__(self,
+ sort_order,
+ filter_inputs,
+ on_hit_item,
+ on_grid_built,
+ on_selection_change):
+ self._sort_order = sort_order
+ self._filter_inputs = filter_inputs
+ self._on_hit_item = on_hit_item
+ self._on_grid_built = on_grid_built
+ self._on_selection_change = on_selection_change
+ self.show_dirs = False
+
+ self.per_row = GALLERY_PER_ROW_DEFAULT
+ self._slot_margin = GALLERY_SLOT_MARGIN
+ self._grid = None
+ self._force_width, self._force_height = 0, 0
+ self.slots = None
+ self.dir_entries = []
+ self.selected_idx = 0
+
+ self._fixed_frame = Gtk.Fixed(hexpand=True, vexpand=True)
+ self.scroller = Gtk.ScrolledWindow(propagate_natural_height=True)
+ self.scroller.get_vadjustment().connect(
+ 'value-changed', lambda _: self._update_view(refocus=False))
+ # We want our viewport at always maximum possible size (so we can
+ # safely calculate what's in it and what not), even if the gallery
+ # would be smaller. Therefore we frame the gallery in an expanding
+ # Fixed, to stretch out the viewport even if the gallery is small.
+ self.scroller.set_child(self._fixed_frame)
+ self._viewport = self._fixed_frame.get_parent()
+
+ self._should_update_view = True
+ GLib.timeout_add(GALLERY_ENSURE_UPDATED_VIEW_INTERVAL_MS,
+ self._ensure_updated_view)
+
+ def _ensure_updated_view(self):
+ """Rather than reload slots every scroll pixel, regularly run this."""
+ if self._should_update_view:
+ self._update_view(refocus=False, force=True)
+ return True
+
+ @property
+ def selected_item(self):
+ """Return slot.item at self.selected_idx."""
+ return self.slots[self.selected_idx].item if self.slots else None
+
+ @property
+ def _viewport_height(self):
+ return self._force_height if self._force_height\
+ else self._viewport.get_height()
+
+ def on_focus_slot(self, slot):
+ """If GallerySlot focused, set .selected_idx to it."""
+ self._set_selection(self.slots.index(slot))
+
+ def _set_selection(self, new_idx, unselect_old=True):
+ """Set self.selected_idx, mark slot as 'selected', unmark old one."""
+ if unselect_old:
+ self.slots[self.selected_idx].mark('selected', False)
+ self.selected_idx = new_idx
+ self.slots[self.selected_idx].mark('selected', True)
+ self.slots[self.selected_idx].grab_focus()
+ self._on_selection_change()
+
+ def build_and_show(self, suggested_selection=None):
+ """Build gallery as sorted GallerySlots, select one, draw gallery."""
+
+ def item_clicker(idx):
+ def f(_):
+ self._set_selection(idx)
+ self._on_hit_item()
+ return f
+
+ if self._grid:
+ self._fixed_frame.remove(self._grid)
+ self.slots = []
+ self._grid = Gtk.Grid()
+ self._fixed_frame.put(self._grid, 0, 0)
+ i_row, i_col = 0, 0
+ for i, item in enumerate(sorted([entry for entry in self.dir_entries
+ if self._filter_func(entry)],
+ key=cmp_to_key(self._sort_cmp))):
+ if self.per_row == i_col:
+ i_col = 0
+ i_row += 1
+ slot = GallerySlot(item, item_clicker(i))
+ self._grid.attach(slot, i_col, i_row, 1, 1)
+ self.slots += [slot]
+ i_col += 1
+ self._on_grid_built()
+ self.selected_idx = 0
+ self._update_view()
+ new_idx = 0
+ if suggested_selection is not None:
+ for i, slot in enumerate(self.slots):
+ item_path = slot.item.full_path
+ if suggested_selection.full_path == item_path:
+ new_idx = i
+ break
+ self._set_selection(new_idx, unselect_old=False)
+
+ def move_selection(self, x_inc, y_inc, buf_end):
+ """Move .selection, update its dependencies, redraw gallery."""
+ min_idx, max_idx = 0, len(self.slots) - 1
+ if -1 == y_inc and self.selected_idx >= self.per_row:
+ new_idx = self.selected_idx - self.per_row
+ elif 1 == y_inc and self.selected_idx <= max_idx - self.per_row:
+ new_idx = self.selected_idx + self.per_row
+ elif -1 == x_inc and self.selected_idx > 0:
+ new_idx = self.selected_idx - 1
+ elif 1 == x_inc and self.selected_idx < max_idx:
+ new_idx = self.selected_idx + 1
+ elif 1 == buf_end:
+ new_idx = max_idx
+ elif -1 == buf_end:
+ new_idx = min_idx
+ else:
+ return
+ self._set_selection(new_idx)
+
+ def on_resize(self, width, height=None):
+ """Re-set ._forced_width, ._forced_height, then call ._update_view."""
+ self._force_width = width
+ if height is not None:
+ self._force_height = height
+ self._update_view()
+
+ def _update_view(self, refocus=True, force=False):
+ """Update gallery slots based on if they're in viewport."""
+ self._should_update_view = True
+ vp_scroll = self._viewport.get_vadjustment()
+ vp_top = vp_scroll.get_value()
+ if (not force) and vp_top % 1 > 0:
+ return
+ vp_bottom = vp_top + self._viewport_height
+ vp_width = (self._force_width if self._force_width
+ else self._viewport.get_width())
+ max_slot_width = (vp_width // self.per_row) - self._slot_margin
+ prefered_slot_height = self._viewport_height - self._slot_margin
+ slot_size = min(prefered_slot_height, max_slot_width)
+ for idx, slot in enumerate(self.slots):
+ slot_top = (idx // self.per_row) * (slot_size + self._slot_margin)
+ slot_bottom = slot_top + slot_size
+ in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom)
+ slot.update_widget(slot_size, self._slot_margin, in_vp)
+ self._should_update_view = False
+ if (not refocus) or (not self.slots):
+ return
+ focused_idx = self.selected_idx
+ full_slot_height = slot_size + self._slot_margin
+ focused_slot_top = (focused_idx // self.per_row) * full_slot_height
+ focused_slot_bottom = focused_slot_top + slot_size
+ if focused_slot_top < vp_top:
+ vp_scroll.set_value(focused_slot_top)
+ elif focused_slot_bottom > vp_bottom:
+ vp_scroll.set_value(focused_slot_bottom - self._viewport_height)
+ else:
+ return
+ self._should_update_view = True
+ vp_scroll.emit('value-changed')
+
+ def get_diversities_for(self, sort_attr):
+ """Return how many diff. values for sort_attr in (un-)filtered store"""
+ diversities = [0, 0]
+ for i, store in enumerate([self.dir_entries,
+ [s.item for s in self.slots]]):
+ values = set()
+ for item in store:
+ if isinstance(item, ImgItem):
+ val = None
+ if hasattr(item, sort_attr):
+ val = getattr(item, sort_attr)
+ values.add(val)
+ diversities[i] = len(values)
+ return diversities
+
+ def _sort_cmp(self, a, b):
+ """Sort [a, b] by user-set sort order, and putting directories first"""
+ # ensure ".." and all DirItems at start of order
+ if self.show_dirs:
+ cmp_upper_dir = f' {UPPER_DIR}'
+ if isinstance(a, DirItem) and a.name == cmp_upper_dir:
+ return -1
+ if isinstance(b, DirItem) and b.name == cmp_upper_dir:
+ return +1
+ if isinstance(a, DirItem) and not isinstance(b, DirItem):
+ return -1
+ if isinstance(b, DirItem) and not isinstance(a, DirItem):
+ return +1
+ # apply ._sort_order within DirItems and FileItems (separately)
+ ret = 0
+ for key in [sorter.name for sorter in self._sort_order]:
+ a_cmp = None
+ b_cmp = None
+ if hasattr(a, key):
+ a_cmp = getattr(a, key)
+ if hasattr(b, key):
+ b_cmp = getattr(b, key)
+ if a_cmp is None and b_cmp is None:
+ continue
+ if a_cmp is None:
+ ret = -1
+ elif b_cmp is None:
+ ret = +1
+ elif a_cmp > b_cmp:
+ ret = +1
+ elif a_cmp < b_cmp:
+ ret = -1
+ return ret
+
+ def _filter_func(self, item):
+ """Return if item matches user-set filters."""
+
+ def number_filter(attr_name, filter_line, to_compare):
+ use_float = attr_name.upper() in GEN_PARAMS_FLOAT
+ constraint_strings = filter_line.split(',')
+ numbers_or = set()
+ unequal = set()
+ less_than = None
+ less_or_equal = None
+ more_or_equal = None
+ more_than = None
+ for constraint_string in constraint_strings:
+ toks = constraint_string.split()
+ if len(toks) == 1:
+ tok = toks[0]
+ if tok[0] in '<>!':
+ if '=' == tok[1]:
+ toks = [tok[:2], tok[2:]]
+ else:
+ toks = [tok[:1], tok[1:]]
+ else:
+ value = float(tok) if use_float else int(tok)
+ numbers_or.add(value)
+ if len(toks) == 2:
+ value = float(toks[1]) if use_float else int(toks[1])
+ if toks[0] == '!=':
+ unequal.add(value)
+ elif toks[0] == '<':
+ if less_than is None or less_than >= value:
+ less_than = value
+ elif toks[0] == '<=':
+ if less_or_equal is None or less_or_equal > value:
+ less_or_equal = value
+ elif toks[0] == '>=':
+ if more_or_equal is None or more_or_equal < value:
+ more_or_equal = value
+ elif toks[0] == '>':
+ if more_than is None or more_than <= value:
+ more_than = value
+ if to_compare in numbers_or:
+ return True
+ if len(numbers_or) > 0 and (less_than == less_or_equal ==
+ more_or_equal == more_than):
+ return False
+ if to_compare in unequal:
+ return False
+ if (less_than is not None
+ and to_compare >= less_than)\
+ or (less_or_equal is not None
+ and to_compare > less_or_equal)\
+ or (more_or_equal is not None
+ and to_compare < more_or_equal)\
+ or (more_than is not None
+ and to_compare <= more_than):
+ return False
+ return True
+
+ if not self.show_dirs and isinstance(item, DirItem):
+ return False
+ for filter_attribute, value in self._filter_inputs.items():
+ if not hasattr(item, filter_attribute):
+ return False
+ to_compare = getattr(item, filter_attribute)
+ number_attributes = set(GEN_PARAMS_INT) | set(GEN_PARAMS_FLOAT) | {
+ 'BOOKMARKED'}
+ if filter_attribute.upper() in number_attributes:
+ if not number_filter(filter_attribute, value, to_compare):
+ return False
+ elif value not in to_compare:
+ return False
+ return True
+