From 9abe2c9ed3160115a41abf1b1b7afda0c03a9148 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Sun, 13 Oct 2024 05:48:47 +0200 Subject: [PATCH] Browser.py: General re-organization. --- browser.py | 948 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 538 insertions(+), 410 deletions(-) diff --git a/browser.py b/browser.py index 30acecc..92bd4c1 100755 --- a/browser.py +++ b/browser.py @@ -6,8 +6,9 @@ from re import search as re_search 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 datetime import datetime, timezone, timedelta from argparse import ArgumentParser +from math import ceil from PIL import Image from PIL.PngImagePlugin import PngImageFile import gi # type: ignore @@ -30,7 +31,9 @@ CACHE_PATH = 'cache.json' BOOKMARKS_PATH = 'bookmarks.json' GALLERY_SLOT_MARGIN = 6 GALLERY_PER_ROW_DEFAULT = 5 -GALLERY_ENSURE_UPDATED_VIEW_INTERVAL_MS = 500 +GALLERY_UPDATE_INTERVAL_MS = 50 +GALLERY_REDRAW_WAIT_MS = 200 +ACCEPTED_IMG_FILE_ENDINGS = {'.png', '.PNG'} OR_H = Gtk.Orientation.HORIZONTAL OR_V = Gtk.Orientation.VERTICAL @@ -63,118 +66,197 @@ def _add_button(parent, label, on_click=None, checkbox=False): return btn -class Sorter(GObject.GObject): - """Sort order box representation of sorting attribute.""" +class JsonDB: + """Representation of our simple .json DB files.""" + + def __init__(self, path): + self._path = path + self._content = {} + self._is_open = False + if not path_exists(path): + with open(path, 'w', encoding='utf8') as f: + json_dump({}, f) + + def _open(self): + if self._is_open: + raise Exception('DB already open') + with open(self._path, 'r', encoding='utf8') as f: + self._content = json_load(f) + self._is_open = True + + def _close(self): + self._is_open = False + self._content = {} + + def write(self): + """Write to ._path what's in ._content.""" + if not self._is_open: + raise Exception('DB not open') + with open(self._path, 'w', encoding='utf8') as f: + json_dump(self._content, f) + self._close() + + def as_dict_copy(self): + """Return content at ._path for read-only purposes.""" + self._open() + dict_copy = self._content.copy() + self._close() + return dict_copy + + def as_dict_ref(self): + """Return content at ._path as ref so that .write() stores changes.""" + self._open() + return self._content + + +class SorterAndFilterer(GObject.GObject): + """Sort order box representation of sorting/filtering attribute.""" widget: Gtk.Box + label: Gtk.Label def __init__(self, name): super().__init__() self.name = name - def setup_on_bind(self, widget, on_filter_enter, filter_text, - vals_filtered, vals_full): - """Set up all the sting only available on factory item bind.""" + def setup_on_bind(self, widget, on_filter_activate, filter_text, vals): + """Set up SorterAndFilterer label, values listing, filter entry.""" self.widget = widget - title = f'{self.name} ({len(vals_filtered)}/{len(vals_full)}) ' - widget.get_first_child().get_first_child().set_text(title) - vals_listed = [f'{v}' if v in vals_filtered else f'{v}' - for v in vals_full] - widget.get_last_child().set_text(', '.join(vals_listed)) - widget.get_last_child().set_use_markup(True) - filter_entry = widget.get_first_child().get_last_child() - filter_buffer = filter_entry.get_buffer() + # label + len_incl = len(vals['incl']) + title = f'{self.name} ({len_incl}/{len_incl + len(vals["excl"])}) ' + self.widget.label.set_text(title) + # values listing + vals_listed = [f'{v}' for v in vals['incl']] + vals_listed += [f'{v}' for v in vals['excl']] + self.widget.values.set_text(', '.join(vals_listed)) + self.widget.values.set_use_markup(True) + # filter input + filter_buffer = self.widget.filter.get_buffer() filter_buffer.set_text(filter_text, -1) - filter_entry.connect('activate', on_filter_enter) + self.widget.filter.connect('activate', on_filter_activate) filter_buffer.connect( 'inserted_text', - lambda a, b, c, d: filter_entry.add_css_class('temp')) + lambda a, b, c, d: self.widget.filter.add_css_class('temp')) filter_buffer.connect( 'deleted_text', - lambda a, b, c: filter_entry.add_css_class('temp')) + lambda a, b, c: self.widget.filter.add_css_class('temp')) -class TableConfig(): +class GalleryConfig(): """Representation of sort and filtering settings.""" - _gallery_update = None - _gallery_items_attrs_full = None - _gallery_items_attrs_filtered = None - _gallery_set_by_1st_sorter = None + _gallery_request_update = None + _gallery_items_attrs = None + _gallery_update_settings = None def __init__(self, sort_order): - def setup_sort_order_item(_, list_item): - vbox = Gtk.Box(orientation=OR_V) + def setup_sorter_list_item(_, list_item): + item_widget = Gtk.Box(orientation=OR_V) + item_widget.values = Gtk.Label( + visible=False, max_width_chars=35, + wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR) + item_widget.label = Gtk.Label(hexpand=True) + item_widget.filter = Gtk.Entry(placeholder_text='filter?') hbox = Gtk.Box(orientation=OR_H) - hbox.append(Gtk.Label(hexpand=True)) - hbox.append(Gtk.Entry.new()) - hbox.get_last_child().props.placeholder_text = 'filter?' - vbox.append(hbox) - vals_listing = Gtk.Label(wrap=True, max_width_chars=35, - wrap_mode=Pango.WrapMode.WORD_CHAR) - vals_listing.hide() - vbox.append(vals_listing) - list_item.set_child(vbox) - - def bind_sort_order_item(_, list_item): - - def on_filter_enter(entry): + hbox.append(item_widget.label) + hbox.append(item_widget.filter) + item_widget.append(hbox) + item_widget.append(item_widget.values) + list_item.set_child(item_widget) + + def bind_sorter_list_item(_, list_item): + + def on_filter_activate(entry): entry.remove_css_class('temp') text = entry.get_buffer().get_text() if '' != text.rstrip(): self.filter_inputs[sorter.name] = text elif sorter.name in self.filter_inputs: del self.filter_inputs[sorter.name] - self._gallery_update() + self._filter_inputs_changed = True sorter = list_item.props.item - filter_text = self.filter_inputs.get(sorter.name, '') - vals_filtered = self._gallery_items_attrs_filtered[sorter.name] - vals_full = self._gallery_items_attrs_full[sorter.name] - sorter.setup_on_bind(list_item.props.child, on_filter_enter, - filter_text, vals_filtered, vals_full) + sorter.setup_on_bind(list_item.props.child, on_filter_activate, + self.filter_inputs.get(sorter.name, ''), + self._gallery_items_attrs[sorter.name]) def select_sort_order(_a, _b, _c): - if self._last_selected: - self._last_selected.get_last_child().hide() - list_item = self._selection.props.selected_item.widget - list_item.get_parent().grab_focus() - self._last_selected = list_item + self._sort_sel.props.selected_item.widget.get_parent().grab_focus() + + def toggle_recurse(_): + self._set_recurse_changed = not self._set_recurse_changed + self._btn_apply.set_sensitive(not self._set_recurse_changed) - def on_by_1st_toggle(btn): + def toggle_by_1st(btn): self._btn_per_row.set_sensitive(not btn.props.active) self._btn_show_dirs.set_sensitive(not btn.props.active) if btn.props.active: self._btn_show_dirs.set_active(False) - self._gallery_set_by_1st_sorter(btn.props.active) + + def apply_config(): + new_order = [] + for i in range(self._store.get_n_items()): + sorter = self._store.get_item(i) + sorter.widget.remove_css_class('temp') + new_order += [sorter] + if self.order != new_order: + self.order.clear() + self.order += new_order[:] + self._gallery_update_settings( + per_row=self._btn_per_row.get_value_as_int(), + by_1st=self._btn_by_1st.get_active(), + show_dirs=self._btn_show_dirs.get_active(), + sort_order=self.order[:], + filter_inputs=self.filter_inputs.copy(), + recurse_dirs=self._btn_recurse.get_active()) + self._gallery_request_update(select=True) + self._set_recurse_changed = False + self._filter_inputs_changed = False + + def full_reload(): + apply_config() + self._gallery_request_update(load=True) + self._btn_apply.set_sensitive(True) self.order = sort_order self.filter_inputs = {} + self._filter_inputs_changed = False + self._set_recurse_changed = False self._last_selected = None - self._store = Gio.ListStore(item_type=Sorter) - self._selection = Gtk.SingleSelection.new(self._store) - self._selection.connect('selection-changed', select_sort_order) - factory = Gtk.SignalListItemFactory() - factory.connect('setup', setup_sort_order_item) - factory.connect('bind', bind_sort_order_item) - self.view = Gtk.ListView(model=self._selection, factory=factory) + + self._store = Gio.ListStore(item_type=SorterAndFilterer) + self._sort_sel = Gtk.SingleSelection.new(self._store) + self._sort_sel.connect('selection-changed', select_sort_order) + fac = Gtk.SignalListItemFactory() + fac.connect('setup', setup_sorter_list_item) + fac.connect('bind', bind_sorter_list_item) + self.sorter_listing = Gtk.ListView(model=self._sort_sel, factory=fac) + + buttons_box = Gtk.Box(orientation=OR_H) + self._btn_apply = _add_button(buttons_box, 'apply config', + lambda _: apply_config()) + self._btn_relaod = _add_button(buttons_box, 'full reload', + lambda _: full_reload()) dirs_box = Gtk.Box(orientation=OR_H) dirs_box.append(Gtk.Label(label='directories:')) self._btn_show_dirs = _add_button(dirs_box, 'show', checkbox=True) - self._btn_recurse = _add_button(dirs_box, 'recurse', checkbox=True) - self._btn_reload_dir = _add_button(dirs_box, 'reload') + self._btn_recurse = _add_button(dirs_box, 'recurse', + toggle_recurse, checkbox=True) per_row_box = Gtk.Box(orientation=OR_H) per_row_box.append(Gtk.Label(label='cols/row:')) - _add_button(per_row_box, 'by 1st sorter', on_by_1st_toggle, True) - self._btn_per_row = Gtk.SpinButton.new_with_range(1, 9, 1) + self._btn_by_1st = _add_button(per_row_box, 'by 1st sorter', + toggle_by_1st, checkbox=True) + self._btn_per_row = Gtk.SpinButton.new_with_range( + GALLERY_PER_ROW_DEFAULT, 9, 1) per_row_box.append(self._btn_per_row) self.box = Gtk.Box(orientation=OR_V) - title = Gtk.Label(label='table config', use_markup=True) - self.box.append(title) + self.box.append(Gtk.Label(label='table config', use_markup=1)) + self.box.append(buttons_box) + self.box.append(self.sorter_listing) self.box.append(dirs_box) - self.box.append(self.view) self.box.append(per_row_box) @classmethod @@ -184,7 +266,7 @@ class TableConfig(): names = [p.lower() for p in GEN_PARAMS] + ['bookmarked'] sort_order = [] for name in names: - sort_order += [Sorter(name)] + sort_order += [SorterAndFilterer(name)] new_sort_order = [] do_reverse = '-' in suggestion for pattern in suggestion: @@ -197,36 +279,37 @@ class TableConfig(): sort_order.reverse() return cls(sort_order) - def bind_gallery(self, on_update, update_per_row, per_row_initial, reload, - toggle_showdirs, toggle_recurse, set_by_1st_sorter, - items_attrs_full, items_attrs_filtered): + def bind_gallery(self, request_update, update_settings, items_attrs): """Connect to Gallery interfaces where necessary.""" - self._gallery_update = on_update - self._btn_show_dirs.connect('toggled', toggle_showdirs) - self._btn_recurse.connect('toggled', toggle_recurse) - self._btn_reload_dir.connect('clicked', reload) - self._gallery_set_by_1st_sorter = set_by_1st_sorter - self._gallery_items_attrs_full = items_attrs_full - self._gallery_items_attrs_filtered = items_attrs_filtered - self._btn_per_row.set_value(per_row_initial) - self._btn_per_row.connect( - 'value-changed', - lambda btn: update_per_row(btn.get_value_as_int())) + self._gallery_request_update = request_update + self._gallery_update_settings = update_settings + self._gallery_items_attrs = items_attrs + + def on_focus_sorter(self, focused): + """If sorter focused, select focused, move display of values there.""" + if self._last_selected: + self._last_selected.values.set_visible(False) + self._last_selected = focused.get_first_child() + self._last_selected.values.set_visible(True) + for i in range(self._sort_sel.get_n_items()): + if self._sort_sel.get_item(i).widget == self._last_selected: + self._sort_sel.props.selected = i + break def move_selection(self, direction): """Move sort order selection by direction (-1 or +1).""" min_idx, max_idx = 0, len(self.order) - 1 - cur_idx = self._selection.props.selected + cur_idx = self._sort_sel.props.selected if (1 == direction and cur_idx < max_idx)\ or (-1 == direction and cur_idx > min_idx): - self._selection.props.selected = cur_idx + direction + self._sort_sel.props.selected = cur_idx + direction def move_sorter(self, direction): """Move selected item in sort order view, ensure temporary state.""" tmp_sort_order = [] for i in range(self._store.get_n_items()): tmp_sort_order += [self._store.get_item(i)] - cur_idx = self._selection.props.selected + cur_idx = self._sort_sel.props.selected selected = tmp_sort_order[cur_idx] if direction == -1 and cur_idx > 0: prev_i = cur_idx - 1 @@ -241,27 +324,18 @@ class TableConfig(): else: # to catch movement beyond limits return self.update_box(tmp_sort_order, cur_idx + direction) - self._selection.props.selected = cur_idx + direction + self._sort_sel.props.selected = cur_idx + direction for i in range(self._store.get_n_items()): sort_item = self._store.get_item(i) sort_item.widget.add_css_class('temp') def update_box(self, alt_order=None, cur_selection=0): - """Rebuild .store from .order, or alt_order if provided.""" + """Rebuild sorter listing in box from .order, or alt_order if set.""" sort_order = alt_order if alt_order else self.order self._store.remove_all() for sorter in sort_order: self._store.append(sorter) - self._selection.props.selected = cur_selection - - def activate_order(self): - """Write sort order box order into .order, mark finalized.""" - self.order.clear() - for i in range(self._store.get_n_items()): - sorter = self._store.get_item(i) - sorter.widget.remove_css_class('temp') - self.order += [sorter] - self._gallery_update() + self._sort_sel.props.selected = cur_selection class GallerySlot(Gtk.Button): @@ -275,6 +349,8 @@ class GallerySlot(Gtk.Button): self.item.slot = self if on_click_file: self.connect('clicked', on_click_file) + self._slot_size = None + self._side_margin = None def mark(self, css_class, do_add=True): """Add or remove css_class from self.""" @@ -283,8 +359,21 @@ class GallerySlot(Gtk.Button): 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""" + def ensure_slot_size(self, slot_size, margin): + """Call ._size_widget to size .props.child; if none, make empty one.""" + self._slot_size = slot_size + self._side_margin = margin // 2 + if self.get_child() is None: + self.set_child(Gtk.Label(label='+')) + self._size_widget() + + def _size_widget(self): + for s in ('bottom', 'top', 'start', 'end'): + setattr(self.get_child().props, f'margin_{s}', self._side_margin) + self.get_child().set_size_request(self._slot_size, self._slot_size) + + def update_widget(self, is_in_vp): + """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class.""" new_content = None if isinstance(self.item, ImgItem): if is_in_vp and not isinstance(self.item, Gtk.Image): @@ -298,16 +387,12 @@ class GallerySlot(Gtk.Button): new_content = box elif (not is_in_vp) and not isinstance(self.item, Gtk.Label): new_content = Gtk.Label(label='?') - elif self.get_child() is None: - label = self.item.name if isinstance(self.item, DirItem) else '+' - new_content = Gtk.Label(label=label) + elif (isinstance(self.item, DirItem) + and self.get_child().props.label == '+'): + new_content = Gtk.Label(label=self.item.name) 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) + self._size_widget() if isinstance(self.item, ImgItem): self.mark('bookmarked', self.item.bookmarked) @@ -315,12 +400,19 @@ class GallerySlot(Gtk.Button): class GalleryItem(GObject.GObject): """Gallery representation of filesystem entry, base to DirItem, ImgItem.""" slot: GallerySlot + _to_hash = ['name', 'full_path'] def __init__(self, path, name): super().__init__() self.name = name self.full_path = path_join(path, self.name) + def __hash__(self): + hashable_values = [] + for attr_name in self._to_hash: + hashable_values += [getattr(self, attr_name)] + return hash(tuple(hashable_values)) + class DirItem(GalleryItem): """Gallery representation of filesystem entry for directory.""" @@ -333,12 +425,19 @@ class DirItem(GalleryItem): class ImgItem(GalleryItem): """Gallery representation of filesystem entry for image file.""" + _to_hash = (['name', 'full_path', 'last_mod_time', 'bookmarked', + 'with_others'] + + [k.lower() for k in GEN_PARAMS]) - def __init__(self, path, name, last_mod_time, cache): + def __init__(self, path, name, cache): super().__init__(path, name) - self.last_mod_time = last_mod_time + mtime = getmtime(self.full_path) + dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + iso8601_str = dt.isoformat(timespec='microseconds') + self.last_mod_time = iso8601_str.replace('+00:00', 'Z') self.bookmarked = False self.with_others = False + self.has_metadata = False for param_name in GEN_PARAMS: if param_name in GEN_PARAMS_STR: setattr(self, param_name.lower(), '') @@ -346,6 +445,7 @@ class ImgItem(GalleryItem): setattr(self, param_name.lower(), 0) if self.full_path in cache: if self.last_mod_time in cache[self.full_path]: + self.has_metadata = True cached = cache[self.full_path][self.last_mod_time] for k in cached.keys(): setattr(self, k, cached[k]) @@ -373,88 +473,160 @@ class ImgItem(GalleryItem): class Gallery: """Representation of GalleryItems 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 + def __init__(self, on_hit_item, on_grid_built, on_selection_change, + bookmarks_db, cache_db): 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_by_1st_sorter = False + self._bookmarks_db, self._cache_db = bookmarks_db, cache_db + self._sort_order = [] + self._filter_inputs = {} + self._img_dir_path = None + + self._shall_load = False + self._shall_build = False + self._shall_redraw = False + self._shall_scroll_to_focus = False + self._shall_select = False + + self._show_dirs = False + self._recurse_dirs = False + self._by_1st = 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.dir_entries_filtered_sorted = [] - self.selected_idx = 0 self.items_attrs = {} - self.items_attrs_filtered = {} + self.selected_idx = 0 + self.slots = None - self._fixed_frame = Gtk.Fixed(hexpand=True, vexpand=True) + self._grid = None + self._force_width, self._force_height = 0, 0 scroller = Gtk.ScrolledWindow(propagate_natural_height=True) - self.frame = Gtk.Box(orientation=OR_V) self._col_headers_frame = Gtk.Fixed() self._col_headers_grid = None + self.frame = Gtk.Box(orientation=OR_V) self.frame.append(self._col_headers_frame) self.frame.append(scroller) - 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._fixed_frame = Gtk.Fixed(hexpand=True, vexpand=True) scroller.set_child(self._fixed_frame) self._viewport = self._fixed_frame.get_parent() + self._viewport.set_scroll_to_focus(False) # prefer our own handling - 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 and self.slots: - self._update_view(refocus=False, force=True) - return True - - def get_per_row(self): - """Wrapper to ._per_row to (for setting) discourage direct access.""" - return self._per_row + def ensure_uptodate(): + if self._img_dir_path is None: + return True + if self._shall_load: + self._load_directory() + if self._shall_build: + self._build() + if self._shall_select: + self._set_selection(self.selected_idx) + if self._shall_redraw: + wait_time_passed = datetime.now() - self._start_redraw_wait + if wait_time_passed > redraw_wait_time: + self._redraw_and_check_focus() + return True - def update_per_row(self, val): - """Wrapper to setting ._per_row to include call to .build_and_show.""" - self._per_row = val - self.build_and_show() + def handle_scroll(_): + self._start_redraw_wait = datetime.now() + self._shall_scroll_to_focus = False + self._shall_redraw = True + + redraw_wait_time = timedelta(milliseconds=GALLERY_REDRAW_WAIT_MS) + self._start_redraw_wait = datetime.now() - redraw_wait_time + scroller.get_vadjustment().connect('value-changed', handle_scroll) + GLib.timeout_add(GALLERY_UPDATE_INTERVAL_MS, ensure_uptodate) + + def update_settings(self, per_row=None, by_1st=None, show_dirs=None, + recurse_dirs=None, img_dir_path=None, sort_order=None, + filter_inputs=None): + """Set Gallery setup fields, request appropriate updates.""" + for val, attr_name in [(per_row, '_per_row'), + (by_1st, '_by_1st'), + (show_dirs, '_show_dirs'), + (recurse_dirs, '_recurse_dirs'), + (img_dir_path, '_img_dir_path'), + (sort_order, '_sort_order'), + (filter_inputs, '_filter_inputs')]: + if val is not None and getattr(self, attr_name) != val: + setattr(self, attr_name, val) + if attr_name in {'_recurse_dirs', '_img_dir_path'}: + self._load_directory() + else: + self.request_update(build=True) + + def _load_directory(self): + """Rewrite .dir_entries from ._img_dir_path, trigger rebuild.""" + + def read_directory(dir_path, make_parent=False): + if make_parent: + parent_dir = DirItem(abspath(path_join(dir_path, UPPER_DIR)), + UPPER_DIR, is_parent=True) + self.dir_entries += [parent_dir] + dirs_to_enter, to_set_metadata_on = [], [] + dir_entries = list(listdir(dir_path)) + for i, filename in enumerate(dir_entries): + msg = f'loading {dir_path}: entry {i+1}/{len(dir_entries)}' + print(msg, end='\r') + full_path = path_join(dir_path, filename) + if isdir(full_path): + self.dir_entries += [DirItem(dir_path, filename)] + dirs_to_enter += [full_path] + continue + _, ext = splitext(filename) + if ext not in ACCEPTED_IMG_FILE_ENDINGS: + continue + img_item = ImgItem(dir_path, filename, cache) + if img_item.full_path in bookmarks: + img_item.bookmarked = True + if not img_item.has_metadata: + to_set_metadata_on += [img_item] + self.dir_entries += [img_item] + print('') + for i, item in enumerate(to_set_metadata_on): + msg = f'setting metadata: {i+1}/{len(to_set_metadata_on)}' + print(msg, end='\r') + item.set_metadata(cache) + msg = '' if to_set_metadata_on else '(no metadata to set)' + print(msg) + if dirs_to_enter and self._recurse_dirs: + prefix = f'entering directories below {dir_path}: directory ' + for i, path in enumerate(dirs_to_enter): + print(f'{prefix}{i+1}/{len(dirs_to_enter)}') + read_directory(path) - def set_by_1st_sorter(self, val): - """On .per_row_by_1st_sorter update, also call .build_and_show.""" - self.per_row_by_1st_sorter = val - self.build_and_show() + self._shall_load = False + self.dir_entries = [] + bookmarks = self._bookmarks_db.as_dict_copy() + cache = self._cache_db.as_dict_ref() + read_directory(self._img_dir_path, make_parent=True) + self._cache_db.write() + self.request_update(build=True) @property def selected_item(self): - """Return slot.item at self.selected_idx.""" + """Return slot.item for slot 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)) + self.request_update(scroll_to_focus=True) - def _set_selection(self, new_idx, unselect_old=True): + def _set_selection(self, new_idx): """Set self.selected_idx, mark slot as 'selected', unmark old one.""" - if unselect_old: + self._shall_select = False + # in ._build(), directly before we are called, no slot will be + # CSS-marked 'selected', so .mark('selected', False) would tolerably + # happen without effect; where called from ._build() however, an old + # .selected_idx might point beyond _any_ of the new .slots, the + # IndexError of which we still want to avoid + if self.selected_idx < len(self.slots): self.slots[self.selected_idx].mark('selected', False) self.selected_idx = new_idx if self.slots: @@ -534,109 +706,137 @@ class Gallery: return False return True - def _build_items_attrs_and_filtered_entries(self): - for d in (self.items_attrs, self.items_attrs_filtered): - d.clear() - d |= {s.name: set() for s in self._sort_order} - entries_filtered = [] - for entry in self.dir_entries: - if not self.show_dirs and isinstance(entry, DirItem): - continue - passes_filters = True - for attr_name in [s.name for s in self._sort_order]: - if isinstance(entry, ImgItem): + def _build(self): + """(Re-)build slot grid from .dir_entries, filters, layout settings.""" + + def build_items_attrs(): + self.items_attrs.clear() + self.items_attrs |= {s.name: {'incl': [], 'excl': []} + for s in self._sort_order} + for attr_name in (s.name for s in self._sort_order): + vals = set() + for entry in [e for e in self.dir_entries + if isinstance(e, ImgItem)]: val = (getattr(entry, attr_name) if hasattr(entry, attr_name) else None) - self.items_attrs[attr_name].add(val) - passes_filter = self._passes_filter(attr_name, val) - passes_filters = passes_filters and passes_filter - if passes_filter: - self.items_attrs_filtered[attr_name].add(val) - if passes_filters: - entries_filtered += [entry] - for d in (self.items_attrs, self.items_attrs_filtered): - for k, v in d.items(): - d[k] = sorted(list(v)) - return entries_filtered - - def _build_grid(self, entries_filtered): - - def item_clicker(idx): - def f(_): - self._set_selection(idx) - self._on_hit_item() - return f - - def build_rows_by_attrs(remaining_attrs, items_of_parent_attr_value): - if not items_of_parent_attr_value: - return - attr_name, attr_values = remaining_attrs[0] - if 1 == len(remaining_attrs): - row = [None] * len(attr_values) - for item in items_of_parent_attr_value: - item_val = getattr(item, attr_name) - idx_item_val_in_attr_values = attr_values.index(item_val) - if row[idx_item_val_in_attr_values]: - item.with_others = True - row[idx_item_val_in_attr_values] = item - for i_col, item in enumerate(row): - if item: - slot = GallerySlot(item, item_clicker(i_slot_ref[0])) - else: - slot = GallerySlot(GalleryItem('', '')) # dummy + if val is not None: + vals.add(val) + for v in vals: + k = 'incl' if self._passes_filter(attr_name, v) else 'excl' + self.items_attrs[attr_name][k] += [v] + for attr_vals in self.items_attrs.values(): + attr_vals['incl'].sort() + attr_vals['excl'].sort() + + def filter_entries(): + entries_filtered = [] + for entry in self.dir_entries: + if (not self._show_dirs) and isinstance(entry, DirItem): + continue + passes_filters = True + for attr_name in (s.name for s in self._sort_order): + if isinstance(entry, ImgItem): + val = (getattr(entry, attr_name) + if hasattr(entry, attr_name) else None) + if val in self.items_attrs[attr_name]['excl']: + passes_filters = False + break + if passes_filters: + entries_filtered += [entry] + return entries_filtered + + def build_grid(entries_filtered): + i_row_ref, i_slot_ref = [0], [0] + + def item_clicker(idx): + def f(_): + self._set_selection(idx) + self._on_hit_item() + return f + + def build_rows_by_attrs(remaining_attrs, + items_of_parent_attr_value): + if not items_of_parent_attr_value: + return + attr_name, attr_values = remaining_attrs[0] + if 1 == len(remaining_attrs): + row = [None] * len(attr_values) + for item in items_of_parent_attr_value: + val = getattr(item, attr_name) + idx_val_in_attr_values = attr_values.index(val) + if row[idx_val_in_attr_values]: + item.with_others = True + row[idx_val_in_attr_values] = item + for i_col, item in enumerate(row): + if item: + slot = GallerySlot(item, + item_clicker(i_slot_ref[0])) + else: + slot = GallerySlot(GalleryItem('', '')) # dummy + self.slots += [slot] + i_slot_ref[0] += 1 + self._grid.attach(slot, i_col, i_row_ref[0], 1, 1) + i_row_ref[0] += 1 + return + for attr_value in attr_values: + items_of_attr_value = [ + x for x in items_of_parent_attr_value + if attr_value == getattr(x, attr_name)] + build_rows_by_attrs(remaining_attrs[1:], + items_of_attr_value) + + if self._grid: + self._fixed_frame.remove(self._grid) + self.slots = [] + self._grid = Gtk.Grid() + self._fixed_frame.put(self._grid, 0, 0) + if self._by_1st: + self._show_dirs = False + sort_attrs = [] + for sorter in reversed(self._sort_order): + sort_attrs += [(sorter.name, + self.items_attrs[sorter.name]['incl'])] + self._per_row = len(sort_attrs[-1][1]) + build_rows_by_attrs(sort_attrs, entries_filtered) + else: + dir_entries_filtered_sorted = sorted( + entries_filtered, key=cmp_to_key(self._sort_cmp)) + i_row, i_col = 0, 0 + for i, item in enumerate(dir_entries_filtered_sorted): + if self._per_row == i_col: + i_col = 0 + i_row += 1 + slot = GallerySlot(item, item_clicker(i)) + self._grid.attach(slot, i_col, i_row, 1, 1) self.slots += [slot] - i_slot_ref[0] += 1 - self._grid.attach(slot, i_col, i_row_ref[0], 1, 1) - i_row_ref[0] += 1 - return - for attr_value in attr_values: - items_of_attr_value = [x for x in items_of_parent_attr_value - if attr_value == getattr(x, attr_name)] - build_rows_by_attrs(remaining_attrs[1:], items_of_attr_value) - - if self._grid: - self._fixed_frame.remove(self._grid) - self.slots = [] - self._grid = Gtk.Grid() - self._fixed_frame.put(self._grid, 0, 0) - if self.per_row_by_1st_sorter: - self.show_dirs = False - sort_attrs = [] - for sorter in reversed(self._sort_order): - sort_attrs += [(sorter.name, - self.items_attrs_filtered[sorter.name])] - i_row_ref = [0] - i_slot_ref = [0] - self._per_row = len(sort_attrs[-1][1]) - build_rows_by_attrs(sort_attrs, entries_filtered) - else: - self.dir_entries_filtered_sorted = sorted( - entries_filtered, key=cmp_to_key(self._sort_cmp)) - i_row, i_col = 0, 0 - for i, item in enumerate(self.dir_entries_filtered_sorted): - 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() - - def build_and_show(self, preserve_selected=True): - """Build gallery as sorted GallerySlots, select one, draw gallery.""" - suggested_selection = self.selected_item if preserve_selected else None - entries_filtered = self._build_items_attrs_and_filtered_entries() - self._build_grid(entries_filtered) - self.selected_idx = 0 - self._update_view() + i_col += 1 + self._on_grid_built() + + self._shall_build = False + old_selected_item = self.selected_item + build_items_attrs() + entries_filtered = filter_entries() + build_grid(entries_filtered) new_idx = 0 - if suggested_selection is not None: + if old_selected_item is not None: for i, slot in enumerate(self.slots): - if suggested_selection == slot.item: + if hash(old_selected_item) == hash(slot.item): new_idx = i break - self._set_selection(new_idx, unselect_old=False) + self._set_selection(new_idx) + + def request_update(self, select=False, scroll_to_focus=False, build=False, + load=False): + """Set ._shall_… to trigger updates on next relevant interval.""" + self._shall_redraw = True + if scroll_to_focus or build or select: + self._shall_select = True + if scroll_to_focus or build: + self._shall_scroll_to_focus = True + if build: + self._shall_build = True + if load: + self._shall_load = True def move_selection(self, x_inc, y_inc, buf_end): """Move .selection, update its dependencies, redraw gallery.""" @@ -657,62 +857,76 @@ class Gallery: 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 on_resize(self, width=0, height=0): + """Force redraw and scroll-to-focus into new geometry.""" + self._force_width, self._force_height = width, height + self.request_update(scroll_to_focus=True) - def _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 + def _redraw_and_check_focus(self): + """Draw gallery; possibly notice and first follow need to re-focus.""" 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) + vp_height = (self._force_height if self._force_height + else self._viewport.get_height()) + self._force_width, self._force_height = 0, 0 + vp_scroll = self._viewport.get_vadjustment() + vp_top = vp_scroll.get_value() + vp_bottom = vp_top + vp_height + max_slot_width = vp_width // self._per_row + slot_size = min(vp_height, max_slot_width) if self._col_headers_grid: self._col_headers_frame.remove(self._col_headers_grid) self._col_headers_grid = None - if self.per_row_by_1st_sorter: + if self._by_1st: self._col_headers_grid = Gtk.Grid() self._col_headers_frame.put(self._col_headers_grid, 0, 0) - attr_values = self.items_attrs_filtered[self._sort_order[0].name] + attr_values = self.items_attrs[self._sort_order[0].name]['incl'] for i, val in enumerate(attr_values): - label = Gtk.Label(label=str(val)) + label = Gtk.Label(label=str(val), + ellipsize=Pango.EllipsizeMode.MIDDLE) label.set_size_request(slot_size, -1) self._col_headers_grid.attach(label, i, 0, 1, 1) - self._should_update_view = False - if (not refocus) or (not self.slots): + slot_size_sans_margin = slot_size - self._slot_margin + for idx, slot in enumerate(self.slots): + slot.ensure_slot_size(slot_size_sans_margin, self._slot_margin) + vp_scroll.set_upper(slot_size * ceil(len(self.slots) / self._per_row)) + if self._scroll_to_focus(slot_size, vp_scroll, vp_top, vp_bottom): 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) + for idx, slot in enumerate(self.slots): + in_vp, _, _ = self._position_to_viewport( + idx, slot_size, vp_top, vp_bottom, True) + slot.update_widget(in_vp) + self._start_redraw_wait = datetime.now() + + def _position_to_viewport( + self, idx, slot_size, vp_top, vp_bottom, in_vp_greedy=False): + slot_top = (idx // self._per_row) * slot_size + slot_bottom = slot_top + slot_size + if in_vp_greedy: + in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom) else: - return - self._should_update_view = True - vp_scroll.emit('value-changed') + in_vp = (slot_top >= vp_top and slot_bottom <= vp_bottom) + return in_vp, slot_top, slot_bottom + + def _scroll_to_focus(self, slot_size, vp_scroll, vp_top, vp_bottom): + scroll_to_focus = self._shall_scroll_to_focus + self._shall_redraw, self._shall_scroll_to_focus = False, False + if scroll_to_focus: + in_vp, slot_top, slot_bottom = self._position_to_viewport( + self.selected_idx, slot_size, vp_top, vp_bottom) + if not in_vp: + self._shall_redraw, self._shall_scroll_to_focus = True, True + if slot_top < vp_top: + vp_scroll.set_value(slot_top) + else: + vp_scroll.set_value(slot_bottom - slot_size) + return True + return False 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: + if self._show_dirs: cmp_upper_dir = f' {UPPER_DIR}' if isinstance(a, DirItem) and a.name == cmp_upper_dir: return -1 @@ -755,7 +969,6 @@ class MainWindow(Gtk.Window): self.app = app def init_navbar(): - navbar = Gtk.Box(orientation=OR_H) self.counter = Gtk.Label() navbar.append(self.counter) @@ -781,14 +994,6 @@ class MainWindow(Gtk.Window): self.add_controller(key_ctl) self.prev_key = [0] - def ensure_db_files(): - if not path_exists(CACHE_PATH): - with open(CACHE_PATH, 'w', encoding='utf8') as f: - json_dump({}, f) - if not path_exists(BOOKMARKS_PATH): - with open(BOOKMARKS_PATH, 'w', encoding='utf8') as f: - json_dump([], f) - def setup_css(): css_provider = Gtk.CssProvider() css_provider.load_from_data(CSS) @@ -797,12 +1002,11 @@ class MainWindow(Gtk.Window): Gtk.STYLE_PROVIDER_PRIORITY_USER) self.gallery = Gallery( - sort_order=self.app.sort.order, - filter_inputs=self.app.sort.filter_inputs, on_hit_item=self.hit_gallery_item, - on_grid_built=self.app.sort.update_box, - on_selection_change=self.update_metadata_on_gallery_selection) - self.recurse_dirs = False + on_grid_built=self.app.conf.update_box, + on_selection_change=self.update_metadata_on_gallery_selection, + bookmarks_db=self.app.bookmarks_db, + cache_db=self.app.cache_db) setup_css() viewer = Gtk.Box(orientation=OR_V) @@ -810,7 +1014,7 @@ class MainWindow(Gtk.Window): viewer.append(self.navbar) viewer.append(self.gallery.frame) self.side_box = Gtk.Box(orientation=OR_V) - self.side_box.append(self.app.sort.box) + self.side_box.append(self.app.conf.box) self.side_box.append(init_metadata_box()) box_outer = Gtk.Box(orientation=OR_H) box_outer.append(self.side_box) @@ -819,32 +1023,27 @@ class MainWindow(Gtk.Window): self.connect('notify::default-width', lambda _, __: self.on_resize()) self.connect('notify::default-height', lambda _, __: self.on_resize()) - ensure_db_files() init_key_control() - self.load_directory(update_gallery_view=False) self.connect('notify::focus-widget', lambda _, __: self.on_focus_change()) - self.app.sort.bind_gallery( - on_update=self.gallery.build_and_show, - reload=lambda _: self.load_directory(), - update_per_row=self.gallery.update_per_row, - per_row_initial=self.gallery.get_per_row(), - toggle_showdirs=self.reset_show_dirs, - toggle_recurse=self.reset_recurse, - set_by_1st_sorter=self.gallery.set_by_1st_sorter, - items_attrs_full=self.gallery.items_attrs, - items_attrs_filtered=self.gallery.items_attrs_filtered) - GLib.idle_add(self.gallery.build_and_show) + self.app.conf.bind_gallery( + request_update=self.gallery.request_update, + update_settings=self.gallery.update_settings, + items_attrs=self.gallery.items_attrs) + GLib.idle_add(lambda: self.gallery.update_settings( + img_dir_path=self.app.img_dir_absolute, + sort_order=self.app.conf.order[:], + filter_inputs=self.app.conf.filter_inputs.copy())) def on_focus_change(self): - """Handle reactions on focus changes in .gallery and .sort.""" + """Handle reactions on focus changes in .gallery and .conf.""" focused = self.get_focus() if not focused: return if isinstance(focused, GallerySlot): self.gallery.on_focus_slot(focused) - elif focused.get_parent() == self.app.sort.view: - focused.get_first_child().get_last_child().show() + elif focused.get_parent() == self.app.conf.sorter_listing: + self.app.conf.on_focus_sorter(focused) def on_resize(self): """On window resize, do .gallery.on_resize towards its new geometry.""" @@ -860,83 +1059,21 @@ class MainWindow(Gtk.Window): """Toggle bookmark on selected gallery item.""" if not isinstance(self.gallery.selected_item, ImgItem): return - with open(BOOKMARKS_PATH, 'r', encoding='utf8') as f: - bookmarks = json_load(f) + bookmarks = self.app.bookmarks_db.as_dict_ref() if self.gallery.selected_item.bookmarked: self.gallery.selected_item.bookmark(False) bookmarks.remove(self.gallery.selected_item.full_path) else: 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.app.sort.update_box() + self.app.bookmarks_db.write() + self.app.conf.update_box() def hit_gallery_item(self): """If current file selection is directory, reload into that one.""" selected = self.gallery.selected_item if isinstance(selected, DirItem): - self.app.img_dir_absolute = selected.full_path - self.load_directory() - - 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): - 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 = [] - dir_entries = list(listdir(dir_path)) - dirs_to_enter = [] - for i, fn in enumerate(dir_entries): - msg = f'loading {dir_path}: entry {i+1}/{len(dir_entries)}' - print(msg, end='\r') - full_path = path_join(dir_path, fn) - if isdir(full_path): - if self.gallery.show_dirs: - self.gallery.dir_entries += [DirItem(dir_path, fn)] - dirs_to_enter += [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] - print('') - if to_set_metadata_on: - for i, item in enumerate(to_set_metadata_on): - msg = f'setting metadata: {i+1}/{len(to_set_metadata_on)}' - print(msg, end='\r') - item.set_metadata(cache) - print('') - else: - print('no metadata to set') - if dirs_to_enter and self.recurse_dirs: - prefix = f'entering directories below {dir_path}: directory ' - for i, path in enumerate(dirs_to_enter): - print(f'{prefix}{i+1}/{len(dirs_to_enter)}') - read_directory_into_gallery_items(path) - - 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: - cache = json_load(f) - 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_view: - self.gallery.build_and_show() + self.gallery.update_settings(img_dir_path=selected.full_path) def toggle_side_box(self): """Toggle window sidebox visible/invisible.""" @@ -946,16 +1083,6 @@ class MainWindow(Gtk.Window): side_box_width = self.side_box.measure(OR_H, -1).natural self.gallery.on_resize(self.get_width() - side_box_width) - def reset_show_dirs(self, button): - """By button's .active, in-/exclude directories from gallery view.""" - self.gallery.show_dirs = button.props.active - self.load_directory() - - def reset_recurse(self, button): - """By button's .active, de-/activate recursion on image collection.""" - self.recurse_dirs = button.props.active - self.load_directory() - def update_metadata_on_gallery_selection(self): """Update .metadata about individual file, .counter on its idx/total""" self.metadata.set_text('') @@ -968,18 +1095,17 @@ class MainWindow(Gtk.Window): bookmarked = 'BOOKMARK' if selected_item.bookmarked else '' self.metadata.set_text( '\n'.join([title, bookmarked] + params_strs)) - total = len(self.gallery.dir_entries_filtered_sorted) + total = len([s for s in self.gallery.slots + if isinstance(s.item, (DirItem, ImgItem))]) self.counter.set_text(f' {self.gallery.selected_idx + 1} of {total} ') def handle_keypress(self, keyval): """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: - if self.get_focus().get_parent().get_parent() == self.app.sort.box: - self.app.sort.activate_order() - else: - self.hit_gallery_item() + if Gdk.KEY_Return == keyval and isinstance(self.get_focus(), + GallerySlot): + self.hit_gallery_item() elif Gdk.KEY_G == keyval: self.gallery.move_selection(None, None, 1) elif Gdk.KEY_h == keyval: @@ -993,13 +1119,13 @@ class MainWindow(Gtk.Window): elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]: self.gallery.move_selection(None, None, -1) elif Gdk.KEY_w == keyval: - self.app.sort.move_selection(-1) + self.app.conf.move_selection(-1) elif Gdk.KEY_W == keyval: - self.app.sort.move_sorter(-1) + self.app.conf.move_sorter(-1) elif Gdk.KEY_s == keyval: - self.app.sort.move_selection(1) + self.app.conf.move_selection(1) elif Gdk.KEY_S == keyval: - self.app.sort.move_sorter(1) + self.app.conf.move_sorter(1) elif Gdk.KEY_b == keyval: self.bookmark() else: @@ -1018,7 +1144,9 @@ class Application(Gtk.Application): parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT) opts = parser.parse_args() self.img_dir_absolute = abspath(opts.directory) - self.sort = TableConfig.from_suggestion(opts.sort_order) + self.conf = GalleryConfig.from_suggestion(opts.sort_order) + self.bookmarks_db = JsonDB(BOOKMARKS_PATH) + self.cache_db = JsonDB(CACHE_PATH) def do_activate(self, *args, **kwargs): """Parse arguments, start window, keep it open.""" -- 2.30.2