From 7a08da2c3b81db57b656f93ac4c2aeddfc299475 Mon Sep 17 00:00:00 2001
From: Christian Heller <c.heller@plomlompom.de>
Date: Thu, 31 Oct 2024 07:14:05 +0100
Subject: [PATCH] Browser.py: Typify.

---
 browser.py | 716 ++++++++++++++++++++++++++++++-----------------------
 1 file changed, 400 insertions(+), 316 deletions(-)

diff --git a/browser.py b/browser.py
index 57dc8ab..95504a5 100755
--- a/browser.py
+++ b/browser.py
@@ -1,6 +1,7 @@
 #!/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
 from functools import cmp_to_key
 from re import search as re_search
 from os import listdir
@@ -24,22 +25,31 @@ from stable.gen_params import (GenParams,  GEN_PARAMS_FLOAT,  # noqa: E402
                                GEN_PARAMS_INT, GEN_PARAMS_STR,  # noqa: E402
                                GEN_PARAMS)  # noqa: E402
 
-IMG_DIR_DEFAULT = '.'
-SORT_DEFAULT = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\
+AttrVals: TypeAlias = list[str]
+AttrValsByVisibility: TypeAlias = dict[str, AttrVals]
+ItemsAttrs: TypeAlias = dict[str, AttrValsByVisibility]
+Cache: TypeAlias = dict[str, dict[str, dict[str, str | float | int]]]
+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 = '..'
-CACHE_PATH = 'cache.json'
-BOOKMARKS_PATH = 'bookmarks.json'
-GALLERY_SLOT_MARGIN = 6
-GALLERY_PER_ROW_DEFAULT = 5
-GALLERY_UPDATE_INTERVAL_MS = 50
-GALLERY_REDRAW_WAIT_MS = 200
-ACCEPTED_IMG_FILE_ENDINGS = {'.png', '.PNG'}
-
-OR_H = Gtk.Orientation.HORIZONTAL
-OR_V = Gtk.Orientation.VERTICAL
-
-CSS = """
+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; }
@@ -54,10 +64,14 @@ button.slot {
   border-left-width: 0;
   border-right-width: 0;
 }
-"""
+'''
 
 
-def _add_button(parent, label, on_click=None, checkbox=False):
+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))
@@ -67,29 +81,29 @@ def _add_button(parent, label, on_click=None, checkbox=False):
     return btn
 
 
-class JsonDB:
+class JsonDb:
     """Representation of our simple .json DB files."""
 
-    def __init__(self, path):
+    def __init__(self, path: str) -> None:
         self._path = path
-        self._content = {}
+        self._content: Db = {}
         self._is_open = False
         if not path_exists(path):
             with open(path, 'w', encoding='utf8') as f:
                 json_dump({}, f)
 
-    def _open(self):
+    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):
+    def _close(self) -> None:
         self._is_open = False
         self._content = {}
 
-    def write(self):
+    def write(self) -> None:
         """Write to ._path what's in ._content."""
         if not self._is_open:
             raise Exception('DB not open')
@@ -97,29 +111,69 @@ class JsonDB:
             json_dump(self._content, f)
         self._close()
 
-    def as_dict_copy(self):
+    def as_copy(self) -> Db:
         """Return content at ._path for read-only purposes."""
         self._open()
         dict_copy = self._content.copy()
         self._close()
         return dict_copy
 
-    def as_dict_ref(self):
+    def as_ref(self) -> Db:
         """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 = JsonDb(BOOKMARKS_PATH)
+        self.cache_db = JsonDb(CACHE_PATH)
+        sort_suggestion = opts.sort_order.split(',')
+        names = [p.lower() for p in GEN_PARAMS] + ['bookmarked']
+        self.sort_order = []
+        for name in names:
+            self.sort_order += [SorterAndFilterer(name)]
+        new_sort_order = []
+        do_reverse = '-' in sort_suggestion
+        for pattern in sort_suggestion:
+            for sorter in [sorter for sorter in self.sort_order
+                           if sorter.name.startswith(pattern)]:
+                self.sort_order.remove(sorter)
+                new_sort_order += [sorter]
+        self.sort_order = new_sort_order + self.sort_order
+        if do_reverse:
+            self.sort_order.reverse()
+
+    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
     label: Gtk.Label
 
-    def __init__(self, name):
+    def __init__(self, name: str) -> None:
         super().__init__()
         self.name = name
 
-    def setup_on_bind(self, widget, on_filter_activate, filter_text, vals):
+    def setup_on_bind(self,
+                      widget: Gtk.Widget,
+                      on_filter_activate: Callable,
+                      filter_text: str,
+                      vals: dict[str, str],
+                      ) -> None:
         """Set up SorterAndFilterer label, values listing, filter entry."""
         self.widget = widget
         # label
@@ -146,15 +200,180 @@ class SorterAndFilterer(GObject.GObject):
                 lambda a, b, c: self.widget.filter.add_css_class('temp'))
 
 
+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 = path_join(path, self.name)
+        self.slot: GallerySlot
+
+    def __hash__(self) -> int:
+        hashable_values = []
+        for attr_name in self._to_hash:
+            hashable_values += [getattr(self, attr_name)]
+        return hash(tuple(hashable_values))
+
+
+class DirItem(GalleryItem):
+    """Gallery representation of filesystem entry for directory."""
+
+    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)
+        mtime = getmtime(self.full_path)
+        dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
+        iso8601_str = dt.isoformat(timespec='microseconds')
+        self.last_mod_time = iso8601_str.replace('+00:00', 'Z')
+        self.bookmarked = False
+        self.with_others = False
+        self.has_metadata = False
+        for param_name in GEN_PARAMS:
+            if param_name in GEN_PARAMS_STR:
+                setattr(self, param_name.lower(), '')
+            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 = {}
+        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 = GALLERY_SLOT_MARGIN
+        assert 0 == self._margin % 2  # avoid ._margin != 2 * .side_margin
+        self.side_margin = self._margin // 2
+        self.size, self.size_sans_margins = -1, -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 = 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."""
-    _gallery_request_update = None
-    _gallery_items_attrs = None
-    _gallery_update_settings = None
-
-    def __init__(self, sort_order):
+    _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: list[SorterAndFilterer],
+                 request_update: Callable,
+                 update_settings: Callable,
+                 items_attrs: ItemsAttrs,
+                 ) -> None:
+        self.order = sort_order
+        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):
+        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,
@@ -168,9 +387,9 @@ class GalleryConfig():
             item_widget.append(item_widget.values)
             list_item.set_child(item_widget)
 
-        def bind_sorter_list_item(_, list_item):
+        def bind_sorter_list_item(_, list_item: SorterAndFilterer) -> None:
 
-            def on_filter_activate(entry):
+            def on_filter_activate(entry: Gtk.Box) -> None:
                 entry.remove_css_class('temp')
                 text = entry.get_buffer().get_text()
                 if '' != text.rstrip():
@@ -184,20 +403,20 @@ class GalleryConfig():
                                  self.filter_inputs.get(sorter.name, ''),
                                  self._gallery_items_attrs[sorter.name])
 
-        def select_sort_order(_a, _b, _c):
+        def select_sort_order(_a, _b, _c) -> None:
             self._sort_sel.props.selected_item.widget.get_parent().grab_focus()
 
-        def toggle_recurse(_):
+        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):
+        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():
+        def apply_config() -> None:
             new_order = []
             for i in range(self._store.get_n_items()):
                 sorter = self._store.get_item(i)
@@ -217,16 +436,15 @@ class GalleryConfig():
             self._set_recurse_changed = False
             self._filter_inputs_changed = False
 
-        def full_reload():
+        def full_reload() -> None:
             apply_config()
             self._gallery_request_update(load=True)
             self._btn_apply.set_sensitive(True)
 
-        self.order = sort_order
-        self.filter_inputs = {}
+        self.filter_inputs: FilterInputs = {}
         self._filter_inputs_changed = False
         self._set_recurse_changed = False
-        self._last_selected = None
+        self._last_selected: Optional[Gtk.Widget] = None
 
         self._store = Gio.ListStore(item_type=SorterAndFilterer)
         self._sort_sel = Gtk.SingleSelection.new(self._store)
@@ -236,6 +454,12 @@ class GalleryConfig():
         fac.connect('bind', bind_sorter_list_item)
         self.sorter_listing = Gtk.ListView(model=self._sort_sel, factory=fac)
 
+        buttons_box = Gtk.Box(orientation=OR_H)
+        self._btn_apply = _add_button(buttons_box, 'apply config',
+                                      lambda _: apply_config())
+        self._btn_relaod = _add_button(buttons_box, 'full reload',
+                                       lambda _: full_reload())
+
         buttons_box = Gtk.Box(orientation=OR_H)
         self._btn_apply = _add_button(buttons_box, 'apply config',
                                       lambda _: apply_config())
@@ -256,39 +480,12 @@ class GalleryConfig():
                 GALLERY_PER_ROW_DEFAULT, 9, 1)
         per_row_box.append(self._btn_per_row)
 
-        self.box = Gtk.Box(orientation=OR_V)
-        self.box.append(self.sorter_listing)
-        self.box.append(dirs_box)
-        self.box.append(per_row_box)
-        self.box.append(buttons_box)
-
-    @classmethod
-    def from_suggestion(cls, suggestion_fused):
-        """Parse suggestion_fused for/into initial sort order to build on."""
-        suggestion = suggestion_fused.split(',')
-        names = [p.lower() for p in GEN_PARAMS] + ['bookmarked']
-        sort_order = []
-        for name in names:
-            sort_order += [SorterAndFilterer(name)]
-        new_sort_order = []
-        do_reverse = '-' in suggestion
-        for pattern in suggestion:
-            for sorter in [sorter for sorter in sort_order
-                           if sorter.name.startswith(pattern)]:
-                sort_order.remove(sorter)
-                new_sort_order += [sorter]
-        sort_order = new_sort_order + sort_order
-        if do_reverse:
-            sort_order.reverse()
-        return cls(sort_order)
+        box.append(self.sorter_listing)
+        box.append(dirs_box)
+        box.append(per_row_box)
+        box.append(buttons_box)
 
-    def bind_gallery(self, request_update, update_settings, items_attrs):
-        """Connect to Gallery interfaces where necessary."""
-        self._gallery_request_update = request_update
-        self._gallery_update_settings = update_settings
-        self._gallery_items_attrs = items_attrs
-
-    def on_focus_sorter(self, focused):
+    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)
@@ -299,7 +496,7 @@ class GalleryConfig():
                 self._sort_sel.props.selected = i
                 break
 
-    def move_selection(self, direction):
+    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
@@ -307,7 +504,7 @@ class GalleryConfig():
                 or (-1 == direction and cur_idx > min_idx):
             self._sort_sel.props.selected = cur_idx + direction
 
-    def move_sorter(self, direction):
+    def move_sorter(self, direction: int) -> None:
         """Move selected item in sort order view, ensure temporary state."""
         tmp_sort_order = []
         for i in range(self._store.get_n_items()):
@@ -332,7 +529,10 @@ class GalleryConfig():
             sort_item = self._store.get_item(i)
             sort_item.widget.add_css_class('temp')
 
-    def update_box(self, alt_order=None, cur_selection=0):
+    def update_box(self,
+                   alt_order: Optional[list[SorterAndFilterer]] = None,
+                   cur_selection: int = 0
+                   ) -> None:
         """Rebuild sorter listing in box from .order, or alt_order if set."""
         sort_order = alt_order if alt_order else self.order
         self._store.remove_all()
@@ -341,156 +541,13 @@ class GalleryConfig():
         self._sort_sel.props.selected = cur_selection
 
 
-class GallerySlot(Gtk.Button):
-    """Slot in Gallery representing a GalleryItem."""
-
-    def __init__(self, item, slots_geometry, on_click_file=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, do_add=True):
-        """Add or remove css_class from self."""
-        if do_add:
-            self.add_css_class(css_class)
-        else:
-            self.remove_css_class(css_class)
-
-    def ensure_slot_size(self):
-        """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):
-        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):
-        """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class."""
-        new_content = None
-        if isinstance(self.item, ImgItem):
-            if is_in_vp and not isinstance(self.item, Gtk.Image):
-                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 GallerySlotsGeometry:
-    """Collect variable sizes shared among all GallerySlots."""
-
-    def __init__(self):
-        self._margin = GALLERY_SLOT_MARGIN
-        assert 0 == self._margin % 2  # avoid ._margin != 2 * .side_margin
-        self.side_margin = self._margin // 2
-        self.size, self.size_sans_margins = -1, -1
-
-    def set_size(self, size):
-        """Not only set .size but also update .size_sans_margins."""
-        self.size = size
-        self.size_sans_margins = self.size - self._margin
-
-
-class GalleryItem(GObject.GObject):
-    """Gallery representation of filesystem entry, base to DirItem, ImgItem."""
-    slot: GallerySlot
-    _to_hash = ['name', 'full_path']
-
-    def __init__(self, path, name):
-        super().__init__()
-        self.name = name
-        self.full_path = path_join(path, self.name)
-
-    def __hash__(self):
-        hashable_values = []
-        for attr_name in self._to_hash:
-            hashable_values += [getattr(self, attr_name)]
-        return hash(tuple(hashable_values))
-
-
-class DirItem(GalleryItem):
-    """Gallery representation of filesystem entry for directory."""
-
-    def __init__(self, path, name, is_parent=False):
-        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, name, cache):
-        super().__init__(path, name)
-        mtime = getmtime(self.full_path)
-        dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
-        iso8601_str = dt.isoformat(timespec='microseconds')
-        self.last_mod_time = iso8601_str.replace('+00:00', 'Z')
-        self.bookmarked = False
-        self.with_others = False
-        self.has_metadata = False
-        for param_name in GEN_PARAMS:
-            if param_name in GEN_PARAMS_STR:
-                setattr(self, param_name.lower(), '')
-            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):
-        """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 = {}
-        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=True):
-        """Set self.bookmark to positive, and update CSS class mark."""
-        self.bookmarked = positive
-        self.slot.mark('bookmarked', positive)
-
-
 class VerticalLabel(Gtk.DrawingArea):
     """Label of vertical text (rotated -90°)."""
 
-    def __init__(self, text, slots_geometry):
+    def __init__(self,
+                 text: str,
+                 slots_geometry: GallerySlotsGeometry
+                 ) -> None:
         super().__init__()
         self._text = text
         self._slots_geometry = slots_geometry
@@ -499,7 +556,12 @@ class VerticalLabel(Gtk.DrawingArea):
         _, self._text_height = test_layout.get_pixel_size()
         self.set_draw_func(self._on_draw)
 
-    def _on_draw(self, _, cairo_ctx, __, height):
+    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)
@@ -512,22 +574,26 @@ class VerticalLabel(Gtk.DrawingArea):
         self.set_size_request(self._text_height, text_width)
 
     @property
-    def width(self):
+    def width(self) -> int:
         """Return (rotated) ._text_height."""
         return self._text_height
 
 
 class Gallery:
     """Representation of GalleryItems below a directory."""
-
-    def __init__(self, on_hit_item, on_grid_built, on_selection_change,
-                 bookmarks_db, cache_db):
+    update_config_box: Callable
+
+    def __init__(self,
+                 on_hit_item: Callable,
+                 on_selection_change: Callable,
+                 bookmarks_db: JsonDb,
+                 cache_db: JsonDb
+                 ) -> None:
         self._on_hit_item = on_hit_item
-        self._on_grid_built = on_grid_built
         self._on_selection_change = on_selection_change
         self._bookmarks_db, self._cache_db = bookmarks_db, cache_db
-        self._sort_order = []
-        self._filter_inputs = {}
+        self._sort_order: list[SorterAndFilterer] = []
+        self._filter_inputs: FilterInputs = {}
         self._img_dir_path = None
 
         self._shall_load = False
@@ -542,10 +608,10 @@ class Gallery:
         self._per_row = GALLERY_PER_ROW_DEFAULT
         self._slots_geometry = GallerySlotsGeometry()
 
-        self.dir_entries = []
-        self.items_attrs = {}
+        self.dir_entries: list[GalleryItem] = []
+        self.items_attrs: ItemsAttrs = {}
         self.selected_idx = 0
-        self.slots = None
+        self.slots: list[GallerySlot] = []
 
         self._grid = None
         self._force_width, self._force_height = 0, 0
@@ -564,7 +630,7 @@ class Gallery:
         self._viewport = self._fixed_frame.get_parent()
         self._viewport.set_scroll_to_focus(False)  # prefer our own handling
 
-        def ensure_uptodate():
+        def ensure_uptodate() -> bool:
             if self._img_dir_path is None:
                 return True
             if self._shall_load:
@@ -579,7 +645,7 @@ class Gallery:
                     self._redraw_and_check_focus()
             return True
 
-        def handle_scroll(_):
+        def handle_scroll(_) -> None:
             self._start_redraw_wait = datetime.now()
             self._shall_scroll_to_focus = False
             self._shall_redraw = True
@@ -589,9 +655,15 @@ class Gallery:
         scroller.get_vadjustment().connect('value-changed', handle_scroll)
         GLib.timeout_add(GALLERY_UPDATE_INTERVAL_MS, ensure_uptodate)
 
-    def update_settings(self, per_row=None, by_1st=None, show_dirs=None,
-                        recurse_dirs=None, img_dir_path=None, sort_order=None,
-                        filter_inputs=None):
+    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,
+                        sort_order: Optional[list[SorterAndFilterer]] = None,
+                        filter_inputs: Optional[FilterInputs] = None
+                        ) -> None:
         """Set Gallery setup fields, request appropriate updates."""
         for val, attr_name in [(per_row, '_per_row'),
                                (by_1st, '_by_1st'),
@@ -607,7 +679,7 @@ class Gallery:
                 else:
                     self.request_update(build=True)
 
-    def _load_directory(self):
+    def _load_directory(self) -> None:
         """Rewrite .dir_entries from ._img_dir_path, trigger rebuild."""
 
         def read_directory(dir_path, make_parent=False):
@@ -649,23 +721,23 @@ class Gallery:
 
         self._shall_load = False
         self.dir_entries = []
-        bookmarks = self._bookmarks_db.as_dict_copy()
-        cache = self._cache_db.as_dict_ref()
+        bookmarks = self._bookmarks_db.as_copy()
+        cache = self._cache_db.as_ref()
         read_directory(self._img_dir_path, make_parent=True)
         self._cache_db.write()
         self.request_update(build=True)
 
     @property
-    def selected_item(self):
+    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):
+    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 _set_selection(self, new_idx):
+    def _set_selection(self, new_idx: int) -> None:
         """Set self.selected_idx, mark slot as 'selected', unmark old one."""
         self._shall_select = False
         # in ._build(), directly before we are called, no slot will be
@@ -681,7 +753,7 @@ class Gallery:
             self.slots[self.selected_idx].grab_focus()
         self._on_selection_change()
 
-    def _passes_filter(self, attr_name, val):
+    def _passes_filter(self, attr_name: str, val: str) -> bool:
         number_attributes = (set(s.lower() for s in GEN_PARAMS_INT) |
                              set(s.lower() for s in GEN_PARAMS_FLOAT) |
                              {'bookmarked'})
@@ -753,14 +825,16 @@ class Gallery:
             return False
         return True
 
-    def _build(self):
+    def _build(self) -> None:
         """(Re-)build slot grid from .dir_entries, filters, layout settings."""
 
-        def build_items_attrs():
+        def build_items_attrs() -> None:
             self.items_attrs.clear()
 
-            def collect_and_split_attr_vals(entries):
-                items_attrs_tmp = {}
+            def collect_and_split_attr_vals(
+                    entries: list[GalleryItem]
+                    ) -> ItemsAttrs:
+                items_attrs_tmp: ItemsAttrs = {}
                 for attr_name in (s.name for s in self._sort_order):
                     items_attrs_tmp[attr_name] = {'incl': [], 'excl': []}
                     vals = set()
@@ -780,7 +854,7 @@ class Gallery:
             filtered_entries = filter_entries(items_attrs_tmp_1)
             items_attrs_tmp_2 = collect_and_split_attr_vals(filtered_entries)
             for attr_name in (s.name for s in self._sort_order):
-                final_values = {'incl': [], 'semi': []}
+                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']
@@ -790,7 +864,7 @@ class Gallery:
                     final_values[category].sort()
                 self.items_attrs[attr_name] = final_values
 
-        def filter_entries(items_attrs):
+        def filter_entries(items_attrs: ItemsAttrs) -> list[GalleryItem]:
             entries_filtered = []
             for entry in self.dir_entries:
                 if (not self._show_dirs) and isinstance(entry, DirItem):
@@ -807,10 +881,14 @@ class Gallery:
                     entries_filtered += [entry]
             return entries_filtered
 
-        def build_grid(entries_filtered):
+        def build_grid(entries_filtered: list[GalleryItem]) -> None:
             i_row_ref, i_slot_ref = [0], [0]
 
-            def build_rows_by_attrs(remaining, items_of_parent, ancestors):
+            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]
@@ -818,20 +896,23 @@ class Gallery:
                     for i, attr in enumerate(ancestors):
                         vlabel = VerticalLabel(f'<b>{attr[0]}</b>: {attr[1]}',
                                                self._slots_geometry)
+                        assert self._grid is not None
                         self._grid.attach(vlabel, i, i_row_ref[0], 1, 1)
+                    row: list[Optional[GalleryItem]]
                     row = [None] * len(attr_values)
-                    for item in items_of_parent:
-                        val = getattr(item, attr_name)
+                    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]:
-                            item.with_others = True
-                        row[idx_val_in_attr_values] = item
+                            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
+                        assert self._grid is not None
                         self._grid.attach(slot, i_col + len(ancestors),
                                           i_row_ref[0], 1, 1)
                     i_row_ref[0] += 1
@@ -847,6 +928,7 @@ class Gallery:
                 self._fixed_frame.remove(self._grid)
             if self._col_headers_grid:
                 self._col_headers_frame.remove(self._col_headers_grid)
+                self._col_headers_grid = None
             self.slots = []
             self._grid = Gtk.Grid()
             self._fixed_frame.put(self._grid, 0, 0)
@@ -864,6 +946,7 @@ class Gallery:
                 build_rows_by_attrs(sort_attrs, entries_filtered, [])
                 self._col_headers_grid = Gtk.Grid()
                 self._col_headers_frame.put(self._col_headers_grid, 0, 0)
+                assert self._col_headers_grid is not None
                 self._col_headers_grid.attach(Gtk.Box(), 0, 0, 1, 1)
                 top_attr_name = sort_attrs[-1][0]
                 for i, val in enumerate(sort_attrs[-1][1]):
@@ -883,10 +966,11 @@ class Gallery:
                         i_row += 1
                     slot = GallerySlot(item, self._slots_geometry,
                                        self._on_hit_item)
+                    assert self._grid is not None
                     self._grid.attach(slot, i_col, i_row, 1, 1)
                     self.slots += [slot]
                     i_col += 1
-            self._on_grid_built()
+            self.update_config_box()
 
         self._shall_build = False
         old_selected_item = self.selected_item
@@ -901,8 +985,12 @@ class Gallery:
                     break
         self._set_selection(new_idx)
 
-    def request_update(self, select=False, scroll_to_focus=False, build=False,
-                       load=False):
+    def request_update(self,
+                       select: bool = False,
+                       scroll_to_focus: bool = False,
+                       build: bool = False,
+                       load: bool = False
+                       ) -> None:
         """Set ._shall_… to trigger updates on next relevant interval."""
         self._shall_redraw = True
         if scroll_to_focus or build or select:
@@ -914,7 +1002,11 @@ class Gallery:
         if load:
             self._shall_load = True
 
-    def move_selection(self, x_inc, y_inc, buf_end):
+    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:
@@ -933,12 +1025,12 @@ class Gallery:
             return
         self._set_selection(new_idx)
 
-    def on_resize(self, width=0, height=0):
+    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):
+    def _redraw_and_check_focus(self) -> None:
         """Draw gallery; possibly notice and first follow need to re-focus."""
         vp_width = (self._force_width if self._force_width
                     else self._viewport.get_width())
@@ -951,6 +1043,7 @@ class Gallery:
         side_offset, i_vlabels = 0, 0
         if self._by_1st:
             while True:
+                assert self._grid is not None
                 widget = self._grid.get_child_at(i_vlabels, 0)
                 if isinstance(widget, VerticalLabel):
                     side_offset += widget.width
@@ -982,8 +1075,12 @@ class Gallery:
             slot.update_widget(in_vp)
         self._start_redraw_wait = datetime.now()
 
-    def _position_to_viewport(
-            self, idx, vp_top, vp_bottom, in_vp_greedy=False):
+    def _position_to_viewport(self,
+                              idx: int,
+                              vp_top: int,
+                              vp_bottom: int,
+                              in_vp_greedy: bool = False
+                              ) -> tuple[bool, int, int]:
         slot_top = (idx // self._per_row) * self._slots_geometry.size
         slot_bottom = slot_top + self._slots_geometry.size
         if in_vp_greedy:
@@ -992,7 +1089,11 @@ class Gallery:
             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, vp_top, vp_bottom):
+    def _scroll_to_focus(self,
+                         vp_scroll: Gtk.Scrollable,
+                         vp_top: int,
+                         vp_bottom: int
+                         ) -> bool:
         scroll_to_focus = self._shall_scroll_to_focus
         self._shall_redraw, self._shall_scroll_to_focus = False, False
         if scroll_to_focus:
@@ -1008,7 +1109,7 @@ class Gallery:
                 return True
         return False
 
-    def _sort_cmp(self, a, b):
+    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:
@@ -1049,25 +1150,25 @@ class MainWindow(Gtk.Window):
     prev_key: list
     topbar: Gtk.Label
 
-    def __init__(self, app, **kwargs):
+    def __init__(self, app: Application, **kwargs) -> None:
         super().__init__(**kwargs)
         self.app = app
 
-        def init_navbar():
+        def init_navbar() -> Gtk.Box:
             navbar = Gtk.Box(orientation=OR_H)
             _add_button(navbar, 'sidebar', lambda _: self.toggle_side_box())
             self.topbar = Gtk.Label()
             navbar.append(self.topbar)
             return navbar
 
-        def init_metadata_box():
+        def init_metadata_box() -> Gtk.TextView:
             text_view = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR,
                                      editable=False)
             text_view.set_size_request(300, -1)
             self.metadata = text_view.get_buffer()
             return text_view
 
-        def init_key_control():
+        def init_key_control() -> None:
             key_ctl = Gtk.EventControllerKey(
                     propagation_phase=Gtk.PropagationPhase.CAPTURE)
             key_ctl.connect('key-pressed',
@@ -1075,7 +1176,7 @@ class MainWindow(Gtk.Window):
             self.add_controller(key_ctl)
             self.prev_key = [0]
 
-        def setup_css():
+        def setup_css() -> None:
             css_provider = Gtk.CssProvider()
             css_provider.load_from_data(CSS)
             Gtk.StyleContext.add_provider_for_display(
@@ -1084,7 +1185,6 @@ class MainWindow(Gtk.Window):
 
         self.gallery = Gallery(
                 on_hit_item=self.hit_gallery_item,
-                on_grid_built=self.app.conf.update_box,
                 on_selection_change=self.update_metadata_on_gallery_selection,
                 bookmarks_db=self.app.bookmarks_db,
                 cache_db=self.app.cache_db)
@@ -1097,7 +1197,8 @@ class MainWindow(Gtk.Window):
         self.side_box = Gtk.Notebook.new()
         self.side_box.append_page(init_metadata_box(),
                                   Gtk.Label(label='metadata'))
-        self.side_box.append_page(self.app.conf.box, Gtk.Label(label='config'))
+        config_box = Gtk.Box(orientation=OR_V)
+        self.side_box.append_page(config_box, Gtk.Label(label='config'))
         box_outer = Gtk.Box(orientation=OR_H)
         box_outer.append(self.side_box)
         box_outer.append(viewer)
@@ -1108,26 +1209,29 @@ class MainWindow(Gtk.Window):
         init_key_control()
         self.connect('notify::focus-widget',
                      lambda _, __: self.on_focus_change())
-        self.app.conf.bind_gallery(
+        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
         GLib.idle_add(lambda: self.gallery.update_settings(
             img_dir_path=self.app.img_dir_absolute,
-            sort_order=self.app.conf.order[:],
-            filter_inputs=self.app.conf.filter_inputs.copy()))
+            sort_order=self.conf.order[:],
+            filter_inputs=self.conf.filter_inputs.copy()))
 
-    def on_focus_change(self):
+    def on_focus_change(self) -> None:
         """Handle reactions on focus changes in .gallery and .conf."""
         focused = self.get_focus()
         if not focused:
             return
         if isinstance(focused, GallerySlot):
             self.gallery.on_focus_slot(focused)
-        elif focused.get_parent() == self.app.conf.sorter_listing:
-            self.app.conf.on_focus_sorter(focused)
+        elif focused.get_parent() == self.conf.sorter_listing:
+            self.conf.on_focus_sorter(focused)
 
-    def on_resize(self):
+    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
@@ -1137,11 +1241,12 @@ class MainWindow(Gtk.Window):
             self.gallery.on_resize(default_size[0] - side_box_width,
                                    default_size[1] - self.navbar.get_height())
 
-    def bookmark(self):
+    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_dict_ref()
+        bookmarks = self.app.bookmarks_db.as_ref()
+        assert isinstance(bookmarks, list)
         if self.gallery.selected_item.bookmarked:
             self.gallery.selected_item.bookmark(False)
             bookmarks.remove(self.gallery.selected_item.full_path)
@@ -1149,15 +1254,15 @@ class MainWindow(Gtk.Window):
             self.gallery.selected_item.bookmark(True)
             bookmarks += [self.gallery.selected_item.full_path]
         self.app.bookmarks_db.write()
-        self.app.conf.update_box()
+        self.conf.update_box()
 
-    def hit_gallery_item(self):
+    def hit_gallery_item(self) -> None:
         """If current file selection is directory, reload into that one."""
         selected = self.gallery.selected_item
         if isinstance(selected, DirItem):
             self.gallery.update_settings(img_dir_path=selected.full_path)
 
-    def toggle_side_box(self):
+    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
@@ -1165,7 +1270,7 @@ class MainWindow(Gtk.Window):
         side_box_width = self.side_box.measure(OR_H, -1).natural
         self.gallery.on_resize(self.get_width() - side_box_width)
 
-    def update_metadata_on_gallery_selection(self):
+    def update_metadata_on_gallery_selection(self) -> None:
         """Update .metadata about individual file, .topbar also on idx/total"""
         self.metadata.set_text('')
         selected_item = self.gallery.selected_item
@@ -1188,7 +1293,7 @@ class MainWindow(Gtk.Window):
         self.topbar.set_text(txt)
         self.topbar.set_use_markup(True)
 
-    def handle_keypress(self, keyval):
+    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
@@ -1208,13 +1313,13 @@ class MainWindow(Gtk.Window):
         elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]:
             self.gallery.move_selection(None, None, -1)
         elif Gdk.KEY_w == keyval:
-            self.app.conf.move_selection(-1)
+            self.conf.move_selection(-1)
         elif Gdk.KEY_W == keyval:
-            self.app.conf.move_sorter(-1)
+            self.conf.move_sorter(-1)
         elif Gdk.KEY_s == keyval:
-            self.app.conf.move_selection(1)
+            self.conf.move_selection(1)
         elif Gdk.KEY_S == keyval:
-            self.app.conf.move_sorter(1)
+            self.conf.move_sorter(1)
         elif Gdk.KEY_b == keyval:
             self.bookmark()
         else:
@@ -1223,26 +1328,5 @@ class MainWindow(Gtk.Window):
         return True
 
 
-class Application(Gtk.Application):
-    """Image browser application class."""
-
-    def __init__(self, *args, **kwargs):
-        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.conf = GalleryConfig.from_suggestion(opts.sort_order)
-        self.bookmarks_db = JsonDB(BOOKMARKS_PATH)
-        self.cache_db = JsonDB(CACHE_PATH)
-
-    def do_activate(self, *args, **kwargs):
-        """Parse arguments, start window, keep it open."""
-        win = MainWindow(self)
-        win.present()
-        self.hold()
-
-
 main_app = Application(application_id='plomlompom.com.StablePixBrowser.App')
 main_app.run()
-- 
2.30.2