home · contact · privacy
Rewrite browser.py for gallery view of directory.
authorChristian Heller <c.heller@plomlompom.de>
Thu, 5 Sep 2024 16:19:37 +0000 (18:19 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Thu, 5 Sep 2024 16:19:37 +0000 (18:19 +0200)
browser.py

index 5a5ce3f85ef0d70a4051aa0b8028b561f0f2436f..37e4e60fe85934ddf05ca8f28d4dd4153b73eaad 100755 (executable)
@@ -1,23 +1,24 @@
 #!/usr/bin/env python3
 from json import dump as json_dump, load as json_load
 from os.path import exists as path_exists, join as path_join, abspath
-from operator import attrgetter
 from exiftool import ExifToolHelper  # type: ignore
 import gi  # type: ignore
 gi.require_version('Gtk', '4.0')
 gi.require_version('Gdk', '4.0')
-gi.require_version('GdkPixbuf', '2.0')
 gi.require_version('Gio', '2.0')
 # pylint: disable=wrong-import-position
-from gi.repository import Gdk, GdkPixbuf, Gtk, Gio, GObject  # type: ignore  # noqa: E402
+from gi.repository import Gdk, Gtk, Gio, GObject, GLib  # noqa: E402
 # pylint: disable=no-name-in-module
 from stable.gen_params import (GenParams,  # noqa: E402
                                GEN_PARAMS, GEN_PARAMS_STR)  # noqa: E402
 
-
 IMG_DIR = '.'
+UPPER_DIR = '..'
 CACHE_PATH = 'cache.json'
 
+OR_H = Gtk.Orientation.HORIZONTAL
+OR_V = Gtk.Orientation.VERTICAL
+
 
 class SortLabelItem(GObject.GObject):
 
@@ -39,7 +40,7 @@ class DirItem(FileItem):
 
     def __init__(self, path, info, is_parent=False):
         super().__init__(path, info)
-        self.name = '  ..' if is_parent else f'  {self.name}/'
+        self.name = f'  {UPPER_DIR}' if is_parent else f'  {self.name}/'
         if is_parent:
             self.full_path = path
 
@@ -77,198 +78,288 @@ class Window(Gtk.ApplicationWindow):
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
 
-        def add_button(label_, on_click, parent_box):
-            btn = Gtk.Button(label=label_)
-            btn.connect('clicked', on_click)
-            parent_box.append(btn)
-
-        keyboard_control = Gtk.EventControllerKey()
-        keyboard_control.connect('key-pressed', self.handle_keypress, self)
-        self.prev_key = ['']
-        self.add_controller(keyboard_control)
-
-        self.metadata = Gtk.Label(xalign=0)
-        self.label_nothing_to_show = Gtk.Label(label='nothing to show')
-
-        box_files_selection = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
-        add_button('folder_view', self.toggle_folder_view, box_files_selection)
-        add_button('reload', lambda _: self.reload_dir(), box_files_selection)
-        self.box_sort_order = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
-        box_files_selection.append(self.box_sort_order)
-
-        # self.fbox = Gtk.FlowBox(orientation=Gtk.Orientation.VERTICAL)
-        # self.fbox.set_max_children_per_line(3)
-        # self.fbox.set_selection_mode(Gtk.SelectionMode.NONE)
-
-        self.viewer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
-        self.viewer.append(box_files_selection)
-        # self.viewer.append(self.fbox)
-        self.viewer.append(self.metadata)
-        self.viewer.append(self.label_nothing_to_show)
-
-        self.dir_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
-        self.list_store = Gio.ListStore(item_type=FileItem)
-        self.selection = Gtk.SingleSelection.new(self.list_store)
-        self.selection.connect('selection-changed', self.update_selected)
-        factory = Gtk.SignalListItemFactory()
-        factory.connect('setup', lambda _, i: i.set_child(Gtk.Label(xalign=0)))
-        factory.connect('bind',
-                        lambda _, i: i.props.child.set_text(i.props.item.name))
-        self.selector = Gtk.ListView(model=self.selection, factory=factory)
-        self.selector.connect('activate', self.on_selector_activate)
-        scrolled = Gtk.ScrolledWindow(child=self.selector, vexpand=True,
-                                      propagate_natural_width=True)
-        self.dir_box.append(scrolled)
-
-        self.dir_box.append(Gtk.Label(label='** sort order **'))
-        self.sort_order = [p.lower() for p in GEN_PARAMS]
-        self.sort_order += ['last_mod_time', 'name']
-        self.sort_store = Gio.ListStore(item_type=SortLabelItem)
-        self.sort_selection = Gtk.SingleSelection.new(self.sort_store)
-        selector = Gtk.ListView(model=self.sort_selection, factory=factory)
-        self.dir_box.append(selector)
-
-        box_outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
-        box_outer.append(self.dir_box)
-        box_outer.append(self.viewer)
+        def init_navbar():
+            def add_button(label_, on_click, parent_box):
+                btn = Gtk.Button(label=label_)
+                btn.connect('clicked', on_click)
+                parent_box.append(btn)
+            navbar = Gtk.Box(orientation=OR_H)
+            add_button('folder_view', lambda _: self.toggle_side_box(), navbar)
+            add_button('reload', lambda _: self.reload_dir(), navbar)
+            navbar.append(Gtk.Label(label=' per row: '))
+            add_button('less', lambda _: self.inc_per_row(-1), navbar)
+            add_button('more', lambda _: self.inc_per_row(+1), navbar)
+            btn = Gtk.CheckButton(label='show directories')
+            btn.connect('toggled', self.reset_include_dirs)
+            navbar.append(btn)
+            return navbar
+
+        def init_gallery_widgets():
+            self.gallery = Gtk.FlowBox(orientation=OR_H)
+            self.gallery.connect(
+                    'child-activated',
+                    lambda _, x: self.hit_gallery_item(x.props.child.item))
+            gallery_scroller = Gtk.ScrolledWindow(
+                    child=self.gallery, propagate_natural_height=True)
+            gallery_scroller.get_vadjustment().connect(
+                    'value-changed', lambda _: self.redraw_gallery_items())
+            # attach a maximally expanded dummy that will be destroyed once we
+            # bind self.gallery to a model; don't know why exactly, but this
+            # seems necessary to have the viewport report a proper (rather
+            # than too small) size when its queried by the first run of
+            # self.rebuild_gallery, which influences what images we're gonna
+            # load then
+            self.gallery.append(Gtk.Box(hexpand=True, vexpand=True))
+            return gallery_scroller
+
+        def init_metadata_box():
+            text_view = Gtk.TextView()
+            text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+            text_view.set_size_request(200, -1)
+            self.metadata = text_view.get_buffer()
+            metadata_box = Gtk.Box(orientation=OR_V)
+            metadata_box.append(Gtk.Label(label='** metadata **'))
+            metadata_box.append(text_view)
+            return metadata_box
+
+        def init_sort_orderer():
+            self.sort_order = [p.lower() for p in GEN_PARAMS]
+            self.sort_order += ['last_mod_time', 'name']
+            self.sort_store = Gio.ListStore(item_type=SortLabelItem)
+            self.sort_selection = Gtk.SingleSelection.new(self.sort_store)
+            factory = Gtk.SignalListItemFactory()
+            factory.connect('setup',
+                            lambda _, i: i.set_child(Gtk.Label(xalign=0)))
+            factory.connect(
+                    'bind',
+                    lambda _, i: i.props.child.set_text(i.props.item.name))
+            selector = Gtk.ListView(model=self.sort_selection, factory=factory)
+            sort_box = Gtk.Box(orientation=OR_V)
+            sort_box.append(Gtk.Label(label='** sort order **'))
+            sort_box.append(selector)
+            return sort_box
+
+        def init_gallery_content():
+            self.gallery_store = Gio.ListStore(item_type=FileItem)
+            list_filter = Gtk.CustomFilter.new(
+                    lambda x: self.include_dirs or isinstance(x, ImgItem))
+            self.gallery_store_filtered = Gtk.FilterListModel(
+                    model=self.gallery_store, filter=list_filter)
+            self.gallery_selection = Gtk.SingleSelection.new(
+                    self.gallery_store_filtered)
+            self.include_dirs = False
+            self.per_row = 3
+
+        def init_key_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 = [0]
+
+        viewer = Gtk.Box(orientation=OR_V)
+        viewer.append(init_navbar())
+        viewer.append(init_gallery_widgets())
+        self.side_box = Gtk.Box(orientation=OR_V)
+        self.side_box.append(init_sort_orderer())
+        self.side_box.append(init_metadata_box())
+        box_outer = Gtk.Box(orientation=OR_H)
+        box_outer.append(self.side_box)
+        box_outer.append(viewer)
         self.props.child = box_outer
 
-        self.item_img, self.item_dir = None, None
-        self.unsorted_dirs, self.unsorted_files = [], []
+        init_key_control()
         self.img_dir_absolute = abspath(IMG_DIR)
-        self.reload_dir()
-        self.update_sort_list()
-
-    def on_selector_activate(self, _, __):
-        if isinstance(self.selection.props.selected_item, DirItem):
-            self.item_dir = self.selection.props.selected_item
-            self.img_dir_absolute = self.item_dir.full_path
-            self.item_img, self.item_dir = None, None
-            self.unsorted_dirs, self.unsorted_files = [], []
-            self.reload_dir()
+        init_gallery_content()
+        self.reload_dir(rebuild_gallery=False)
+        self.update_sort_list(rebuild_gallery=False)
+        # self.gallery.grab_focus()
+        GLib.idle_add(self.rebuild_gallery)
 
-    def toggle_folder_view(self, _):
-        self.dir_box.props.visible = not self.dir_box.props.visible
+    # various gallery management tasks
 
     def sort(self):
-        # self.fbox.remove_all()
-        self.list_store.remove_all()
-        for key in self.sort_order:
-            self.unsorted_files.sort(key=attrgetter(key))
-            if key in {'name', 'last_mod_time'}:
-                self.unsorted_dirs.sort(key=attrgetter(key))
-        for file_item in [self.parent_dir_item]\
-                + self.unsorted_dirs + self.unsorted_files:
-            self.list_store.append(file_item)
-        for self_item in (self.item_dir, self.item_img):
-            if self_item:
-                for pos, item in enumerate(self.list_store):
-                    if item.full_path == self_item.full_path:
-                        self.selection.set_selected(pos)
-                return
+        def sorter(a, b):
+            if self.include_dirs:
+                if isinstance(a, DirItem) and isinstance(b, DirItem):
+                    cmp_upper_dir = f'  {UPPER_DIR}'
+                    if cmp_upper_dir == a.name:
+                        return False
+                    if cmp_upper_dir == b.name:
+                        return True
+                elif isinstance(a, DirItem):
+                    return False
+                elif isinstance(b, DirItem):
+                    return True
+            for key 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:
+                    return True
+                if a_cmp is None:
+                    return False
+                return a_cmp > b_cmp
+        self.gallery_store.sort(sorter)
+
+    @property
+    def viewport_geometry(self):
+        viewport = self.gallery.props.parent
+        vp_height = viewport.get_height()
+        vp_width = viewport.get_width()
+        vp_top = viewport.get_vadjustment().get_value()
+        return vp_width, vp_height, vp_top
+
+    def rebuild_gallery(self):
+
+        def init_gallery_slot(file_item):
+            vp_width, vp_height, _ = self.viewport_geometry
+            max_slot_width = vp_width / self.per_row - 6
+            slot = Gtk.Box()
+            slot.item = file_item
+            slot.props.hexpand = True
+            slot.size = min(vp_height, max_slot_width)
+            slot.set_size_request(slot.size, slot.size)
+            if isinstance(file_item, ImgItem):
+                slot.content = Gtk.Label(label='?')
+            else:
+                slot.content = Gtk.Button(label=file_item.name)
+                slot.content.connect(
+                        'clicked', lambda _: self.hit_gallery_item(file_item))
+                slot.content.set_size_request(slot.size, slot.size)
+            slot.append(slot.content)
+            return slot
+
+        self.gallery.set_min_children_per_line(self.per_row)
+        self.gallery.set_max_children_per_line(self.per_row)
+        self.gallery.bind_model(self.gallery_selection, init_gallery_slot)
         self.update_selected()
-        # for file_item in self.unsorted_files:
-        #     pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(file_item.full_path, 128, 128, True)
-        #     img = Gtk.Image()
-        #     img.set_from_pixbuf(pixbuf)
-        #     box = Gtk.Box()
-        #     box.img = img
-        #     self.fbox.append(box)
+        self.redraw_gallery_items()
+
+    def redraw_gallery_items(self):
+        _, vp_height, vp_top = self.viewport_geometry
+        vp_bottom = vp_top + vp_height
+        for i in range(self.gallery_store_filtered.props.n_items):
+            slot = self.gallery.get_child_at_index(i).props.child
+            if isinstance(slot.item, DirItem):
+                continue
+            slot_top = (i // self.per_row) * slot.size
+            slot_bottom = slot_top + slot.size
+            in_viewport = (slot_bottom >= vp_top and slot_top <= vp_bottom)
+            if in_viewport:
+                if not isinstance(slot.content, Gtk.Image):
+                    slot.remove(slot.content)
+                    slot.content = Gtk.Image.new_from_file(slot.item.full_path)
+                    slot.content.set_size_request(slot.size, slot.size)
+                    slot.append(slot.content)
+            elif isinstance(slot.content, Gtk.Image):
+                slot.remove(slot.content)
+                slot.content = Gtk.Label(label='?')
+                slot.append(slot.content)
+
+        # # DEBUG: mere statistics
+        # from datetime import datetime
+        # n_imgs, n_buttons, n_others = 0, 0, 0
+        # for i in range(self.gallery_store_filtered.props.n_items):
+        #     child = self.gallery.get_child_at_index(i).props.child
+        #     grandchild = child.get_first_child()
+        #     if isinstance(grandchild, Gtk.Image):
+        #         n_imgs += 1
+        #     elif isinstance(grandchild, Gtk.Button):
+        #         n_buttons += 1
+        #     else:
+        #         n_others += 1
+        # print(datetime.now(), "DEBUG", n_imgs, n_buttons, n_others)
+
+    def hit_gallery_item(self, activated):
+        self.redraw_gallery_items()
+        if isinstance(activated, DirItem):
+            self.img_dir_absolute = activated.full_path
+            self.reload_dir()
 
     def update_selected(self, *_args):
-        if isinstance(self.selection.props.selected_item, ImgItem):
-            self.item_img = self.selection.props.selected_item
-            self.item_dir = None
-            self.reload_image()
-        else:
-            self.item_dir = self.selection.props.selected_item
-        self.selector.scroll_to(self.selection.props.selected,
-                                Gtk.ListScrollFlags.NONE, None)
-
-    def reload_image(self):
-        self.viewer.remove(self.viewer.get_last_child())
-        if self.item_img:
-            params_strs = [f'{k}: ' + str(getattr(self.item_img, k.lower()))
+        idx = self.gallery_selection.props.selected
+        slot = self.gallery.get_child_at_index(idx).props.child
+        self.gallery.props.parent.scroll_to(slot)
+        # slot.grab_focus()
+        if isinstance(self.gallery_selection.props.selected_item, ImgItem):
+            item = self.gallery_selection.props.selected_item
+            params_strs = [f'{k}: ' + str(getattr(item, k.lower()))
                            for k in GEN_PARAMS]
-            offset = len(self.unsorted_dirs) + 1
-            position = self.selection.props.selected + 1 - offset
-            total = len(self.unsorted_files)
-            title = f'{self.item_img.full_path} ({position} of {total})'
-            self.metadata.props.label = '\n'.join([title] + params_strs)
-            pic = Gtk.Picture.new_for_filename(self.item_img.full_path)
-            pic.props.halign = Gtk.Align.START
-            self.viewer.append(pic)
+            title = f'{item.full_path}'
+            self.metadata.set_text('\n'.join([title] + params_strs))
         else:
-            self.metadata.props.label = None
-            self.viewer.append(self.label_nothing_to_show)
+            self.metadata.set_text('')
 
-    def reload_dir(self):
+    # navbar callables
+
+    def reload_dir(self, rebuild_gallery=True):
+        self.gallery_store.remove_all()
         self.dir = Gio.File.new_for_path(self.img_dir_absolute)
-        old_dir_path = self.item_dir.full_path if self.item_dir else ''
-        old_img_path = self.item_img.full_path if self.item_img else ''
         if not path_exists(CACHE_PATH):
             with open(CACHE_PATH, 'w', encoding='utf8') as f:
                 json_dump({}, f)
         with open(CACHE_PATH, 'r', encoding='utf8') as f:
             cache = json_load(f)
-        query_attrs = 'standard::name,standard::type,time::*'
-        self.item_img, self.item_dir = None, None
-        parent_path = abspath(path_join(self.img_dir_absolute, '..'))
+        query_attrs = 'standard::name,time::*'
+        parent_path = abspath(path_join(self.img_dir_absolute, UPPER_DIR))
         parent_dir = self.dir.get_parent()
         parent_dir_info = parent_dir.query_info(
                 query_attrs, Gio.FileQueryInfoFlags.NONE, None)
-        self.parent_dir_item = DirItem(
+        parent_dir_item = DirItem(
                 parent_path, parent_dir_info, is_parent=True)
-        self.unsorted_dirs, self.unsorted_files = [], []
+        self.gallery_store.append(parent_dir_item)
+        query_attrs = query_attrs + ',standard::content-type'
         enumerator = self.dir.enumerate_children(
                 query_attrs, Gio.FileQueryInfoFlags.NONE, None)
-        for info in [info for info in enumerator
-                     if info.get_file_type() == Gio.FileType.DIRECTORY]:
-            item = DirItem(self.img_dir_absolute, info)
-            if old_dir_path == item.full_path:
-                self.item_dir = item
-            self.unsorted_dirs += [item]
-        enumerator = self.dir.enumerate_children(
-                query_attrs + ',standard::content-type',
-                Gio.FileQueryInfoFlags.NONE, None)
-        for info in [info for info in enumerator
-                     if info.get_file_type() == Gio.FileType.REGULAR
-                     and info.get_content_type().startswith('image/')]:
-            item = ImgItem(self.img_dir_absolute, info, cache)
-            if old_img_path == item.full_path:
-                self.item_img = item
-            self.unsorted_files += [item]
+        to_set_metadata_on = []
+        for info in enumerator:
+            if self.include_dirs\
+                    and info.get_file_type() == Gio.FileType.DIRECTORY:
+                self.gallery_store.append(DirItem(self.img_dir_absolute, info))
+            elif info.get_content_type()\
+                    and info.get_content_type().startswith('image/'):
+                item = ImgItem(self.img_dir_absolute, info, cache)
+                if '' == item.model:
+                    to_set_metadata_on += [item]
+                self.gallery_store.append(item)
         with ExifToolHelper() as et:
-            for item in [item for item
-                         in self.unsorted_files if '' == item.model]:
+            for item in to_set_metadata_on:
                 item.set_metadata(et, cache)
-        self.sort()
         with open(CACHE_PATH, 'w', encoding='utf8') as f:
             json_dump(cache, f)
+        self.sort()
+        self.gallery_selection.props.selected = 0
+        if rebuild_gallery:
+            self.rebuild_gallery()
 
-    def move_selection_in_sort_order(self, direction):
-        self.move_selection(self.sort_selection, direction, None,
-                            0, len(self.sort_order) - 1)
+    def toggle_side_box(self):
+        self.side_box.props.visible = not self.side_box.props.visible
+        self.rebuild_gallery()
 
-    def move_selection_in_directory(self, direction, absolute_position):
-        max_index = len(self.unsorted_files + self.unsorted_dirs)
-        if len(self.unsorted_files) > 2:
-            min_index = len(self.unsorted_dirs) + 1
-        else:
-            min_index = 0
-        self.move_selection(self.selection, direction, absolute_position,
-                            min_index, max_index)
+    def reset_include_dirs(self, button):
+        self.include_dirs = button.props.active
+        self.reload_dir()
 
-    def move_selection(self, selection, increment, absolute_position,
-                       min_index, max_index):
-        cur_index = selection.props.selected
-        if 0 == absolute_position:
-            selection.props.selected = min_index
-        elif -1 == absolute_position:
-            selection.props.selected = max_index
-        elif (1 == increment and cur_index < max_index)\
-                or (-1 == increment and cur_index > min_index):
-            selection.props.selected = cur_index + increment
+    def inc_per_row(self, inc):
+        if self.per_row + inc > 0:
+            self.per_row += inc
+            self.rebuild_gallery()
+
+    # key-bound movements
+
+    def update_sort_list(self, start_position=0, rebuild_gallery=True):
+        self.sort_store.remove_all()
+        for s in self.sort_order:
+            self.sort_store.append(SortLabelItem(s))
+        self.sort_selection.props.selected = start_position
+        self.sort()
+        if rebuild_gallery:
+            self.rebuild_gallery()
 
     def move_sort(self, direction):
         current_i = self.sort_selection.props.selected
@@ -287,32 +378,47 @@ class Window(Gtk.ApplicationWindow):
             return
         self.update_sort_list(current_i + direction)
 
-    def update_sort_list(self, start_position=0):
-        self.sort_store.remove_all()
-        for s in self.sort_order:
-            self.sort_store.append(SortLabelItem(s))
-        self.sort_selection.props.selected = start_position
-        self.sort()
+    def move_selection_in_sort_order(self, direction):
+        self.move_selection(self.sort_selection, direction, None,
+                            0, len(self.sort_order) - 1)
+
+    def move_selection_in_directory(self, direction, absolute_position):
+        self.move_selection(self.gallery_selection,
+                            direction, absolute_position,
+                            0, self.gallery_store_filtered.props.n_items - 1)
+        self.update_selected(None, self.gallery_selection.props.selected, None)
+
+    def move_selection(self, selection, increment, absolute_position,
+                       min_index, max_index):
+        cur_index = selection.props.selected
+        if 0 == absolute_position:
+            selection.props.selected = min_index
+        elif -1 == absolute_position:
+            selection.props.selected = max_index
+        elif (1 == increment and cur_index < max_index)\
+                or (-1 == increment and cur_index > min_index):
+            selection.props.selected = cur_index + increment
 
-    def handle_keypress(self, _keyval, keycode, state, _user_data, _win):
-        if ord('G') == keycode:
+    def handle_keypress(self, keyval):
+        if Gdk.KEY_G == keyval:
             self.move_selection_in_directory(None, -1)
-        elif ord('j') == keycode:
+        elif Gdk.KEY_j == keyval:
             self.move_selection_in_directory(1, None)
-        elif ord('k') == keycode:
+        elif Gdk.KEY_k == keyval:
             self.move_selection_in_directory(-1, None)
-        elif ord('g') == keycode and 'g' == self.prev_key[0]:
+        elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]:
             self.move_selection_in_directory(None, 0)
-        elif ord('n') == keycode:
+        elif Gdk.KEY_n == keyval:
             self.move_selection_in_sort_order(-1)
-        elif ord('N') == keycode:
+        elif Gdk.KEY_N == keyval:
             self.move_sort(-1)
-        elif ord('m') == keycode:
+        elif Gdk.KEY_m == keyval:
             self.move_selection_in_sort_order(1)
-        elif ord('M') == keycode:
+        elif Gdk.KEY_M == keyval:
             self.move_sort(1)
         else:
-            self.prev_key[0] = chr(keycode)
+            self.prev_key[0] = keyval
+        return True
 
 
 def on_activate(app_):