home · contact · privacy
Greatly refactor, and rely more on Gtk navigation rather than DIY.
authorChristian Heller <c.heller@plomlompom.de>
Sat, 7 Sep 2024 19:53:04 +0000 (21:53 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sat, 7 Sep 2024 19:53:04 +0000 (21:53 +0200)
browser.py

index 37e4e60fe85934ddf05ca8f28d4dd4153b73eaad..d67fd21046c0665c74d4b29f580a3ae043c45534 100755 (executable)
@@ -1,4 +1,5 @@
 #!/usr/bin/env python3
+"""Browser for image files."""
 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 exiftool import ExifToolHelper  # type: ignore
@@ -19,8 +20,15 @@ CACHE_PATH = 'cache.json'
 OR_H = Gtk.Orientation.HORIZONTAL
 OR_V = Gtk.Orientation.VERTICAL
 
+DEBUGGING_CSS = """
+:focus { outline: none; box-shadow: none; background: blue; }
+flowboxchild:selected { outline: none; box-shadow: none; background: green; }
+flowboxchild:hover{ outline: none; box-shadow: none; background: yellow; }
+flowboxchild:active { outline: none; box-shadow: none; background: red; }
+"""
 
 class SortLabelItem(GObject.GObject):
+    """Sort order list representation of sorter label."""
 
     def __init__(self, name):
         super().__init__()
@@ -28,6 +36,7 @@ class SortLabelItem(GObject.GObject):
 
 
 class FileItem(GObject.GObject):
+    """Gallery representation of filesystem entry, base to DirItem, ImgItem."""
 
     def __init__(self, path, info):
         super().__init__()
@@ -37,6 +46,7 @@ class FileItem(GObject.GObject):
 
 
 class DirItem(FileItem):
+    """Gallery representation of filesystem entry for directory."""
 
     def __init__(self, path, info, is_parent=False):
         super().__init__(path, info)
@@ -46,6 +56,7 @@ class DirItem(FileItem):
 
 
 class ImgItem(FileItem):
+    """Gallery representation of filesystem entry for image file."""
 
     def __init__(self, path, info, cache):
         super().__init__(path, info)
@@ -60,8 +71,9 @@ class ImgItem(FileItem):
                 for k in cached.keys():
                     setattr(self, k, cached[k])
 
-    def set_metadata(self, et, cache):
-        for d in et.get_tags([self.full_path], ['Comment']):
+    def set_metadata(self, exif_tool, cache):
+        """Set instance attributes from 'Comment' EXIF tag, write to cache."""
+        for d in exif_tool.get_tags([self.full_path], ['Comment']):
             for k, v in d.items():
                 if k.endswith('Comment'):
                     gen_params = GenParams.from_str(v)
@@ -73,9 +85,21 @@ class ImgItem(FileItem):
         cache[self.full_path] = {self.last_mod_time: cached}
 
 
-class Window(Gtk.ApplicationWindow):
-
-    def __init__(self, **kwargs):
+class MainWindow(Gtk.Window):
+    """Image browser app top-level window."""
+    gallery: Gtk.FlowBox
+    gallery_store: Gio.ListStore
+    gallery_store_filtered: Gtk.FilterListModel
+    gallery_selection: Gtk.SingleSelection
+    include_dirs: bool
+    per_row: int
+    metadata: Gtk.TextBuffer
+    sort_order: list
+    sort_store: Gtk.ListStore
+    sort_selection: Gtk.SingleSelection
+    prev_key: list
+
+    def __init__(self, _app, **kwargs):
         super().__init__(**kwargs)
 
         def init_navbar():
@@ -85,7 +109,7 @@ class Window(Gtk.ApplicationWindow):
                 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)
+            add_button('reload', lambda _: self.load_directory(), 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)
@@ -97,18 +121,18 @@ class Window(Gtk.ApplicationWindow):
         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))
+                    'selected-children-changed',
+                    lambda _: self.update_file_selection())
+            self.gallery.connect(
+                    'child-activated', lambda _, __: self.hit_file_selection())
             gallery_scroller = Gtk.ScrolledWindow(
                     child=self.gallery, propagate_natural_height=True)
             gallery_scroller.get_vadjustment().connect(
-                    'value-changed', lambda _: self.redraw_gallery_items())
+                    'value-changed', lambda _: self.update_gallery_view())
             # 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
+            # bind self.gallery to a model; seems necessary to pre-stretch the
+            # gallery_scroller's viewport for our first calculation (in the
+            # first run of update_gallery) of what images to load into it
             self.gallery.append(Gtk.Box(hexpand=True, vexpand=True))
             return gallery_scroller
 
@@ -158,6 +182,10 @@ class Window(Gtk.ApplicationWindow):
             self.add_controller(key_ctl)
             self.prev_key = [0]
 
+        self.img_dir_absolute = abspath(IMG_DIR)
+        self.block_once_hit_file_selection = False
+        self.block_file_selection_updates = False
+
         viewer = Gtk.Box(orientation=OR_V)
         viewer.append(init_navbar())
         viewer.append(init_gallery_widgets())
@@ -170,28 +198,44 @@ class Window(Gtk.ApplicationWindow):
         self.props.child = box_outer
 
         init_key_control()
-        self.img_dir_absolute = abspath(IMG_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)
+        self.load_directory(update_gallery=False)
+        self.update_sort_order(update_gallery=False)
+        GLib.idle_add(self.update_gallery)
+
+        # # useful for debugging
+        # css_provider = Gtk.CssProvider()
+        # css_provider.load_from_data(DEBUGGING_CSS)
+        # Gtk.StyleContext.add_provider_for_display(
+        #         self.get_display(), css_provider,
+        #         Gtk.STYLE_PROVIDER_PRIORITY_USER)
 
     # various gallery management tasks
 
-    def sort(self):
+    @property
+    def gallery_viewport_geometry(self):
+        """Return gallery viewport's width, height, top."""
+        viewport = self.gallery.get_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 update_gallery(self, suggested_selection=None, sort=True):
+        """Build gallery based on .per_row and .gallery_selection."""
+
         def sorter(a, b):
+            # ensure ".." and all DirItems at start of order
             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
+                    if cmp_upper_dir in (a.name, b.name):
+                        return cmp_upper_dir == b.name
                 elif isinstance(a, DirItem):
                     return False
                 elif isinstance(b, DirItem):
                     return True
+            # apply self.sort_order within DirItems and FileItems (separately)
             for key in self.sort_order:
                 a_cmp = None
                 b_cmp = None
@@ -204,20 +248,9 @@ class Window(Gtk.ApplicationWindow):
                 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
+            vp_width, vp_height, _ = self.gallery_viewport_geometry
             max_slot_width = vp_width / self.per_row - 6
             slot = Gtk.Box()
             slot.item = file_item
@@ -228,22 +261,38 @@ class Window(Gtk.ApplicationWindow):
                 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.content.connect('clicked', self.on_click_file)
             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()
-        self.redraw_gallery_items()
-
-    def redraw_gallery_items(self):
-        _, vp_height, vp_top = self.viewport_geometry
+        if sort:
+            self.gallery_store.sort(sorter)
+        to_select = self.gallery.get_child_at_index(0)
+        if suggested_selection:
+            i = 0
+            while True:
+                gallery_item_at_i = self.gallery.get_child_at_index(i)
+                item_path = gallery_item_at_i.props.child.item.full_path
+                if gallery_item_at_i is None:
+                    break
+                if suggested_selection.full_path == item_path:
+                    to_select = gallery_item_at_i
+                    break
+                i += 1
+        if to_select:
+            self.block_once_hit_file_selection = True
+            to_select.activate()
+        self.update_gallery_view()
+
+    def update_gallery_view(self):
+        """Load/unload gallery's file images based on viewport visibility."""
+        _, vp_height, vp_top = self.gallery_viewport_geometry
         vp_bottom = vp_top + vp_height
-        for i in range(self.gallery_store_filtered.props.n_items):
+        for i in range(self.gallery_store_filtered.get_n_items()):
             slot = self.gallery.get_child_at_index(i).props.child
             if isinstance(slot.item, DirItem):
                 continue
@@ -260,7 +309,6 @@ class Window(Gtk.ApplicationWindow):
                 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
@@ -275,157 +323,208 @@ class Window(Gtk.ApplicationWindow):
         #         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):
-        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]
-            title = f'{item.full_path}'
-            self.metadata.set_text('\n'.join([title] + params_strs))
-        else:
+    def hit_file_selection(self):
+        """If current file selection is directory, reload into that one."""
+        if self.block_once_hit_file_selection:
+            self.block_once_hit_file_selection = False
+            return
+        selected = self.gallery_selection.props.selected_item
+        if isinstance(selected, DirItem):
+            self.img_dir_absolute = selected.full_path
+            self.load_directory()
+
+    def update_file_selection(self):
+        """Sync gallery selection, update metadata on selected file."""
+
+        def sync_fbox_selection_to_gallery_selection():
+            fbox_candidates = self.gallery.get_selected_children()
+            if fbox_candidates:
+                fbox_selected_item = fbox_candidates[0].props.child.item
+                i = 0
+                while True:
+                    gallery_item_at_i = self.gallery_store_filtered.get_item(i)
+                    if fbox_selected_item == gallery_item_at_i:
+                        self.gallery_selection.props.selected = i
+                        break
+                    i += 1
+
+        def update_metadata_on_file():
+            selected_item = self.gallery_selection.props.selected_item
+            if selected_item:
+                if isinstance(selected_item, ImgItem):
+                    params_strs = [f'{k}: {getattr(selected_item, k.lower())}'
+                                   for k in GEN_PARAMS]
+                    title = f'{selected_item.full_path}'
+                    self.metadata.set_text('\n'.join([title] + params_strs))
+                    return
             self.metadata.set_text('')
 
+        sync_fbox_selection_to_gallery_selection()
+        update_metadata_on_file()
+
+    def update_sort_order(self, cur_selection=0, update_gallery=True):
+        """Rebuild self.sort_store from self.sort_order."""
+        self.sort_store.remove_all()
+        for s in self.sort_order:
+            self.sort_store.append(SortLabelItem(s))
+        self.sort_selection.props.selected = cur_selection
+        old_selection = self.gallery_selection.props.selected_item
+        if update_gallery:
+            self.update_gallery(old_selection)
+
     # navbar callables
 
-    def reload_dir(self, rebuild_gallery=True):
+    def load_directory(self, update_gallery=True):
+        """Load into gallery directory at self.img_dir_absolute."""
+
+        def read_directory_into_gallery_items(cache):
+            directory = Gio.File.new_for_path(self.img_dir_absolute)
+            query_attrs = 'standard::name,time::*'
+            parent_path = abspath(path_join(self.img_dir_absolute, UPPER_DIR))
+            parent_dir = directory.get_parent()
+            parent_dir_info = parent_dir.query_info(
+                    query_attrs, Gio.FileQueryInfoFlags.NONE, None)
+            parent_dir_item = DirItem(
+                    parent_path, parent_dir_info, is_parent=True)
+            self.gallery_store.append(parent_dir_item)
+            query_attrs = query_attrs + ',standard::content-type'
+            enumerator = directory.enumerate_children(
+                    query_attrs, Gio.FileQueryInfoFlags.NONE, None)
+            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 to_set_metadata_on:
+                    item.set_metadata(et, cache)
+
+        old_selection = self.gallery_selection.props.selected_item
+        self.block_file_selection_updates = True
         self.gallery_store.remove_all()
-        self.dir = Gio.File.new_for_path(self.img_dir_absolute)
+        self.block_file_selection_updates = False
         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,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)
-        parent_dir_item = DirItem(
-                parent_path, parent_dir_info, is_parent=True)
-        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)
-        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 to_set_metadata_on:
-                item.set_metadata(et, cache)
+        read_directory_into_gallery_items(cache)
         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()
+        if update_gallery:
+            self.update_gallery(old_selection)
 
     def toggle_side_box(self):
-        self.side_box.props.visible = not self.side_box.props.visible
-        self.rebuild_gallery()
+        """Toggle window sidebox visible/invisible."""
+        self.side_box.props.visible = not self.side_box.get_visible()
+        self.update_gallery(self.gallery_selection.props.selected_item,
+                            sort=False)
 
     def reset_include_dirs(self, button):
+        """By button's .active, in-/exclude directories from gallery view."""
         self.include_dirs = button.props.active
-        self.reload_dir()
+        self.load_directory()
 
-    def inc_per_row(self, inc):
-        if self.per_row + inc > 0:
-            self.per_row += inc
-            self.rebuild_gallery()
+    def inc_per_row(self, increment):
+        """Change by increment how many items max to display in gallery row."""
+        if self.per_row + increment > 0:
+            self.per_row += increment
+            self.update_gallery(self.gallery_selection.props.selected_item,
+                                sort=False)
 
-    # 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()
+    # movement
 
     def move_sort(self, direction):
-        current_i = self.sort_selection.props.selected
-        selected = self.sort_order[current_i]
-        if direction == -1 and current_i > 0:
-            prev_i = current_i - 1
+        """In sort order list, move selected item up (-1) or down (+1)."""
+        cur_idx = self.sort_selection.props.selected
+        selected = self.sort_order[cur_idx]
+        if direction == -1 and cur_idx > 0:
+            prev_i = cur_idx - 1
             old_prev = self.sort_order[prev_i]
             self.sort_order[prev_i] = selected
-            self.sort_order[current_i] = old_prev
-        elif direction == 1 and current_i < (len(self.sort_order) - 1):
-            next_i = current_i + 1
+            self.sort_order[cur_idx] = old_prev
+        elif direction == 1 and cur_idx < (len(self.sort_order) - 1):
+            next_i = cur_idx + 1
             old_next = self.sort_order[next_i]
             self.sort_order[next_i] = selected
-            self.sort_order[current_i] = old_next
+            self.sort_order[cur_idx] = old_next
         else:
             return
-        self.update_sort_list(current_i + direction)
+        self.update_sort_order(cur_idx + direction)
 
     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
+        """Move sort order selection by direction (-1 or +1)."""
+        min_idx, max_idx = 0, len(self.sort_order) - 1
+        cur_idx = self.sort_selection.props.selected
+        if (1 == direction and cur_idx < max_idx)\
+                or (-1 == direction and cur_idx > min_idx):
+            self.sort_selection.props.selected = cur_idx + direction
+
+    def move_selection_in_gallery(self, x_inc, y_inc, buf_end):
+        """Move gallery selection in x or y axis, or to start(-1)/end(+1)."""
+        mov_steps = (Gtk.MovementStep.VISUAL_POSITIONS,
+                     Gtk.MovementStep.DISPLAY_LINES,
+                     Gtk.MovementStep.BUFFER_ENDS)
+        for step_size, step_type in zip((x_inc, y_inc, buf_end), mov_steps):
+            if step_size is not None:
+                self.gallery.emit('move-cursor',
+                                  step_type, step_size, False, False)
+
+    # handling of keypresses and clicks
+
+    def on_click_file(self, button):
+        """Set gallery selection to clicked, *then* do .hit_file_selection."""
+        self.gallery.select_child(button.props.parent.props.parent)
+        self.hit_file_selection()
 
     def handle_keypress(self, keyval):
+        """Handle keys, and if, return True to declare key handling done."""
         if Gdk.KEY_G == keyval:
-            self.move_selection_in_directory(None, -1)
+            self.move_selection_in_gallery(None, None, 1)
+        elif Gdk.KEY_h == keyval:
+            self.move_selection_in_gallery(-1, None, None)
         elif Gdk.KEY_j == keyval:
-            self.move_selection_in_directory(1, None)
+            self.move_selection_in_gallery(None, +1, None)
         elif Gdk.KEY_k == keyval:
-            self.move_selection_in_directory(-1, None)
+            self.move_selection_in_gallery(None, -1, None)
+        elif Gdk.KEY_l == keyval:
+            self.move_selection_in_gallery(+1, None, None)
         elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]:
-            self.move_selection_in_directory(None, 0)
-        elif Gdk.KEY_n == keyval:
+            self.move_selection_in_gallery(None, None, -1)
+        elif Gdk.KEY_w == keyval:
             self.move_selection_in_sort_order(-1)
-        elif Gdk.KEY_N == keyval:
+        elif Gdk.KEY_W == keyval:
             self.move_sort(-1)
-        elif Gdk.KEY_m == keyval:
+        elif Gdk.KEY_s == keyval:
             self.move_selection_in_sort_order(1)
-        elif Gdk.KEY_M == keyval:
+        elif Gdk.KEY_S == keyval:
             self.move_sort(1)
         else:
             self.prev_key[0] = keyval
+            return False
         return True
 
 
-def on_activate(app_):
-    win = Window(application=app_)
-    win.present()
+class Application(Gtk.Application):
+    """Image browser application class."""
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.connect('activate', self.on_activate)
+
+    def on_activate(self, app_):
+        """Start window and keep it open."""
+        win = MainWindow(app_)
+        win.present()
+        self.hold()
 
 
-app = Gtk.Application(application_id='plomlompom.com.StablePixBrowser.App')
-app.connect('activate', on_activate)
+app = Application(application_id='plomlompom.com.StablePixBrowser.App')
 app.run(None)