From: Christian Heller Date: Thu, 5 Sep 2024 16:19:37 +0000 (+0200) Subject: Rewrite browser.py for gallery view of directory. X-Git-Url: https://plomlompom.com/repos/%7B%7B%20web_path%20%7D%7D/decks/%7B%7Bdeck_id%7D%7D/cards/%7B%7B%20card_id%20%7D%7D/static/todo?a=commitdiff_plain;h=5d6bf5c5ca0dc4070b396bbe4cf387b141c8caa0;p=stable_plom Rewrite browser.py for gallery view of directory. --- diff --git a/browser.py b/browser.py index 5a5ce3f..37e4e60 100755 --- a/browser.py +++ b/browser.py @@ -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_):