home · contact · privacy
Superficial code-reorganization to easen understanding. master
authorPlom Heller <plom@plomlompom.com>
Fri, 17 Apr 2026 23:27:32 +0000 (01:27 +0200)
committerPlom Heller <plom@plomlompom.com>
Fri, 17 Apr 2026 23:27:32 +0000 (01:27 +0200)
browser/gallery.py

index 76cd0e3ec3ed4ad27f37ba5d5437ca6d99100088..4d9fac1a3a5b20550e2b65224198130126cdab82 100644 (file)
@@ -279,25 +279,6 @@ class Gallery:
         self._viewport = self._fixed_frame.get_parent()
         self._viewport.set_scroll_to_focus(False)  # prefer our own handling
 
         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
         def handle_scroll(_) -> None:
             self._start_redraw_wait = datetime.now()
             self._shall_scroll_to_focus = False
@@ -306,7 +287,42 @@ class Gallery:
         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)
         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)
+        GLib.timeout_add(GALLERY_UPDATE_INTERVAL_MS,
+                         lambda: self._ensure_uptodate(redraw_wait_time))
+
+    # view update management
+
+    def _ensure_uptodate(self, redraw_wait_time: timedelta) -> 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 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 update_settings(self,
                         per_row: Optional[int] = None,
 
     def update_settings(self,
                         per_row: Optional[int] = None,
@@ -328,75 +344,12 @@ class Gallery:
                 else:
                     self.request_update(build_grid=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 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 _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
+    # load_directory and helpers
 
     def _load_directory(self) -> None:
         """(Re-)build .dir_entries from ._img_dir_path, ._basic_items_attrs."""
 
     def _load_directory(self) -> None:
         """(Re-)build .dir_entries from ._img_dir_path, ._basic_items_attrs."""
@@ -465,175 +418,72 @@ class Gallery:
             del self._basic_items_attrs[attr_name]
         self._cache_db.write()
 
             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
+    @staticmethod
+    def _diff_prompts(prompts: list[str]) -> dict[str, tuple[str, str]]:
+        if not prompts:
+            return {}
 
 
-    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 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
 
 
-    def _assert_selection(self) -> None:
-        if self.slots:
-            self.slots[self.selected_idx].mark('selected', True)
-            self.slots[self.selected_idx].grab_focus()
+        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 _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()
+    # build_grid and helpers
 
     def _build_grid(self) -> None:
         """(Re-)build slot grid from .dir_entries, filters, layout settings."""
         old_selected_item: Optional[GalleryItem] = self.selected_item
 
     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)
+        self._update_items_attrs()
+        entries_filtered = self._filter_entries(self.items_attrs)
+        self._build_grid_core(entries_filtered)
         new_idx = 0
         if old_selected_item is not None:
             for i, slot in enumerate(self.slots):
         new_idx = 0
         if old_selected_item is not None:
             for i, slot in enumerate(self.slots):
@@ -642,49 +492,175 @@ class Gallery:
                     break
         self._set_selection(new_idx)
 
                     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
+    def _update_items_attrs(self) -> 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 = self._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(self, 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_grid_core(self, entries_filtered: list[GalleryItem]) -> None:
+        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)])
+
+        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)
+        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:
         else:
-            return
-        self._set_selection(new_idx)
+            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()
 
 
-    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 _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
+
+    # redraw_and_check_focus and helpers
 
     def _redraw_and_check_focus(self) -> None:
         """Draw gallery; possibly notice and first follow need to re-focus."""
 
     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
         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
@@ -729,20 +705,6 @@ class Gallery:
             slot.update_widget(in_vp)
         self._start_redraw_wait = datetime.now()
 
             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,
     def _scroll_to_focus(self,
                          vp_scroll: Gtk.Scrollable,
                          vp_top: int,
@@ -763,36 +725,83 @@ class Gallery:
                 return True
         return False
 
                 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
+    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
+
+    # helpers
+
+    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 _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()
+
+    # selection management
+
+    @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 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)