home · contact · privacy
Re-write gallery without FlowBox, instead as Grid, for future better row control.
authorChristian Heller <c.heller@plomlompom.de>
Fri, 20 Sep 2024 23:43:34 +0000 (01:43 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Fri, 20 Sep 2024 23:43:34 +0000 (01:43 +0200)
browser.py

index b38cf1037d02f59abf859421bf904c57508ec804..f74e0b7908a683ba77344f4e65051ec83a2ce8e0 100755 (executable)
@@ -1,7 +1,11 @@
 #!/usr/bin/env python3
 """Browser for image files."""
 from json import dump as json_dump, load as json_load
-from os.path import exists as path_exists, join as path_join, abspath
+from functools import cmp_to_key
+from os import listdir
+from os.path import (exists as path_exists, join as path_join, abspath, isdir,
+                     splitext, getmtime)
+from datetime import datetime, timezone
 from argparse import ArgumentParser
 from PIL import Image
 from PIL.PngImagePlugin import PngImageFile
@@ -23,14 +27,28 @@ SORT_DEFAULT = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\
 UPPER_DIR = '..'
 CACHE_PATH = 'cache.json'
 BOOKMARKS_PATH = 'bookmarks.json'
+GALLERY_SLOT_MARGIN = 6
+GALLERY_PER_ROW_DEFAULT = 5
+GALLERY_ENSURE_UPDATED_VIEW_INTERVAL_MS = 500
 
 OR_H = Gtk.Orientation.HORIZONTAL
 OR_V = Gtk.Orientation.VERTICAL
 
 CSS = """
-.bookmarked { background: green; }
-.bookmarked:selected { background: #00aaaa; }
 .temp { background: #aaaa00; }
+.bookmarked { background: #000000; }
+.selected { background: #008800; }
+:focus { background: #00ff00; }
+button.slot {
+  padding-top: 0;
+  padding-bottom: 0;
+  padding-left: 0;
+  padding-right: 0;
+  border-top-width: 0;
+  border-bottom-width: 0;
+  border-left-width: 0;
+  border-right-width: 0;
+}
 """
 
 
@@ -42,19 +60,8 @@ class Sorter(GObject.GObject):
         super().__init__()
         self.name = name
 
-    def set_label(self, gallery_store, gallery_store_filtered):
+    def set_label(self, diversities):
         """Set .list_item's label to .name and n of different values for it."""
-        diversities = [0, 0]
-        for i, store in enumerate([gallery_store_filtered, gallery_store]):
-            values = set()
-            for j in range(store.get_n_items()):
-                item = store.get_item(j)
-                if isinstance(item, ImgItem):
-                    val = None
-                    if hasattr(item, self.name):
-                        val = getattr(item, self.name)
-                    values.add(val)
-            diversities[i] = len(values)
         label = f'{self.name} ({diversities[0]}/{diversities[1]}) '
         self.list_item.get_first_child().set_text(label)
 
@@ -62,20 +69,17 @@ class Sorter(GObject.GObject):
 class FileItem(GObject.GObject):
     """Gallery representation of filesystem entry, base to DirItem, ImgItem."""
 
-    def __init__(self, path, info):
+    def __init__(self, path, name):
         super().__init__()
-        self.name = info.get_name()
-        self.last_mod_time = info.get_modification_date_time().format_iso8601()
+        self.name = name
         self.full_path = path_join(path, self.name)
-        self.bookmarked = False
 
 
 class DirItem(FileItem):
     """Gallery representation of filesystem entry for directory."""
 
-    def __init__(self, path, info, is_parent=False):
-        super().__init__(path, info)
-        self.name = f'  {UPPER_DIR}' if is_parent else f'  {self.name}/'
+    def __init__(self, path, name, is_parent=False):
+        super().__init__(path, name)
         if is_parent:
             self.full_path = path
 
@@ -83,8 +87,10 @@ class DirItem(FileItem):
 class ImgItem(FileItem):
     """Gallery representation of filesystem entry for image file."""
 
-    def __init__(self, path, info, cache):
-        super().__init__(path, info)
+    def __init__(self, path, name, last_mod_time, cache):
+        super().__init__(path, name)
+        self.last_mod_time = last_mod_time
+        self.bookmarked = False
         for param_name in GEN_PARAMS:
             if param_name in GEN_PARAMS_STR:
                 setattr(self, param_name.lower(), '')
@@ -97,7 +103,7 @@ class ImgItem(FileItem):
                     setattr(self, k, cached[k])
 
     def set_metadata(self, cache):
-        """Set instance attributes from 'image file's GenParams PNG chunk."""
+        """Set attrs from file's GenParams PNG chunk, update into cache."""
         img = Image.open(self.full_path)
         if isinstance(img, PngImageFile):
             gen_params_as_str = img.text.get('generation_parameters', '')
@@ -110,21 +116,345 @@ class ImgItem(FileItem):
             cached[k] = getattr(self, k)
         cache[self.full_path] = {self.last_mod_time: cached}
 
+    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
+
 
 class MainWindow(Gtk.Window):
     """Image browser app top-level window."""
-    gallery: Gtk.FlowBox
-    gallery_store: Gio.ListStore
-    gallery_store_filtered: Gtk.FilterListModel
-    gallery_selection: Gtk.SingleSelection
-    include_dirs: bool
-    recurse_dirs: bool
-    per_row: int
     metadata: Gtk.TextBuffer
     sort_store: Gtk.ListStore
     sort_selection: Gtk.SingleSelection
     prev_key: list
-    filter_inputs = dict
     button_activate_sort: Gtk.Button
     counter: Gtk.Label
 
@@ -146,36 +476,17 @@ class MainWindow(Gtk.Window):
             add_button('less', lambda _: self.inc_per_row(-1), navbar)
             add_button('more', lambda _: self.inc_per_row(+1), navbar)
             btn = Gtk.CheckButton(label='show directories')
-            btn.connect('toggled', self.reset_include_dirs)
+            btn.connect('toggled', self.reset_show_dirs)
             navbar.append(btn)
             btn = Gtk.CheckButton(label='recurse directories')
             btn.connect('toggled', self.reset_recurse)
             navbar.append(btn)
             return navbar
 
-        def init_gallery_widgets():
-            self.gallery = Gtk.FlowBox(orientation=OR_H)
-            self.gallery.connect(
-                    'selected-children-changed',
-                    lambda _: self.update_file_selection())
-            self.gallery.connect(
-                    'child-activated', lambda _, __: self.hit_file_selection())
-            scroller = Gtk.ScrolledWindow(propagate_natural_height=True)
-            scroller.get_vadjustment().connect(
-                    'value-changed', lambda _: self.update_gallery_view())
-            # 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.
-            viewport_stretcher = Gtk.Fixed(hexpand=True, vexpand=True)
-            viewport_stretcher.put(self.gallery, 0, 0)
-            scroller.props.child = viewport_stretcher
-            return scroller
-
         def init_metadata_box():
             text_view = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR,
                                      editable=False)
-            text_view.set_size_request(200, -1)
+            text_view.set_size_request(300, -1)
             self.metadata = text_view.get_buffer()
             metadata_box = Gtk.Box(orientation=OR_V)
             metadata_box.append(Gtk.Label(label='** metadata **'))
@@ -207,12 +518,11 @@ class MainWindow(Gtk.Window):
                         self.filter_inputs[sorter.name] = text
                     elif sorter.name in self.filter_inputs:
                         del self.filter_inputs[sorter.name]
-                    self.update_gallery()
+                    self.gallery.build_and_show()
 
                 sorter = list_item.props.item
                 sorter.list_item = list_item.props.child
-                sorter.set_label(self.gallery_store,
-                                 self.gallery_store_filtered)
+                sorter.set_label(self.gallery.get_diversities_for(sorter.name))
                 sorter.filterer = sorter.list_item.get_last_child()
                 filter_entry = sorter.list_item.get_last_child()
                 filter_text = self.filter_inputs.get(sorter.name, '')
@@ -237,20 +547,8 @@ class MainWindow(Gtk.Window):
             self.button_activate_sort.connect(
                     'clicked', lambda _: self.activate_sort_order())
             sort_box.append(self.button_activate_sort)
-            self.filter_inputs = {}
             return sort_box
 
-        def init_gallery_content():
-            self.gallery_store = Gio.ListStore(item_type=FileItem)
-            list_filter = Gtk.CustomFilter.new(self.gallery_filter)
-            self.gallery_store_filtered = Gtk.FilterListModel(
-                    model=self.gallery_store, filter=list_filter)
-            self.gallery_selection = Gtk.SingleSelection.new(
-                    self.gallery_store_filtered)
-            self.include_dirs = False
-            self.recurse_dirs = False
-            self.per_row = 5
-
         def init_key_control():
             key_ctl = Gtk.EventControllerKey(
                     propagation_phase=Gtk.PropagationPhase.CAPTURE)
@@ -274,15 +572,20 @@ class MainWindow(Gtk.Window):
                     self.get_display(), css_provider,
                     Gtk.STYLE_PROVIDER_PRIORITY_USER)
 
-        self.block_once_hit_file_selection = False
-        self.block_file_selection_updates = False
-        self.force_width, self.force_height = 0, 0
+        self.filter_inputs = {}
+        self.gallery = Gallery(
+                sort_order=self.app.sort_order,
+                filter_inputs=self.filter_inputs,
+                on_hit_item=self.hit_gallery_item,
+                on_grid_built=self.update_sort_order_box,
+                on_selection_change=self.update_metadata_on_gallery_selection)
+        self.recurse_dirs = False
 
         setup_css()
         viewer = Gtk.Box(orientation=OR_V)
         self.navbar = init_navbar()
         viewer.append(self.navbar)
-        viewer.append(init_gallery_widgets())
+        viewer.append(self.gallery.scroller)
         self.side_box = Gtk.Box(orientation=OR_V)
         self.sort_box = init_sorter_and_filterer()
         self.side_box.append(self.sort_box)
@@ -296,208 +599,52 @@ class MainWindow(Gtk.Window):
 
         ensure_db_files()
         init_key_control()
-        init_gallery_content()
-        self.load_directory(update_gallery=False)
-        GLib.idle_add(self.update_gallery)
+        self.load_directory(update_gallery_view=False)
+        self.connect('notify::focus-widget',
+                     lambda _, __: self.on_focus_change())
+        GLib.idle_add(self.gallery.build_and_show)
+
+    def on_focus_change(self):
+        """If new focus on GallerySlot, call gallery.on_focus_slot."""
+        focused = self.get_focus()
+        if isinstance(focused, GallerySlot):
+            self.gallery.on_focus_slot(focused)
 
     def on_resize(self):
-        """Adapt .force_(width|height) to new .default_(width|height)"""
-        if self.get_width() > 0:  # so we don't call this on initial resize
-            # NB: we .measure side_box because its width is changing, whereas
-            # for the unchanging navbar .get_height is sufficient
+        """On window resize, do .gallery.on_resize towards its new geometry."""
+        if self.get_width() > 0:  # So we don't call this on initial resize.
+            # NB: We .measure side_box because its width is changing, whereas
+            # for the unchanging navbar .get_height is sufficient.
             side_box_width = self.side_box.measure(OR_H, -1).natural
-            self.force_width = self.get_default_size()[0] - side_box_width
-            self.force_height = (self.get_default_size()[1]
-                                 - self.navbar.get_height())
-            self.update_gallery_view(refocus=True)
-
-    # various gallery management tasks
+            default_size = self.get_default_size()
+            self.gallery.on_resize(default_size[0] - side_box_width,
+                                   default_size[1] - self.navbar.get_height())
 
     def bookmark(self):
         """Toggle bookmark on selected gallery item."""
-        selected_item = self.gallery_selection.props.selected_item
+        if not isinstance(self.gallery.selected_item, ImgItem):
+            return
         with open(BOOKMARKS_PATH, 'r', encoding='utf8') as f:
             bookmarks = json_load(f)
-        if selected_item.bookmarked:
-            selected_item.bookmarked = False
-            bookmarks.remove(selected_item.full_path)
+        if self.gallery.selected_item.bookmarked:
+            self.gallery.selected_item.bookmark(False)
+            bookmarks.remove(self.gallery.selected_item.full_path)
         else:
-            selected_item.bookmarked = True
-            bookmarks += [selected_item.full_path]
+            self.gallery.selected_item.bookmark(True)
+            bookmarks += [self.gallery.selected_item.full_path]
         with open(BOOKMARKS_PATH, 'w', encoding='utf8') as f:
             json_dump(list(bookmarks), f)
-        self.update_file_selection()
-        self.update_gallery_view()
         self.update_sort_order_box()
 
-    def update_gallery(self, suggested_selection=None):
-        """Build gallery based on .per_row and .gallery_selection."""
-
-        def sorter(a, b):
-            # ensure ".." and all DirItems at start of order
-            if self.include_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 self.sort_order within DirItems and FileItems (separately)
-            ret = 0
-            for key in [sorter.name for sorter in self.app.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 init_gallery_slot(file_item):
-            slot = Gtk.Box()
-            slot.item = file_item
-            slot.props.hexpand = True
-            if isinstance(file_item, ImgItem):
-                slot.content = Gtk.Label(label='?')
-            else:
-                slot.content = Gtk.Button(label=file_item.name)
-                slot.content.connect('clicked', self.on_click_file)
-            slot.append(slot.content)
-            return slot
-
-        self.gallery.bind_model(None, lambda _: Gtk.Box())
-        self.gallery_store.sort(sorter)
-        self.gallery.bind_model(self.gallery_selection, init_gallery_slot)
-        to_select = self.gallery.get_child_at_index(0)
-        if suggested_selection:
-            i = 0
-            while True:
-                gallery_item_at_i = self.gallery.get_child_at_index(i)
-                if gallery_item_at_i is None:
-                    break
-                item_path = gallery_item_at_i.props.child.item.full_path
-                if suggested_selection.full_path == item_path:
-                    to_select = gallery_item_at_i
-                    break
-                i += 1
-        if to_select:
-            self.block_once_hit_file_selection = True
-            to_select.activate()
-        else:
-            self.counter.set_text(' (nothing) ')
-        self.update_sort_order_box()
-        self.update_gallery_view()
-
-    def update_gallery_view(self, refocus=False):
-        """Load/unload gallery's file images based on viewport visibility."""
-        self.gallery.set_min_children_per_line(self.per_row)
-        self.gallery.set_max_children_per_line(self.per_row)
-        vp = self.gallery.get_parent().get_parent()
-        # because sometimes vp.[size] updates too late for our measurements
-        vp_height = self.force_height if self.force_height else vp.get_height()
-        vp_width = self.force_width if self.force_width else vp.get_width()
-        vp_scroll = vp.get_vadjustment()
-        vp_top = vp_scroll.get_value()
-        vp_bottom = vp_top + vp_height
-        margin = 6
-        max_slot_width = (vp_width // self.per_row) - margin
-        prefered_slot_height = vp_height - margin
-        slot_size = min(prefered_slot_height, max_slot_width)
-        for i in range(self.gallery_store_filtered.get_n_items()):
-            slot = self.gallery.get_child_at_index(i).props.child
-            if isinstance(slot.item, DirItem):
-                slot.content.set_size_request(slot_size, slot_size)
-                continue
-            if slot.item.bookmarked:
-                slot.props.parent.add_css_class('bookmarked')
-            else:
-                slot.props.parent.remove_css_class('bookmarked')
-            slot_top = (i // self.per_row) * (slot_size + margin)
-            slot_bottom = slot_top + slot_size
-            in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom)
-            if in_vp:
-                if not isinstance(slot.content, Gtk.Image):
-                    slot.remove(slot.content)
-                    slot.content = Gtk.Image.new_from_file(slot.item.full_path)
-                    slot.append(slot.content)
-            elif isinstance(slot.content, Gtk.Image):
-                slot.remove(slot.content)
-                slot.content = Gtk.Label(label='?')
-                slot.append(slot.content)
-            slot.content.set_size_request(slot_size, slot_size)
-        # because for some reason vp.scroll_to doesn't do what we want
-        if refocus:
-            for c in self.gallery.get_selected_children():
-                for i in range(self.gallery_store_filtered.get_n_items()):
-                    if c == self.gallery.get_child_at_index(i):
-                        slot = self.gallery.get_child_at_index(i).props.child
-                        slot_top = (i // self.per_row) * (slot_size + margin)
-                        slot_bottom = slot_top + slot_size
-                        if slot_top < vp_top:
-                            vp_scroll.set_value(slot_top)
-                        elif slot_bottom > vp_bottom:
-                            vp_scroll.set_value(slot_bottom - vp_height)
-                        vp_scroll.emit('value-changed')
-
-    def hit_file_selection(self):
+    def hit_gallery_item(self):
         """If current file selection is directory, reload into that one."""
-        if self.block_once_hit_file_selection:
-            self.block_once_hit_file_selection = False
-            return
-        selected = self.gallery_selection.props.selected_item
+        selected = self.gallery.selected_item
         if isinstance(selected, DirItem):
             self.app.img_dir_absolute = selected.full_path
             self.load_directory()
 
-    def update_file_selection(self):
-        """Sync gallery selection, update metadata on selected file."""
-
-        def sync_fbox_selection_to_gallery_selection():
-            fbox_candidates = self.gallery.get_selected_children()
-            if fbox_candidates:
-                fbox_selected_item = fbox_candidates[0].props.child.item
-                i = 0
-                while True:
-                    gallery_item_at_i = self.gallery_store_filtered.get_item(i)
-                    if fbox_selected_item == gallery_item_at_i:
-                        self.gallery_selection.props.selected = i
-                        break
-                    i += 1
-
-        def update_metadata_on_file():
-            selected_item = self.gallery_selection.props.selected_item
-            if selected_item:
-                if isinstance(selected_item, ImgItem):
-                    params_strs = [f'{k}: {getattr(selected_item, k.lower())}'
-                                   for k in GEN_PARAMS]
-                    title = f'{selected_item.full_path}'
-                    bookmarked = 'BOOKMARK' if selected_item.bookmarked else ''
-                    self.metadata.set_text(
-                            '\n'.join([title, bookmarked] + params_strs))
-                    return
-            self.metadata.set_text('')
-
-        sync_fbox_selection_to_gallery_selection()
-        update_metadata_on_file()
-        idx = self.gallery_selection.props.selected + 1
-        total = self.gallery_selection.get_n_items()
-        self.counter.set_text(f' {idx} of {total} ')
-
     def update_sort_order_box(self, alt_order=None, cur_selection=0):
-        """Rebuild .sort_store from .sort_order or alt_order."""
+        """Rebuild .sort_store from .sort_order, or alt_order if provided."""
         sort_order = alt_order if alt_order else self.app.sort_order
         self.sort_store.remove_all()
         for sorter in sort_order:
@@ -506,130 +653,50 @@ class MainWindow(Gtk.Window):
 
     def activate_sort_order(self):
         """Write sort order box order into .app.sort_order, mark finalized."""
-        self.app.sort_order = []
+        self.app.sort_order.clear()
         for i in range(self.sort_store.get_n_items()):
             sorter = self.sort_store.get_item(i)
             sorter.list_item.remove_css_class('temp')
             self.app.sort_order += [sorter]
         self.button_activate_sort.props.sensitive = False
-        old_selection = self.gallery_selection.props.selected_item
-        self.update_gallery(old_selection)
-
-    # navbar callables
+        old_selection = self.gallery.selected_item
+        self.gallery.build_and_show(old_selection)
 
-    def gallery_filter(self, item):
-        """Apply user-set filters to gallery."""
-
-        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.include_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
-
-    def load_directory(self, update_gallery=True):
-        """Load into gallery directory at .app.img_dir_absolute."""
+    def load_directory(self, update_gallery_view=True):
+        """Load .gallery.store_unfiltered from .app.img_dir_absolute path."""
 
         def read_directory_into_gallery_items(dir_path, make_parent=False):
-            directory = Gio.File.new_for_path(dir_path)
-            query_attrs = 'standard::name,time::*'
-            if make_parent:
-                parent_path = abspath(path_join(dir_path, UPPER_DIR))
-                parent_dir = directory.get_parent()
-                parent_dir_info = parent_dir.query_info(
-                        query_attrs, Gio.FileQueryInfoFlags.NONE, None)
-                parent_dir_item = DirItem(
-                        parent_path, parent_dir_info, is_parent=True)
-                self.gallery_store.append(parent_dir_item)
-            query_attrs = query_attrs + ',standard::content-type'
-            enumerator = directory.enumerate_children(
-                    query_attrs, Gio.FileQueryInfoFlags.NONE, None)
+            if make_parent and self.gallery.show_dirs:
+                parent_dir = DirItem(abspath(path_join(dir_path, UPPER_DIR)),
+                                     UPPER_DIR, is_parent=True)
+                self.gallery.dir_entries += [parent_dir]
             to_set_metadata_on = []
-            for info in enumerator:
-                if info.get_file_type() == Gio.FileType.DIRECTORY:
-                    if self.include_dirs:
-                        self.gallery_store.append(DirItem(dir_path, info))
+            for fn in list(listdir(dir_path)):
+                full_path = path_join(dir_path, fn)
+                if isdir(full_path):
+                    if self.gallery.show_dirs:
+                        self.gallery.dir_entries += [DirItem(dir_path, fn)]
                     if self.recurse_dirs:
-                        read_directory_into_gallery_items(
-                                path_join(dir_path, info.get_name()))
-                elif info.get_content_type()\
-                        and info.get_content_type().startswith('image/'):
-                    item = ImgItem(dir_path, info, cache)
-                    if item.full_path in bookmarks:
-                        item.bookmarked = True
-                    if '' == item.model:
-                        to_set_metadata_on += [item]
-                    self.gallery_store.append(item)
+                        read_directory_into_gallery_items(full_path)
+                    continue
+                _, ext = splitext(fn)
+                if ext not in {'.png', '.PNG'}:
+                    continue
+                mtime = getmtime(full_path)
+                dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
+                iso8601_str = dt.isoformat(
+                        timespec='microseconds').replace('+00:00', 'Z')
+                item = ImgItem(dir_path, fn, iso8601_str, cache)
+                if item.full_path in bookmarks:
+                    item.bookmarked = True
+                if '' == item.model:
+                    to_set_metadata_on += [item]
+                self.gallery.dir_entries += [item]
             for item in to_set_metadata_on:
                 item.set_metadata(cache)
 
-        old_selection = self.gallery_selection.props.selected_item
-        self.block_file_selection_updates = True
-        self.gallery_store.remove_all()
-        self.block_file_selection_updates = False
+        old_selection = self.gallery.selected_item
+        self.gallery.dir_entries = []
         with open(BOOKMARKS_PATH, 'r', encoding='utf8') as f:
             bookmarks = json_load(f)
         with open(CACHE_PATH, 'r', encoding='utf8') as f:
@@ -637,35 +704,33 @@ class MainWindow(Gtk.Window):
         read_directory_into_gallery_items(self.app.img_dir_absolute, True)
         with open(CACHE_PATH, 'w', encoding='utf8') as f:
             json_dump(cache, f)
-        if update_gallery:
-            self.update_gallery(old_selection)
+        if update_gallery_view:
+            self.gallery.build_and_show(old_selection)
 
     def toggle_side_box(self):
         """Toggle window sidebox visible/invisible."""
         self.side_box.props.visible = not self.side_box.get_visible()
+        # Calculate new viewport directly, because GTK's respective viewport
+        # measurement happens too late for our needs.
         side_box_width = self.side_box.measure(OR_H, -1).natural
-        # because relevant viewport measurement happens too late for our needs
-        self.force_width = self.get_width() - side_box_width
-        self.update_gallery_view(refocus=True)
+        self.gallery.on_resize(self.get_width() - side_box_width)
 
-    def reset_include_dirs(self, button):
+    def reset_show_dirs(self, button):
         """By button's .active, in-/exclude directories from gallery view."""
-        self.include_dirs = button.props.active
+        self.gallery.show_dirs = button.props.active
         self.load_directory()
 
     def inc_per_row(self, increment):
         """Change by increment how many items max to display in gallery row."""
-        if self.per_row + increment > 0:
-            self.per_row += increment
-            self.update_gallery_view(refocus=True)
+        if self.gallery.per_row + increment > 0:
+            self.gallery.per_row += increment
+            self.gallery.build_and_show(self.gallery.selected_item)
 
     def reset_recurse(self, button):
         """By button's .active, de-/activate recursion on image collection."""
         self.recurse_dirs = button.props.active
         self.load_directory()
 
-    # movement
-
     def move_sort(self, direction):
         """Move selected item in sort order view, ensure temporary state."""
         tmp_sort_order = []
@@ -683,7 +748,7 @@ class MainWindow(Gtk.Window):
             old_next = tmp_sort_order[next_i]
             tmp_sort_order[next_i] = selected
             tmp_sort_order[cur_idx] = old_next
-        else:
+        else:  # to catch movement beyond limits
             return
         self.update_sort_order_box(tmp_sort_order, cur_idx + direction)
         self.sort_selection.props.selected = cur_idx + direction
@@ -700,42 +765,42 @@ class MainWindow(Gtk.Window):
                 or (-1 == direction and cur_idx > min_idx):
             self.sort_selection.props.selected = cur_idx + direction
 
-    def move_selection_in_gallery(self, x_inc, y_inc, buf_end):
-        """Move gallery selection in x or y axis, or to start(-1)/end(+1)."""
-        mov_steps = (Gtk.MovementStep.VISUAL_POSITIONS,
-                     Gtk.MovementStep.DISPLAY_LINES,
-                     Gtk.MovementStep.BUFFER_ENDS)
-        for step_size, step_type in zip((x_inc, y_inc, buf_end), mov_steps):
-            if step_size is not None:
-                self.gallery.emit('move-cursor',
-                                  step_type, step_size, False, False)
-
-    # handling of keypresses and clicks
-
-    def on_click_file(self, button):
-        """Set gallery selection to clicked, *then* do .hit_file_selection."""
-        self.gallery.select_child(button.props.parent.props.parent)
-        self.hit_file_selection()
+    def update_metadata_on_gallery_selection(self):
+        """Update .metadata about individual file, .counter on its idx/total"""
+        self.metadata.set_text('')
+        selected_item = self.gallery.selected_item
+        if selected_item:
+            if isinstance(selected_item, ImgItem):
+                params_strs = [f'{k}: {getattr(selected_item, k.lower())}'
+                               for k in GEN_PARAMS]
+                title = f'{selected_item.full_path}'
+                bookmarked = 'BOOKMARK' if selected_item.bookmarked else ''
+                self.metadata.set_text(
+                        '\n'.join([title, bookmarked] + params_strs))
+        total = len(self.gallery.slots)
+        self.counter.set_text(f' {self.gallery.selected_idx + 1} of {total} ')
 
     def handle_keypress(self, keyval):
-        """Handle keys if not in Entry, return True if key handling done."""
+        """Handle keys if not in Gtk.Entry, return True if key handling done"""
         if isinstance(self.get_focus().get_parent(), Gtk.Entry):
             return False
-        if Gdk.KEY_Return == keyval and\
-                self.get_focus().get_parent().get_parent() == self.sort_box:
-            self.activate_sort_order()
+        if Gdk.KEY_Return == keyval:
+            if self.get_focus().get_parent().get_parent() == self.sort_box:
+                self.activate_sort_order()
+            else:
+                self.hit_gallery_item()
         elif Gdk.KEY_G == keyval:
-            self.move_selection_in_gallery(None, None, 1)
+            self.gallery.move_selection(None, None, 1)
         elif Gdk.KEY_h == keyval:
-            self.move_selection_in_gallery(-1, None, None)
+            self.gallery.move_selection(-1, None, None)
         elif Gdk.KEY_j == keyval:
-            self.move_selection_in_gallery(None, +1, None)
+            self.gallery.move_selection(None, +1, None)
         elif Gdk.KEY_k == keyval:
-            self.move_selection_in_gallery(None, -1, None)
+            self.gallery.move_selection(None, -1, None)
         elif Gdk.KEY_l == keyval:
-            self.move_selection_in_gallery(+1, None, None)
+            self.gallery.move_selection(+1, None, None)
         elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]:
-            self.move_selection_in_gallery(None, None, -1)
+            self.gallery.move_selection(None, None, -1)
         elif Gdk.KEY_w == keyval:
             self.move_selection_in_sort_order(-1)
         elif Gdk.KEY_W == keyval:
@@ -761,6 +826,7 @@ class Application(Gtk.Application):
         super().__init__(*args, **kwargs)
 
     def _build_sort_order(self, suggestion_fused):
+        """Parse suggestion_fused for/into sort order suggestion."""
         suggestion = suggestion_fused.split(',')
         names = [p.lower() for p in GEN_PARAMS] + ['bookmarked']
         sort_order = []
@@ -779,7 +845,7 @@ class Application(Gtk.Application):
         return sort_order
 
     def do_activate(self, *args, **kwargs):
-        """Parse arguments, start window, and keep it open."""
+        """Parse arguments, start window, keep it open."""
         parser = ArgumentParser()
         parser.add_argument('directory', default=IMG_DIR_DEFAULT, nargs='?')
         parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT)