home · contact · privacy
Browser.py: General re-organization. master
authorChristian Heller <c.heller@plomlompom.de>
Sun, 13 Oct 2024 03:48:47 +0000 (05:48 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sun, 13 Oct 2024 03:48:47 +0000 (05:48 +0200)
browser.py

index 30acecc61aa43f90c195ab339403912aab695e75..92bd4c15b32621a59a5bea214aaf1e201acaf44e 100755 (executable)
@@ -6,8 +6,9 @@ from re import search as re_search
 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 datetime import datetime, timezone, timedelta
 from argparse import ArgumentParser
+from math import ceil
 from PIL import Image
 from PIL.PngImagePlugin import PngImageFile
 import gi  # type: ignore
@@ -30,7 +31,9 @@ CACHE_PATH = 'cache.json'
 BOOKMARKS_PATH = 'bookmarks.json'
 GALLERY_SLOT_MARGIN = 6
 GALLERY_PER_ROW_DEFAULT = 5
-GALLERY_ENSURE_UPDATED_VIEW_INTERVAL_MS = 500
+GALLERY_UPDATE_INTERVAL_MS = 50
+GALLERY_REDRAW_WAIT_MS = 200
+ACCEPTED_IMG_FILE_ENDINGS = {'.png', '.PNG'}
 
 OR_H = Gtk.Orientation.HORIZONTAL
 OR_V = Gtk.Orientation.VERTICAL
@@ -63,118 +66,197 @@ def _add_button(parent, label, on_click=None, checkbox=False):
     return btn
 
 
-class Sorter(GObject.GObject):
-    """Sort order box representation of sorting attribute."""
+class JsonDB:
+    """Representation of our simple .json DB files."""
+
+    def __init__(self, path):
+        self._path = path
+        self._content = {}
+        self._is_open = False
+        if not path_exists(path):
+            with open(path, 'w', encoding='utf8') as f:
+                json_dump({}, f)
+
+    def _open(self):
+        if self._is_open:
+            raise Exception('DB already open')
+        with open(self._path, 'r', encoding='utf8') as f:
+            self._content = json_load(f)
+        self._is_open = True
+
+    def _close(self):
+        self._is_open = False
+        self._content = {}
+
+    def write(self):
+        """Write to ._path what's in ._content."""
+        if not self._is_open:
+            raise Exception('DB not open')
+        with open(self._path, 'w', encoding='utf8') as f:
+            json_dump(self._content, f)
+        self._close()
+
+    def as_dict_copy(self):
+        """Return content at ._path for read-only purposes."""
+        self._open()
+        dict_copy = self._content.copy()
+        self._close()
+        return dict_copy
+
+    def as_dict_ref(self):
+        """Return content at ._path as ref so that .write() stores changes."""
+        self._open()
+        return self._content
+
+
+class SorterAndFilterer(GObject.GObject):
+    """Sort order box representation of sorting/filtering attribute."""
     widget: Gtk.Box
+    label: Gtk.Label
 
     def __init__(self, name):
         super().__init__()
         self.name = name
 
-    def setup_on_bind(self, widget, on_filter_enter, filter_text,
-                      vals_filtered, vals_full):
-        """Set up all the sting only available on factory item bind."""
+    def setup_on_bind(self, widget, on_filter_activate, filter_text, vals):
+        """Set up SorterAndFilterer label, values listing, filter entry."""
         self.widget = widget
-        title = f'{self.name} ({len(vals_filtered)}/{len(vals_full)}) '
-        widget.get_first_child().get_first_child().set_text(title)
-        vals_listed = [f'<b>{v}</b>' if v in vals_filtered else f'<s>{v}</s>'
-                       for v in vals_full]
-        widget.get_last_child().set_text(', '.join(vals_listed))
-        widget.get_last_child().set_use_markup(True)
-        filter_entry = widget.get_first_child().get_last_child()
-        filter_buffer = filter_entry.get_buffer()
+        # label
+        len_incl = len(vals['incl'])
+        title = f'{self.name} ({len_incl}/{len_incl + len(vals["excl"])}) '
+        self.widget.label.set_text(title)
+        # values listing
+        vals_listed = [f'<b>{v}</b>' for v in vals['incl']]
+        vals_listed += [f'<s>{v}</s>' for v in vals['excl']]
+        self.widget.values.set_text(', '.join(vals_listed))
+        self.widget.values.set_use_markup(True)
+        # filter input
+        filter_buffer = self.widget.filter.get_buffer()
         filter_buffer.set_text(filter_text, -1)
-        filter_entry.connect('activate', on_filter_enter)
+        self.widget.filter.connect('activate', on_filter_activate)
         filter_buffer.connect(
                 'inserted_text',
-                lambda a, b, c, d: filter_entry.add_css_class('temp'))
+                lambda a, b, c, d: self.widget.filter.add_css_class('temp'))
         filter_buffer.connect(
                 'deleted_text',
-                lambda a, b, c: filter_entry.add_css_class('temp'))
+                lambda a, b, c: self.widget.filter.add_css_class('temp'))
 
 
-class TableConfig():
+class GalleryConfig():
     """Representation of sort and filtering settings."""
-    _gallery_update = None
-    _gallery_items_attrs_full = None
-    _gallery_items_attrs_filtered = None
-    _gallery_set_by_1st_sorter = None
+    _gallery_request_update = None
+    _gallery_items_attrs = None
+    _gallery_update_settings = None
 
     def __init__(self, sort_order):
 
-        def setup_sort_order_item(_, list_item):
-            vbox = Gtk.Box(orientation=OR_V)
+        def setup_sorter_list_item(_, list_item):
+            item_widget = Gtk.Box(orientation=OR_V)
+            item_widget.values = Gtk.Label(
+                    visible=False, max_width_chars=35,
+                    wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR)
+            item_widget.label = Gtk.Label(hexpand=True)
+            item_widget.filter = Gtk.Entry(placeholder_text='filter?')
             hbox = Gtk.Box(orientation=OR_H)
-            hbox.append(Gtk.Label(hexpand=True))
-            hbox.append(Gtk.Entry.new())
-            hbox.get_last_child().props.placeholder_text = 'filter?'
-            vbox.append(hbox)
-            vals_listing = Gtk.Label(wrap=True, max_width_chars=35,
-                                     wrap_mode=Pango.WrapMode.WORD_CHAR)
-            vals_listing.hide()
-            vbox.append(vals_listing)
-            list_item.set_child(vbox)
-
-        def bind_sort_order_item(_, list_item):
-
-            def on_filter_enter(entry):
+            hbox.append(item_widget.label)
+            hbox.append(item_widget.filter)
+            item_widget.append(hbox)
+            item_widget.append(item_widget.values)
+            list_item.set_child(item_widget)
+
+        def bind_sorter_list_item(_, list_item):
+
+            def on_filter_activate(entry):
                 entry.remove_css_class('temp')
                 text = entry.get_buffer().get_text()
                 if '' != text.rstrip():
                     self.filter_inputs[sorter.name] = text
                 elif sorter.name in self.filter_inputs:
                     del self.filter_inputs[sorter.name]
-                self._gallery_update()
+                self._filter_inputs_changed = True
 
             sorter = list_item.props.item
-            filter_text = self.filter_inputs.get(sorter.name, '')
-            vals_filtered = self._gallery_items_attrs_filtered[sorter.name]
-            vals_full = self._gallery_items_attrs_full[sorter.name]
-            sorter.setup_on_bind(list_item.props.child, on_filter_enter,
-                                 filter_text, vals_filtered, vals_full)
+            sorter.setup_on_bind(list_item.props.child, on_filter_activate,
+                                 self.filter_inputs.get(sorter.name, ''),
+                                 self._gallery_items_attrs[sorter.name])
 
         def select_sort_order(_a, _b, _c):
-            if self._last_selected:
-                self._last_selected.get_last_child().hide()
-            list_item = self._selection.props.selected_item.widget
-            list_item.get_parent().grab_focus()
-            self._last_selected = list_item
+            self._sort_sel.props.selected_item.widget.get_parent().grab_focus()
+
+        def toggle_recurse(_):
+            self._set_recurse_changed = not self._set_recurse_changed
+            self._btn_apply.set_sensitive(not self._set_recurse_changed)
 
-        def on_by_1st_toggle(btn):
+        def toggle_by_1st(btn):
             self._btn_per_row.set_sensitive(not btn.props.active)
             self._btn_show_dirs.set_sensitive(not btn.props.active)
             if btn.props.active:
                 self._btn_show_dirs.set_active(False)
-            self._gallery_set_by_1st_sorter(btn.props.active)
+
+        def apply_config():
+            new_order = []
+            for i in range(self._store.get_n_items()):
+                sorter = self._store.get_item(i)
+                sorter.widget.remove_css_class('temp')
+                new_order += [sorter]
+            if self.order != new_order:
+                self.order.clear()
+                self.order += new_order[:]
+            self._gallery_update_settings(
+                    per_row=self._btn_per_row.get_value_as_int(),
+                    by_1st=self._btn_by_1st.get_active(),
+                    show_dirs=self._btn_show_dirs.get_active(),
+                    sort_order=self.order[:],
+                    filter_inputs=self.filter_inputs.copy(),
+                    recurse_dirs=self._btn_recurse.get_active())
+            self._gallery_request_update(select=True)
+            self._set_recurse_changed = False
+            self._filter_inputs_changed = False
+
+        def full_reload():
+            apply_config()
+            self._gallery_request_update(load=True)
+            self._btn_apply.set_sensitive(True)
 
         self.order = sort_order
         self.filter_inputs = {}
+        self._filter_inputs_changed = False
+        self._set_recurse_changed = False
         self._last_selected = None
-        self._store = Gio.ListStore(item_type=Sorter)
-        self._selection = Gtk.SingleSelection.new(self._store)
-        self._selection.connect('selection-changed', select_sort_order)
-        factory = Gtk.SignalListItemFactory()
-        factory.connect('setup', setup_sort_order_item)
-        factory.connect('bind', bind_sort_order_item)
-        self.view = Gtk.ListView(model=self._selection, factory=factory)
+
+        self._store = Gio.ListStore(item_type=SorterAndFilterer)
+        self._sort_sel = Gtk.SingleSelection.new(self._store)
+        self._sort_sel.connect('selection-changed', select_sort_order)
+        fac = Gtk.SignalListItemFactory()
+        fac.connect('setup', setup_sorter_list_item)
+        fac.connect('bind', bind_sorter_list_item)
+        self.sorter_listing = Gtk.ListView(model=self._sort_sel, factory=fac)
+
+        buttons_box = Gtk.Box(orientation=OR_H)
+        self._btn_apply = _add_button(buttons_box, 'apply config',
+                                      lambda _: apply_config())
+        self._btn_relaod = _add_button(buttons_box, 'full reload',
+                                       lambda _: full_reload())
 
         dirs_box = Gtk.Box(orientation=OR_H)
         dirs_box.append(Gtk.Label(label='directories:'))
         self._btn_show_dirs = _add_button(dirs_box, 'show', checkbox=True)
-        self._btn_recurse = _add_button(dirs_box, 'recurse', checkbox=True)
-        self._btn_reload_dir = _add_button(dirs_box, 'reload')
+        self._btn_recurse = _add_button(dirs_box, 'recurse',
+                                        toggle_recurse, checkbox=True)
 
         per_row_box = Gtk.Box(orientation=OR_H)
         per_row_box.append(Gtk.Label(label='cols/row:'))
-        _add_button(per_row_box, 'by 1st sorter', on_by_1st_toggle, True)
-        self._btn_per_row = Gtk.SpinButton.new_with_range(1, 9, 1)
+        self._btn_by_1st = _add_button(per_row_box, 'by 1st sorter',
+                                       toggle_by_1st, checkbox=True)
+        self._btn_per_row = Gtk.SpinButton.new_with_range(
+                GALLERY_PER_ROW_DEFAULT, 9, 1)
         per_row_box.append(self._btn_per_row)
 
         self.box = Gtk.Box(orientation=OR_V)
-        title = Gtk.Label(label='<b>table config</b>', use_markup=True)
-        self.box.append(title)
+        self.box.append(Gtk.Label(label='<b>table config</b>', use_markup=1))
+        self.box.append(buttons_box)
+        self.box.append(self.sorter_listing)
         self.box.append(dirs_box)
-        self.box.append(self.view)
         self.box.append(per_row_box)
 
     @classmethod
@@ -184,7 +266,7 @@ class TableConfig():
         names = [p.lower() for p in GEN_PARAMS] + ['bookmarked']
         sort_order = []
         for name in names:
-            sort_order += [Sorter(name)]
+            sort_order += [SorterAndFilterer(name)]
         new_sort_order = []
         do_reverse = '-' in suggestion
         for pattern in suggestion:
@@ -197,36 +279,37 @@ class TableConfig():
             sort_order.reverse()
         return cls(sort_order)
 
-    def bind_gallery(self, on_update, update_per_row, per_row_initial, reload,
-                     toggle_showdirs, toggle_recurse, set_by_1st_sorter,
-                     items_attrs_full, items_attrs_filtered):
+    def bind_gallery(self, request_update, update_settings, items_attrs):
         """Connect to Gallery interfaces where necessary."""
-        self._gallery_update = on_update
-        self._btn_show_dirs.connect('toggled', toggle_showdirs)
-        self._btn_recurse.connect('toggled', toggle_recurse)
-        self._btn_reload_dir.connect('clicked', reload)
-        self._gallery_set_by_1st_sorter = set_by_1st_sorter
-        self._gallery_items_attrs_full = items_attrs_full
-        self._gallery_items_attrs_filtered = items_attrs_filtered
-        self._btn_per_row.set_value(per_row_initial)
-        self._btn_per_row.connect(
-                'value-changed',
-                lambda btn: update_per_row(btn.get_value_as_int()))
+        self._gallery_request_update = request_update
+        self._gallery_update_settings = update_settings
+        self._gallery_items_attrs = items_attrs
+
+    def on_focus_sorter(self, focused):
+        """If sorter focused, select focused, move display of values there."""
+        if self._last_selected:
+            self._last_selected.values.set_visible(False)
+        self._last_selected = focused.get_first_child()
+        self._last_selected.values.set_visible(True)
+        for i in range(self._sort_sel.get_n_items()):
+            if self._sort_sel.get_item(i).widget == self._last_selected:
+                self._sort_sel.props.selected = i
+                break
 
     def move_selection(self, direction):
         """Move sort order selection by direction (-1 or +1)."""
         min_idx, max_idx = 0, len(self.order) - 1
-        cur_idx = self._selection.props.selected
+        cur_idx = self._sort_sel.props.selected
         if (1 == direction and cur_idx < max_idx)\
                 or (-1 == direction and cur_idx > min_idx):
-            self._selection.props.selected = cur_idx + direction
+            self._sort_sel.props.selected = cur_idx + direction
 
     def move_sorter(self, direction):
         """Move selected item in sort order view, ensure temporary state."""
         tmp_sort_order = []
         for i in range(self._store.get_n_items()):
             tmp_sort_order += [self._store.get_item(i)]
-        cur_idx = self._selection.props.selected
+        cur_idx = self._sort_sel.props.selected
         selected = tmp_sort_order[cur_idx]
         if direction == -1 and cur_idx > 0:
             prev_i = cur_idx - 1
@@ -241,27 +324,18 @@ class TableConfig():
         else:  # to catch movement beyond limits
             return
         self.update_box(tmp_sort_order, cur_idx + direction)
-        self._selection.props.selected = cur_idx + direction
+        self._sort_sel.props.selected = cur_idx + direction
         for i in range(self._store.get_n_items()):
             sort_item = self._store.get_item(i)
             sort_item.widget.add_css_class('temp')
 
     def update_box(self, alt_order=None, cur_selection=0):
-        """Rebuild .store from .order, or alt_order if provided."""
+        """Rebuild sorter listing in box from .order, or alt_order if set."""
         sort_order = alt_order if alt_order else self.order
         self._store.remove_all()
         for sorter in sort_order:
             self._store.append(sorter)
-        self._selection.props.selected = cur_selection
-
-    def activate_order(self):
-        """Write sort order box order into .order, mark finalized."""
-        self.order.clear()
-        for i in range(self._store.get_n_items()):
-            sorter = self._store.get_item(i)
-            sorter.widget.remove_css_class('temp')
-            self.order += [sorter]
-        self._gallery_update()
+        self._sort_sel.props.selected = cur_selection
 
 
 class GallerySlot(Gtk.Button):
@@ -275,6 +349,8 @@ class GallerySlot(Gtk.Button):
         self.item.slot = self
         if on_click_file:
             self.connect('clicked', on_click_file)
+        self._slot_size = None
+        self._side_margin = None
 
     def mark(self, css_class, do_add=True):
         """Add or remove css_class from self."""
@@ -283,8 +359,21 @@ class GallerySlot(Gtk.Button):
         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"""
+    def ensure_slot_size(self, slot_size, margin):
+        """Call ._size_widget to size .props.child; if none, make empty one."""
+        self._slot_size = slot_size
+        self._side_margin = margin // 2
+        if self.get_child() is None:
+            self.set_child(Gtk.Label(label='+'))
+        self._size_widget()
+
+    def _size_widget(self):
+        for s in ('bottom', 'top', 'start', 'end'):
+            setattr(self.get_child().props, f'margin_{s}', self._side_margin)
+        self.get_child().set_size_request(self._slot_size, self._slot_size)
+
+    def update_widget(self, is_in_vp):
+        """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class."""
         new_content = None
         if isinstance(self.item, ImgItem):
             if is_in_vp and not isinstance(self.item, Gtk.Image):
@@ -298,16 +387,12 @@ class GallerySlot(Gtk.Button):
                     new_content = box
             elif (not is_in_vp) and not isinstance(self.item, Gtk.Label):
                 new_content = Gtk.Label(label='?')
-        elif self.get_child() is None:
-            label = self.item.name if isinstance(self.item, DirItem) else '+'
-            new_content = Gtk.Label(label=label)
+        elif (isinstance(self.item, DirItem)
+              and self.get_child().props.label == '+'):
+            new_content = Gtk.Label(label=self.item.name)
         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)
+            self._size_widget()
         if isinstance(self.item, ImgItem):
             self.mark('bookmarked', self.item.bookmarked)
 
@@ -315,12 +400,19 @@ class GallerySlot(Gtk.Button):
 class GalleryItem(GObject.GObject):
     """Gallery representation of filesystem entry, base to DirItem, ImgItem."""
     slot: GallerySlot
+    _to_hash = ['name', 'full_path']
 
     def __init__(self, path, name):
         super().__init__()
         self.name = name
         self.full_path = path_join(path, self.name)
 
+    def __hash__(self):
+        hashable_values = []
+        for attr_name in self._to_hash:
+            hashable_values += [getattr(self, attr_name)]
+        return hash(tuple(hashable_values))
+
 
 class DirItem(GalleryItem):
     """Gallery representation of filesystem entry for directory."""
@@ -333,12 +425,19 @@ class DirItem(GalleryItem):
 
 class ImgItem(GalleryItem):
     """Gallery representation of filesystem entry for image file."""
+    _to_hash = (['name', 'full_path', 'last_mod_time', 'bookmarked',
+                 'with_others']
+                + [k.lower() for k in GEN_PARAMS])
 
-    def __init__(self, path, name, last_mod_time, cache):
+    def __init__(self, path, name, cache):
         super().__init__(path, name)
-        self.last_mod_time = last_mod_time
+        mtime = getmtime(self.full_path)
+        dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
+        iso8601_str = dt.isoformat(timespec='microseconds')
+        self.last_mod_time = iso8601_str.replace('+00:00', 'Z')
         self.bookmarked = False
         self.with_others = False
+        self.has_metadata = False
         for param_name in GEN_PARAMS:
             if param_name in GEN_PARAMS_STR:
                 setattr(self, param_name.lower(), '')
@@ -346,6 +445,7 @@ class ImgItem(GalleryItem):
                 setattr(self, param_name.lower(), 0)
         if self.full_path in cache:
             if self.last_mod_time in cache[self.full_path]:
+                self.has_metadata = True
                 cached = cache[self.full_path][self.last_mod_time]
                 for k in cached.keys():
                     setattr(self, k, cached[k])
@@ -373,88 +473,160 @@ class ImgItem(GalleryItem):
 class Gallery:
     """Representation of GalleryItems 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
+    def __init__(self, on_hit_item, on_grid_built, on_selection_change,
+                 bookmarks_db, cache_db):
         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_by_1st_sorter = False
+        self._bookmarks_db, self._cache_db = bookmarks_db, cache_db
+        self._sort_order = []
+        self._filter_inputs = {}
+        self._img_dir_path = None
+
+        self._shall_load = False
+        self._shall_build = False
+        self._shall_redraw = False
+        self._shall_scroll_to_focus = False
+        self._shall_select = False
+
+        self._show_dirs = False
+        self._recurse_dirs = False
+        self._by_1st = 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.dir_entries_filtered_sorted = []
-        self.selected_idx = 0
         self.items_attrs = {}
-        self.items_attrs_filtered = {}
+        self.selected_idx = 0
+        self.slots = None
 
-        self._fixed_frame = Gtk.Fixed(hexpand=True, vexpand=True)
+        self._grid = None
+        self._force_width, self._force_height = 0, 0
         scroller = Gtk.ScrolledWindow(propagate_natural_height=True)
-        self.frame = Gtk.Box(orientation=OR_V)
         self._col_headers_frame = Gtk.Fixed()
         self._col_headers_grid = None
+        self.frame = Gtk.Box(orientation=OR_V)
         self.frame.append(self._col_headers_frame)
         self.frame.append(scroller)
-        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._fixed_frame = Gtk.Fixed(hexpand=True, vexpand=True)
         scroller.set_child(self._fixed_frame)
         self._viewport = self._fixed_frame.get_parent()
+        self._viewport.set_scroll_to_focus(False)  # prefer our own handling
 
-        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 and self.slots:
-            self._update_view(refocus=False, force=True)
-        return True
-
-    def get_per_row(self):
-        """Wrapper to ._per_row to (for setting) discourage direct access."""
-        return self._per_row
+        def ensure_uptodate():
+            if self._img_dir_path is None:
+                return True
+            if self._shall_load:
+                self._load_directory()
+            if self._shall_build:
+                self._build()
+            if self._shall_select:
+                self._set_selection(self.selected_idx)
+            if self._shall_redraw:
+                wait_time_passed = datetime.now() - self._start_redraw_wait
+                if wait_time_passed > redraw_wait_time:
+                    self._redraw_and_check_focus()
+            return True
 
-    def update_per_row(self, val):
-        """Wrapper to setting ._per_row to include call to .build_and_show."""
-        self._per_row = val
-        self.build_and_show()
+        def handle_scroll(_):
+            self._start_redraw_wait = datetime.now()
+            self._shall_scroll_to_focus = False
+            self._shall_redraw = True
+
+        redraw_wait_time = timedelta(milliseconds=GALLERY_REDRAW_WAIT_MS)
+        self._start_redraw_wait = datetime.now() - redraw_wait_time
+        scroller.get_vadjustment().connect('value-changed', handle_scroll)
+        GLib.timeout_add(GALLERY_UPDATE_INTERVAL_MS, ensure_uptodate)
+
+    def update_settings(self, per_row=None, by_1st=None, show_dirs=None,
+                        recurse_dirs=None, img_dir_path=None, sort_order=None,
+                        filter_inputs=None):
+        """Set Gallery setup fields, request appropriate updates."""
+        for val, attr_name in [(per_row, '_per_row'),
+                               (by_1st, '_by_1st'),
+                               (show_dirs, '_show_dirs'),
+                               (recurse_dirs, '_recurse_dirs'),
+                               (img_dir_path, '_img_dir_path'),
+                               (sort_order, '_sort_order'),
+                               (filter_inputs, '_filter_inputs')]:
+            if val is not None and getattr(self, attr_name) != val:
+                setattr(self, attr_name, val)
+                if attr_name in {'_recurse_dirs', '_img_dir_path'}:
+                    self._load_directory()
+                else:
+                    self.request_update(build=True)
+
+    def _load_directory(self):
+        """Rewrite .dir_entries from ._img_dir_path, trigger rebuild."""
+
+        def read_directory(dir_path, make_parent=False):
+            if make_parent:
+                parent_dir = DirItem(abspath(path_join(dir_path, UPPER_DIR)),
+                                     UPPER_DIR, is_parent=True)
+                self.dir_entries += [parent_dir]
+            dirs_to_enter, to_set_metadata_on = [], []
+            dir_entries = list(listdir(dir_path))
+            for i, filename in enumerate(dir_entries):
+                msg = f'loading {dir_path}: entry {i+1}/{len(dir_entries)}'
+                print(msg, end='\r')
+                full_path = path_join(dir_path, filename)
+                if isdir(full_path):
+                    self.dir_entries += [DirItem(dir_path, filename)]
+                    dirs_to_enter += [full_path]
+                    continue
+                _, ext = splitext(filename)
+                if ext not in ACCEPTED_IMG_FILE_ENDINGS:
+                    continue
+                img_item = ImgItem(dir_path, filename, cache)
+                if img_item.full_path in bookmarks:
+                    img_item.bookmarked = True
+                if not img_item.has_metadata:
+                    to_set_metadata_on += [img_item]
+                self.dir_entries += [img_item]
+            print('')
+            for i, item in enumerate(to_set_metadata_on):
+                msg = f'setting metadata: {i+1}/{len(to_set_metadata_on)}'
+                print(msg, end='\r')
+                item.set_metadata(cache)
+            msg = '' if to_set_metadata_on else '(no metadata to set)'
+            print(msg)
+            if dirs_to_enter and self._recurse_dirs:
+                prefix = f'entering directories below {dir_path}: directory '
+                for i, path in enumerate(dirs_to_enter):
+                    print(f'{prefix}{i+1}/{len(dirs_to_enter)}')
+                    read_directory(path)
 
-    def set_by_1st_sorter(self, val):
-        """On .per_row_by_1st_sorter update, also call .build_and_show."""
-        self.per_row_by_1st_sorter = val
-        self.build_and_show()
+        self._shall_load = False
+        self.dir_entries = []
+        bookmarks = self._bookmarks_db.as_dict_copy()
+        cache = self._cache_db.as_dict_ref()
+        read_directory(self._img_dir_path, make_parent=True)
+        self._cache_db.write()
+        self.request_update(build=True)
 
     @property
     def selected_item(self):
-        """Return slot.item at self.selected_idx."""
+        """Return slot.item for slot 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))
+        self.request_update(scroll_to_focus=True)
 
-    def _set_selection(self, new_idx, unselect_old=True):
+    def _set_selection(self, new_idx):
         """Set self.selected_idx, mark slot as 'selected', unmark old one."""
-        if unselect_old:
+        self._shall_select = False
+        # in ._build(), directly before we are called, no slot will be
+        # CSS-marked 'selected', so .mark('selected', False) would tolerably
+        # happen without effect; where called from ._build() however, an old
+        # .selected_idx might point beyond _any_ of the new .slots, the
+        # IndexError of which we still want to avoid
+        if self.selected_idx < len(self.slots):
             self.slots[self.selected_idx].mark('selected', False)
         self.selected_idx = new_idx
         if self.slots:
@@ -534,109 +706,137 @@ class Gallery:
             return False
         return True
 
-    def _build_items_attrs_and_filtered_entries(self):
-        for d in (self.items_attrs, self.items_attrs_filtered):
-            d.clear()
-            d |= {s.name: set() for s in self._sort_order}
-        entries_filtered = []
-        for entry in self.dir_entries:
-            if not self.show_dirs and isinstance(entry, DirItem):
-                continue
-            passes_filters = True
-            for attr_name in [s.name for s in self._sort_order]:
-                if isinstance(entry, ImgItem):
+    def _build(self):
+        """(Re-)build slot grid from .dir_entries, filters, layout settings."""
+
+        def build_items_attrs():
+            self.items_attrs.clear()
+            self.items_attrs |= {s.name: {'incl': [], 'excl': []}
+                                 for s in self._sort_order}
+            for attr_name in (s.name for s in self._sort_order):
+                vals = set()
+                for entry in [e for e in self.dir_entries
+                              if isinstance(e, ImgItem)]:
                     val = (getattr(entry, attr_name)
                            if hasattr(entry, attr_name) else None)
-                    self.items_attrs[attr_name].add(val)
-                    passes_filter = self._passes_filter(attr_name, val)
-                    passes_filters = passes_filters and passes_filter
-                    if passes_filter:
-                        self.items_attrs_filtered[attr_name].add(val)
-            if passes_filters:
-                entries_filtered += [entry]
-        for d in (self.items_attrs, self.items_attrs_filtered):
-            for k, v in d.items():
-                d[k] = sorted(list(v))
-        return entries_filtered
-
-    def _build_grid(self, entries_filtered):
-
-        def item_clicker(idx):
-            def f(_):
-                self._set_selection(idx)
-                self._on_hit_item()
-            return f
-
-        def build_rows_by_attrs(remaining_attrs, items_of_parent_attr_value):
-            if not items_of_parent_attr_value:
-                return
-            attr_name, attr_values = remaining_attrs[0]
-            if 1 == len(remaining_attrs):
-                row = [None] * len(attr_values)
-                for item in items_of_parent_attr_value:
-                    item_val = getattr(item, attr_name)
-                    idx_item_val_in_attr_values = attr_values.index(item_val)
-                    if row[idx_item_val_in_attr_values]:
-                        item.with_others = True
-                    row[idx_item_val_in_attr_values] = item
-                for i_col, item in enumerate(row):
-                    if item:
-                        slot = GallerySlot(item, item_clicker(i_slot_ref[0]))
-                    else:
-                        slot = GallerySlot(GalleryItem('', ''))  # dummy
+                    if val is not None:
+                        vals.add(val)
+                for v in vals:
+                    k = 'incl' if self._passes_filter(attr_name, v) else 'excl'
+                    self.items_attrs[attr_name][k] += [v]
+            for attr_vals in self.items_attrs.values():
+                attr_vals['incl'].sort()
+                attr_vals['excl'].sort()
+
+        def filter_entries():
+            entries_filtered = []
+            for entry in self.dir_entries:
+                if (not self._show_dirs) and isinstance(entry, DirItem):
+                    continue
+                passes_filters = True
+                for attr_name in (s.name for s in self._sort_order):
+                    if isinstance(entry, ImgItem):
+                        val = (getattr(entry, attr_name)
+                               if hasattr(entry, attr_name) else None)
+                        if val in self.items_attrs[attr_name]['excl']:
+                            passes_filters = False
+                            break
+                if passes_filters:
+                    entries_filtered += [entry]
+            return entries_filtered
+
+        def build_grid(entries_filtered):
+            i_row_ref, i_slot_ref = [0], [0]
+
+            def item_clicker(idx):
+                def f(_):
+                    self._set_selection(idx)
+                    self._on_hit_item()
+                return f
+
+            def build_rows_by_attrs(remaining_attrs,
+                                    items_of_parent_attr_value):
+                if not items_of_parent_attr_value:
+                    return
+                attr_name, attr_values = remaining_attrs[0]
+                if 1 == len(remaining_attrs):
+                    row = [None] * len(attr_values)
+                    for item in items_of_parent_attr_value:
+                        val = getattr(item, attr_name)
+                        idx_val_in_attr_values = attr_values.index(val)
+                        if row[idx_val_in_attr_values]:
+                            item.with_others = True
+                        row[idx_val_in_attr_values] = item
+                    for i_col, item in enumerate(row):
+                        if item:
+                            slot = GallerySlot(item,
+                                               item_clicker(i_slot_ref[0]))
+                        else:
+                            slot = GallerySlot(GalleryItem('', ''))  # dummy
+                        self.slots += [slot]
+                        i_slot_ref[0] += 1
+                        self._grid.attach(slot, i_col, i_row_ref[0], 1, 1)
+                    i_row_ref[0] += 1
+                    return
+                for attr_value in attr_values:
+                    items_of_attr_value = [
+                            x for x in items_of_parent_attr_value
+                            if attr_value == getattr(x, attr_name)]
+                    build_rows_by_attrs(remaining_attrs[1:],
+                                        items_of_attr_value)
+
+            if self._grid:
+                self._fixed_frame.remove(self._grid)
+            self.slots = []
+            self._grid = Gtk.Grid()
+            self._fixed_frame.put(self._grid, 0, 0)
+            if self._by_1st:
+                self._show_dirs = False
+                sort_attrs = []
+                for sorter in reversed(self._sort_order):
+                    sort_attrs += [(sorter.name,
+                                    self.items_attrs[sorter.name]['incl'])]
+                self._per_row = len(sort_attrs[-1][1])
+                build_rows_by_attrs(sort_attrs, entries_filtered)
+            else:
+                dir_entries_filtered_sorted = sorted(
+                        entries_filtered, key=cmp_to_key(self._sort_cmp))
+                i_row, i_col = 0, 0
+                for i, item in enumerate(dir_entries_filtered_sorted):
+                    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_slot_ref[0] += 1
-                    self._grid.attach(slot, i_col, i_row_ref[0], 1, 1)
-                i_row_ref[0] += 1
-                return
-            for attr_value in attr_values:
-                items_of_attr_value = [x for x in items_of_parent_attr_value
-                                       if attr_value == getattr(x, attr_name)]
-                build_rows_by_attrs(remaining_attrs[1:], items_of_attr_value)
-
-        if self._grid:
-            self._fixed_frame.remove(self._grid)
-        self.slots = []
-        self._grid = Gtk.Grid()
-        self._fixed_frame.put(self._grid, 0, 0)
-        if self.per_row_by_1st_sorter:
-            self.show_dirs = False
-            sort_attrs = []
-            for sorter in reversed(self._sort_order):
-                sort_attrs += [(sorter.name,
-                                self.items_attrs_filtered[sorter.name])]
-            i_row_ref = [0]
-            i_slot_ref = [0]
-            self._per_row = len(sort_attrs[-1][1])
-            build_rows_by_attrs(sort_attrs, entries_filtered)
-        else:
-            self.dir_entries_filtered_sorted = sorted(
-                    entries_filtered, key=cmp_to_key(self._sort_cmp))
-            i_row, i_col = 0, 0
-            for i, item in enumerate(self.dir_entries_filtered_sorted):
-                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()
-
-    def build_and_show(self, preserve_selected=True):
-        """Build gallery as sorted GallerySlots, select one, draw gallery."""
-        suggested_selection = self.selected_item if preserve_selected else None
-        entries_filtered = self._build_items_attrs_and_filtered_entries()
-        self._build_grid(entries_filtered)
-        self.selected_idx = 0
-        self._update_view()
+                    i_col += 1
+            self._on_grid_built()
+
+        self._shall_build = False
+        old_selected_item = self.selected_item
+        build_items_attrs()
+        entries_filtered = filter_entries()
+        build_grid(entries_filtered)
         new_idx = 0
-        if suggested_selection is not None:
+        if old_selected_item is not None:
             for i, slot in enumerate(self.slots):
-                if suggested_selection == slot.item:
+                if hash(old_selected_item) == hash(slot.item):
                     new_idx = i
                     break
-        self._set_selection(new_idx, unselect_old=False)
+        self._set_selection(new_idx)
+
+    def request_update(self, select=False, scroll_to_focus=False, build=False,
+                       load=False):
+        """Set ._shall_… to trigger updates on next relevant interval."""
+        self._shall_redraw = True
+        if scroll_to_focus or build or select:
+            self._shall_select = True
+        if scroll_to_focus or build:
+            self._shall_scroll_to_focus = True
+        if build:
+            self._shall_build = True
+        if load:
+            self._shall_load = True
 
     def move_selection(self, x_inc, y_inc, buf_end):
         """Move .selection, update its dependencies, redraw gallery."""
@@ -657,62 +857,76 @@ class Gallery:
             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 on_resize(self, width=0, height=0):
+        """Force redraw and scroll-to-focus into new geometry."""
+        self._force_width, self._force_height = width, height
+        self.request_update(scroll_to_focus=True)
 
-    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
+    def _redraw_and_check_focus(self):
+        """Draw gallery; possibly notice and first follow need to re-focus."""
         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)
+        vp_height = (self._force_height if self._force_height
+                     else self._viewport.get_height())
+        self._force_width, self._force_height = 0, 0
+        vp_scroll = self._viewport.get_vadjustment()
+        vp_top = vp_scroll.get_value()
+        vp_bottom = vp_top + vp_height
+        max_slot_width = vp_width // self._per_row
+        slot_size = min(vp_height, max_slot_width)
         if self._col_headers_grid:
             self._col_headers_frame.remove(self._col_headers_grid)
             self._col_headers_grid = None
-        if self.per_row_by_1st_sorter:
+        if self._by_1st:
             self._col_headers_grid = Gtk.Grid()
             self._col_headers_frame.put(self._col_headers_grid, 0, 0)
-            attr_values = self.items_attrs_filtered[self._sort_order[0].name]
+            attr_values = self.items_attrs[self._sort_order[0].name]['incl']
             for i, val in enumerate(attr_values):
-                label = Gtk.Label(label=str(val))
+                label = Gtk.Label(label=str(val),
+                                  ellipsize=Pango.EllipsizeMode.MIDDLE)
                 label.set_size_request(slot_size, -1)
                 self._col_headers_grid.attach(label, i, 0, 1, 1)
-        self._should_update_view = False
-        if (not refocus) or (not self.slots):
+        slot_size_sans_margin = slot_size - self._slot_margin
+        for idx, slot in enumerate(self.slots):
+            slot.ensure_slot_size(slot_size_sans_margin, self._slot_margin)
+        vp_scroll.set_upper(slot_size * ceil(len(self.slots) / self._per_row))
+        if self._scroll_to_focus(slot_size, vp_scroll, vp_top, vp_bottom):
             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)
+        for idx, slot in enumerate(self.slots):
+            in_vp, _, _ = self._position_to_viewport(
+                    idx, slot_size, vp_top, vp_bottom, True)
+            slot.update_widget(in_vp)
+        self._start_redraw_wait = datetime.now()
+
+    def _position_to_viewport(
+            self, idx, slot_size, vp_top, vp_bottom, in_vp_greedy=False):
+        slot_top = (idx // self._per_row) * slot_size
+        slot_bottom = slot_top + slot_size
+        if in_vp_greedy:
+            in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom)
         else:
-            return
-        self._should_update_view = True
-        vp_scroll.emit('value-changed')
+            in_vp = (slot_top >= vp_top and slot_bottom <= vp_bottom)
+        return in_vp, slot_top, slot_bottom
+
+    def _scroll_to_focus(self, slot_size, vp_scroll, vp_top, vp_bottom):
+        scroll_to_focus = self._shall_scroll_to_focus
+        self._shall_redraw, self._shall_scroll_to_focus = False, False
+        if scroll_to_focus:
+            in_vp, slot_top, slot_bottom = self._position_to_viewport(
+                    self.selected_idx, slot_size, vp_top, vp_bottom)
+            if not in_vp:
+                self._shall_redraw, self._shall_scroll_to_focus = True, True
+                if slot_top < vp_top:
+                    vp_scroll.set_value(slot_top)
+                else:
+                    vp_scroll.set_value(slot_bottom - slot_size)
+                return True
+        return False
 
     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:
+        if self._show_dirs:
             cmp_upper_dir = f'  {UPPER_DIR}'
             if isinstance(a, DirItem) and a.name == cmp_upper_dir:
                 return -1
@@ -755,7 +969,6 @@ class MainWindow(Gtk.Window):
         self.app = app
 
         def init_navbar():
-
             navbar = Gtk.Box(orientation=OR_H)
             self.counter = Gtk.Label()
             navbar.append(self.counter)
@@ -781,14 +994,6 @@ class MainWindow(Gtk.Window):
             self.add_controller(key_ctl)
             self.prev_key = [0]
 
-        def ensure_db_files():
-            if not path_exists(CACHE_PATH):
-                with open(CACHE_PATH, 'w', encoding='utf8') as f:
-                    json_dump({}, f)
-            if not path_exists(BOOKMARKS_PATH):
-                with open(BOOKMARKS_PATH, 'w', encoding='utf8') as f:
-                    json_dump([], f)
-
         def setup_css():
             css_provider = Gtk.CssProvider()
             css_provider.load_from_data(CSS)
@@ -797,12 +1002,11 @@ class MainWindow(Gtk.Window):
                     Gtk.STYLE_PROVIDER_PRIORITY_USER)
 
         self.gallery = Gallery(
-                sort_order=self.app.sort.order,
-                filter_inputs=self.app.sort.filter_inputs,
                 on_hit_item=self.hit_gallery_item,
-                on_grid_built=self.app.sort.update_box,
-                on_selection_change=self.update_metadata_on_gallery_selection)
-        self.recurse_dirs = False
+                on_grid_built=self.app.conf.update_box,
+                on_selection_change=self.update_metadata_on_gallery_selection,
+                bookmarks_db=self.app.bookmarks_db,
+                cache_db=self.app.cache_db)
 
         setup_css()
         viewer = Gtk.Box(orientation=OR_V)
@@ -810,7 +1014,7 @@ class MainWindow(Gtk.Window):
         viewer.append(self.navbar)
         viewer.append(self.gallery.frame)
         self.side_box = Gtk.Box(orientation=OR_V)
-        self.side_box.append(self.app.sort.box)
+        self.side_box.append(self.app.conf.box)
         self.side_box.append(init_metadata_box())
         box_outer = Gtk.Box(orientation=OR_H)
         box_outer.append(self.side_box)
@@ -819,32 +1023,27 @@ class MainWindow(Gtk.Window):
         self.connect('notify::default-width', lambda _, __: self.on_resize())
         self.connect('notify::default-height', lambda _, __: self.on_resize())
 
-        ensure_db_files()
         init_key_control()
-        self.load_directory(update_gallery_view=False)
         self.connect('notify::focus-widget',
                      lambda _, __: self.on_focus_change())
-        self.app.sort.bind_gallery(
-                on_update=self.gallery.build_and_show,
-                reload=lambda _: self.load_directory(),
-                update_per_row=self.gallery.update_per_row,
-                per_row_initial=self.gallery.get_per_row(),
-                toggle_showdirs=self.reset_show_dirs,
-                toggle_recurse=self.reset_recurse,
-                set_by_1st_sorter=self.gallery.set_by_1st_sorter,
-                items_attrs_full=self.gallery.items_attrs,
-                items_attrs_filtered=self.gallery.items_attrs_filtered)
-        GLib.idle_add(self.gallery.build_and_show)
+        self.app.conf.bind_gallery(
+                request_update=self.gallery.request_update,
+                update_settings=self.gallery.update_settings,
+                items_attrs=self.gallery.items_attrs)
+        GLib.idle_add(lambda: self.gallery.update_settings(
+            img_dir_path=self.app.img_dir_absolute,
+            sort_order=self.app.conf.order[:],
+            filter_inputs=self.app.conf.filter_inputs.copy()))
 
     def on_focus_change(self):
-        """Handle reactions on focus changes in .gallery and .sort."""
+        """Handle reactions on focus changes in .gallery and .conf."""
         focused = self.get_focus()
         if not focused:
             return
         if isinstance(focused, GallerySlot):
             self.gallery.on_focus_slot(focused)
-        elif focused.get_parent() == self.app.sort.view:
-            focused.get_first_child().get_last_child().show()
+        elif focused.get_parent() == self.app.conf.sorter_listing:
+            self.app.conf.on_focus_sorter(focused)
 
     def on_resize(self):
         """On window resize, do .gallery.on_resize towards its new geometry."""
@@ -860,83 +1059,21 @@ class MainWindow(Gtk.Window):
         """Toggle bookmark on selected gallery item."""
         if not isinstance(self.gallery.selected_item, ImgItem):
             return
-        with open(BOOKMARKS_PATH, 'r', encoding='utf8') as f:
-            bookmarks = json_load(f)
+        bookmarks = self.app.bookmarks_db.as_dict_ref()
         if self.gallery.selected_item.bookmarked:
             self.gallery.selected_item.bookmark(False)
             bookmarks.remove(self.gallery.selected_item.full_path)
         else:
             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.app.sort.update_box()
+        self.app.bookmarks_db.write()
+        self.app.conf.update_box()
 
     def hit_gallery_item(self):
         """If current file selection is directory, reload into that one."""
         selected = self.gallery.selected_item
         if isinstance(selected, DirItem):
-            self.app.img_dir_absolute = selected.full_path
-            self.load_directory()
-
-    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):
-            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 = []
-            dir_entries = list(listdir(dir_path))
-            dirs_to_enter = []
-            for i, fn in enumerate(dir_entries):
-                msg = f'loading {dir_path}: entry {i+1}/{len(dir_entries)}'
-                print(msg, end='\r')
-                full_path = path_join(dir_path, fn)
-                if isdir(full_path):
-                    if self.gallery.show_dirs:
-                        self.gallery.dir_entries += [DirItem(dir_path, fn)]
-                    dirs_to_enter += [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]
-            print('')
-            if to_set_metadata_on:
-                for i, item in enumerate(to_set_metadata_on):
-                    msg = f'setting metadata: {i+1}/{len(to_set_metadata_on)}'
-                    print(msg, end='\r')
-                    item.set_metadata(cache)
-                print('')
-            else:
-                print('no metadata to set')
-            if dirs_to_enter and self.recurse_dirs:
-                prefix = f'entering directories below {dir_path}: directory '
-                for i, path in enumerate(dirs_to_enter):
-                    print(f'{prefix}{i+1}/{len(dirs_to_enter)}')
-                    read_directory_into_gallery_items(path)
-
-        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:
-            cache = json_load(f)
-        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_view:
-            self.gallery.build_and_show()
+            self.gallery.update_settings(img_dir_path=selected.full_path)
 
     def toggle_side_box(self):
         """Toggle window sidebox visible/invisible."""
@@ -946,16 +1083,6 @@ class MainWindow(Gtk.Window):
         side_box_width = self.side_box.measure(OR_H, -1).natural
         self.gallery.on_resize(self.get_width() - side_box_width)
 
-    def reset_show_dirs(self, button):
-        """By button's .active, in-/exclude directories from gallery view."""
-        self.gallery.show_dirs = button.props.active
-        self.load_directory()
-
-    def reset_recurse(self, button):
-        """By button's .active, de-/activate recursion on image collection."""
-        self.recurse_dirs = button.props.active
-        self.load_directory()
-
     def update_metadata_on_gallery_selection(self):
         """Update .metadata about individual file, .counter on its idx/total"""
         self.metadata.set_text('')
@@ -968,18 +1095,17 @@ class MainWindow(Gtk.Window):
                 bookmarked = 'BOOKMARK' if selected_item.bookmarked else ''
                 self.metadata.set_text(
                         '\n'.join([title, bookmarked] + params_strs))
-        total = len(self.gallery.dir_entries_filtered_sorted)
+        total = len([s for s in self.gallery.slots
+                     if isinstance(s.item, (DirItem, ImgItem))])
         self.counter.set_text(f' {self.gallery.selected_idx + 1} of {total} ')
 
     def handle_keypress(self, keyval):
         """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:
-            if self.get_focus().get_parent().get_parent() == self.app.sort.box:
-                self.app.sort.activate_order()
-            else:
-                self.hit_gallery_item()
+        if Gdk.KEY_Return == keyval and isinstance(self.get_focus(),
+                                                   GallerySlot):
+            self.hit_gallery_item()
         elif Gdk.KEY_G == keyval:
             self.gallery.move_selection(None, None, 1)
         elif Gdk.KEY_h == keyval:
@@ -993,13 +1119,13 @@ class MainWindow(Gtk.Window):
         elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]:
             self.gallery.move_selection(None, None, -1)
         elif Gdk.KEY_w == keyval:
-            self.app.sort.move_selection(-1)
+            self.app.conf.move_selection(-1)
         elif Gdk.KEY_W == keyval:
-            self.app.sort.move_sorter(-1)
+            self.app.conf.move_sorter(-1)
         elif Gdk.KEY_s == keyval:
-            self.app.sort.move_selection(1)
+            self.app.conf.move_selection(1)
         elif Gdk.KEY_S == keyval:
-            self.app.sort.move_sorter(1)
+            self.app.conf.move_sorter(1)
         elif Gdk.KEY_b == keyval:
             self.bookmark()
         else:
@@ -1018,7 +1144,9 @@ class Application(Gtk.Application):
         parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT)
         opts = parser.parse_args()
         self.img_dir_absolute = abspath(opts.directory)
-        self.sort = TableConfig.from_suggestion(opts.sort_order)
+        self.conf = GalleryConfig.from_suggestion(opts.sort_order)
+        self.bookmarks_db = JsonDB(BOOKMARKS_PATH)
+        self.cache_db = JsonDB(CACHE_PATH)
 
     def do_activate(self, *args, **kwargs):
         """Parse arguments, start window, keep it open."""