From: Christian Heller Date: Fri, 20 Sep 2024 23:43:34 +0000 (+0200) Subject: Re-write gallery without FlowBox, instead as Grid, for future better row control. X-Git-Url: https://plomlompom.com/repos/%7B%7Bprefix%7D%7D/static/template?a=commitdiff_plain;h=fcb02fb7a3589b27c6e1e2b6b585bbf5f7275574;p=stable_plom Re-write gallery without FlowBox, instead as Grid, for future better row control. --- diff --git a/browser.py b/browser.py index b38cf10..f74e0b7 100755 --- a/browser.py +++ b/browser.py @@ -1,7 +1,11 @@ #!/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 functools import cmp_to_key +from os import listdir +from os.path import (exists as path_exists, join as path_join, abspath, isdir, + splitext, getmtime) +from datetime import datetime, timezone from argparse import ArgumentParser from PIL import Image from PIL.PngImagePlugin import PngImageFile @@ -23,14 +27,28 @@ SORT_DEFAULT = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\ UPPER_DIR = '..' CACHE_PATH = 'cache.json' BOOKMARKS_PATH = 'bookmarks.json' +GALLERY_SLOT_MARGIN = 6 +GALLERY_PER_ROW_DEFAULT = 5 +GALLERY_ENSURE_UPDATED_VIEW_INTERVAL_MS = 500 OR_H = Gtk.Orientation.HORIZONTAL OR_V = Gtk.Orientation.VERTICAL CSS = """ -.bookmarked { background: green; } -.bookmarked:selected { background: #00aaaa; } .temp { background: #aaaa00; } +.bookmarked { background: #000000; } +.selected { background: #008800; } +:focus { background: #00ff00; } +button.slot { + padding-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + border-top-width: 0; + border-bottom-width: 0; + border-left-width: 0; + border-right-width: 0; +} """ @@ -42,19 +60,8 @@ class Sorter(GObject.GObject): super().__init__() self.name = name - def set_label(self, gallery_store, gallery_store_filtered): + def set_label(self, diversities): """Set .list_item's label to .name and n of different values for it.""" - diversities = [0, 0] - for i, store in enumerate([gallery_store_filtered, gallery_store]): - values = set() - for j in range(store.get_n_items()): - item = store.get_item(j) - if isinstance(item, ImgItem): - val = None - if hasattr(item, self.name): - val = getattr(item, self.name) - values.add(val) - diversities[i] = len(values) label = f'{self.name} ({diversities[0]}/{diversities[1]}) ' self.list_item.get_first_child().set_text(label) @@ -62,20 +69,17 @@ class Sorter(GObject.GObject): class FileItem(GObject.GObject): """Gallery representation of filesystem entry, base to DirItem, ImgItem.""" - def __init__(self, path, info): + def __init__(self, path, name): super().__init__() - self.name = info.get_name() - self.last_mod_time = info.get_modification_date_time().format_iso8601() + self.name = name self.full_path = path_join(path, self.name) - self.bookmarked = False class DirItem(FileItem): """Gallery representation of filesystem entry for directory.""" - def __init__(self, path, info, is_parent=False): - super().__init__(path, info) - self.name = f' {UPPER_DIR}' if is_parent else f' {self.name}/' + def __init__(self, path, name, is_parent=False): + super().__init__(path, name) if is_parent: self.full_path = path @@ -83,8 +87,10 @@ class DirItem(FileItem): class ImgItem(FileItem): """Gallery representation of filesystem entry for image file.""" - def __init__(self, path, info, cache): - super().__init__(path, info) + def __init__(self, path, name, last_mod_time, cache): + super().__init__(path, name) + self.last_mod_time = last_mod_time + self.bookmarked = False for param_name in GEN_PARAMS: if param_name in GEN_PARAMS_STR: setattr(self, param_name.lower(), '') @@ -97,7 +103,7 @@ class ImgItem(FileItem): setattr(self, k, cached[k]) def set_metadata(self, cache): - """Set instance attributes from 'image file's GenParams PNG chunk.""" + """Set attrs from file's GenParams PNG chunk, update into cache.""" img = Image.open(self.full_path) if isinstance(img, PngImageFile): gen_params_as_str = img.text.get('generation_parameters', '') @@ -110,21 +116,345 @@ class ImgItem(FileItem): cached[k] = getattr(self, k) cache[self.full_path] = {self.last_mod_time: cached} + def bookmark(self, positive=True): + """Set self.bookmark to positive, and update CSS class mark.""" + self.bookmarked = positive + self.slot.mark('bookmarked', positive) + + +class GallerySlot(Gtk.Button): + """Slot in Gallery representing a FileItem.""" + + def __init__(self, item, on_click_file): + super().__init__() + self.add_css_class('slot') + self.set_hexpand(True) + self.item = item + self.item.slot = self + self.connect('clicked', on_click_file) + + def mark(self, css_class, do_add=True): + """Add or remove css_class from self.""" + if do_add: + self.add_css_class(css_class) + else: + self.remove_css_class(css_class) + + def update_widget(self, slot_size, margin, is_in_vp): + """(Un-)Load content if (not) is_in_vp, update geometry, CSS classes""" + new_content = None + if self.get_child() is None and isinstance(self.item, DirItem): + new_content = Gtk.Label(label=self.item.name) + elif isinstance(self.item, ImgItem): + if is_in_vp and not isinstance(self.item, Gtk.Image): + new_content = Gtk.Image.new_from_file(self.item.full_path) + elif (not is_in_vp) and not isinstance(self.item, Gtk.Label): + new_content = Gtk.Label(label='?') + if new_content: + self.set_child(new_content) + side_margin = margin // 2 + if side_margin: + for s in ('bottom', 'top', 'start', 'end'): + setattr(self.get_child().props, f'margin_{s}', side_margin) + self.get_child().set_size_request(slot_size, slot_size) + if isinstance(self.item, ImgItem): + self.mark('bookmarked', self.item.bookmarked) + + +class Gallery: + """Representation of FileItems below a directory.""" + + def __init__(self, + sort_order, + filter_inputs, + on_hit_item, + on_grid_built, + on_selection_change): + self._sort_order = sort_order + self._filter_inputs = filter_inputs + self._on_hit_item = on_hit_item + self._on_grid_built = on_grid_built + self._on_selection_change = on_selection_change + self.show_dirs = False + + self.per_row = GALLERY_PER_ROW_DEFAULT + self._slot_margin = GALLERY_SLOT_MARGIN + self._grid = None + self._force_width, self._force_height = 0, 0 + self.slots = None + self.dir_entries = [] + self.selected_idx = 0 + + self._fixed_frame = Gtk.Fixed(hexpand=True, vexpand=True) + self.scroller = Gtk.ScrolledWindow(propagate_natural_height=True) + self.scroller.get_vadjustment().connect( + 'value-changed', lambda _: self._update_view(refocus=False)) + # We want our viewport at always maximum possible size (so we can + # safely calculate what's in it and what not), even if the gallery + # would be smaller. Therefore we frame the gallery in an expanding + # Fixed, to stretch out the viewport even if the gallery is small. + self.scroller.set_child(self._fixed_frame) + self._viewport = self._fixed_frame.get_parent() + + self._should_update_view = True + GLib.timeout_add(GALLERY_ENSURE_UPDATED_VIEW_INTERVAL_MS, + self._ensure_updated_view) + + def _ensure_updated_view(self): + """Rather than reload slots every scroll pixel, regularly run this.""" + if self._should_update_view: + self._update_view(refocus=False, force=True) + return True + + @property + def selected_item(self): + """Return slot.item at self.selected_idx.""" + return self.slots[self.selected_idx].item if self.slots else None + + @property + def _viewport_height(self): + return self._force_height if self._force_height\ + else self._viewport.get_height() + + def on_focus_slot(self, slot): + """If GallerySlot focused, set .selected_idx to it.""" + self._set_selection(self.slots.index(slot)) + + def _set_selection(self, new_idx, unselect_old=True): + """Set self.selected_idx, mark slot as 'selected', unmark old one.""" + if unselect_old: + self.slots[self.selected_idx].mark('selected', False) + self.selected_idx = new_idx + self.slots[self.selected_idx].mark('selected', True) + self.slots[self.selected_idx].grab_focus() + self._on_selection_change() + + def build_and_show(self, suggested_selection=None): + """Build gallery as sorted GallerySlots, select one, draw gallery.""" + + def item_clicker(idx): + def f(_): + self._set_selection(idx) + self._on_hit_item() + return f + + if self._grid: + self._fixed_frame.remove(self._grid) + self.slots = [] + self._grid = Gtk.Grid() + self._fixed_frame.put(self._grid, 0, 0) + i_row, i_col = 0, 0 + for i, item in enumerate(sorted([entry for entry in self.dir_entries + if self._filter_func(entry)], + key=cmp_to_key(self._sort_cmp))): + if self.per_row == i_col: + i_col = 0 + i_row += 1 + slot = GallerySlot(item, item_clicker(i)) + self._grid.attach(slot, i_col, i_row, 1, 1) + self.slots += [slot] + i_col += 1 + self._on_grid_built() + self.selected_idx = 0 + self._update_view() + new_idx = 0 + if suggested_selection is not None: + for i, slot in enumerate(self.slots): + item_path = slot.item.full_path + if suggested_selection.full_path == item_path: + new_idx = i + break + self._set_selection(new_idx, unselect_old=False) + + def move_selection(self, x_inc, y_inc, buf_end): + """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) + + def on_resize(self, width, height=None): + """Re-set ._forced_width, ._forced_height, then call ._update_view.""" + self._force_width = width + if height is not None: + self._force_height = height + self._update_view() + + def _update_view(self, refocus=True, force=False): + """Update gallery slots based on if they're in viewport.""" + self._should_update_view = True + vp_scroll = self._viewport.get_vadjustment() + vp_top = vp_scroll.get_value() + if (not force) and vp_top % 1 > 0: + return + vp_bottom = vp_top + self._viewport_height + vp_width = (self._force_width if self._force_width + else self._viewport.get_width()) + max_slot_width = (vp_width // self.per_row) - self._slot_margin + prefered_slot_height = self._viewport_height - self._slot_margin + slot_size = min(prefered_slot_height, max_slot_width) + for idx, slot in enumerate(self.slots): + slot_top = (idx // self.per_row) * (slot_size + self._slot_margin) + slot_bottom = slot_top + slot_size + in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom) + slot.update_widget(slot_size, self._slot_margin, in_vp) + self._should_update_view = False + if (not refocus) or (not self.slots): + return + focused_idx = self.selected_idx + full_slot_height = slot_size + self._slot_margin + focused_slot_top = (focused_idx // self.per_row) * full_slot_height + focused_slot_bottom = focused_slot_top + slot_size + if focused_slot_top < vp_top: + vp_scroll.set_value(focused_slot_top) + elif focused_slot_bottom > vp_bottom: + vp_scroll.set_value(focused_slot_bottom - self._viewport_height) + else: + return + self._should_update_view = True + vp_scroll.emit('value-changed') + + def get_diversities_for(self, sort_attr): + """Return how many diff. values for sort_attr in (un-)filtered store""" + diversities = [0, 0] + for i, store in enumerate([self.dir_entries, + [s.item for s in self.slots]]): + values = set() + for item in store: + if isinstance(item, ImgItem): + val = None + if hasattr(item, sort_attr): + val = getattr(item, sort_attr) + values.add(val) + diversities[i] = len(values) + return diversities + + def _sort_cmp(self, a, b): + """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 FileItems (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 _filter_func(self, item): + """Return if item matches user-set filters.""" + + def number_filter(attr_name, filter_line, to_compare): + use_float = attr_name.upper() in GEN_PARAMS_FLOAT + constraint_strings = filter_line.split(',') + numbers_or = set() + unequal = set() + less_than = None + less_or_equal = None + more_or_equal = None + more_than = None + for constraint_string in constraint_strings: + toks = constraint_string.split() + if len(toks) == 1: + tok = toks[0] + if tok[0] in '<>!': + if '=' == tok[1]: + toks = [tok[:2], tok[2:]] + else: + toks = [tok[:1], tok[1:]] + else: + value = float(tok) if use_float else int(tok) + numbers_or.add(value) + if len(toks) == 2: + value = float(toks[1]) if use_float else int(toks[1]) + if toks[0] == '!=': + unequal.add(value) + elif toks[0] == '<': + if less_than is None or less_than >= value: + less_than = value + elif toks[0] == '<=': + if less_or_equal is None or less_or_equal > value: + less_or_equal = value + elif toks[0] == '>=': + if more_or_equal is None or more_or_equal < value: + more_or_equal = value + elif toks[0] == '>': + if more_than is None or more_than <= value: + more_than = value + if to_compare in numbers_or: + return True + if len(numbers_or) > 0 and (less_than == less_or_equal == + more_or_equal == more_than): + return False + if to_compare in unequal: + return False + if (less_than is not None + and to_compare >= less_than)\ + or (less_or_equal is not None + and to_compare > less_or_equal)\ + or (more_or_equal is not None + and to_compare < more_or_equal)\ + or (more_than is not None + and to_compare <= more_than): + return False + return True + + if not self.show_dirs and isinstance(item, DirItem): + return False + for filter_attribute, value in self._filter_inputs.items(): + if not hasattr(item, filter_attribute): + return False + to_compare = getattr(item, filter_attribute) + number_attributes = set(GEN_PARAMS_INT) | set(GEN_PARAMS_FLOAT) | { + 'BOOKMARKED'} + if filter_attribute.upper() in number_attributes: + if not number_filter(filter_attribute, value, to_compare): + return False + elif value not in to_compare: + return False + return True + 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 - recurse_dirs: bool - per_row: int metadata: Gtk.TextBuffer sort_store: Gtk.ListStore sort_selection: Gtk.SingleSelection prev_key: list - filter_inputs = dict button_activate_sort: Gtk.Button counter: Gtk.Label @@ -146,36 +476,17 @@ class MainWindow(Gtk.Window): 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) + btn.connect('toggled', self.reset_show_dirs) navbar.append(btn) btn = Gtk.CheckButton(label='recurse directories') btn.connect('toggled', self.reset_recurse) navbar.append(btn) return navbar - def init_gallery_widgets(): - self.gallery = Gtk.FlowBox(orientation=OR_H) - self.gallery.connect( - 'selected-children-changed', - lambda _: self.update_file_selection()) - self.gallery.connect( - 'child-activated', lambda _, __: self.hit_file_selection()) - scroller = Gtk.ScrolledWindow(propagate_natural_height=True) - scroller.get_vadjustment().connect( - 'value-changed', lambda _: self.update_gallery_view()) - # We want our viewport at always maximum possible size (so we can - # safely calculate what's in it and what not), even if the gallery - # would be smaller. Therefore we frame the gallery in an expanding - # Fixed, to stretch out the viewport even if the gallery is small. - viewport_stretcher = Gtk.Fixed(hexpand=True, vexpand=True) - viewport_stretcher.put(self.gallery, 0, 0) - scroller.props.child = viewport_stretcher - return scroller - def init_metadata_box(): text_view = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR, editable=False) - text_view.set_size_request(200, -1) + text_view.set_size_request(300, -1) self.metadata = text_view.get_buffer() metadata_box = Gtk.Box(orientation=OR_V) metadata_box.append(Gtk.Label(label='** metadata **')) @@ -207,12 +518,11 @@ class MainWindow(Gtk.Window): self.filter_inputs[sorter.name] = text elif sorter.name in self.filter_inputs: del self.filter_inputs[sorter.name] - self.update_gallery() + self.gallery.build_and_show() sorter = list_item.props.item sorter.list_item = list_item.props.child - sorter.set_label(self.gallery_store, - self.gallery_store_filtered) + sorter.set_label(self.gallery.get_diversities_for(sorter.name)) sorter.filterer = sorter.list_item.get_last_child() filter_entry = sorter.list_item.get_last_child() filter_text = self.filter_inputs.get(sorter.name, '') @@ -237,20 +547,8 @@ class MainWindow(Gtk.Window): self.button_activate_sort.connect( 'clicked', lambda _: self.activate_sort_order()) sort_box.append(self.button_activate_sort) - self.filter_inputs = {} return sort_box - def init_gallery_content(): - self.gallery_store = Gio.ListStore(item_type=FileItem) - list_filter = Gtk.CustomFilter.new(self.gallery_filter) - 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.recurse_dirs = False - self.per_row = 5 - def init_key_control(): key_ctl = Gtk.EventControllerKey( propagation_phase=Gtk.PropagationPhase.CAPTURE) @@ -274,15 +572,20 @@ class MainWindow(Gtk.Window): self.get_display(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) - self.block_once_hit_file_selection = False - self.block_file_selection_updates = False - self.force_width, self.force_height = 0, 0 + self.filter_inputs = {} + self.gallery = Gallery( + sort_order=self.app.sort_order, + filter_inputs=self.filter_inputs, + on_hit_item=self.hit_gallery_item, + on_grid_built=self.update_sort_order_box, + on_selection_change=self.update_metadata_on_gallery_selection) + self.recurse_dirs = False setup_css() viewer = Gtk.Box(orientation=OR_V) self.navbar = init_navbar() viewer.append(self.navbar) - viewer.append(init_gallery_widgets()) + viewer.append(self.gallery.scroller) self.side_box = Gtk.Box(orientation=OR_V) self.sort_box = init_sorter_and_filterer() self.side_box.append(self.sort_box) @@ -296,208 +599,52 @@ class MainWindow(Gtk.Window): ensure_db_files() init_key_control() - init_gallery_content() - self.load_directory(update_gallery=False) - GLib.idle_add(self.update_gallery) + self.load_directory(update_gallery_view=False) + self.connect('notify::focus-widget', + lambda _, __: self.on_focus_change()) + GLib.idle_add(self.gallery.build_and_show) + + def on_focus_change(self): + """If new focus on GallerySlot, call gallery.on_focus_slot.""" + focused = self.get_focus() + if isinstance(focused, GallerySlot): + self.gallery.on_focus_slot(focused) def on_resize(self): - """Adapt .force_(width|height) to new .default_(width|height)""" - if self.get_width() > 0: # so we don't call this on initial resize - # NB: we .measure side_box because its width is changing, whereas - # for the unchanging navbar .get_height is sufficient + """On window resize, do .gallery.on_resize towards its new geometry.""" + if self.get_width() > 0: # So we don't call this on initial resize. + # NB: We .measure side_box because its width is changing, whereas + # for the unchanging navbar .get_height is sufficient. side_box_width = self.side_box.measure(OR_H, -1).natural - self.force_width = self.get_default_size()[0] - side_box_width - self.force_height = (self.get_default_size()[1] - - self.navbar.get_height()) - self.update_gallery_view(refocus=True) - - # various gallery management tasks + default_size = self.get_default_size() + self.gallery.on_resize(default_size[0] - side_box_width, + default_size[1] - self.navbar.get_height()) def bookmark(self): """Toggle bookmark on selected gallery item.""" - selected_item = self.gallery_selection.props.selected_item + if not isinstance(self.gallery.selected_item, ImgItem): + return with open(BOOKMARKS_PATH, 'r', encoding='utf8') as f: bookmarks = json_load(f) - if selected_item.bookmarked: - selected_item.bookmarked = False - bookmarks.remove(selected_item.full_path) + if self.gallery.selected_item.bookmarked: + self.gallery.selected_item.bookmark(False) + bookmarks.remove(self.gallery.selected_item.full_path) else: - selected_item.bookmarked = True - bookmarks += [selected_item.full_path] + self.gallery.selected_item.bookmark(True) + bookmarks += [self.gallery.selected_item.full_path] with open(BOOKMARKS_PATH, 'w', encoding='utf8') as f: json_dump(list(bookmarks), f) - self.update_file_selection() - self.update_gallery_view() self.update_sort_order_box() - def update_gallery(self, suggested_selection=None): - """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: - 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 self.sort_order within DirItems and FileItems (separately) - ret = 0 - for key in [sorter.name for sorter in self.app.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 init_gallery_slot(file_item): - slot = Gtk.Box() - slot.item = file_item - slot.props.hexpand = True - if isinstance(file_item, ImgItem): - slot.content = Gtk.Label(label='?') - else: - slot.content = Gtk.Button(label=file_item.name) - slot.content.connect('clicked', self.on_click_file) - slot.append(slot.content) - return slot - - self.gallery.bind_model(None, lambda _: Gtk.Box()) - self.gallery_store.sort(sorter) - self.gallery.bind_model(self.gallery_selection, init_gallery_slot) - 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) - if gallery_item_at_i is None: - break - item_path = gallery_item_at_i.props.child.item.full_path - 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() - else: - self.counter.set_text(' (nothing) ') - self.update_sort_order_box() - self.update_gallery_view() - - def update_gallery_view(self, refocus=False): - """Load/unload gallery's file images based on viewport visibility.""" - self.gallery.set_min_children_per_line(self.per_row) - self.gallery.set_max_children_per_line(self.per_row) - vp = self.gallery.get_parent().get_parent() - # because sometimes vp.[size] updates too late for our measurements - vp_height = self.force_height if self.force_height else vp.get_height() - vp_width = self.force_width if self.force_width else vp.get_width() - vp_scroll = vp.get_vadjustment() - vp_top = vp_scroll.get_value() - vp_bottom = vp_top + vp_height - margin = 6 - max_slot_width = (vp_width // self.per_row) - margin - prefered_slot_height = vp_height - margin - slot_size = min(prefered_slot_height, max_slot_width) - 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): - slot.content.set_size_request(slot_size, slot_size) - continue - if slot.item.bookmarked: - slot.props.parent.add_css_class('bookmarked') - else: - slot.props.parent.remove_css_class('bookmarked') - slot_top = (i // self.per_row) * (slot_size + margin) - slot_bottom = slot_top + slot_size - in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom) - if in_vp: - if not isinstance(slot.content, Gtk.Image): - slot.remove(slot.content) - slot.content = Gtk.Image.new_from_file(slot.item.full_path) - slot.append(slot.content) - elif isinstance(slot.content, Gtk.Image): - slot.remove(slot.content) - slot.content = Gtk.Label(label='?') - slot.append(slot.content) - slot.content.set_size_request(slot_size, slot_size) - # because for some reason vp.scroll_to doesn't do what we want - if refocus: - for c in self.gallery.get_selected_children(): - for i in range(self.gallery_store_filtered.get_n_items()): - if c == self.gallery.get_child_at_index(i): - slot = self.gallery.get_child_at_index(i).props.child - slot_top = (i // self.per_row) * (slot_size + margin) - slot_bottom = slot_top + slot_size - if slot_top < vp_top: - vp_scroll.set_value(slot_top) - elif slot_bottom > vp_bottom: - vp_scroll.set_value(slot_bottom - vp_height) - vp_scroll.emit('value-changed') - - def hit_file_selection(self): + def hit_gallery_item(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 + selected = self.gallery.selected_item if isinstance(selected, DirItem): self.app.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}' - bookmarked = 'BOOKMARK' if selected_item.bookmarked else '' - self.metadata.set_text( - '\n'.join([title, bookmarked] + params_strs)) - return - self.metadata.set_text('') - - sync_fbox_selection_to_gallery_selection() - update_metadata_on_file() - idx = self.gallery_selection.props.selected + 1 - total = self.gallery_selection.get_n_items() - self.counter.set_text(f' {idx} of {total} ') - def update_sort_order_box(self, alt_order=None, cur_selection=0): - """Rebuild .sort_store from .sort_order or alt_order.""" + """Rebuild .sort_store from .sort_order, or alt_order if provided.""" sort_order = alt_order if alt_order else self.app.sort_order self.sort_store.remove_all() for sorter in sort_order: @@ -506,130 +653,50 @@ class MainWindow(Gtk.Window): def activate_sort_order(self): """Write sort order box order into .app.sort_order, mark finalized.""" - self.app.sort_order = [] + self.app.sort_order.clear() for i in range(self.sort_store.get_n_items()): sorter = self.sort_store.get_item(i) sorter.list_item.remove_css_class('temp') self.app.sort_order += [sorter] self.button_activate_sort.props.sensitive = False - old_selection = self.gallery_selection.props.selected_item - self.update_gallery(old_selection) - - # navbar callables + old_selection = self.gallery.selected_item + self.gallery.build_and_show(old_selection) - def gallery_filter(self, item): - """Apply user-set filters to gallery.""" - - def number_filter(attr_name, filter_line, to_compare): - use_float = attr_name.upper() in GEN_PARAMS_FLOAT - constraint_strings = filter_line.split(',') - numbers_or = set() - unequal = set() - less_than = None - less_or_equal = None - more_or_equal = None - more_than = None - for constraint_string in constraint_strings: - toks = constraint_string.split() - if len(toks) == 1: - tok = toks[0] - if tok[0] in '<>!': - if '=' == tok[1]: - toks = [tok[:2], tok[2:]] - else: - toks = [tok[:1], tok[1:]] - else: - value = float(tok) if use_float else int(tok) - numbers_or.add(value) - if len(toks) == 2: - value = float(toks[1]) if use_float else int(toks[1]) - if toks[0] == '!=': - unequal.add(value) - elif toks[0] == '<': - if less_than is None or less_than >= value: - less_than = value - elif toks[0] == '<=': - if less_or_equal is None or less_or_equal > value: - less_or_equal = value - elif toks[0] == '>=': - if more_or_equal is None or more_or_equal < value: - more_or_equal = value - elif toks[0] == '>': - if more_than is None or more_than <= value: - more_than = value - if to_compare in numbers_or: - return True - if len(numbers_or) > 0 and (less_than == less_or_equal == - more_or_equal == more_than): - return False - if to_compare in unequal: - return False - if (less_than is not None - and to_compare >= less_than)\ - or (less_or_equal is not None - and to_compare > less_or_equal)\ - or (more_or_equal is not None - and to_compare < more_or_equal)\ - or (more_than is not None - and to_compare <= more_than): - return False - return True - - if not self.include_dirs and isinstance(item, DirItem): - return False - for filter_attribute, value in self.filter_inputs.items(): - if not hasattr(item, filter_attribute): - return False - to_compare = getattr(item, filter_attribute) - number_attributes = set(GEN_PARAMS_INT) | set(GEN_PARAMS_FLOAT) | { - 'BOOKMARKED'} - if filter_attribute.upper() in number_attributes: - if not number_filter(filter_attribute, value, to_compare): - return False - elif value not in to_compare: - return False - return True - - def load_directory(self, update_gallery=True): - """Load into gallery directory at .app.img_dir_absolute.""" + def load_directory(self, update_gallery_view=True): + """Load .gallery.store_unfiltered from .app.img_dir_absolute path.""" def read_directory_into_gallery_items(dir_path, make_parent=False): - directory = Gio.File.new_for_path(dir_path) - query_attrs = 'standard::name,time::*' - if make_parent: - parent_path = abspath(path_join(dir_path, 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) + if make_parent and self.gallery.show_dirs: + parent_dir = DirItem(abspath(path_join(dir_path, UPPER_DIR)), + UPPER_DIR, is_parent=True) + self.gallery.dir_entries += [parent_dir] to_set_metadata_on = [] - for info in enumerator: - if info.get_file_type() == Gio.FileType.DIRECTORY: - if self.include_dirs: - self.gallery_store.append(DirItem(dir_path, info)) + for fn in list(listdir(dir_path)): + full_path = path_join(dir_path, fn) + if isdir(full_path): + if self.gallery.show_dirs: + self.gallery.dir_entries += [DirItem(dir_path, fn)] if self.recurse_dirs: - read_directory_into_gallery_items( - path_join(dir_path, info.get_name())) - elif info.get_content_type()\ - and info.get_content_type().startswith('image/'): - item = ImgItem(dir_path, info, cache) - if item.full_path in bookmarks: - item.bookmarked = True - if '' == item.model: - to_set_metadata_on += [item] - self.gallery_store.append(item) + read_directory_into_gallery_items(full_path) + continue + _, ext = splitext(fn) + if ext not in {'.png', '.PNG'}: + continue + mtime = getmtime(full_path) + dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + iso8601_str = dt.isoformat( + timespec='microseconds').replace('+00:00', 'Z') + item = ImgItem(dir_path, fn, iso8601_str, cache) + if item.full_path in bookmarks: + item.bookmarked = True + if '' == item.model: + to_set_metadata_on += [item] + self.gallery.dir_entries += [item] for item in to_set_metadata_on: item.set_metadata(cache) - old_selection = self.gallery_selection.props.selected_item - self.block_file_selection_updates = True - self.gallery_store.remove_all() - self.block_file_selection_updates = False + old_selection = self.gallery.selected_item + self.gallery.dir_entries = [] with open(BOOKMARKS_PATH, 'r', encoding='utf8') as f: bookmarks = json_load(f) with open(CACHE_PATH, 'r', encoding='utf8') as f: @@ -637,35 +704,33 @@ class MainWindow(Gtk.Window): read_directory_into_gallery_items(self.app.img_dir_absolute, True) with open(CACHE_PATH, 'w', encoding='utf8') as f: json_dump(cache, f) - if update_gallery: - self.update_gallery(old_selection) + if update_gallery_view: + self.gallery.build_and_show(old_selection) def toggle_side_box(self): """Toggle window sidebox visible/invisible.""" self.side_box.props.visible = not self.side_box.get_visible() + # Calculate new viewport directly, because GTK's respective viewport + # measurement happens too late for our needs. side_box_width = self.side_box.measure(OR_H, -1).natural - # because relevant viewport measurement happens too late for our needs - self.force_width = self.get_width() - side_box_width - self.update_gallery_view(refocus=True) + self.gallery.on_resize(self.get_width() - side_box_width) - def reset_include_dirs(self, button): + def reset_show_dirs(self, button): """By button's .active, in-/exclude directories from gallery view.""" - self.include_dirs = button.props.active + self.gallery.show_dirs = button.props.active self.load_directory() 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_view(refocus=True) + if self.gallery.per_row + increment > 0: + self.gallery.per_row += increment + self.gallery.build_and_show(self.gallery.selected_item) def reset_recurse(self, button): """By button's .active, de-/activate recursion on image collection.""" self.recurse_dirs = button.props.active self.load_directory() - # movement - def move_sort(self, direction): """Move selected item in sort order view, ensure temporary state.""" tmp_sort_order = [] @@ -683,7 +748,7 @@ class MainWindow(Gtk.Window): old_next = tmp_sort_order[next_i] tmp_sort_order[next_i] = selected tmp_sort_order[cur_idx] = old_next - else: + else: # to catch movement beyond limits return self.update_sort_order_box(tmp_sort_order, cur_idx + direction) self.sort_selection.props.selected = cur_idx + direction @@ -700,42 +765,42 @@ class MainWindow(Gtk.Window): 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 update_metadata_on_gallery_selection(self): + """Update .metadata about individual file, .counter on its idx/total""" + self.metadata.set_text('') + selected_item = self.gallery.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}' + bookmarked = 'BOOKMARK' if selected_item.bookmarked else '' + self.metadata.set_text( + '\n'.join([title, bookmarked] + params_strs)) + total = len(self.gallery.slots) + self.counter.set_text(f' {self.gallery.selected_idx + 1} of {total} ') def handle_keypress(self, keyval): - """Handle keys if not in Entry, return True if key handling done.""" + """Handle keys if not in Gtk.Entry, return True if key handling done""" if isinstance(self.get_focus().get_parent(), Gtk.Entry): return False - if Gdk.KEY_Return == keyval and\ - self.get_focus().get_parent().get_parent() == self.sort_box: - self.activate_sort_order() + if Gdk.KEY_Return == keyval: + if self.get_focus().get_parent().get_parent() == self.sort_box: + self.activate_sort_order() + else: + self.hit_gallery_item() elif Gdk.KEY_G == keyval: - self.move_selection_in_gallery(None, None, 1) + self.gallery.move_selection(None, None, 1) elif Gdk.KEY_h == keyval: - self.move_selection_in_gallery(-1, None, None) + self.gallery.move_selection(-1, None, None) elif Gdk.KEY_j == keyval: - self.move_selection_in_gallery(None, +1, None) + self.gallery.move_selection(None, +1, None) elif Gdk.KEY_k == keyval: - self.move_selection_in_gallery(None, -1, None) + self.gallery.move_selection(None, -1, None) elif Gdk.KEY_l == keyval: - self.move_selection_in_gallery(+1, None, None) + self.gallery.move_selection(+1, None, None) elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]: - self.move_selection_in_gallery(None, None, -1) + self.gallery.move_selection(None, None, -1) elif Gdk.KEY_w == keyval: self.move_selection_in_sort_order(-1) elif Gdk.KEY_W == keyval: @@ -761,6 +826,7 @@ class Application(Gtk.Application): super().__init__(*args, **kwargs) def _build_sort_order(self, suggestion_fused): + """Parse suggestion_fused for/into sort order suggestion.""" suggestion = suggestion_fused.split(',') names = [p.lower() for p in GEN_PARAMS] + ['bookmarked'] sort_order = [] @@ -779,7 +845,7 @@ class Application(Gtk.Application): return sort_order def do_activate(self, *args, **kwargs): - """Parse arguments, start window, and keep it open.""" + """Parse arguments, start window, keep it open.""" parser = ArgumentParser() parser.add_argument('directory', default=IMG_DIR_DEFAULT, nargs='?') parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT)