home · contact · privacy
Browser.py: Modularize. master
authorChristian Heller <c.heller@plomlompom.de>
Mon, 18 Nov 2024 16:49:28 +0000 (17:49 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 18 Nov 2024 16:49:28 +0000 (17:49 +0100)
browser.py
browser/__init__.py [new file with mode: 0644]
browser/config_constants.py [new file with mode: 0644]
browser/gallery.py [new file with mode: 0644]
browser/gallery_config.py [new file with mode: 0644]
browser/gtk_app.py [new file with mode: 0644]
browser/gtk_helpers.py [new file with mode: 0644]
browser/json_dbs.py [new file with mode: 0644]
browser/types.py [new file with mode: 0644]

index f6ff73de4cb0a5e547a144a99cb5ebf8e3e89b1e..9d86d05e0a67fe31f5b54865aeabab778f89a4c1 100755 (executable)
 #!/usr/bin/env python3
 """Browser for image files."""
 #!/usr/bin/env python3
 """Browser for image files."""
-from json import dump as json_dump, load as json_load
-from typing import TypeAlias, Callable, Optional, Self
-from functools import cmp_to_key
-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, timedelta
-from argparse import ArgumentParser
-from math import ceil, radians
-from PIL import Image
-from PIL.PngImagePlugin import PngImageFile
-import gi  # type: ignore
-gi.require_version('Gtk', '4.0')
-gi.require_version('Gdk', '4.0')
-gi.require_version('Gio', '2.0')
-# pylint: disable=wrong-import-position
-from gi.repository import (Gdk, Gio, GLib,  # type: ignore  # noqa: E402
-                           GObject, Gtk, Pango,  # type: ignore  # noqa: E402
-                           PangoCairo)  # type: ignore  # noqa: E402
-# pylint: disable=no-name-in-module
-from stable.gen_params import (GenParams,  GEN_PARAMS_FLOAT,  # noqa: E402
-                               GEN_PARAMS_INT, GEN_PARAMS_STR,  # noqa: E402
-                               GEN_PARAMS)  # noqa: E402
-
-BasicItemsAttrs = dict[str, set[str]]
-AttrVals: TypeAlias = list[str]
-AttrValsByVisibility: TypeAlias = dict[str, AttrVals]
-ItemsAttrs: TypeAlias = dict[str, AttrValsByVisibility]
-CachedImg: TypeAlias = dict[str, str | float | int]
-Cache: TypeAlias = dict[str, dict[str, CachedImg]]
-Bookmarks: TypeAlias = list[str]
-Db: TypeAlias = Cache | Bookmarks
-FilterInputs: TypeAlias = dict[str, str]
-
-
-IMG_DIR_DEFAULT: str = '.'
-SORT_DEFAULT: str = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\
-        'model,prompt'
-UPPER_DIR: str = '..'
-CACHE_PATH: str = 'cache.json'
-BOOKMARKS_PATH: str = 'bookmarks.json'
-GALLERY_SLOT_MARGIN: int = 6
-GALLERY_PER_ROW_DEFAULT: int = 5
-GALLERY_UPDATE_INTERVAL_MS: int = 50
-GALLERY_REDRAW_WAIT_MS: int = 200
-ACCEPTED_IMG_FILE_ENDINGS: set[str] = {'.png', '.PNG'}
-
-OR_H: int = Gtk.Orientation.HORIZONTAL
-OR_V: int = Gtk.Orientation.VERTICAL
-
-CSS: str = '''
-.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;
-}
-'''
-
-
-def _add_button(parent: Gtk.Widget,
-                label: str,
-                on_click: Optional[Callable] = None,
-                checkbox: bool = False
-                ) -> Gtk.Button | Gtk.CheckButton:
-    """Helper to add Gtk.Button or .CheckButton to parent."""
-    btn = (Gtk.CheckButton(label=label) if checkbox
-           else Gtk.Button(label=label))
-    if on_click:
-        btn.connect('toggled' if checkbox else 'clicked', on_click)
-    parent.append(btn)
-    return btn
-
-
-class JsonDb:
-    """Representation of our simple .json DB files."""
-    _content: Db
-
-    def __init__(self, path: str) -> None:
-        self._path = path
-        self._is_open = False
-        if not path_exists(path):
-            with open(path, 'w', encoding='utf8') as f:
-                json_dump({}, f)
-
-    def _open(self) -> None:
-        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) -> None:
-        self._is_open = False
-        self._content = {}
-
-    def write(self) -> None:
-        """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()
-
-
-class BookmarksDb(JsonDb):
-    """Representation of Bookmarks DB files."""
-    _content: Bookmarks
-
-    def as_ref(self) -> Bookmarks:
-        """Return content at ._path as ref so that .write() stores changes."""
-        self._open()
-        return self._content
-
-    def as_copy(self) -> Bookmarks:
-        """Return content at ._path for read-only purposes."""
-        self._open()
-        copy = self._content.copy()
-        self._close()
-        return copy
-
-
-class CacheDb(JsonDb):
-    """Representation of Cache DB files."""
-    _content: Cache
-
-    def as_ref(self) -> Cache:
-        """Return content at ._path as ref so that .write() stores changes."""
-        self._open()
-        return self._content
-
-
-class Application(Gtk.Application):
-    """Image browser application class."""
-
-    def __init__(self, *args, **kwargs) -> None:
-        super().__init__(*args, **kwargs)
-        parser = ArgumentParser()
-        parser.add_argument('directory', default=IMG_DIR_DEFAULT, nargs='?')
-        parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT)
-        opts = parser.parse_args()
-        self.img_dir_absolute = abspath(opts.directory)
-        self.bookmarks_db = BookmarksDb(BOOKMARKS_PATH)
-        self.cache_db = CacheDb(CACHE_PATH)
-        self.sort_order = SorterAndFiltererOrder.from_suggestion(
-                opts.sort_order.split(','))
-
-    def do_activate(self, *args, **kwargs) -> None:
-        """Parse arguments, start window, keep it open."""
-        win = MainWindow(self)
-        win.present()
-        self.hold()
-
-
-class SorterAndFilterer(GObject.GObject):
-    """Sort order box representation of sorting/filtering attribute."""
-    widget: Gtk.Box
-
-    def __init__(self, name: str) -> None:
-        super().__init__()
-        self.name = name
-        self.filter_text = ''
-
-    def setup_on_bind(self,
-                      widget: Gtk.Box,
-                      on_filter_activate: Callable,
-                      vals: AttrValsByVisibility,
-                      ) -> None:
-        """Set up SorterAndFilterer label, values listing, filter entry."""
-        self.widget = widget
-        # label
-        len_incl = len(vals['incl'])
-        len_semi_total: int = len_incl + len(vals['semi'])
-        len_total: int = len_semi_total + len(vals['excl'])
-        title = f'{self.name} ({len_incl}/{len_semi_total}/{len_total}) '
-        self.widget.label.set_text(title)
-        # values listing
-        vals_listed: list[str] = [f'<b>{v}</b>' for v in vals['incl']]
-        vals_listed += [f'<s>{v}</s>' for v in vals['semi']]
-        vals_listed += [f'<b><s>{v}</s></b>' for v in vals['excl']]
-        self.widget.values.set_text(', '.join(vals_listed))
-        self.widget.values.set_use_markup(True)
-        # filter input
-
-        def filter_activate() -> None:
-            self.widget.filter_input.remove_css_class('temp')
-            self.filter_text = self.widget.filter_input.get_buffer().get_text()
-            on_filter_activate()
-
-        filter_buffer = self.widget.filter_input.get_buffer()
-        filter_buffer.set_text(self.filter_text, -1)  # triggers 'temp' class
-        self.widget.filter_input.remove_css_class('temp')  # set, that's why …
-        self.widget.filter_input.connect('activate',
-                                         lambda _: filter_activate())
-        filter_buffer.connect(
-            'inserted_text',
-            lambda a, b, c, d: self.widget.filter_input.add_css_class('temp'))
-        filter_buffer.connect(
-            'deleted_text',
-            lambda a, b, c: self.widget.filter_input.add_css_class('temp'))
-
-    def filter_allows_value(self, value: str | int | float) -> bool:
-        """Return if value passes filter defined by .name and .filter_text."""
-        number_attributes = (set(s.lower() for s in GEN_PARAMS_INT) |
-                             set(s.lower() for s in GEN_PARAMS_FLOAT) |
-                             {'bookmarked'})
-        if value is None:
-            return False
-        if self.name not in number_attributes:
-            assert isinstance(value, str)
-            return bool(re_search(self.filter_text, value))
-        assert isinstance(value, (int, float))
-        use_float = self.name in {s.lower() for s in GEN_PARAMS_FLOAT}
-        numbers_or, unequal = (set(),) * 2
-        less_than, less_or_equal, more_or_equal, more_than = (None,) * 4
-        for constraint_string in self.filter_text.split(','):
-            toks = constraint_string.split()
-            if len(toks) == 1:
-                tok = toks[0]
-                if tok[0] in '<>!':  # operator sans space after: split, re-try
-                    if '=' == tok[1]:
-                        toks = [tok[:2], tok[2:]]
-                    else:
-                        toks = [tok[:1], tok[1:]]
-                else:
-                    pattern_number = float(tok) if use_float else int(tok)
-                    numbers_or.add(pattern_number)
-            if len(toks) == 2:  # assume operator followed by number
-                pattern_number = float(toks[1]) if use_float else int(toks[1])
-                if toks[0] == '!=':
-                    unequal.add(pattern_number)
-                elif toks[0] == '<':
-                    if less_than is None or less_than >= pattern_number:
-                        less_than = pattern_number
-                elif toks[0] == '<=':
-                    if less_or_equal is None or less_or_equal > pattern_number:
-                        less_or_equal = pattern_number
-                elif toks[0] == '>=':
-                    if more_or_equal is None or more_or_equal < pattern_number:
-                        more_or_equal = pattern_number
-                elif toks[0] == '>':
-                    if more_than is None or more_than <= pattern_number:
-                        more_than = pattern_number
-        if value in numbers_or:
-            return True
-        if len(numbers_or) > 0 and (less_than == less_or_equal ==
-                                    more_or_equal == more_than):
-            return False
-        if value in unequal:
-            return False
-        return ((less_than is None or value < less_than)
-                and (less_or_equal is None or value <= less_or_equal)
-                and (more_or_equal is None or value >= more_or_equal)
-                and (more_than is None or value > more_than))
-
-
-class SorterAndFiltererOrder:
-    """Represents sorted list of SorterAndFilterer items."""
-
-    def __init__(self, as_list: list[SorterAndFilterer]) -> None:
-        self._list = as_list
-
-    def __eq__(self, other) -> bool:
-        return self._list == other._list
-
-    def __len__(self) -> int:
-        return len(self._list)
-
-    def __getitem__(self, idx: int) -> SorterAndFilterer:
-        return self._list[idx]
-
-    def __iter__(self):
-        return self._list.__iter__()
-
-    @staticmethod
-    def _list_from_store(store: Gio.ListStore) -> list[SorterAndFilterer]:
-        order = []
-        for i in range(store.get_n_items()):
-            order += [store.get_item(i)]
-        return order
-
-    @classmethod
-    def from_suggestion(cls, suggestion: list[str]) -> Self:
-        """Create new, interpreting order of strings in suggestion."""
-        names: list[str] = [p.lower() for p in GEN_PARAMS] + ['bookmarked']
-        order: list[SorterAndFilterer] = []
-        for name in names:
-            order += [SorterAndFilterer(name)]
-        new_order: list[SorterAndFilterer] = []
-        do_reverse: bool = '-' in suggestion
-        for pattern in suggestion:
-            for sorter in [sorter for sorter in order
-                           if sorter.name.startswith(pattern)]:
-                order.remove(sorter)
-                new_order += [sorter]
-        order = new_order + order
-        if do_reverse:
-            order.reverse()
-        return cls(order)
-
-    @classmethod
-    def from_store(cls, store: Gio.ListStore) -> Self:
-        """Create new, mirroring order in store."""
-        return cls(cls._list_from_store(store))
-
-    def by_name(self, name: str) -> Optional[SorterAndFilterer]:
-        """Return included SorterAndFilterer of name."""
-        for s in [s for s in self._list if name == s.name]:
-            return s
-        return None
-
-    def copy(self) -> Self:
-        """Create new, of equal order."""
-        return self.__class__(self._list[:])
-
-    def sync_from(self, other_order: Self) -> None:
-        """Sync internal state from other order."""
-        self._list = other_order._list
-
-    def remove(self, sorter_name: str) -> None:
-        """Remove sorter of sorter_name from self."""
-        candidate = self.by_name(sorter_name)
-        assert candidate is not None
-        self._list.remove(candidate)
-
-    def update_from_store(self, store: Gio.ListStore) -> None:
-        """Update self from store."""
-        self._list = self._list_from_store(store)
-
-    def into_store(self, store: Gio.ListStore) -> None:
-        """Update store to represent self."""
-        store.remove_all()
-        for sorter in self:
-            store.append(sorter)
-
-    def switch_at(self, selected_idx: int, forward: bool) -> None:
-        """Switch elements at selected_idx and its neighbor."""
-        selected: SorterAndFilterer = self[selected_idx]
-        other_idx: int = selected_idx + (1 if forward else -1)
-        other: SorterAndFilterer = self[other_idx]
-        self._list[other_idx] = selected
-        self._list[selected_idx] = other
-
-
-class GalleryItem(GObject.GObject):
-    """Gallery representation of filesystem entry, base to DirItem, ImgItem."""
-    _to_hash = ['name', 'full_path']
-
-    def __init__(self, path: str, name: str) -> None:
-        super().__init__()
-        self.name = name
-        self.full_path: str = path_join(path, self.name)
-        self.slot: GallerySlot
-
-    def __hash__(self) -> int:
-        hashable_values: list[str | bool] = []
-        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."""
-
-    def __init__(self, path: str, name: str, is_parent: bool = False) -> None:
-        super().__init__(path, name)
-        if is_parent:
-            self.full_path = path
-
-
-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: str, name: str, cache: Cache) -> None:
-        super().__init__(path, name)
-        self.subprompt1: str = ''
-        self.subprompt2: str = ''
-        mtime = getmtime(self.full_path)
-        dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
-        iso8601_str: str = dt.isoformat(timespec='microseconds')
-        self.last_mod_time: str = 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(), '')
-            else:
-                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])
-
-    def set_metadata(self, cache: Cache) -> None:
-        """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', '')
-            if gen_params_as_str:
-                gen_params = GenParams.from_str(gen_params_as_str)
-                for k, v_ in gen_params.as_dict.items():
-                    setattr(self, k, v_)
-        cached: CachedImg = {}
-        for k in (k.lower() for k in GEN_PARAMS):
-            cached[k] = getattr(self, k)
-        cache[self.full_path] = {self.last_mod_time: cached}
-
-    def bookmark(self, positive: bool = True) -> None:
-        """Set self.bookmark to positive, and update CSS class mark."""
-        self.bookmarked = positive
-        self.slot.mark('bookmarked', positive)
-
-
-class GallerySlotsGeometry:
-    """Collect variable sizes shared among all GallerySlots."""
-
-    def __init__(self) -> None:
-        self._margin: int = GALLERY_SLOT_MARGIN
-        assert 0 == self._margin % 2  # avoid ._margin != 2 * .side_margin
-        self.side_margin: int = self._margin // 2
-        self.size: int = -1
-        self.size_sans_margins: int = -1
-
-    def set_size(self, size: int) -> None:
-        """Not only set .size but also update .size_sans_margins."""
-        self.size = size
-        self.size_sans_margins = self.size - self._margin
-
-
-class GallerySlot(Gtk.Button):
-    """Slot in Gallery representing a GalleryItem."""
-
-    def __init__(self,
-                 item: GalleryItem,
-                 slots_geometry: GallerySlotsGeometry,
-                 on_click_file: Optional[Callable] = None
-                 ) -> None:
-        super().__init__()
-        self._geometry = slots_geometry
-        self.add_css_class('slot')
-        self.set_hexpand(True)
-        self.item = item
-        self.item.slot = self
-        if on_click_file:
-            self.connect('clicked', lambda _: on_click_file())
-
-    def mark(self, css_class: str, do_add: bool = True) -> None:
-        """Add or remove css_class from self."""
-        if do_add:
-            self.add_css_class(css_class)
-        else:
-            self.remove_css_class(css_class)
-
-    def ensure_slot_size(self) -> None:
-        """Call ._size_widget to size .props.child; if none, make empty one."""
-        if self.get_child() is None:
-            self.set_child(Gtk.Label(label='+'))
-        self._size_widget()
-
-    def _size_widget(self) -> None:
-        for s in ('bottom', 'top', 'start', 'end'):
-            setattr(self.get_child().props, f'margin_{s}',
-                    self._geometry.side_margin)
-        self.get_child().set_size_request(self._geometry.size_sans_margins,
-                                          self._geometry.size_sans_margins)
-
-    def update_widget(self, is_in_vp: bool) -> None:
-        """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class."""
-        new_content: Optional[Gtk.Image | Gtk.Label] = None
-        if 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)
-                if self.item.with_others:
-                    new_content.set_vexpand(True)
-                    box = Gtk.Box(orientation=OR_V)
-                    box.append(new_content)
-                    msg = 'and one or more other images of this configuration'
-                    box.append(Gtk.Label(label=msg))
-                    new_content = box
-            elif (not is_in_vp) and not isinstance(self.item, Gtk.Label):
-                new_content = Gtk.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)
-            self._size_widget()
-        if isinstance(self.item, ImgItem):
-            self.mark('bookmarked', self.item.bookmarked)
-
-
-class GalleryConfig():
-    """Representation of sort and filtering settings."""
-    _sort_sel = Gtk.SingleSelection
-    _set_recurse_changed: bool
-    _btn_apply: Gtk.Button
-    _btn_by_1st: Gtk.CheckButton
-    _btn_recurse: Gtk.CheckButton
-    _btn_per_row: Gtk.CheckButton
-    _btn_show_dirs: Gtk.CheckButton
-    _store: Gio.ListStore
-
-    def __init__(self,
-                 box: Gtk.Box,
-                 sort_order: SorterAndFiltererOrder,
-                 request_update: Callable,
-                 update_settings: Callable,
-                 items_attrs: ItemsAttrs,
-                 ) -> None:
-        self.order = sort_order
-        self._tmp_order: Optional[SorterAndFiltererOrder] = None
-        self._gallery_request_update = request_update
-        self._gallery_update_settings = update_settings
-        self._gallery_items_attrs = items_attrs
-
-        def setup_sorter_list_item(_, list_item: SorterAndFilterer) -> None:
-            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_input = Gtk.Entry(placeholder_text='filter?')
-            hbox = Gtk.Box(orientation=OR_H)
-            hbox.append(item_widget.label)
-            hbox.append(item_widget.filter_input)
-            item_widget.append(hbox)
-            item_widget.append(item_widget.values)
-            list_item.set_child(item_widget)
-
-        def bind_sorter_list_item(_, list_item: SorterAndFilterer) -> None:
-            def on_filter_activate():
-                self._filter_changed = True
-            sorter: SorterAndFilterer = list_item.props.item
-            sorter.setup_on_bind(list_item.props.child,
-                                 on_filter_activate,
-                                 self._gallery_items_attrs[sorter.name])
 
 
-        def select_sort_order(_a, _b, _c) -> None:
-            self._sort_sel.props.selected_item.widget.get_parent().grab_focus()
-
-        def toggle_recurse(_) -> None:
-            self._set_recurse_changed = not self._set_recurse_changed
-            self._btn_apply.set_sensitive(not self._set_recurse_changed)
-
-        def toggle_by_1st(btn: Gtk.CheckButton) -> None:
-            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)
-
-        def apply_config() -> None:
-            if self._tmp_order:
-                self.order.sync_from(self._tmp_order)
-                self._tmp_order = None
-                self._gallery_request_update(build_grid=True)
-            if self._filter_changed:
-                self._gallery_request_update(build_grid=True)
-            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(),
-                    recurse_dirs=self._btn_recurse.get_active())
-            self._gallery_request_update(select=True)
-            self._set_recurse_changed = False
-            self._filter_changed = False
-
-        def full_reload() -> None:
-            apply_config()
-            self._gallery_request_update(load=True)
-            self._btn_apply.set_sensitive(True)
-
-        self._filter_changed = False
-        self._set_recurse_changed = False
-        self._last_selected: Optional[Gtk.Widget] = None
-
-        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_reload = _add_button(buttons_box, 'full reload',
-                                       lambda _: full_reload())
-
-        buttons_box = Gtk.Box(orientation=OR_H)
-        self._btn_apply = _add_button(buttons_box, 'apply config',
-                                      lambda _: apply_config())
-        self._btn_reload = _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',
-                                        toggle_recurse, checkbox=True)
-
-        per_row_box = Gtk.Box(orientation=OR_H)
-        per_row_box.append(Gtk.Label(label='cols/row:'))
-        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)
-
-        box.append(self.sorter_listing)
-        box.append(dirs_box)
-        box.append(per_row_box)
-        box.append(buttons_box)
-
-    def on_focus_sorter(self, focused: SorterAndFilterer) -> None:
-        """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: int) -> None:
-        """Move sort order selection by direction (-1 or +1)."""
-        min_idx, max_idx = 0, len(self.order) - 1
-        cur_idx = self._sort_sel.props.selected
-        if (1 == direction and cur_idx < max_idx)\
-                or (-1 == direction and cur_idx > min_idx):
-            self._sort_sel.props.selected = cur_idx + direction
-
-    def move_sorter(self, direction: int) -> None:
-        """Move selected item in sort order view, ensure temporary state."""
-        tmp_order = self._tmp_order if self._tmp_order else self.order.copy()
-        cur_idx = self._sort_sel.props.selected
-        if direction == -1 and cur_idx > 0:
-            tmp_order.switch_at(cur_idx, forward=False)
-        elif direction == 1 and cur_idx < (len(tmp_order) - 1):
-            tmp_order.switch_at(cur_idx, forward=True)
-        else:  # to catch movement beyond limits
-            return
-        if not self._tmp_order:
-            self._tmp_order = tmp_order
-            for sorter in self._tmp_order:
-                sorter.widget.add_css_class('temp')
-        self.update_box(cur_idx + direction)
-        self._sort_sel.props.selected = cur_idx + direction
-        for i in range(self._store.get_n_items()):
-            sort_item: SorterAndFilterer = self._store.get_item(i)
-            sort_item.widget.add_css_class('temp')
-
-    def update_box(self, cur_selection: int = 0) -> None:
-        """Rebuild sorter listing in box from .order, or alt_order if set."""
-        sort_order = self._tmp_order if self._tmp_order else self.order
-        sort_order.into_store(self._store)
-        self._sort_sel.props.selected = cur_selection
-
-
-class VerticalLabel(Gtk.DrawingArea):
-    """Label of vertical text (rotated -90°)."""
-
-    def __init__(self,
-                 text: str,
-                 slots_geometry: GallerySlotsGeometry
-                 ) -> None:
-        super().__init__()
-        self._text = text
-        self._slots_geometry = slots_geometry
-        test_layout = self.create_pango_layout()
-        test_layout.set_markup(text)
-        _, self._text_height = test_layout.get_pixel_size()
-        self.set_draw_func(self._on_draw)
-
-    def _on_draw(self,
-                 _,
-                 cairo_ctx: Pango.Context,
-                 __,
-                 height: int
-                 ) -> None:
-        """Create layout, rotate by 90°, size widget to measurements."""
-        layout = self.create_pango_layout()
-        layout.set_markup(self._text)
-        layout.set_width(self._slots_geometry.size * Pango.SCALE)
-        layout.set_ellipsize(Pango.EllipsizeMode.MIDDLE)
-        text_width, _ = layout.get_pixel_size()
-        cairo_ctx.translate(0, text_width + (height - text_width))
-        cairo_ctx.rotate(radians(-90))
-        PangoCairo.show_layout(cairo_ctx, layout)
-        self.set_size_request(self._text_height, text_width)
-
-    @property
-    def width(self) -> int:
-        """Return (rotated) ._text_height."""
-        return self._text_height
-
-
-class Gallery:
-    """Representation of GalleryItems below a directory."""
-    update_config_box: Callable
-
-    def __init__(self,
-                 sort_order: SorterAndFiltererOrder,
-                 on_hit_item: Callable,
-                 on_selection_change: Callable,
-                 bookmarks_db: BookmarksDb,
-                 cache_db: CacheDb
-                 ) -> None:
-        self._on_hit_item = on_hit_item
-        self._on_selection_change = on_selection_change
-        self._bookmarks_db, self._cache_db = bookmarks_db, cache_db
-        self._sort_order = sort_order
-        self._img_dir_path = ''
-
-        self._shall_load = False
-        self._shall_build_grid = 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._slots_geometry = GallerySlotsGeometry()
-
-        self.dir_entries: list[GalleryItem] = []
-        self._basic_items_attrs: BasicItemsAttrs = {}
-        self.items_attrs: ItemsAttrs = {}
-        self.selected_idx = 0
-        self.slots: list[GallerySlot] = []
-
-        self._grid = Gtk.Grid()
-        self._force_width, self._force_height = 0, 0
-        scroller = Gtk.ScrolledWindow(propagate_natural_height=True)
-        self._col_headers_frame = Gtk.Fixed()
-        self._col_headers_grid = Gtk.Grid()
-        self.frame = Gtk.Box(orientation=OR_V)
-        self.frame.append(self._col_headers_frame)
-        self.frame.append(scroller)
-        # 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
-
-        def ensure_uptodate() -> bool:
-            if not self._img_dir_path:
-                return True
-            if self._shall_load:
-                self._shall_load = False
-                self._load_directory()
-            if self._shall_build_grid:
-                self._shall_build_grid = False
-                self._build_grid()
-            if self._shall_select:
-                self._shall_select = False
-                self._assert_selection()
-            if self._shall_redraw:
-                wait_time_passed = datetime.now() - self._start_redraw_wait
-                if wait_time_passed > redraw_wait_time:
-                    self._shall_redraw = False
-                    self._redraw_and_check_focus()
-            return True
-
-        def handle_scroll(_) -> None:
-            self._start_redraw_wait = datetime.now()
-            self._shall_scroll_to_focus = False
-            self.request_update()  # only request redraw
-
-        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: Optional[int] = None,
-                        by_1st: Optional[bool] = None,
-                        show_dirs: Optional[bool] = None,
-                        recurse_dirs: Optional[bool] = None,
-                        img_dir_path: Optional[str] = None,
-                        ) -> 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')]:
-            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.request_update(load=True)
-                else:
-                    self.request_update(build_grid=True)
-
-    @staticmethod
-    def _diff_prompts(prompts: list[str]) -> dict[str, tuple[str, str]]:
-        if not prompts:
-            return {}
-
-        def find_longest_equal(prompts, j, matcher):
-            longest_total, temp_longest = '', ''
-            while j < len(prompts[0]):
-                if 'end' == matcher:
-                    temp_longest = prompts[0][-j] + temp_longest
-                else:
-                    temp_longest += prompts[0][j]
-                if len(temp_longest) > len(longest_total):
-                    found_in_all = True
-                    for prompt in prompts[1:]:
-                        if ('start' == matcher
-                            and not prompt.startswith(temp_longest)) or\
-                                ('end' == matcher
-                                 and not prompt.endswith(temp_longest)) or\
-                                ('in' == matcher
-                                 and temp_longest not in prompt):
-                            found_in_all = False
-                            break
-                    if not found_in_all:
-                        break
-                    longest_total = temp_longest
-                j += 1
-            return longest_total
-
-        prefix = find_longest_equal(prompts, 0, 'start')
-        suffix = find_longest_equal(prompts, 1, matcher='end')
-        cores = [p[len(prefix):] for p in prompts]
-        if suffix:
-            for i, p in enumerate(cores):
-                cores[i] = p[:-len(suffix)]
-        longest_total = ''
-        for i in range(len(cores[0])):
-            temp_longest = find_longest_equal(cores, j=i, matcher='in')
-            if len(temp_longest) > len(longest_total):
-                longest_total = temp_longest
-        middle = longest_total
-        prompts_diff = {}
-        for i, p in enumerate(prompts):
-            remains = p[len(prefix):] if prefix else p
-            idx_middle = remains.index(middle)
-            first = remains[:idx_middle] if idx_middle else ''
-            remains = remains[idx_middle + len(middle):]
-            second = remains[:-len(suffix)] if suffix else remains
-            if first:
-                first = f'…{first}' if prefix else first
-                first = f'{first}…' if suffix or middle else first
-            if second:
-                second = f'…{second}' if prefix or middle else second
-                second = f'{second}…' if suffix else second
-            prompts_diff[p] = (first if first else '…',
-                               second if second else '…')
-        return prompts_diff
-
-    def _prep_items_attrs(self, entries: list[GalleryItem]) -> BasicItemsAttrs:
-        basic_items_attrs = {}
-        for attr_name in (s.name for s in self._sort_order):
-            vals: set[str] = set()
-            for entry in [e for e in entries if isinstance(e, ImgItem)]:
-                val = (getattr(entry, attr_name)
-                       if hasattr(entry, attr_name) else None)
-                if val is not None:
-                    vals.add(val)
-            basic_items_attrs[attr_name] = vals
-        return basic_items_attrs
-
-    def _load_directory(self) -> None:
-        """(Re-)build .dir_entries from ._img_dir_path, ._basic_items_attrs."""
-        self.dir_entries.clear()
-        bookmarks = self._bookmarks_db.as_copy()
-        cache = self._cache_db.as_ref()
-
-        def read_directory(dir_path: str, make_parent: bool = False) -> None:
-            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: list[str] = []
-            to_set_metadata_on: list[ImgItem] = []
-            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)
-
-        read_directory(self._img_dir_path, make_parent=True)
-        prompts_set: set[str] = set()
-        for entry in [e for e in self.dir_entries
-                      if isinstance(e, ImgItem) and hasattr(e, 'prompt')]:
-            prompts_set.add(entry.prompt)
-        prompts_diff = self._diff_prompts(list(prompts_set))
-        for entry in [e for e in self.dir_entries if isinstance(e, ImgItem)]:
-            entry.subprompt1 = prompts_diff[entry.prompt][0]
-            entry.subprompt2 = prompts_diff[entry.prompt][1]
-        if self._sort_order.by_name('prompt'):
-            self._sort_order.remove('prompt')
-            self._sort_order._list.append(SorterAndFilterer('subprompt1'))
-            self._sort_order._list.append(SorterAndFilterer('subprompt2'))
-        self._basic_items_attrs = self._prep_items_attrs(self.dir_entries)
-        ignorable_attrs = []
-        for attr_name, attr_vals in self._basic_items_attrs.items():
-            if len(attr_vals) < 2:
-                ignorable_attrs += [attr_name]
-        for attr_name in ignorable_attrs:
-            self._sort_order.remove(attr_name)
-            del self._basic_items_attrs[attr_name]
-        self._cache_db.write()
-
-    @property
-    def selected_item(self) -> Optional[GalleryItem]:
-        """Return slot.item for slot at self.selected_idx."""
-        return self.slots[self.selected_idx].item if self.slots else None
-
-    def on_focus_slot(self, slot: GallerySlot) -> None:
-        """If GallerySlot focused, set .selected_idx to it."""
-        self._set_selection(self.slots.index(slot))
-        self.request_update(scroll_to_focus=True)
-
-    def _assert_selection(self) -> None:
-        if self.slots:
-            self.slots[self.selected_idx].mark('selected', True)
-            self.slots[self.selected_idx].grab_focus()
-
-    def _set_selection(self, new_idx: int) -> None:
-        """Set self.selected_idx, mark slot as 'selected', unmark old one."""
-        # in ._build_grid(), 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_grid() 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
-        self._assert_selection()
-        self._on_selection_change()
-
-    def _build_grid(self) -> None:
-        """(Re-)build slot grid from .dir_entries, filters, layout settings."""
-        old_selected_item: Optional[GalleryItem] = self.selected_item
-
-        def update_items_attrs() -> None:
-            self.items_attrs.clear()
-
-            def separate_items_attrs(basic_items_attrs) -> ItemsAttrs:
-                items_attrs: ItemsAttrs = {}
-                for attr_name, vals in basic_items_attrs.items():
-                    sorter = self._sort_order.by_name(attr_name)
-                    items_attrs[attr_name] = {'incl': [], 'excl': []}
-                    for v in vals:
-                        passes_filter = sorter is None
-                        if sorter:
-                            passes_filter = sorter.filter_allows_value(v)
-                        k = 'incl' if passes_filter else 'excl'
-                        items_attrs[attr_name][k] += [v]
-                return items_attrs
-
-            items_attrs_tmp_1 = separate_items_attrs(self._basic_items_attrs)
-            filtered = filter_entries(items_attrs_tmp_1)
-            reduced_basic_items_attrs = self._prep_items_attrs(filtered)
-            items_attrs_tmp_2 = separate_items_attrs(reduced_basic_items_attrs)
-            for attr_name in (s.name for s in self._sort_order):
-                final_values: AttrValsByVisibility = {'incl': [], 'semi': []}
-                final_values['excl'] = items_attrs_tmp_1[attr_name]['excl']
-                for v in items_attrs_tmp_1[attr_name]['incl']:
-                    k = ('incl' if v in items_attrs_tmp_2[attr_name]['incl']
-                         else 'semi')
-                    final_values[k] += [v]
-                for category in ('incl', 'semi', 'excl'):
-                    final_values[category].sort()
-                self.items_attrs[attr_name] = final_values
-
-        def filter_entries(items_attrs: ItemsAttrs) -> list[GalleryItem]:
-            entries_filtered: list[GalleryItem] = []
-            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 not in items_attrs[attr_name]['incl']:
-                            passes_filters = False
-                            break
-                if passes_filters:
-                    entries_filtered += [entry]
-            return entries_filtered
-
-        def build(entries_filtered: list[GalleryItem]) -> None:
-            i_row_ref, i_slot_ref = [0], [0]
-            if self._grid.get_parent():
-                self._fixed_frame.remove(self._grid)
-            self._grid = Gtk.Grid()
-            if self._col_headers_grid.get_parent():
-                self._col_headers_frame.remove(self._col_headers_grid)
-                self._col_headers_grid = Gtk.Grid()
-            self.slots.clear()
-            self._fixed_frame.put(self._grid, 0, 0)
-
-            def build_rows_by_attrs(
-                    remaining: list[tuple[str, AttrVals]],
-                    items_of_parent: list[GalleryItem],
-                    ancestors: list[tuple[str, str]]
-                    ) -> None:
-                if not items_of_parent:
-                    return
-                attr_name, attr_values = remaining[0]
-                if 1 == len(remaining):
-                    for i, attr in enumerate(ancestors):
-                        txt = f'<b>{attr[0]}</b>: {attr[1]}'
-                        vlabel = VerticalLabel(txt, self._slots_geometry)
-                        self._grid.attach(vlabel, i, i_row_ref[0], 1, 1)
-                    row: list[Optional[GalleryItem]]
-                    row = [None] * len(attr_values)
-                    for gallery_item in items_of_parent:
-                        val = getattr(gallery_item, attr_name)
-                        idx_val_in_attr_values = attr_values.index(val)
-                        if row[idx_val_in_attr_values]:
-                            gallery_item.with_others = True
-                        row[idx_val_in_attr_values] = gallery_item
-                    for i_col, item in enumerate(row):
-                        slot = GallerySlot(  # build empty dummy if necessary
-                                item if item else GalleryItem('', ''),
-                                self._slots_geometry)
-                        self.slots += [slot]
-                        i_slot_ref[0] += 1
-                        self._grid.attach(slot, i_col + len(ancestors),
-                                          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
-                                           if attr_value == getattr(x,
-                                                                    attr_name)]
-                    build_rows_by_attrs(remaining[1:], items_of_attr_value,
-                                        ancestors + [(attr_name, attr_value)])
-
-            if self._by_1st:
-                self._show_dirs = False
-                sort_attrs: list[tuple[str, AttrVals]] = []
-                for sorter in reversed(self._sort_order):
-                    vals: AttrVals = self.items_attrs[sorter.name]['incl']
-                    if len(vals) > 1:
-                        sort_attrs += [(sorter.name, vals)]
-                if not sort_attrs:
-                    s_name: str = self._sort_order[0].name
-                    sort_attrs += [(s_name, self.items_attrs[s_name]['incl'])]
-                self._per_row = len(sort_attrs[-1][1])
-                build_rows_by_attrs(sort_attrs, entries_filtered, [])
-                self._col_headers_frame.put(self._col_headers_grid, 0, 0)
-                self._col_headers_grid.attach(Gtk.Box(), 0, 0, 1, 1)
-                top_attr_name: str = sort_attrs[-1][0]
-                for i, val in enumerate(sort_attrs[-1][1]):
-                    label = Gtk.Label(label=f'<b>{top_attr_name}</b>: {val}',
-                                      xalign=0,
-                                      ellipsize=Pango.EllipsizeMode.MIDDLE)
-                    label.set_use_markup(True)
-                    self._col_headers_grid.attach(label, i + 1, 0, 1, 1)
-
-            else:
-                dir_entries_filtered_sorted: list[GalleryItem] = 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, self._slots_geometry,
-                                       self._on_hit_item)
-                    self._grid.attach(slot, i_col, i_row, 1, 1)
-                    self.slots += [slot]
-                    i_col += 1
-            self.update_config_box()
-
-        update_items_attrs()
-        entries_filtered = filter_entries(self.items_attrs)
-        build(entries_filtered)
-        new_idx = 0
-        if old_selected_item is not None:
-            for i, slot in enumerate(self.slots):
-                if hash(old_selected_item) == hash(slot.item):
-                    new_idx = i
-                    break
-        self._set_selection(new_idx)
-
-    def request_update(self,
-                       select: bool = False,
-                       scroll_to_focus: bool = False,
-                       build_grid: bool = False,
-                       load: bool = False
-                       ) -> None:
-        """Set ._shall_… to trigger updates on next relevant interval."""
-        self._shall_redraw = True
-        self._shall_select |= select or scroll_to_focus or build_grid or load
-        self._shall_scroll_to_focus |= scroll_to_focus or build_grid or load
-        self._shall_build_grid |= build_grid or load
-        self._shall_load |= load
-
-    def move_selection(self,
-                       x_inc: Optional[int],
-                       y_inc: Optional[int],
-                       buf_end: Optional[int]
-                       ) -> None:
-        """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: int = 0, height: int = 0) -> None:
-        """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 _redraw_and_check_focus(self) -> None:
-        """Draw gallery; possibly notice and first follow need to re-focus."""
-        vp_width: int = (self._force_width if self._force_width
-                         else self._viewport.get_width())
-        vp_height: int = (self._force_height if self._force_height
-                          else self._viewport.get_height())
-        self._force_width, self._force_height = 0, 0
-        vp_scroll: Gtk.Adjustment = self._viewport.get_vadjustment()
-        vp_top: int = vp_scroll.get_value()
-        vp_bottom: int = vp_top + vp_height
-        side_offset, i_vlabels = 0, 0
-        if self._by_1st:
-            while True:
-                gal_widget: VerticalLabel | GalleryItem
-                gal_widget = self._grid.get_child_at(i_vlabels, 0)
-                if isinstance(gal_widget, VerticalLabel):
-                    side_offset += gal_widget.width
-                else:
-                    break
-                i_vlabels += 1
-        max_slot_width: int = (vp_width - side_offset) // self._per_row
-        self._slots_geometry.set_size(min(vp_height, max_slot_width))
-        if self._by_1st:
-            i_widgets = 0
-            while True:
-                head_widget: Gtk.Box | Gtk.Label | None
-                head_widget = self._col_headers_grid.get_child_at(i_widgets, 0)
-                if 0 == i_widgets:
-                    head_widget.set_size_request(side_offset, -1)
-                elif isinstance(head_widget, Gtk.Label):
-                    head_widget.set_size_request(self._slots_geometry.size, -1)
-                else:
-                    break
-                i_widgets += 1
-        for idx, slot in enumerate(self.slots):
-            slot.ensure_slot_size()
-        vp_scroll.set_upper(self._slots_geometry.size * ceil(len(self.slots)
-                                                             / self._per_row))
-        if self._scroll_to_focus(vp_scroll, vp_top, vp_bottom):
-            return
-        for idx, slot in enumerate(self.slots):
-            in_vp, _, _ = self._position_to_viewport(idx,
-                                                     vp_top, vp_bottom, True)
-            slot.update_widget(in_vp)
-        self._start_redraw_wait = datetime.now()
-
-    def _position_to_viewport(self,
-                              idx: int,
-                              vp_top: int,
-                              vp_bottom: int,
-                              in_vp_greedy: bool = False
-                              ) -> tuple[bool, int, int]:
-        slot_top: int = (idx // self._per_row) * self._slots_geometry.size
-        slot_bottom: int = slot_top + self._slots_geometry.size
-        if in_vp_greedy:
-            in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom)
-        else:
-            in_vp = (slot_top >= vp_top and slot_bottom <= vp_bottom)
-        return in_vp, slot_top, slot_bottom
-
-    def _scroll_to_focus(self,
-                         vp_scroll: Gtk.Scrollable,
-                         vp_top: int,
-                         vp_bottom: int
-                         ) -> bool:
-        scroll_to_focus: bool = 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, 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
-                                        - self._slots_geometry.size)
-                return True
-        return False
-
-    def _sort_cmp(self, a: GalleryItem, b: GalleryItem) -> int:
-        """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 ImgItems (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
-
-
-class MainWindow(Gtk.Window):
-    """Image browser app top-level window."""
-
-    def __init__(self, app: Application, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self.app = app
-        self.gallery = Gallery(
-                sort_order=self.app.sort_order,
-                on_hit_item=self.hit_gallery_item,
-                on_selection_change=self.update_metadata_on_gallery_selection,
-                bookmarks_db=self.app.bookmarks_db,
-                cache_db=self.app.cache_db)
-        config_box = Gtk.Box(orientation=OR_V)
-        self.conf = GalleryConfig(
-                sort_order=self.app.sort_order,
-                box=config_box,
-                request_update=self.gallery.request_update,
-                update_settings=self.gallery.update_settings,
-                items_attrs=self.gallery.items_attrs)
-        self.gallery.update_config_box = self.conf.update_box
-        metadata_textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR,
-                                         editable=False)
-        self.metadata = metadata_textview.get_buffer()
-        self.idx_display = Gtk.Label()
-
-        # layout: outer box, CSS, sizings
-        box_outer = Gtk.Box(orientation=OR_H)
-        self.set_child(box_outer)
-        css_provider = Gtk.CssProvider()
-        css_provider.load_from_data(CSS)
-        Gtk.StyleContext.add_provider_for_display(
-                self.get_display(), css_provider,
-                Gtk.STYLE_PROVIDER_PRIORITY_USER)
-        metadata_textview.set_size_request(300, -1)
-        self.connect('notify::default-width', lambda _, __: self.on_resize())
-        self.connect('notify::default-height', lambda _, __: self.on_resize())
-
-        # layout: sidebar
-        self.side_box = Gtk.Notebook.new()
-        self.side_box.append_page(metadata_textview,
-                                  Gtk.Label(label='metadata'))
-        self.side_box.append_page(config_box, Gtk.Label(label='config'))
-        box_outer.append(self.side_box)
-
-        # layout: gallery viewer
-        viewer = Gtk.Box(orientation=OR_V)
-        self.navbar = Gtk.Box(orientation=OR_H)
-        _add_button(self.navbar, 'sidebar', lambda _: self.toggle_side_box())
-        self.navbar.append(self.idx_display)
-        viewer.append(self.navbar)
-        viewer.append(self.gallery.frame)
-        box_outer.append(viewer)
-
-        # init key and focus control
-        key_ctl = Gtk.EventControllerKey(
-                propagation_phase=Gtk.PropagationPhase.CAPTURE)
-        key_ctl.connect('key-pressed',
-                        lambda _, kval, _0, _1: self.handle_keypress(kval))
-        self.add_controller(key_ctl)
-        self.prev_key_ref = [0]
-        self.connect('notify::focus-widget',
-                     lambda _, __: self.on_focus_change())
-
-        # only now we're ready for actually running the gallery
-        GLib.idle_add(lambda: self.gallery.update_settings(
-            img_dir_path=self.app.img_dir_absolute))
-
-    def on_focus_change(self) -> None:
-        """Handle reactions on focus changes in .gallery and .conf."""
-        focused: Optional[Gtk.Widget] = self.get_focus()
-        if not focused:
-            return
-        if isinstance(focused, GallerySlot):
-            self.gallery.on_focus_slot(focused)
-        elif focused.get_parent() == self.conf.sorter_listing:
-            self.conf.on_focus_sorter(focused)
-
-    def on_resize(self) -> None:
-        """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: int = self.side_box.measure(OR_H, -1).natural
-            default_size: tuple[int, int] = self.get_default_size()
-            self.gallery.on_resize(default_size[0] - side_box_width,
-                                   default_size[1] - self.navbar.get_height())
-
-    def bookmark(self) -> None:
-        """Toggle bookmark on selected gallery item."""
-        if not isinstance(self.gallery.selected_item, ImgItem):
-            return
-        bookmarks = self.app.bookmarks_db.as_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]
-        self.app.bookmarks_db.write()
-        self.conf.update_box()
-
-    def hit_gallery_item(self) -> None:
-        """If current file selection is directory, reload into that one."""
-        selected: Optional[GalleryItem] = self.gallery.selected_item
-        if isinstance(selected, DirItem):
-            self.gallery.update_settings(img_dir_path=selected.full_path)
-
-    def toggle_side_box(self) -> None:
-        """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: int = self.side_box.measure(OR_H, -1).natural
-        self.gallery.on_resize(self.get_width() - side_box_width)
-
-    def update_metadata_on_gallery_selection(self) -> None:
-        """Update .metadata about individual file, and .idx_display."""
-        self.metadata.set_text('')
-        selected_item: Optional[GalleryItem] = self.gallery.selected_item
-        display_name = '(none)'
-        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))
-                display_name = selected_item.full_path
-            elif isinstance(selected_item, DirItem):
-                display_name = selected_item.full_path
-        total = len([s for s in self.gallery.slots
-                     if isinstance(s.item, (DirItem, ImgItem))])
-        n_selected: int = self.gallery.selected_idx + 1
-        txt = f' {n_selected} of {total} – <b>{display_name}</b>'
-        self.idx_display.set_text(txt)
-        self.idx_display.set_use_markup(True)
-
-    def handle_keypress(self, keyval: int) -> bool:
-        """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 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:
-            self.gallery.move_selection(-1, None, None)
-        elif Gdk.KEY_j == keyval:
-            self.gallery.move_selection(None, +1, None)
-        elif Gdk.KEY_k == keyval:
-            self.gallery.move_selection(None, -1, None)
-        elif Gdk.KEY_l == keyval:
-            self.gallery.move_selection(+1, None, None)
-        elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key_ref[0]:
-            self.gallery.move_selection(None, None, -1)
-        elif Gdk.KEY_w == keyval:
-            self.conf.move_selection(-1)
-        elif Gdk.KEY_W == keyval:
-            self.conf.move_sorter(-1)
-        elif Gdk.KEY_s == keyval:
-            self.conf.move_selection(1)
-        elif Gdk.KEY_S == keyval:
-            self.conf.move_sorter(1)
-        elif Gdk.KEY_b == keyval:
-            self.bookmark()
-        else:
-            self.prev_key_ref[0] = keyval
-            return False
-        return True
-
-
-main_app = Application(application_id='plomlompom.com.StablePixBrowser.App')
-main_app.run()
+from argparse import ArgumentParser
+from os.path import abspath
+
+from browser.gtk_app import GtkApp
+from browser.config_constants import (
+        BOOKMARKS_PATH, CACHE_PATH, GTK_APP_ID, IMG_DIR_DEFAULT, SORT_DEFAULT)
+
+
+parser = ArgumentParser()
+parser.add_argument('directory', nargs='?', default=IMG_DIR_DEFAULT)
+parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT)
+opts = parser.parse_args()
+sort_suggestion = opts.sort_order.split(',')
+GtkApp(application_id=GTK_APP_ID,
+       bookmarks_file=BOOKMARKS_PATH,
+       cache_file=CACHE_PATH,
+       start_dir=abspath(opts.directory),
+       sort_order_suggestion=opts.sort_order.split(',')
+       ).run()
diff --git a/browser/__init__.py b/browser/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/browser/config_constants.py b/browser/config_constants.py
new file mode 100644 (file)
index 0000000..e635065
--- /dev/null
@@ -0,0 +1,9 @@
+"""Configuration-ready constants."""
+
+GALLERY_PER_ROW_DEFAULT = 5
+GTK_APP_ID = 'plomlompom.com.StablePixBrowser.App'
+CACHE_PATH = 'cache.json'
+BOOKMARKS_PATH = 'bookmarks.json'
+IMG_DIR_DEFAULT = '.'
+SORT_DEFAULT = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\
+        'model,prompt'
diff --git a/browser/gallery.py b/browser/gallery.py
new file mode 100644 (file)
index 0000000..76cd0e3
--- /dev/null
@@ -0,0 +1,798 @@
+"""Gallery class."""
+
+from datetime import datetime, timedelta, timezone
+from functools import cmp_to_key
+from math import ceil, radians
+from os import listdir
+from os.path import abspath, getmtime, isdir, join as path_join, splitext
+from typing import Callable, Optional, TypeAlias
+from PIL import Image
+from PIL.PngImagePlugin import PngImageFile
+import gi  # type: ignore
+
+from browser.config_constants import GALLERY_PER_ROW_DEFAULT
+from browser.gtk_helpers import OR_V
+from browser.json_dbs import BookmarksDb, CacheDb
+from browser.gallery_config import SorterAndFilterer, SorterAndFiltererOrder
+from browser.types import (
+        AttrVals, AttrValsByVisibility, Cache, CachedImg, ItemsAttrs
+        )
+from stable.gen_params import GenParams, GEN_PARAMS, GEN_PARAMS_STR
+
+# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes
+gi.require_version('Gtk', '4.0')
+# pylint: disable=wrong-import-order,wrong-import-position
+from gi.repository import (  # type: ignore  # noqa: E402
+        GLib, GObject, Gtk, Pango, PangoCairo)
+
+
+BasicItemsAttrs: TypeAlias = dict[str, set[str]]
+
+
+UPPER_DIR: str = '..'
+GALLERY_SLOT_MARGIN: int = 6
+GALLERY_UPDATE_INTERVAL_MS: int = 50
+GALLERY_REDRAW_WAIT_MS: int = 200
+ACCEPTED_IMG_FILE_ENDINGS: set[str] = {'.png', '.PNG'}
+
+
+class GalleryItem(GObject.GObject):
+    """Gallery representation of filesystem entry, base to DirItem, ImgItem."""
+    _to_hash = ['name', 'full_path']
+
+    def __init__(self, path: str, name: str) -> None:
+        super().__init__()
+        self.name = name
+        self.full_path: str = path_join(path, self.name)
+        self.slot: GallerySlot
+
+    def __hash__(self) -> int:
+        hashable_values: list[str | bool] = []
+        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."""
+
+    def __init__(self, path: str, name: str, is_parent: bool = False) -> None:
+        super().__init__(path, name)
+        if is_parent:
+            self.full_path = path
+
+
+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: str, name: str, cache: Cache) -> None:
+        super().__init__(path, name)
+        self.subprompt1: str = ''
+        self.subprompt2: str = ''
+        mtime = getmtime(self.full_path)
+        dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
+        iso8601_str: str = dt.isoformat(timespec='microseconds')
+        self.last_mod_time: str = 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(), '')
+            else:
+                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])
+
+    def set_metadata(self, cache: Cache) -> None:
+        """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', '')
+            if gen_params_as_str:
+                gen_params = GenParams.from_str(gen_params_as_str)
+                for k, v_ in gen_params.as_dict.items():
+                    setattr(self, k, v_)
+        cached: CachedImg = {}
+        for k in (k.lower() for k in GEN_PARAMS):
+            cached[k] = getattr(self, k)
+        cache[self.full_path] = {self.last_mod_time: cached}
+
+    def bookmark(self, positive: bool = True) -> None:
+        """Set self.bookmark to positive, and update CSS class mark."""
+        self.bookmarked = positive
+        self.slot.mark('bookmarked', positive)
+
+
+class GallerySlotsGeometry:
+    """Collect variable sizes shared among all GallerySlots."""
+
+    def __init__(self) -> None:
+        self._margin: int = GALLERY_SLOT_MARGIN
+        assert 0 == self._margin % 2  # avoid ._margin != 2 * .side_margin
+        self.side_margin: int = self._margin // 2
+        self.size: int = -1
+        self.size_sans_margins: int = -1
+
+    def set_size(self, size: int) -> None:
+        """Not only set .size but also update .size_sans_margins."""
+        self.size = size
+        self.size_sans_margins = self.size - self._margin
+
+
+class GallerySlot(Gtk.Button):
+    """Slot in Gallery representing a GalleryItem."""
+
+    def __init__(self,
+                 item: GalleryItem,
+                 slots_geometry: GallerySlotsGeometry,
+                 on_click_file: Optional[Callable] = None
+                 ) -> None:
+        super().__init__()
+        self._geometry = slots_geometry
+        self.add_css_class('slot')
+        self.set_hexpand(True)
+        self.item = item
+        self.item.slot = self
+        if on_click_file:
+            self.connect('clicked', lambda _: on_click_file())
+
+    def mark(self, css_class: str, do_add: bool = True) -> None:
+        """Add or remove css_class from self."""
+        if do_add:
+            self.add_css_class(css_class)
+        else:
+            self.remove_css_class(css_class)
+
+    def ensure_slot_size(self) -> None:
+        """Call ._size_widget to size .props.child; if none, make empty one."""
+        if self.get_child() is None:
+            self.set_child(Gtk.Label(label='+'))
+        self._size_widget()
+
+    def _size_widget(self) -> None:
+        for s in ('bottom', 'top', 'start', 'end'):
+            setattr(self.get_child().props, f'margin_{s}',
+                    self._geometry.side_margin)
+        self.get_child().set_size_request(self._geometry.size_sans_margins,
+                                          self._geometry.size_sans_margins)
+
+    def update_widget(self, is_in_vp: bool) -> None:
+        """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class."""
+        new_content: Optional[Gtk.Image | Gtk.Label] = None
+        if 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)
+                if self.item.with_others:
+                    new_content.set_vexpand(True)
+                    box = Gtk.Box(orientation=OR_V)
+                    box.append(new_content)
+                    msg = 'and one or more other images of this configuration'
+                    box.append(Gtk.Label(label=msg))
+                    new_content = box
+            elif (not is_in_vp) and not isinstance(self.item, Gtk.Label):
+                new_content = Gtk.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)
+            self._size_widget()
+        if isinstance(self.item, ImgItem):
+            self.mark('bookmarked', self.item.bookmarked)
+
+
+class _VerticalLabel(Gtk.DrawingArea):
+    """Label of vertical text (rotated -90°)."""
+
+    def __init__(self,
+                 text: str,
+                 slots_geometry: GallerySlotsGeometry
+                 ) -> None:
+        super().__init__()
+        self._text = text
+        self._slots_geometry = slots_geometry
+        test_layout = self.create_pango_layout()
+        test_layout.set_markup(text)
+        _, self._text_height = test_layout.get_pixel_size()
+        self.set_draw_func(self._on_draw)
+
+    def _on_draw(self,
+                 _,
+                 cairo_ctx: Pango.Context,
+                 __,
+                 height: int
+                 ) -> None:
+        """Create layout, rotate by 90°, size widget to measurements."""
+        layout = self.create_pango_layout()
+        layout.set_markup(self._text)
+        layout.set_width(self._slots_geometry.size * Pango.SCALE)
+        layout.set_ellipsize(Pango.EllipsizeMode.MIDDLE)
+        text_width, _ = layout.get_pixel_size()
+        cairo_ctx.translate(0, text_width + (height - text_width))
+        cairo_ctx.rotate(radians(-90))
+        PangoCairo.show_layout(cairo_ctx, layout)
+        self.set_size_request(self._text_height, text_width)
+
+    @property
+    def width(self) -> int:
+        """Return (rotated) ._text_height."""
+        return self._text_height
+
+
+class Gallery:
+    """Representation of GalleryItems below a directory."""
+    update_config_box: Callable
+
+    def __init__(self,
+                 sort_order: SorterAndFiltererOrder,
+                 on_hit_item: Callable,
+                 on_selection_change: Callable,
+                 bookmarks_db: BookmarksDb,
+                 cache_db: CacheDb
+                 ) -> None:
+        self._on_hit_item = on_hit_item
+        self._on_selection_change = on_selection_change
+        self._bookmarks_db, self._cache_db = bookmarks_db, cache_db
+        self._sort_order = sort_order
+        self._img_dir_path = ''
+
+        self._shall_load = False
+        self._shall_build_grid = 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._slots_geometry = GallerySlotsGeometry()
+
+        self.dir_entries: list[GalleryItem] = []
+        self._basic_items_attrs: BasicItemsAttrs = {}
+        self.items_attrs: ItemsAttrs = {}
+        self.selected_idx = 0
+        self.slots: list[GallerySlot] = []
+
+        self._grid = Gtk.Grid()
+        self._force_width, self._force_height = 0, 0
+        scroller = Gtk.ScrolledWindow(propagate_natural_height=True)
+        self._col_headers_frame = Gtk.Fixed()
+        self._col_headers_grid = Gtk.Grid()
+        self.frame = Gtk.Box(orientation=OR_V)
+        self.frame.append(self._col_headers_frame)
+        self.frame.append(scroller)
+        # 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
+
+        def ensure_uptodate() -> bool:
+            if not self._img_dir_path:
+                return True
+            if self._shall_load:
+                self._shall_load = False
+                self._load_directory()
+            if self._shall_build_grid:
+                self._shall_build_grid = False
+                self._build_grid()
+            if self._shall_select:
+                self._shall_select = False
+                self._assert_selection()
+            if self._shall_redraw:
+                wait_time_passed = datetime.now() - self._start_redraw_wait
+                if wait_time_passed > redraw_wait_time:
+                    self._shall_redraw = False
+                    self._redraw_and_check_focus()
+            return True
+
+        def handle_scroll(_) -> None:
+            self._start_redraw_wait = datetime.now()
+            self._shall_scroll_to_focus = False
+            self.request_update()  # only request redraw
+
+        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: Optional[int] = None,
+                        by_1st: Optional[bool] = None,
+                        show_dirs: Optional[bool] = None,
+                        recurse_dirs: Optional[bool] = None,
+                        img_dir_path: Optional[str] = None,
+                        ) -> 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')]:
+            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.request_update(load=True)
+                else:
+                    self.request_update(build_grid=True)
+
+    @staticmethod
+    def _diff_prompts(prompts: list[str]) -> dict[str, tuple[str, str]]:
+        if not prompts:
+            return {}
+
+        def find_longest_equal(prompts, j, matcher):
+            longest_total, temp_longest = '', ''
+            while j < len(prompts[0]):
+                if 'end' == matcher:
+                    temp_longest = prompts[0][-j] + temp_longest
+                else:
+                    temp_longest += prompts[0][j]
+                if len(temp_longest) > len(longest_total):
+                    found_in_all = True
+                    for prompt in prompts[1:]:
+                        if ('start' == matcher
+                            and not prompt.startswith(temp_longest)) or\
+                                ('end' == matcher
+                                 and not prompt.endswith(temp_longest)) or\
+                                ('in' == matcher
+                                 and temp_longest not in prompt):
+                            found_in_all = False
+                            break
+                    if not found_in_all:
+                        break
+                    longest_total = temp_longest
+                j += 1
+            return longest_total
+
+        prefix = find_longest_equal(prompts, 0, 'start')
+        suffix = find_longest_equal(prompts, 1, matcher='end')
+        cores = [p[len(prefix):] for p in prompts]
+        if suffix:
+            for i, p in enumerate(cores):
+                cores[i] = p[:-len(suffix)]
+        longest_total = ''
+        for i in range(len(cores[0])):
+            temp_longest = find_longest_equal(cores, j=i, matcher='in')
+            if len(temp_longest) > len(longest_total):
+                longest_total = temp_longest
+        middle = longest_total
+        prompts_diff = {}
+        for i, p in enumerate(prompts):
+            remains = p[len(prefix):] if prefix else p
+            idx_middle = remains.index(middle)
+            first = remains[:idx_middle] if idx_middle else ''
+            remains = remains[idx_middle + len(middle):]
+            second = remains[:-len(suffix)] if suffix else remains
+            if first:
+                first = f'…{first}' if prefix else first
+                first = f'{first}…' if suffix or middle else first
+            if second:
+                second = f'…{second}' if prefix or middle else second
+                second = f'{second}…' if suffix else second
+            prompts_diff[p] = (first if first else '…',
+                               second if second else '…')
+        return prompts_diff
+
+    def _prep_items_attrs(self, entries: list[GalleryItem]) -> BasicItemsAttrs:
+        basic_items_attrs = {}
+        for attr_name in (s.name for s in self._sort_order):
+            vals: set[str] = set()
+            for entry in [e for e in entries if isinstance(e, ImgItem)]:
+                val = (getattr(entry, attr_name)
+                       if hasattr(entry, attr_name) else None)
+                if val is not None:
+                    vals.add(val)
+            basic_items_attrs[attr_name] = vals
+        return basic_items_attrs
+
+    def _load_directory(self) -> None:
+        """(Re-)build .dir_entries from ._img_dir_path, ._basic_items_attrs."""
+        self.dir_entries.clear()
+        bookmarks = self._bookmarks_db.as_copy()
+        cache = self._cache_db.as_ref()
+
+        def read_directory(dir_path: str, make_parent: bool = False) -> None:
+            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: list[str] = []
+            to_set_metadata_on: list[ImgItem] = []
+            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)
+
+        read_directory(self._img_dir_path, make_parent=True)
+        prompts_set: set[str] = set()
+        for entry in [e for e in self.dir_entries
+                      if isinstance(e, ImgItem) and hasattr(e, 'prompt')]:
+            prompts_set.add(entry.prompt)
+        prompts_diff = self._diff_prompts(list(prompts_set))
+        for entry in [e for e in self.dir_entries if isinstance(e, ImgItem)]:
+            entry.subprompt1 = prompts_diff[entry.prompt][0]
+            entry.subprompt2 = prompts_diff[entry.prompt][1]
+        if self._sort_order.by_name('prompt'):
+            self._sort_order.remove('prompt')
+            self._sort_order._list.append(SorterAndFilterer('subprompt1'))
+            self._sort_order._list.append(SorterAndFilterer('subprompt2'))
+        self._basic_items_attrs = self._prep_items_attrs(self.dir_entries)
+        ignorable_attrs = []
+        for attr_name, attr_vals in self._basic_items_attrs.items():
+            if len(attr_vals) < 2:
+                ignorable_attrs += [attr_name]
+        for attr_name in ignorable_attrs:
+            self._sort_order.remove(attr_name)
+            del self._basic_items_attrs[attr_name]
+        self._cache_db.write()
+
+    @property
+    def selected_item(self) -> Optional[GalleryItem]:
+        """Return slot.item for slot at self.selected_idx."""
+        return self.slots[self.selected_idx].item if self.slots else None
+
+    def on_focus_slot(self, slot: GallerySlot) -> None:
+        """If GallerySlot focused, set .selected_idx to it."""
+        self._set_selection(self.slots.index(slot))
+        self.request_update(scroll_to_focus=True)
+
+    def _assert_selection(self) -> None:
+        if self.slots:
+            self.slots[self.selected_idx].mark('selected', True)
+            self.slots[self.selected_idx].grab_focus()
+
+    def _set_selection(self, new_idx: int) -> None:
+        """Set self.selected_idx, mark slot as 'selected', unmark old one."""
+        # in ._build_grid(), 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_grid() 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
+        self._assert_selection()
+        self._on_selection_change()
+
+    def _build_grid(self) -> None:
+        """(Re-)build slot grid from .dir_entries, filters, layout settings."""
+        old_selected_item: Optional[GalleryItem] = self.selected_item
+
+        def update_items_attrs() -> None:
+            self.items_attrs.clear()
+
+            def separate_items_attrs(basic_items_attrs) -> ItemsAttrs:
+                items_attrs: ItemsAttrs = {}
+                for attr_name, vals in basic_items_attrs.items():
+                    sorter = self._sort_order.by_name(attr_name)
+                    items_attrs[attr_name] = {'incl': [], 'excl': []}
+                    for v in vals:
+                        passes_filter = sorter is None
+                        if sorter:
+                            passes_filter = sorter.filter_allows_value(v)
+                        k = 'incl' if passes_filter else 'excl'
+                        items_attrs[attr_name][k] += [v]
+                return items_attrs
+
+            items_attrs_tmp_1 = separate_items_attrs(self._basic_items_attrs)
+            filtered = filter_entries(items_attrs_tmp_1)
+            reduced_basic_items_attrs = self._prep_items_attrs(filtered)
+            items_attrs_tmp_2 = separate_items_attrs(reduced_basic_items_attrs)
+            for attr_name in (s.name for s in self._sort_order):
+                final_values: AttrValsByVisibility = {'incl': [], 'semi': []}
+                final_values['excl'] = items_attrs_tmp_1[attr_name]['excl']
+                for v in items_attrs_tmp_1[attr_name]['incl']:
+                    k = ('incl' if v in items_attrs_tmp_2[attr_name]['incl']
+                         else 'semi')
+                    final_values[k] += [v]
+                for category in ('incl', 'semi', 'excl'):
+                    final_values[category].sort()
+                self.items_attrs[attr_name] = final_values
+
+        def filter_entries(items_attrs: ItemsAttrs) -> list[GalleryItem]:
+            entries_filtered: list[GalleryItem] = []
+            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 not in items_attrs[attr_name]['incl']:
+                            passes_filters = False
+                            break
+                if passes_filters:
+                    entries_filtered += [entry]
+            return entries_filtered
+
+        def build(entries_filtered: list[GalleryItem]) -> None:
+            i_row_ref, i_slot_ref = [0], [0]
+            if self._grid.get_parent():
+                self._fixed_frame.remove(self._grid)
+            self._grid = Gtk.Grid()
+            if self._col_headers_grid.get_parent():
+                self._col_headers_frame.remove(self._col_headers_grid)
+                self._col_headers_grid = Gtk.Grid()
+            self.slots.clear()
+            self._fixed_frame.put(self._grid, 0, 0)
+
+            def build_rows_by_attrs(
+                    remaining: list[tuple[str, AttrVals]],
+                    items_of_parent: list[GalleryItem],
+                    ancestors: list[tuple[str, str]]
+                    ) -> None:
+                if not items_of_parent:
+                    return
+                attr_name, attr_values = remaining[0]
+                if 1 == len(remaining):
+                    for i, attr in enumerate(ancestors):
+                        txt = f'<b>{attr[0]}</b>: {attr[1]}'
+                        vlabel = _VerticalLabel(txt, self._slots_geometry)
+                        self._grid.attach(vlabel, i, i_row_ref[0], 1, 1)
+                    row: list[Optional[GalleryItem]]
+                    row = [None] * len(attr_values)
+                    for gallery_item in items_of_parent:
+                        val = getattr(gallery_item, attr_name)
+                        idx_val_in_attr_values = attr_values.index(val)
+                        if row[idx_val_in_attr_values]:
+                            gallery_item.with_others = True
+                        row[idx_val_in_attr_values] = gallery_item
+                    for i_col, item in enumerate(row):
+                        slot = GallerySlot(  # build empty dummy if necessary
+                                item if item else GalleryItem('', ''),
+                                self._slots_geometry)
+                        self.slots += [slot]
+                        i_slot_ref[0] += 1
+                        self._grid.attach(slot, i_col + len(ancestors),
+                                          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
+                                           if attr_value == getattr(x,
+                                                                    attr_name)]
+                    build_rows_by_attrs(remaining[1:], items_of_attr_value,
+                                        ancestors + [(attr_name, attr_value)])
+
+            if self._by_1st:
+                self._show_dirs = False
+                sort_attrs: list[tuple[str, AttrVals]] = []
+                for sorter in reversed(self._sort_order):
+                    vals: AttrVals = self.items_attrs[sorter.name]['incl']
+                    if len(vals) > 1:
+                        sort_attrs += [(sorter.name, vals)]
+                if not sort_attrs:
+                    s_name: str = self._sort_order[0].name
+                    sort_attrs += [(s_name, self.items_attrs[s_name]['incl'])]
+                self._per_row = len(sort_attrs[-1][1])
+                build_rows_by_attrs(sort_attrs, entries_filtered, [])
+                self._col_headers_frame.put(self._col_headers_grid, 0, 0)
+                self._col_headers_grid.attach(Gtk.Box(), 0, 0, 1, 1)
+                top_attr_name: str = sort_attrs[-1][0]
+                for i, val in enumerate(sort_attrs[-1][1]):
+                    label = Gtk.Label(label=f'<b>{top_attr_name}</b>: {val}',
+                                      xalign=0,
+                                      ellipsize=Pango.EllipsizeMode.MIDDLE)
+                    label.set_use_markup(True)
+                    self._col_headers_grid.attach(label, i + 1, 0, 1, 1)
+
+            else:
+                dir_entries_filtered_sorted: list[GalleryItem] = 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, self._slots_geometry,
+                                       self._on_hit_item)
+                    self._grid.attach(slot, i_col, i_row, 1, 1)
+                    self.slots += [slot]
+                    i_col += 1
+            self.update_config_box()
+
+        update_items_attrs()
+        entries_filtered = filter_entries(self.items_attrs)
+        build(entries_filtered)
+        new_idx = 0
+        if old_selected_item is not None:
+            for i, slot in enumerate(self.slots):
+                if hash(old_selected_item) == hash(slot.item):
+                    new_idx = i
+                    break
+        self._set_selection(new_idx)
+
+    def request_update(self,
+                       select: bool = False,
+                       scroll_to_focus: bool = False,
+                       build_grid: bool = False,
+                       load: bool = False
+                       ) -> None:
+        """Set ._shall_… to trigger updates on next relevant interval."""
+        self._shall_redraw = True
+        self._shall_select |= select or scroll_to_focus or build_grid or load
+        self._shall_scroll_to_focus |= scroll_to_focus or build_grid or load
+        self._shall_build_grid |= build_grid or load
+        self._shall_load |= load
+
+    def move_selection(self,
+                       x_inc: Optional[int],
+                       y_inc: Optional[int],
+                       buf_end: Optional[int]
+                       ) -> None:
+        """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: int = 0, height: int = 0) -> None:
+        """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 _redraw_and_check_focus(self) -> None:
+        """Draw gallery; possibly notice and first follow need to re-focus."""
+        vp_width: int = (self._force_width if self._force_width
+                         else self._viewport.get_width())
+        vp_height: int = (self._force_height if self._force_height
+                          else self._viewport.get_height())
+        self._force_width, self._force_height = 0, 0
+        vp_scroll: Gtk.Adjustment = self._viewport.get_vadjustment()
+        vp_top: int = vp_scroll.get_value()
+        vp_bottom: int = vp_top + vp_height
+        side_offset, i_vlabels = 0, 0
+        if self._by_1st:
+            while True:
+                gal_widget: _VerticalLabel | GalleryItem
+                gal_widget = self._grid.get_child_at(i_vlabels, 0)
+                if isinstance(gal_widget, _VerticalLabel):
+                    side_offset += gal_widget.width
+                else:
+                    break
+                i_vlabels += 1
+        max_slot_width: int = (vp_width - side_offset) // self._per_row
+        self._slots_geometry.set_size(min(vp_height, max_slot_width))
+        if self._by_1st:
+            i_widgets = 0
+            while True:
+                head_widget: Gtk.Box | Gtk.Label | None
+                head_widget = self._col_headers_grid.get_child_at(i_widgets, 0)
+                if 0 == i_widgets:
+                    head_widget.set_size_request(side_offset, -1)
+                elif isinstance(head_widget, Gtk.Label):
+                    head_widget.set_size_request(self._slots_geometry.size, -1)
+                else:
+                    break
+                i_widgets += 1
+        for idx, slot in enumerate(self.slots):
+            slot.ensure_slot_size()
+        vp_scroll.set_upper(self._slots_geometry.size * ceil(len(self.slots)
+                                                             / self._per_row))
+        if self._scroll_to_focus(vp_scroll, vp_top, vp_bottom):
+            return
+        for idx, slot in enumerate(self.slots):
+            in_vp, _, _ = self._position_to_viewport(idx,
+                                                     vp_top, vp_bottom, True)
+            slot.update_widget(in_vp)
+        self._start_redraw_wait = datetime.now()
+
+    def _position_to_viewport(self,
+                              idx: int,
+                              vp_top: int,
+                              vp_bottom: int,
+                              in_vp_greedy: bool = False
+                              ) -> tuple[bool, int, int]:
+        slot_top: int = (idx // self._per_row) * self._slots_geometry.size
+        slot_bottom: int = slot_top + self._slots_geometry.size
+        if in_vp_greedy:
+            in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom)
+        else:
+            in_vp = (slot_top >= vp_top and slot_bottom <= vp_bottom)
+        return in_vp, slot_top, slot_bottom
+
+    def _scroll_to_focus(self,
+                         vp_scroll: Gtk.Scrollable,
+                         vp_top: int,
+                         vp_bottom: int
+                         ) -> bool:
+        scroll_to_focus: bool = 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, 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
+                                        - self._slots_geometry.size)
+                return True
+        return False
+
+    def _sort_cmp(self, a: GalleryItem, b: GalleryItem) -> int:
+        """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 ImgItems (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
diff --git a/browser/gallery_config.py b/browser/gallery_config.py
new file mode 100644 (file)
index 0000000..20f25ee
--- /dev/null
@@ -0,0 +1,376 @@
+"""Gallery configuration, sorting, filtering widgets/logic."""
+
+from re import search as re_search
+from typing import Callable, Optional, Self
+import gi  # type: ignore
+
+from browser.config_constants import GALLERY_PER_ROW_DEFAULT
+from browser.gtk_helpers import add_button, OR_H, OR_V
+from browser.types import ItemsAttrs, AttrValsByVisibility
+from stable.gen_params import (
+        GEN_PARAMS, GEN_PARAMS_FLOAT, GEN_PARAMS_INT)
+
+# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gio', '2.0')
+# pylint: disable=wrong-import-order,wrong-import-position
+from gi.repository import Gio, GObject, Gtk, Pango  # type: ignore # noqa: E402
+
+
+class SorterAndFilterer(GObject.GObject):
+    """Sort order box representation of sorting/filtering attribute."""
+    widget: Gtk.Box
+
+    def __init__(self, name: str) -> None:
+        super().__init__()
+        self.name = name
+        self.filter_text = ''
+
+    def setup_on_bind(self,
+                      widget: Gtk.Box,
+                      on_filter_activate: Callable,
+                      vals: AttrValsByVisibility,
+                      ) -> None:
+        """Set up SorterAndFilterer label, values listing, filter entry."""
+        self.widget = widget
+        # label
+        len_incl = len(vals['incl'])
+        len_semi_total: int = len_incl + len(vals['semi'])
+        len_total: int = len_semi_total + len(vals['excl'])
+        title = f'{self.name} ({len_incl}/{len_semi_total}/{len_total}) '
+        self.widget.label.set_text(title)
+        # values listing
+        vals_listed: list[str] = [f'<b>{v}</b>' for v in vals['incl']]
+        vals_listed += [f'<s>{v}</s>' for v in vals['semi']]
+        vals_listed += [f'<b><s>{v}</s></b>' for v in vals['excl']]
+        self.widget.values.set_text(', '.join(vals_listed))
+        self.widget.values.set_use_markup(True)
+        # filter input
+
+        def filter_activate() -> None:
+            self.widget.filter_input.remove_css_class('temp')
+            self.filter_text = self.widget.filter_input.get_buffer().get_text()
+            on_filter_activate()
+
+        filter_buffer = self.widget.filter_input.get_buffer()
+        filter_buffer.set_text(self.filter_text, -1)  # triggers 'temp' class
+        self.widget.filter_input.remove_css_class('temp')  # set, that's why …
+        self.widget.filter_input.connect('activate',
+                                         lambda _: filter_activate())
+        filter_buffer.connect(
+            'inserted_text',
+            lambda a, b, c, d: self.widget.filter_input.add_css_class('temp'))
+        filter_buffer.connect(
+            'deleted_text',
+            lambda a, b, c: self.widget.filter_input.add_css_class('temp'))
+
+    def filter_allows_value(self, value: str | int | float) -> bool:
+        """Return if value passes filter defined by .name and .filter_text."""
+        number_attributes = (set(s.lower() for s in GEN_PARAMS_INT) |
+                             set(s.lower() for s in GEN_PARAMS_FLOAT) |
+                             {'bookmarked'})
+        if value is None:
+            return False
+        if self.name not in number_attributes:
+            assert isinstance(value, str)
+            return bool(re_search(self.filter_text, value))
+        assert isinstance(value, (int, float))
+        use_float = self.name in {s.lower() for s in GEN_PARAMS_FLOAT}
+        numbers_or, unequal = (set(),) * 2
+        less_than, less_or_equal, more_or_equal, more_than = (None,) * 4
+        for constraint_string in self.filter_text.split(','):
+            toks = constraint_string.split()
+            if len(toks) == 1:
+                tok = toks[0]
+                if tok[0] in '<>!':  # operator sans space after: split, re-try
+                    if '=' == tok[1]:
+                        toks = [tok[:2], tok[2:]]
+                    else:
+                        toks = [tok[:1], tok[1:]]
+                else:
+                    pattern_number = float(tok) if use_float else int(tok)
+                    numbers_or.add(pattern_number)
+            if len(toks) == 2:  # assume operator followed by number
+                pattern_number = float(toks[1]) if use_float else int(toks[1])
+                if toks[0] == '!=':
+                    unequal.add(pattern_number)
+                elif toks[0] == '<':
+                    if less_than is None or less_than >= pattern_number:
+                        less_than = pattern_number
+                elif toks[0] == '<=':
+                    if less_or_equal is None or less_or_equal > pattern_number:
+                        less_or_equal = pattern_number
+                elif toks[0] == '>=':
+                    if more_or_equal is None or more_or_equal < pattern_number:
+                        more_or_equal = pattern_number
+                elif toks[0] == '>':
+                    if more_than is None or more_than <= pattern_number:
+                        more_than = pattern_number
+        if value in numbers_or:
+            return True
+        if len(numbers_or) > 0 and (less_than == less_or_equal ==
+                                    more_or_equal == more_than):
+            return False
+        if value in unequal:
+            return False
+        return ((less_than is None or value < less_than)
+                and (less_or_equal is None or value <= less_or_equal)
+                and (more_or_equal is None or value >= more_or_equal)
+                and (more_than is None or value > more_than))
+
+
+class SorterAndFiltererOrder:
+    """Represents sorted list of SorterAndFilterer items."""
+
+    def __init__(self, as_list: list[SorterAndFilterer]) -> None:
+        self._list = as_list
+
+    def __eq__(self, other) -> bool:
+        return self._list == other._list
+
+    def __len__(self) -> int:
+        return len(self._list)
+
+    def __getitem__(self, idx: int) -> SorterAndFilterer:
+        return self._list[idx]
+
+    def __iter__(self):
+        return self._list.__iter__()
+
+    @staticmethod
+    def _list_from_store(store: Gio.ListStore) -> list[SorterAndFilterer]:
+        order = []
+        for i in range(store.get_n_items()):
+            order += [store.get_item(i)]
+        return order
+
+    @classmethod
+    def from_suggestion(cls, suggestion: list[str]) -> Self:
+        """Create new, interpreting order of strings in suggestion."""
+        names: list[str] = [p.lower() for p in GEN_PARAMS] + ['bookmarked']
+        order: list[SorterAndFilterer] = []
+        for name in names:
+            order += [SorterAndFilterer(name)]
+        new_order: list[SorterAndFilterer] = []
+        do_reverse: bool = '-' in suggestion
+        for pattern in suggestion:
+            for sorter in [sorter for sorter in order
+                           if sorter.name.startswith(pattern)]:
+                order.remove(sorter)
+                new_order += [sorter]
+        order = new_order + order
+        if do_reverse:
+            order.reverse()
+        return cls(order)
+
+    @classmethod
+    def from_store(cls, store: Gio.ListStore) -> Self:
+        """Create new, mirroring order in store."""
+        return cls(cls._list_from_store(store))
+
+    def by_name(self, name: str) -> Optional[SorterAndFilterer]:
+        """Return included SorterAndFilterer of name."""
+        for s in [s for s in self._list if name == s.name]:
+            return s
+        return None
+
+    def copy(self) -> Self:
+        """Create new, of equal order."""
+        return self.__class__(self._list[:])
+
+    def sync_from(self, other_order: Self) -> None:
+        """Sync internal state from other order."""
+        self._list = other_order._list
+
+    def remove(self, sorter_name: str) -> None:
+        """Remove sorter of sorter_name from self."""
+        candidate = self.by_name(sorter_name)
+        assert candidate is not None
+        self._list.remove(candidate)
+
+    def update_from_store(self, store: Gio.ListStore) -> None:
+        """Update self from store."""
+        self._list = self._list_from_store(store)
+
+    def into_store(self, store: Gio.ListStore) -> None:
+        """Update store to represent self."""
+        store.remove_all()
+        for sorter in self:
+            store.append(sorter)
+
+    def switch_at(self, selected_idx: int, forward: bool) -> None:
+        """Switch elements at selected_idx and its neighbor."""
+        selected: SorterAndFilterer = self[selected_idx]
+        other_idx: int = selected_idx + (1 if forward else -1)
+        other: SorterAndFilterer = self[other_idx]
+        self._list[other_idx] = selected
+        self._list[selected_idx] = other
+
+
+class GalleryConfig():
+    """Representation of sort and filtering settings."""
+    _sort_sel = Gtk.SingleSelection
+    _set_recurse_changed: bool
+    _btn_apply: Gtk.Button
+    _btn_by_1st: Gtk.CheckButton
+    _btn_recurse: Gtk.CheckButton
+    _btn_per_row: Gtk.CheckButton
+    _btn_show_dirs: Gtk.CheckButton
+    _store: Gio.ListStore
+
+    def __init__(self,
+                 box: Gtk.Box,
+                 sort_order: SorterAndFiltererOrder,
+                 request_update: Callable,
+                 update_settings: Callable,
+                 items_attrs: ItemsAttrs,
+                 ) -> None:
+        self.order = sort_order
+        self._tmp_order: Optional[SorterAndFiltererOrder] = None
+        self._gallery_request_update = request_update
+        self._gallery_update_settings = update_settings
+        self._gallery_items_attrs = items_attrs
+
+        def setup_sorter_list_item(_, list_item: SorterAndFilterer) -> None:
+            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_input = Gtk.Entry(placeholder_text='filter?')
+            hbox = Gtk.Box(orientation=OR_H)
+            hbox.append(item_widget.label)
+            hbox.append(item_widget.filter_input)
+            item_widget.append(hbox)
+            item_widget.append(item_widget.values)
+            list_item.set_child(item_widget)
+
+        def bind_sorter_list_item(_, list_item: SorterAndFilterer) -> None:
+            def on_filter_activate():
+                self._filter_changed = True
+            sorter: SorterAndFilterer = list_item.props.item
+            sorter.setup_on_bind(list_item.props.child,
+                                 on_filter_activate,
+                                 self._gallery_items_attrs[sorter.name])
+
+        def select_sort_order(_a, _b, _c) -> None:
+            self._sort_sel.props.selected_item.widget.get_parent().grab_focus()
+
+        def toggle_recurse(_) -> None:
+            self._set_recurse_changed = not self._set_recurse_changed
+            self._btn_apply.set_sensitive(not self._set_recurse_changed)
+
+        def toggle_by_1st(btn: Gtk.CheckButton) -> None:
+            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)
+
+        def apply_config() -> None:
+            if self._tmp_order:
+                self.order.sync_from(self._tmp_order)
+                self._tmp_order = None
+                self._gallery_request_update(build_grid=True)
+            if self._filter_changed:
+                self._gallery_request_update(build_grid=True)
+            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(),
+                    recurse_dirs=self._btn_recurse.get_active())
+            self._gallery_request_update(select=True)
+            self._set_recurse_changed = False
+            self._filter_changed = False
+
+        def full_reload() -> None:
+            apply_config()
+            self._gallery_request_update(load=True)
+            self._btn_apply.set_sensitive(True)
+
+        self._filter_changed = False
+        self._set_recurse_changed = False
+        self._last_selected: Optional[Gtk.Widget] = None
+
+        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_reload = add_button(buttons_box, 'full reload',
+                                      lambda _: full_reload())
+
+        buttons_box = Gtk.Box(orientation=OR_H)
+        self._btn_apply = add_button(buttons_box, 'apply config',
+                                     lambda _: apply_config())
+        self._btn_reload = 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',
+                                       toggle_recurse, checkbox=True)
+
+        per_row_box = Gtk.Box(orientation=OR_H)
+        per_row_box.append(Gtk.Label(label='cols/row:'))
+        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)
+
+        box.append(self.sorter_listing)
+        box.append(dirs_box)
+        box.append(per_row_box)
+        box.append(buttons_box)
+
+    def on_focus_sorter(self, focused: SorterAndFilterer) -> None:
+        """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: int) -> None:
+        """Move sort order selection by direction (-1 or +1)."""
+        min_idx, max_idx = 0, len(self.order) - 1
+        cur_idx = self._sort_sel.props.selected
+        if (1 == direction and cur_idx < max_idx)\
+                or (-1 == direction and cur_idx > min_idx):
+            self._sort_sel.props.selected = cur_idx + direction
+
+    def move_sorter(self, direction: int) -> None:
+        """Move selected item in sort order view, ensure temporary state."""
+        tmp_order = self._tmp_order if self._tmp_order else self.order.copy()
+        cur_idx = self._sort_sel.props.selected
+        if direction == -1 and cur_idx > 0:
+            tmp_order.switch_at(cur_idx, forward=False)
+        elif direction == 1 and cur_idx < (len(tmp_order) - 1):
+            tmp_order.switch_at(cur_idx, forward=True)
+        else:  # to catch movement beyond limits
+            return
+        if not self._tmp_order:
+            self._tmp_order = tmp_order
+            for sorter in self._tmp_order:
+                sorter.widget.add_css_class('temp')
+        self.update_box(cur_idx + direction)
+        self._sort_sel.props.selected = cur_idx + direction
+        for i in range(self._store.get_n_items()):
+            sort_item: SorterAndFilterer = self._store.get_item(i)
+            sort_item.widget.add_css_class('temp')
+
+    def update_box(self, cur_selection: int = 0) -> None:
+        """Rebuild sorter listing in box from .order, or alt_order if set."""
+        sort_order = self._tmp_order if self._tmp_order else self.order
+        sort_order.into_store(self._store)
+        self._sort_sel.props.selected = cur_selection
diff --git a/browser/gtk_app.py b/browser/gtk_app.py
new file mode 100644 (file)
index 0000000..9f24249
--- /dev/null
@@ -0,0 +1,232 @@
+"""Main Gtk code."""
+
+from typing import Optional
+import gi  # type: ignore
+
+from browser.gallery import (
+        DirItem, Gallery, GalleryItem, GallerySlot, ImgItem)
+from browser.gallery_config import GalleryConfig, SorterAndFiltererOrder
+from browser.json_dbs import BookmarksDb, CacheDb
+from browser.gtk_helpers import add_button, OR_H, OR_V
+from stable.gen_params import GEN_PARAMS
+
+# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+# pylint: disable=wrong-import-order,wrong-import-position
+from gi.repository import Gdk, GLib, Gtk  # type: ignore  # noqa: E402
+
+
+CSS: str = '''
+.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;
+}
+'''
+
+
+class GtkApp(Gtk.Application):
+    """Image browser application class."""
+
+    def __init__(self,
+                 bookmarks_file: str,
+                 cache_file: str,
+                 start_dir: str,
+                 sort_order_suggestion: list[str],
+                 *args, **kwargs
+                 ) -> None:
+        super().__init__(*args, **kwargs)
+        self.img_dir_absolute = start_dir
+        self.bookmarks_db = BookmarksDb(bookmarks_file)
+        self.cache_db = CacheDb(cache_file)
+        self.sort_order = SorterAndFiltererOrder.from_suggestion(
+                sort_order_suggestion)
+
+    def do_activate(self, *args, **kwargs) -> None:
+        """Parse arguments, start window, keep it open."""
+        win = MainWindow(self)
+        win.present()
+        self.hold()
+
+
+class MainWindow(Gtk.Window):
+    """Image browser app top-level window."""
+
+    def __init__(self, app: GtkApp, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self.app = app
+        self.gallery = Gallery(
+                sort_order=self.app.sort_order,
+                on_hit_item=self.hit_gallery_item,
+                on_selection_change=self.update_metadata_on_gallery_selection,
+                bookmarks_db=self.app.bookmarks_db,
+                cache_db=self.app.cache_db)
+        config_box = Gtk.Box(orientation=OR_V)
+        self.conf = GalleryConfig(
+                sort_order=self.app.sort_order,
+                box=config_box,
+                request_update=self.gallery.request_update,
+                update_settings=self.gallery.update_settings,
+                items_attrs=self.gallery.items_attrs)
+        self.gallery.update_config_box = self.conf.update_box
+        metadata_textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR,
+                                         editable=False)
+        self.metadata = metadata_textview.get_buffer()
+        self.idx_display = Gtk.Label()
+
+        # layout: outer box, CSS, sizings
+        box_outer = Gtk.Box(orientation=OR_H)
+        self.set_child(box_outer)
+        css_provider = Gtk.CssProvider()
+        css_provider.load_from_data(CSS)
+        Gtk.StyleContext.add_provider_for_display(
+                self.get_display(), css_provider,
+                Gtk.STYLE_PROVIDER_PRIORITY_USER)
+        metadata_textview.set_size_request(300, -1)
+        self.connect('notify::default-width', lambda _, __: self.on_resize())
+        self.connect('notify::default-height', lambda _, __: self.on_resize())
+
+        # layout: sidebar
+        self.side_box = Gtk.Notebook.new()
+        self.side_box.append_page(metadata_textview,
+                                  Gtk.Label(label='metadata'))
+        self.side_box.append_page(config_box, Gtk.Label(label='config'))
+        box_outer.append(self.side_box)
+
+        # layout: gallery viewer
+        viewer = Gtk.Box(orientation=OR_V)
+        self.navbar = Gtk.Box(orientation=OR_H)
+        add_button(self.navbar, 'sidebar', lambda _: self.toggle_side_box())
+        self.navbar.append(self.idx_display)
+        viewer.append(self.navbar)
+        viewer.append(self.gallery.frame)
+        box_outer.append(viewer)
+
+        # init key and focus control
+        key_ctl = Gtk.EventControllerKey(
+                propagation_phase=Gtk.PropagationPhase.CAPTURE)
+        key_ctl.connect('key-pressed',
+                        lambda _, kval, _0, _1: self.handle_keypress(kval))
+        self.add_controller(key_ctl)
+        self.prev_key_ref = [0]
+        self.connect('notify::focus-widget',
+                     lambda _, __: self.on_focus_change())
+
+        # only now we're ready for actually running the gallery
+        GLib.idle_add(lambda: self.gallery.update_settings(
+            img_dir_path=self.app.img_dir_absolute))
+
+    def on_focus_change(self) -> None:
+        """Handle reactions on focus changes in .gallery and .conf."""
+        focused: Optional[Gtk.Widget] = self.get_focus()
+        if not focused:
+            return
+        if isinstance(focused, GallerySlot):
+            self.gallery.on_focus_slot(focused)
+        elif focused.get_parent() == self.conf.sorter_listing:
+            self.conf.on_focus_sorter(focused)
+
+    def on_resize(self) -> None:
+        """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: int = self.side_box.measure(OR_H, -1).natural
+            default_size: tuple[int, int] = self.get_default_size()
+            self.gallery.on_resize(default_size[0] - side_box_width,
+                                   default_size[1] - self.navbar.get_height())
+
+    def bookmark(self) -> None:
+        """Toggle bookmark on selected gallery item."""
+        if not isinstance(self.gallery.selected_item, ImgItem):
+            return
+        bookmarks = self.app.bookmarks_db.as_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]
+        self.app.bookmarks_db.write()
+        self.conf.update_box()
+
+    def hit_gallery_item(self) -> None:
+        """If current file selection is directory, reload into that one."""
+        selected: Optional[GalleryItem] = self.gallery.selected_item
+        if isinstance(selected, DirItem):
+            self.gallery.update_settings(img_dir_path=selected.full_path)
+
+    def toggle_side_box(self) -> None:
+        """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: int = self.side_box.measure(OR_H, -1).natural
+        self.gallery.on_resize(self.get_width() - side_box_width)
+
+    def update_metadata_on_gallery_selection(self) -> None:
+        """Update .metadata about individual file, and .idx_display."""
+        self.metadata.set_text('')
+        selected_item: Optional[GalleryItem] = self.gallery.selected_item
+        display_name = '(none)'
+        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))
+                display_name = selected_item.full_path
+            elif isinstance(selected_item, DirItem):
+                display_name = selected_item.full_path
+        total = len([s for s in self.gallery.slots
+                     if isinstance(s.item, (DirItem, ImgItem))])
+        n_selected: int = self.gallery.selected_idx + 1
+        txt = f' {n_selected} of {total} – <b>{display_name}</b>'
+        self.idx_display.set_text(txt)
+        self.idx_display.set_use_markup(True)
+
+    def handle_keypress(self, keyval: int) -> bool:
+        """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 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:
+            self.gallery.move_selection(-1, None, None)
+        elif Gdk.KEY_j == keyval:
+            self.gallery.move_selection(None, +1, None)
+        elif Gdk.KEY_k == keyval:
+            self.gallery.move_selection(None, -1, None)
+        elif Gdk.KEY_l == keyval:
+            self.gallery.move_selection(+1, None, None)
+        elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key_ref[0]:
+            self.gallery.move_selection(None, None, -1)
+        elif Gdk.KEY_w == keyval:
+            self.conf.move_selection(-1)
+        elif Gdk.KEY_W == keyval:
+            self.conf.move_sorter(-1)
+        elif Gdk.KEY_s == keyval:
+            self.conf.move_selection(1)
+        elif Gdk.KEY_S == keyval:
+            self.conf.move_sorter(1)
+        elif Gdk.KEY_b == keyval:
+            self.bookmark()
+        else:
+            self.prev_key_ref[0] = keyval
+            return False
+        return True
diff --git a/browser/gtk_helpers.py b/browser/gtk_helpers.py
new file mode 100644 (file)
index 0000000..030cbda
--- /dev/null
@@ -0,0 +1,27 @@
+"""Helpers for working with GTK."""
+
+from typing import Callable, Optional
+import gi  # type: ignore
+
+# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes
+gi.require_version('Gtk', '4.0')
+# pylint: disable=wrong-import-order,wrong-import-position
+from gi.repository import Gtk  # type: ignore # noqa: E402
+
+
+OR_H: int = Gtk.Orientation.HORIZONTAL
+OR_V: int = Gtk.Orientation.VERTICAL
+
+
+def add_button(parent: Gtk.Widget,
+               label: str,
+               on_click: Optional[Callable] = None,
+               checkbox: bool = False
+               ) -> Gtk.Button | Gtk.CheckButton:
+    """Helper to add Gtk.Button or .CheckButton to parent."""
+    btn = (Gtk.CheckButton(label=label) if checkbox
+           else Gtk.Button(label=label))
+    if on_click:
+        btn.connect('toggled' if checkbox else 'clicked', on_click)
+    parent.append(btn)
+    return btn
diff --git a/browser/json_dbs.py b/browser/json_dbs.py
new file mode 100644 (file)
index 0000000..084f63a
--- /dev/null
@@ -0,0 +1,69 @@
+"""Classes for our DB files."""
+
+from json import dump as json_dump, load as json_load
+from os.path import exists as path_exists
+from typing import TypeAlias
+
+from browser.types import Cache
+
+
+Bookmarks: TypeAlias = list[str]
+Db: TypeAlias = Cache | Bookmarks
+
+
+class _JsonDb:
+    """Representation of our simple .json DB files."""
+    _content: Db
+
+    def __init__(self, path: str) -> None:
+        self._path = path
+        self._is_open = False
+        if not path_exists(path):
+            with open(path, 'w', encoding='utf8') as f:
+                json_dump({}, f)
+
+    def _open(self) -> None:
+        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) -> None:
+        self._is_open = False
+        self._content = {}
+
+    def write(self) -> None:
+        """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()
+
+
+class BookmarksDb(_JsonDb):
+    """Representation of Bookmarks DB files."""
+    _content: Bookmarks
+
+    def as_ref(self) -> Bookmarks:
+        """Return content at ._path as ref so that .write() stores changes."""
+        self._open()
+        return self._content
+
+    def as_copy(self) -> Bookmarks:
+        """Return content at ._path for read-only purposes."""
+        self._open()
+        copy = self._content.copy()
+        self._close()
+        return copy
+
+
+class CacheDb(_JsonDb):
+    """Representation of Cache DB files."""
+    _content: Cache
+
+    def as_ref(self) -> Cache:
+        """Return content at ._path as ref so that .write() stores changes."""
+        self._open()
+        return self._content
diff --git a/browser/types.py b/browser/types.py
new file mode 100644 (file)
index 0000000..bbac2f6
--- /dev/null
@@ -0,0 +1,9 @@
+"""Types used among multiple modules."""
+
+from typing import TypeAlias
+
+AttrVals: TypeAlias = list[str]
+AttrValsByVisibility: TypeAlias = dict[str, AttrVals]
+CachedImg: TypeAlias = dict[str, str | float | int]
+Cache: TypeAlias = dict[str, dict[str, CachedImg]]
+ItemsAttrs: TypeAlias = dict[str, AttrValsByVisibility]