From: Christian Heller Date: Mon, 18 Nov 2024 16:49:28 +0000 (+0100) Subject: Browser.py: Modularize. X-Git-Url: https://plomlompom.com/repos/%7B%7Bprefix%7D%7D/todo?a=commitdiff_plain;p=stable_plom Browser.py: Modularize. --- diff --git a/browser.py b/browser.py index f6ff73d..9d86d05 100755 --- a/browser.py +++ b/browser.py @@ -1,1461 +1,22 @@ #!/usr/bin/env python3 """Browser for image files.""" -from json import dump as json_dump, load as json_load -from typing import TypeAlias, Callable, Optional, Self -from functools import cmp_to_key -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, timedelta -from argparse import ArgumentParser -from math import ceil, radians -from PIL import Image -from PIL.PngImagePlugin import PngImageFile -import gi # type: ignore -gi.require_version('Gtk', '4.0') -gi.require_version('Gdk', '4.0') -gi.require_version('Gio', '2.0') -# pylint: disable=wrong-import-position -from gi.repository import (Gdk, Gio, GLib, # type: ignore # noqa: E402 - GObject, Gtk, Pango, # type: ignore # noqa: E402 - PangoCairo) # type: ignore # noqa: E402 -# pylint: disable=no-name-in-module -from stable.gen_params import (GenParams, GEN_PARAMS_FLOAT, # noqa: E402 - GEN_PARAMS_INT, GEN_PARAMS_STR, # noqa: E402 - GEN_PARAMS) # noqa: E402 - -BasicItemsAttrs = dict[str, set[str]] -AttrVals: TypeAlias = list[str] -AttrValsByVisibility: TypeAlias = dict[str, AttrVals] -ItemsAttrs: TypeAlias = dict[str, AttrValsByVisibility] -CachedImg: TypeAlias = dict[str, str | float | int] -Cache: TypeAlias = dict[str, dict[str, CachedImg]] -Bookmarks: TypeAlias = list[str] -Db: TypeAlias = Cache | Bookmarks -FilterInputs: TypeAlias = dict[str, str] - - -IMG_DIR_DEFAULT: str = '.' -SORT_DEFAULT: str = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\ - 'model,prompt' -UPPER_DIR: str = '..' -CACHE_PATH: str = 'cache.json' -BOOKMARKS_PATH: str = 'bookmarks.json' -GALLERY_SLOT_MARGIN: int = 6 -GALLERY_PER_ROW_DEFAULT: int = 5 -GALLERY_UPDATE_INTERVAL_MS: int = 50 -GALLERY_REDRAW_WAIT_MS: int = 200 -ACCEPTED_IMG_FILE_ENDINGS: set[str] = {'.png', '.PNG'} - -OR_H: int = Gtk.Orientation.HORIZONTAL -OR_V: int = Gtk.Orientation.VERTICAL - -CSS: str = ''' -.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; -} -''' - - -def _add_button(parent: Gtk.Widget, - label: str, - on_click: Optional[Callable] = None, - checkbox: bool = False - ) -> Gtk.Button | Gtk.CheckButton: - """Helper to add Gtk.Button or .CheckButton to parent.""" - btn = (Gtk.CheckButton(label=label) if checkbox - else Gtk.Button(label=label)) - if on_click: - btn.connect('toggled' if checkbox else 'clicked', on_click) - parent.append(btn) - return btn - - -class JsonDb: - """Representation of our simple .json DB files.""" - _content: Db - - def __init__(self, path: str) -> None: - self._path = path - self._is_open = False - if not path_exists(path): - with open(path, 'w', encoding='utf8') as f: - json_dump({}, f) - - def _open(self) -> None: - 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) -> None: - self._is_open = False - self._content = {} - - def write(self) -> None: - """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() - - -class BookmarksDb(JsonDb): - """Representation of Bookmarks DB files.""" - _content: Bookmarks - - def as_ref(self) -> Bookmarks: - """Return content at ._path as ref so that .write() stores changes.""" - self._open() - return self._content - - def as_copy(self) -> Bookmarks: - """Return content at ._path for read-only purposes.""" - self._open() - copy = self._content.copy() - self._close() - return copy - - -class CacheDb(JsonDb): - """Representation of Cache DB files.""" - _content: Cache - - def as_ref(self) -> Cache: - """Return content at ._path as ref so that .write() stores changes.""" - self._open() - return self._content - - -class Application(Gtk.Application): - """Image browser application class.""" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - parser = ArgumentParser() - parser.add_argument('directory', default=IMG_DIR_DEFAULT, nargs='?') - parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT) - opts = parser.parse_args() - self.img_dir_absolute = abspath(opts.directory) - self.bookmarks_db = BookmarksDb(BOOKMARKS_PATH) - self.cache_db = CacheDb(CACHE_PATH) - self.sort_order = SorterAndFiltererOrder.from_suggestion( - opts.sort_order.split(',')) - - def do_activate(self, *args, **kwargs) -> None: - """Parse arguments, start window, keep it open.""" - win = MainWindow(self) - win.present() - self.hold() - - -class SorterAndFilterer(GObject.GObject): - """Sort order box representation of sorting/filtering attribute.""" - widget: Gtk.Box - - def __init__(self, name: str) -> None: - super().__init__() - self.name = name - self.filter_text = '' - - def setup_on_bind(self, - widget: Gtk.Box, - on_filter_activate: Callable, - vals: AttrValsByVisibility, - ) -> None: - """Set up SorterAndFilterer label, values listing, filter entry.""" - self.widget = widget - # label - len_incl = len(vals['incl']) - len_semi_total: int = len_incl + len(vals['semi']) - len_total: int = len_semi_total + len(vals['excl']) - title = f'{self.name} ({len_incl}/{len_semi_total}/{len_total}) ' - self.widget.label.set_text(title) - # values listing - vals_listed: list[str] = [f'{v}' for v in vals['incl']] - vals_listed += [f'{v}' for v in vals['semi']] - 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 - - def filter_activate() -> None: - self.widget.filter_input.remove_css_class('temp') - self.filter_text = self.widget.filter_input.get_buffer().get_text() - on_filter_activate() - - filter_buffer = self.widget.filter_input.get_buffer() - filter_buffer.set_text(self.filter_text, -1) # triggers 'temp' class - self.widget.filter_input.remove_css_class('temp') # set, that's why … - self.widget.filter_input.connect('activate', - lambda _: filter_activate()) - filter_buffer.connect( - 'inserted_text', - lambda a, b, c, d: self.widget.filter_input.add_css_class('temp')) - filter_buffer.connect( - 'deleted_text', - lambda a, b, c: self.widget.filter_input.add_css_class('temp')) - - def filter_allows_value(self, value: str | int | float) -> bool: - """Return if value passes filter defined by .name and .filter_text.""" - number_attributes = (set(s.lower() for s in GEN_PARAMS_INT) | - set(s.lower() for s in GEN_PARAMS_FLOAT) | - {'bookmarked'}) - if value is None: - return False - if self.name not in number_attributes: - assert isinstance(value, str) - return bool(re_search(self.filter_text, value)) - assert isinstance(value, (int, float)) - use_float = self.name in {s.lower() for s in GEN_PARAMS_FLOAT} - numbers_or, unequal = (set(),) * 2 - less_than, less_or_equal, more_or_equal, more_than = (None,) * 4 - for constraint_string in self.filter_text.split(','): - toks = constraint_string.split() - if len(toks) == 1: - tok = toks[0] - if tok[0] in '<>!': # operator sans space after: split, re-try - if '=' == tok[1]: - toks = [tok[:2], tok[2:]] - else: - toks = [tok[:1], tok[1:]] - else: - pattern_number = float(tok) if use_float else int(tok) - numbers_or.add(pattern_number) - if len(toks) == 2: # assume operator followed by number - pattern_number = float(toks[1]) if use_float else int(toks[1]) - if toks[0] == '!=': - unequal.add(pattern_number) - elif toks[0] == '<': - if less_than is None or less_than >= pattern_number: - less_than = pattern_number - elif toks[0] == '<=': - if less_or_equal is None or less_or_equal > pattern_number: - less_or_equal = pattern_number - elif toks[0] == '>=': - if more_or_equal is None or more_or_equal < pattern_number: - more_or_equal = pattern_number - elif toks[0] == '>': - if more_than is None or more_than <= pattern_number: - more_than = pattern_number - if value in numbers_or: - return True - if len(numbers_or) > 0 and (less_than == less_or_equal == - more_or_equal == more_than): - return False - if value in unequal: - return False - return ((less_than is None or value < less_than) - and (less_or_equal is None or value <= less_or_equal) - and (more_or_equal is None or value >= more_or_equal) - and (more_than is None or value > more_than)) - - -class SorterAndFiltererOrder: - """Represents sorted list of SorterAndFilterer items.""" - - def __init__(self, as_list: list[SorterAndFilterer]) -> None: - self._list = as_list - - def __eq__(self, other) -> bool: - return self._list == other._list - - def __len__(self) -> int: - return len(self._list) - - def __getitem__(self, idx: int) -> SorterAndFilterer: - return self._list[idx] - - def __iter__(self): - return self._list.__iter__() - - @staticmethod - def _list_from_store(store: Gio.ListStore) -> list[SorterAndFilterer]: - order = [] - for i in range(store.get_n_items()): - order += [store.get_item(i)] - return order - - @classmethod - def from_suggestion(cls, suggestion: list[str]) -> Self: - """Create new, interpreting order of strings in suggestion.""" - names: list[str] = [p.lower() for p in GEN_PARAMS] + ['bookmarked'] - order: list[SorterAndFilterer] = [] - for name in names: - order += [SorterAndFilterer(name)] - new_order: list[SorterAndFilterer] = [] - do_reverse: bool = '-' in suggestion - for pattern in suggestion: - for sorter in [sorter for sorter in order - if sorter.name.startswith(pattern)]: - order.remove(sorter) - new_order += [sorter] - order = new_order + order - if do_reverse: - order.reverse() - return cls(order) - - @classmethod - def from_store(cls, store: Gio.ListStore) -> Self: - """Create new, mirroring order in store.""" - return cls(cls._list_from_store(store)) - - def by_name(self, name: str) -> Optional[SorterAndFilterer]: - """Return included SorterAndFilterer of name.""" - for s in [s for s in self._list if name == s.name]: - return s - return None - - def copy(self) -> Self: - """Create new, of equal order.""" - return self.__class__(self._list[:]) - - def sync_from(self, other_order: Self) -> None: - """Sync internal state from other order.""" - self._list = other_order._list - - def remove(self, sorter_name: str) -> None: - """Remove sorter of sorter_name from self.""" - candidate = self.by_name(sorter_name) - assert candidate is not None - self._list.remove(candidate) - - def update_from_store(self, store: Gio.ListStore) -> None: - """Update self from store.""" - self._list = self._list_from_store(store) - - def into_store(self, store: Gio.ListStore) -> None: - """Update store to represent self.""" - store.remove_all() - for sorter in self: - store.append(sorter) - - def switch_at(self, selected_idx: int, forward: bool) -> None: - """Switch elements at selected_idx and its neighbor.""" - selected: SorterAndFilterer = self[selected_idx] - other_idx: int = selected_idx + (1 if forward else -1) - other: SorterAndFilterer = self[other_idx] - self._list[other_idx] = selected - self._list[selected_idx] = other - - -class GalleryItem(GObject.GObject): - """Gallery representation of filesystem entry, base to DirItem, ImgItem.""" - _to_hash = ['name', 'full_path'] - - def __init__(self, path: str, name: str) -> None: - super().__init__() - self.name = name - self.full_path: str = path_join(path, self.name) - self.slot: GallerySlot - - def __hash__(self) -> int: - hashable_values: list[str | bool] = [] - 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.""" - - def __init__(self, path: str, name: str, is_parent: bool = False) -> None: - super().__init__(path, name) - if is_parent: - self.full_path = path - - -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: str, name: str, cache: Cache) -> None: - super().__init__(path, name) - self.subprompt1: str = '' - self.subprompt2: str = '' - mtime = getmtime(self.full_path) - dt = datetime.fromtimestamp(mtime, tz=timezone.utc) - iso8601_str: str = dt.isoformat(timespec='microseconds') - self.last_mod_time: str = 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(), '') - else: - 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]) - - def set_metadata(self, cache: Cache) -> None: - """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', '') - if gen_params_as_str: - gen_params = GenParams.from_str(gen_params_as_str) - for k, v_ in gen_params.as_dict.items(): - setattr(self, k, v_) - cached: CachedImg = {} - for k in (k.lower() for k in GEN_PARAMS): - cached[k] = getattr(self, k) - cache[self.full_path] = {self.last_mod_time: cached} - - def bookmark(self, positive: bool = True) -> None: - """Set self.bookmark to positive, and update CSS class mark.""" - self.bookmarked = positive - self.slot.mark('bookmarked', positive) - - -class GallerySlotsGeometry: - """Collect variable sizes shared among all GallerySlots.""" - - def __init__(self) -> None: - self._margin: int = GALLERY_SLOT_MARGIN - assert 0 == self._margin % 2 # avoid ._margin != 2 * .side_margin - self.side_margin: int = self._margin // 2 - self.size: int = -1 - self.size_sans_margins: int = -1 - - def set_size(self, size: int) -> None: - """Not only set .size but also update .size_sans_margins.""" - self.size = size - self.size_sans_margins = self.size - self._margin - - -class GallerySlot(Gtk.Button): - """Slot in Gallery representing a GalleryItem.""" - - def __init__(self, - item: GalleryItem, - slots_geometry: GallerySlotsGeometry, - on_click_file: Optional[Callable] = None - ) -> None: - super().__init__() - self._geometry = slots_geometry - self.add_css_class('slot') - self.set_hexpand(True) - self.item = item - self.item.slot = self - if on_click_file: - self.connect('clicked', lambda _: on_click_file()) - - def mark(self, css_class: str, do_add: bool = True) -> None: - """Add or remove css_class from self.""" - if do_add: - self.add_css_class(css_class) - else: - self.remove_css_class(css_class) - - def ensure_slot_size(self) -> None: - """Call ._size_widget to size .props.child; if none, make empty one.""" - if self.get_child() is None: - self.set_child(Gtk.Label(label='+')) - self._size_widget() - - def _size_widget(self) -> None: - for s in ('bottom', 'top', 'start', 'end'): - setattr(self.get_child().props, f'margin_{s}', - self._geometry.side_margin) - self.get_child().set_size_request(self._geometry.size_sans_margins, - self._geometry.size_sans_margins) - - def update_widget(self, is_in_vp: bool) -> None: - """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class.""" - new_content: Optional[Gtk.Image | Gtk.Label] = None - if 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) - if self.item.with_others: - new_content.set_vexpand(True) - box = Gtk.Box(orientation=OR_V) - box.append(new_content) - msg = 'and one or more other images of this configuration' - box.append(Gtk.Label(label=msg)) - new_content = box - elif (not is_in_vp) and not isinstance(self.item, Gtk.Label): - new_content = Gtk.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) - self._size_widget() - if isinstance(self.item, ImgItem): - self.mark('bookmarked', self.item.bookmarked) - - -class GalleryConfig(): - """Representation of sort and filtering settings.""" - _sort_sel = Gtk.SingleSelection - _set_recurse_changed: bool - _btn_apply: Gtk.Button - _btn_by_1st: Gtk.CheckButton - _btn_recurse: Gtk.CheckButton - _btn_per_row: Gtk.CheckButton - _btn_show_dirs: Gtk.CheckButton - _store: Gio.ListStore - - def __init__(self, - box: Gtk.Box, - sort_order: SorterAndFiltererOrder, - request_update: Callable, - update_settings: Callable, - items_attrs: ItemsAttrs, - ) -> None: - self.order = sort_order - self._tmp_order: Optional[SorterAndFiltererOrder] = None - self._gallery_request_update = request_update - self._gallery_update_settings = update_settings - self._gallery_items_attrs = items_attrs - - def setup_sorter_list_item(_, list_item: SorterAndFilterer) -> None: - 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_input = Gtk.Entry(placeholder_text='filter?') - hbox = Gtk.Box(orientation=OR_H) - hbox.append(item_widget.label) - hbox.append(item_widget.filter_input) - item_widget.append(hbox) - item_widget.append(item_widget.values) - list_item.set_child(item_widget) - - def bind_sorter_list_item(_, list_item: SorterAndFilterer) -> None: - def on_filter_activate(): - self._filter_changed = True - sorter: SorterAndFilterer = list_item.props.item - sorter.setup_on_bind(list_item.props.child, - on_filter_activate, - self._gallery_items_attrs[sorter.name]) - def select_sort_order(_a, _b, _c) -> None: - self._sort_sel.props.selected_item.widget.get_parent().grab_focus() - - def toggle_recurse(_) -> None: - self._set_recurse_changed = not self._set_recurse_changed - self._btn_apply.set_sensitive(not self._set_recurse_changed) - - def toggle_by_1st(btn: Gtk.CheckButton) -> None: - 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) - - def apply_config() -> None: - if self._tmp_order: - self.order.sync_from(self._tmp_order) - self._tmp_order = None - self._gallery_request_update(build_grid=True) - if self._filter_changed: - self._gallery_request_update(build_grid=True) - 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(), - recurse_dirs=self._btn_recurse.get_active()) - self._gallery_request_update(select=True) - self._set_recurse_changed = False - self._filter_changed = False - - def full_reload() -> None: - apply_config() - self._gallery_request_update(load=True) - self._btn_apply.set_sensitive(True) - - self._filter_changed = False - self._set_recurse_changed = False - self._last_selected: Optional[Gtk.Widget] = None - - 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_reload = _add_button(buttons_box, 'full reload', - lambda _: full_reload()) - - buttons_box = Gtk.Box(orientation=OR_H) - self._btn_apply = _add_button(buttons_box, 'apply config', - lambda _: apply_config()) - self._btn_reload = _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', - toggle_recurse, checkbox=True) - - per_row_box = Gtk.Box(orientation=OR_H) - per_row_box.append(Gtk.Label(label='cols/row:')) - 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) - - box.append(self.sorter_listing) - box.append(dirs_box) - box.append(per_row_box) - box.append(buttons_box) - - def on_focus_sorter(self, focused: SorterAndFilterer) -> None: - """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: int) -> None: - """Move sort order selection by direction (-1 or +1).""" - min_idx, max_idx = 0, len(self.order) - 1 - cur_idx = self._sort_sel.props.selected - if (1 == direction and cur_idx < max_idx)\ - or (-1 == direction and cur_idx > min_idx): - self._sort_sel.props.selected = cur_idx + direction - - def move_sorter(self, direction: int) -> None: - """Move selected item in sort order view, ensure temporary state.""" - tmp_order = self._tmp_order if self._tmp_order else self.order.copy() - cur_idx = self._sort_sel.props.selected - if direction == -1 and cur_idx > 0: - tmp_order.switch_at(cur_idx, forward=False) - elif direction == 1 and cur_idx < (len(tmp_order) - 1): - tmp_order.switch_at(cur_idx, forward=True) - else: # to catch movement beyond limits - return - if not self._tmp_order: - self._tmp_order = tmp_order - for sorter in self._tmp_order: - sorter.widget.add_css_class('temp') - self.update_box(cur_idx + direction) - self._sort_sel.props.selected = cur_idx + direction - for i in range(self._store.get_n_items()): - sort_item: SorterAndFilterer = self._store.get_item(i) - sort_item.widget.add_css_class('temp') - - def update_box(self, cur_selection: int = 0) -> None: - """Rebuild sorter listing in box from .order, or alt_order if set.""" - sort_order = self._tmp_order if self._tmp_order else self.order - sort_order.into_store(self._store) - self._sort_sel.props.selected = cur_selection - - -class VerticalLabel(Gtk.DrawingArea): - """Label of vertical text (rotated -90°).""" - - def __init__(self, - text: str, - slots_geometry: GallerySlotsGeometry - ) -> None: - super().__init__() - self._text = text - self._slots_geometry = slots_geometry - test_layout = self.create_pango_layout() - test_layout.set_markup(text) - _, self._text_height = test_layout.get_pixel_size() - self.set_draw_func(self._on_draw) - - def _on_draw(self, - _, - cairo_ctx: Pango.Context, - __, - height: int - ) -> None: - """Create layout, rotate by 90°, size widget to measurements.""" - layout = self.create_pango_layout() - layout.set_markup(self._text) - layout.set_width(self._slots_geometry.size * Pango.SCALE) - layout.set_ellipsize(Pango.EllipsizeMode.MIDDLE) - text_width, _ = layout.get_pixel_size() - cairo_ctx.translate(0, text_width + (height - text_width)) - cairo_ctx.rotate(radians(-90)) - PangoCairo.show_layout(cairo_ctx, layout) - self.set_size_request(self._text_height, text_width) - - @property - def width(self) -> int: - """Return (rotated) ._text_height.""" - return self._text_height - - -class Gallery: - """Representation of GalleryItems below a directory.""" - update_config_box: Callable - - def __init__(self, - sort_order: SorterAndFiltererOrder, - on_hit_item: Callable, - on_selection_change: Callable, - bookmarks_db: BookmarksDb, - cache_db: CacheDb - ) -> None: - self._on_hit_item = on_hit_item - self._on_selection_change = on_selection_change - self._bookmarks_db, self._cache_db = bookmarks_db, cache_db - self._sort_order = sort_order - self._img_dir_path = '' - - self._shall_load = False - self._shall_build_grid = 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._slots_geometry = GallerySlotsGeometry() - - self.dir_entries: list[GalleryItem] = [] - self._basic_items_attrs: BasicItemsAttrs = {} - self.items_attrs: ItemsAttrs = {} - self.selected_idx = 0 - self.slots: list[GallerySlot] = [] - - self._grid = Gtk.Grid() - self._force_width, self._force_height = 0, 0 - scroller = Gtk.ScrolledWindow(propagate_natural_height=True) - self._col_headers_frame = Gtk.Fixed() - self._col_headers_grid = Gtk.Grid() - self.frame = Gtk.Box(orientation=OR_V) - self.frame.append(self._col_headers_frame) - self.frame.append(scroller) - # 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 - - def ensure_uptodate() -> bool: - if not self._img_dir_path: - return True - if self._shall_load: - self._shall_load = False - self._load_directory() - if self._shall_build_grid: - self._shall_build_grid = False - self._build_grid() - if self._shall_select: - self._shall_select = False - self._assert_selection() - if self._shall_redraw: - wait_time_passed = datetime.now() - self._start_redraw_wait - if wait_time_passed > redraw_wait_time: - self._shall_redraw = False - self._redraw_and_check_focus() - return True - - def handle_scroll(_) -> None: - self._start_redraw_wait = datetime.now() - self._shall_scroll_to_focus = False - self.request_update() # only request redraw - - 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: Optional[int] = None, - by_1st: Optional[bool] = None, - show_dirs: Optional[bool] = None, - recurse_dirs: Optional[bool] = None, - img_dir_path: Optional[str] = None, - ) -> 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')]: - 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.request_update(load=True) - else: - self.request_update(build_grid=True) - - @staticmethod - def _diff_prompts(prompts: list[str]) -> dict[str, tuple[str, str]]: - if not prompts: - return {} - - def find_longest_equal(prompts, j, matcher): - longest_total, temp_longest = '', '' - while j < len(prompts[0]): - if 'end' == matcher: - temp_longest = prompts[0][-j] + temp_longest - else: - temp_longest += prompts[0][j] - if len(temp_longest) > len(longest_total): - found_in_all = True - for prompt in prompts[1:]: - if ('start' == matcher - and not prompt.startswith(temp_longest)) or\ - ('end' == matcher - and not prompt.endswith(temp_longest)) or\ - ('in' == matcher - and temp_longest not in prompt): - found_in_all = False - break - if not found_in_all: - break - longest_total = temp_longest - j += 1 - return longest_total - - prefix = find_longest_equal(prompts, 0, 'start') - suffix = find_longest_equal(prompts, 1, matcher='end') - cores = [p[len(prefix):] for p in prompts] - if suffix: - for i, p in enumerate(cores): - cores[i] = p[:-len(suffix)] - longest_total = '' - for i in range(len(cores[0])): - temp_longest = find_longest_equal(cores, j=i, matcher='in') - if len(temp_longest) > len(longest_total): - longest_total = temp_longest - middle = longest_total - prompts_diff = {} - for i, p in enumerate(prompts): - remains = p[len(prefix):] if prefix else p - idx_middle = remains.index(middle) - first = remains[:idx_middle] if idx_middle else '' - remains = remains[idx_middle + len(middle):] - second = remains[:-len(suffix)] if suffix else remains - if first: - first = f'…{first}' if prefix else first - first = f'{first}…' if suffix or middle else first - if second: - second = f'…{second}' if prefix or middle else second - second = f'{second}…' if suffix else second - prompts_diff[p] = (first if first else '…', - second if second else '…') - return prompts_diff - - def _prep_items_attrs(self, entries: list[GalleryItem]) -> BasicItemsAttrs: - basic_items_attrs = {} - for attr_name in (s.name for s in self._sort_order): - vals: set[str] = set() - for entry in [e for e in entries if isinstance(e, ImgItem)]: - val = (getattr(entry, attr_name) - if hasattr(entry, attr_name) else None) - if val is not None: - vals.add(val) - basic_items_attrs[attr_name] = vals - return basic_items_attrs - - def _load_directory(self) -> None: - """(Re-)build .dir_entries from ._img_dir_path, ._basic_items_attrs.""" - self.dir_entries.clear() - bookmarks = self._bookmarks_db.as_copy() - cache = self._cache_db.as_ref() - - def read_directory(dir_path: str, make_parent: bool = False) -> None: - 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: list[str] = [] - to_set_metadata_on: list[ImgItem] = [] - 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) - - read_directory(self._img_dir_path, make_parent=True) - prompts_set: set[str] = set() - for entry in [e for e in self.dir_entries - if isinstance(e, ImgItem) and hasattr(e, 'prompt')]: - prompts_set.add(entry.prompt) - prompts_diff = self._diff_prompts(list(prompts_set)) - for entry in [e for e in self.dir_entries if isinstance(e, ImgItem)]: - entry.subprompt1 = prompts_diff[entry.prompt][0] - entry.subprompt2 = prompts_diff[entry.prompt][1] - if self._sort_order.by_name('prompt'): - self._sort_order.remove('prompt') - self._sort_order._list.append(SorterAndFilterer('subprompt1')) - self._sort_order._list.append(SorterAndFilterer('subprompt2')) - self._basic_items_attrs = self._prep_items_attrs(self.dir_entries) - ignorable_attrs = [] - for attr_name, attr_vals in self._basic_items_attrs.items(): - if len(attr_vals) < 2: - ignorable_attrs += [attr_name] - for attr_name in ignorable_attrs: - self._sort_order.remove(attr_name) - del self._basic_items_attrs[attr_name] - self._cache_db.write() - - @property - def selected_item(self) -> Optional[GalleryItem]: - """Return slot.item for slot at self.selected_idx.""" - return self.slots[self.selected_idx].item if self.slots else None - - def on_focus_slot(self, slot: GallerySlot) -> None: - """If GallerySlot focused, set .selected_idx to it.""" - self._set_selection(self.slots.index(slot)) - self.request_update(scroll_to_focus=True) - - def _assert_selection(self) -> None: - if self.slots: - self.slots[self.selected_idx].mark('selected', True) - self.slots[self.selected_idx].grab_focus() - - def _set_selection(self, new_idx: int) -> None: - """Set self.selected_idx, mark slot as 'selected', unmark old one.""" - # in ._build_grid(), 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_grid() 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 - self._assert_selection() - self._on_selection_change() - - def _build_grid(self) -> None: - """(Re-)build slot grid from .dir_entries, filters, layout settings.""" - old_selected_item: Optional[GalleryItem] = self.selected_item - - def update_items_attrs() -> None: - self.items_attrs.clear() - - def separate_items_attrs(basic_items_attrs) -> ItemsAttrs: - items_attrs: ItemsAttrs = {} - for attr_name, vals in basic_items_attrs.items(): - sorter = self._sort_order.by_name(attr_name) - items_attrs[attr_name] = {'incl': [], 'excl': []} - for v in vals: - passes_filter = sorter is None - if sorter: - passes_filter = sorter.filter_allows_value(v) - k = 'incl' if passes_filter else 'excl' - items_attrs[attr_name][k] += [v] - return items_attrs - - items_attrs_tmp_1 = separate_items_attrs(self._basic_items_attrs) - filtered = filter_entries(items_attrs_tmp_1) - reduced_basic_items_attrs = self._prep_items_attrs(filtered) - items_attrs_tmp_2 = separate_items_attrs(reduced_basic_items_attrs) - for attr_name in (s.name for s in self._sort_order): - final_values: AttrValsByVisibility = {'incl': [], 'semi': []} - final_values['excl'] = items_attrs_tmp_1[attr_name]['excl'] - for v in items_attrs_tmp_1[attr_name]['incl']: - k = ('incl' if v in items_attrs_tmp_2[attr_name]['incl'] - else 'semi') - final_values[k] += [v] - for category in ('incl', 'semi', 'excl'): - final_values[category].sort() - self.items_attrs[attr_name] = final_values - - def filter_entries(items_attrs: ItemsAttrs) -> list[GalleryItem]: - entries_filtered: list[GalleryItem] = [] - 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 not in items_attrs[attr_name]['incl']: - passes_filters = False - break - if passes_filters: - entries_filtered += [entry] - return entries_filtered - - def build(entries_filtered: list[GalleryItem]) -> None: - i_row_ref, i_slot_ref = [0], [0] - if self._grid.get_parent(): - self._fixed_frame.remove(self._grid) - self._grid = Gtk.Grid() - if self._col_headers_grid.get_parent(): - self._col_headers_frame.remove(self._col_headers_grid) - self._col_headers_grid = Gtk.Grid() - self.slots.clear() - self._fixed_frame.put(self._grid, 0, 0) - - def build_rows_by_attrs( - remaining: list[tuple[str, AttrVals]], - items_of_parent: list[GalleryItem], - ancestors: list[tuple[str, str]] - ) -> None: - if not items_of_parent: - return - attr_name, attr_values = remaining[0] - if 1 == len(remaining): - for i, attr in enumerate(ancestors): - txt = f'{attr[0]}: {attr[1]}' - vlabel = VerticalLabel(txt, self._slots_geometry) - self._grid.attach(vlabel, i, i_row_ref[0], 1, 1) - row: list[Optional[GalleryItem]] - row = [None] * len(attr_values) - for gallery_item in items_of_parent: - val = getattr(gallery_item, attr_name) - idx_val_in_attr_values = attr_values.index(val) - if row[idx_val_in_attr_values]: - gallery_item.with_others = True - row[idx_val_in_attr_values] = gallery_item - for i_col, item in enumerate(row): - slot = GallerySlot( # build empty dummy if necessary - item if item else GalleryItem('', ''), - self._slots_geometry) - self.slots += [slot] - i_slot_ref[0] += 1 - self._grid.attach(slot, i_col + len(ancestors), - 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 - if attr_value == getattr(x, - attr_name)] - build_rows_by_attrs(remaining[1:], items_of_attr_value, - ancestors + [(attr_name, attr_value)]) - - if self._by_1st: - self._show_dirs = False - sort_attrs: list[tuple[str, AttrVals]] = [] - for sorter in reversed(self._sort_order): - vals: AttrVals = self.items_attrs[sorter.name]['incl'] - if len(vals) > 1: - sort_attrs += [(sorter.name, vals)] - if not sort_attrs: - s_name: str = self._sort_order[0].name - sort_attrs += [(s_name, self.items_attrs[s_name]['incl'])] - self._per_row = len(sort_attrs[-1][1]) - build_rows_by_attrs(sort_attrs, entries_filtered, []) - self._col_headers_frame.put(self._col_headers_grid, 0, 0) - self._col_headers_grid.attach(Gtk.Box(), 0, 0, 1, 1) - top_attr_name: str = sort_attrs[-1][0] - for i, val in enumerate(sort_attrs[-1][1]): - label = Gtk.Label(label=f'{top_attr_name}: {val}', - xalign=0, - ellipsize=Pango.EllipsizeMode.MIDDLE) - label.set_use_markup(True) - self._col_headers_grid.attach(label, i + 1, 0, 1, 1) - - else: - dir_entries_filtered_sorted: list[GalleryItem] = 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, self._slots_geometry, - self._on_hit_item) - self._grid.attach(slot, i_col, i_row, 1, 1) - self.slots += [slot] - i_col += 1 - self.update_config_box() - - update_items_attrs() - entries_filtered = filter_entries(self.items_attrs) - build(entries_filtered) - new_idx = 0 - if old_selected_item is not None: - for i, slot in enumerate(self.slots): - if hash(old_selected_item) == hash(slot.item): - new_idx = i - break - self._set_selection(new_idx) - - def request_update(self, - select: bool = False, - scroll_to_focus: bool = False, - build_grid: bool = False, - load: bool = False - ) -> None: - """Set ._shall_… to trigger updates on next relevant interval.""" - self._shall_redraw = True - self._shall_select |= select or scroll_to_focus or build_grid or load - self._shall_scroll_to_focus |= scroll_to_focus or build_grid or load - self._shall_build_grid |= build_grid or load - self._shall_load |= load - - def move_selection(self, - x_inc: Optional[int], - y_inc: Optional[int], - buf_end: Optional[int] - ) -> None: - """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: int = 0, height: int = 0) -> None: - """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 _redraw_and_check_focus(self) -> None: - """Draw gallery; possibly notice and first follow need to re-focus.""" - vp_width: int = (self._force_width if self._force_width - else self._viewport.get_width()) - vp_height: int = (self._force_height if self._force_height - else self._viewport.get_height()) - self._force_width, self._force_height = 0, 0 - vp_scroll: Gtk.Adjustment = self._viewport.get_vadjustment() - vp_top: int = vp_scroll.get_value() - vp_bottom: int = vp_top + vp_height - side_offset, i_vlabels = 0, 0 - if self._by_1st: - while True: - gal_widget: VerticalLabel | GalleryItem - gal_widget = self._grid.get_child_at(i_vlabels, 0) - if isinstance(gal_widget, VerticalLabel): - side_offset += gal_widget.width - else: - break - i_vlabels += 1 - max_slot_width: int = (vp_width - side_offset) // self._per_row - self._slots_geometry.set_size(min(vp_height, max_slot_width)) - if self._by_1st: - i_widgets = 0 - while True: - head_widget: Gtk.Box | Gtk.Label | None - head_widget = self._col_headers_grid.get_child_at(i_widgets, 0) - if 0 == i_widgets: - head_widget.set_size_request(side_offset, -1) - elif isinstance(head_widget, Gtk.Label): - head_widget.set_size_request(self._slots_geometry.size, -1) - else: - break - i_widgets += 1 - for idx, slot in enumerate(self.slots): - slot.ensure_slot_size() - vp_scroll.set_upper(self._slots_geometry.size * ceil(len(self.slots) - / self._per_row)) - if self._scroll_to_focus(vp_scroll, vp_top, vp_bottom): - return - for idx, slot in enumerate(self.slots): - in_vp, _, _ = self._position_to_viewport(idx, - vp_top, vp_bottom, True) - slot.update_widget(in_vp) - self._start_redraw_wait = datetime.now() - - def _position_to_viewport(self, - idx: int, - vp_top: int, - vp_bottom: int, - in_vp_greedy: bool = False - ) -> tuple[bool, int, int]: - slot_top: int = (idx // self._per_row) * self._slots_geometry.size - slot_bottom: int = slot_top + self._slots_geometry.size - if in_vp_greedy: - in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom) - else: - in_vp = (slot_top >= vp_top and slot_bottom <= vp_bottom) - return in_vp, slot_top, slot_bottom - - def _scroll_to_focus(self, - vp_scroll: Gtk.Scrollable, - vp_top: int, - vp_bottom: int - ) -> bool: - scroll_to_focus: bool = 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, 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 - - self._slots_geometry.size) - return True - return False - - def _sort_cmp(self, a: GalleryItem, b: GalleryItem) -> int: - """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 ImgItems (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 - - -class MainWindow(Gtk.Window): - """Image browser app top-level window.""" - - def __init__(self, app: Application, **kwargs) -> None: - super().__init__(**kwargs) - self.app = app - self.gallery = Gallery( - sort_order=self.app.sort_order, - on_hit_item=self.hit_gallery_item, - on_selection_change=self.update_metadata_on_gallery_selection, - bookmarks_db=self.app.bookmarks_db, - cache_db=self.app.cache_db) - config_box = Gtk.Box(orientation=OR_V) - self.conf = GalleryConfig( - sort_order=self.app.sort_order, - box=config_box, - request_update=self.gallery.request_update, - update_settings=self.gallery.update_settings, - items_attrs=self.gallery.items_attrs) - self.gallery.update_config_box = self.conf.update_box - metadata_textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR, - editable=False) - self.metadata = metadata_textview.get_buffer() - self.idx_display = Gtk.Label() - - # layout: outer box, CSS, sizings - box_outer = Gtk.Box(orientation=OR_H) - self.set_child(box_outer) - css_provider = Gtk.CssProvider() - css_provider.load_from_data(CSS) - Gtk.StyleContext.add_provider_for_display( - self.get_display(), css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_USER) - metadata_textview.set_size_request(300, -1) - self.connect('notify::default-width', lambda _, __: self.on_resize()) - self.connect('notify::default-height', lambda _, __: self.on_resize()) - - # layout: sidebar - self.side_box = Gtk.Notebook.new() - self.side_box.append_page(metadata_textview, - Gtk.Label(label='metadata')) - self.side_box.append_page(config_box, Gtk.Label(label='config')) - box_outer.append(self.side_box) - - # layout: gallery viewer - viewer = Gtk.Box(orientation=OR_V) - self.navbar = Gtk.Box(orientation=OR_H) - _add_button(self.navbar, 'sidebar', lambda _: self.toggle_side_box()) - self.navbar.append(self.idx_display) - viewer.append(self.navbar) - viewer.append(self.gallery.frame) - box_outer.append(viewer) - - # init key and focus 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_ref = [0] - self.connect('notify::focus-widget', - lambda _, __: self.on_focus_change()) - - # only now we're ready for actually running the gallery - GLib.idle_add(lambda: self.gallery.update_settings( - img_dir_path=self.app.img_dir_absolute)) - - def on_focus_change(self) -> None: - """Handle reactions on focus changes in .gallery and .conf.""" - focused: Optional[Gtk.Widget] = self.get_focus() - if not focused: - return - if isinstance(focused, GallerySlot): - self.gallery.on_focus_slot(focused) - elif focused.get_parent() == self.conf.sorter_listing: - self.conf.on_focus_sorter(focused) - - def on_resize(self) -> None: - """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: int = self.side_box.measure(OR_H, -1).natural - default_size: tuple[int, int] = self.get_default_size() - self.gallery.on_resize(default_size[0] - side_box_width, - default_size[1] - self.navbar.get_height()) - - def bookmark(self) -> None: - """Toggle bookmark on selected gallery item.""" - if not isinstance(self.gallery.selected_item, ImgItem): - return - bookmarks = self.app.bookmarks_db.as_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] - self.app.bookmarks_db.write() - self.conf.update_box() - - def hit_gallery_item(self) -> None: - """If current file selection is directory, reload into that one.""" - selected: Optional[GalleryItem] = self.gallery.selected_item - if isinstance(selected, DirItem): - self.gallery.update_settings(img_dir_path=selected.full_path) - - def toggle_side_box(self) -> None: - """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: int = self.side_box.measure(OR_H, -1).natural - self.gallery.on_resize(self.get_width() - side_box_width) - - def update_metadata_on_gallery_selection(self) -> None: - """Update .metadata about individual file, and .idx_display.""" - self.metadata.set_text('') - selected_item: Optional[GalleryItem] = self.gallery.selected_item - display_name = '(none)' - 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)) - display_name = selected_item.full_path - elif isinstance(selected_item, DirItem): - display_name = selected_item.full_path - total = len([s for s in self.gallery.slots - if isinstance(s.item, (DirItem, ImgItem))]) - n_selected: int = self.gallery.selected_idx + 1 - txt = f' {n_selected} of {total} – {display_name}' - self.idx_display.set_text(txt) - self.idx_display.set_use_markup(True) - - def handle_keypress(self, keyval: int) -> bool: - """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 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: - self.gallery.move_selection(-1, None, None) - elif Gdk.KEY_j == keyval: - self.gallery.move_selection(None, +1, None) - elif Gdk.KEY_k == keyval: - self.gallery.move_selection(None, -1, None) - elif Gdk.KEY_l == keyval: - self.gallery.move_selection(+1, None, None) - elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key_ref[0]: - self.gallery.move_selection(None, None, -1) - elif Gdk.KEY_w == keyval: - self.conf.move_selection(-1) - elif Gdk.KEY_W == keyval: - self.conf.move_sorter(-1) - elif Gdk.KEY_s == keyval: - self.conf.move_selection(1) - elif Gdk.KEY_S == keyval: - self.conf.move_sorter(1) - elif Gdk.KEY_b == keyval: - self.bookmark() - else: - self.prev_key_ref[0] = keyval - return False - return True - - -main_app = Application(application_id='plomlompom.com.StablePixBrowser.App') -main_app.run() +from argparse import ArgumentParser +from os.path import abspath + +from browser.gtk_app import GtkApp +from browser.config_constants import ( + BOOKMARKS_PATH, CACHE_PATH, GTK_APP_ID, IMG_DIR_DEFAULT, SORT_DEFAULT) + + +parser = ArgumentParser() +parser.add_argument('directory', nargs='?', default=IMG_DIR_DEFAULT) +parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT) +opts = parser.parse_args() +sort_suggestion = opts.sort_order.split(',') +GtkApp(application_id=GTK_APP_ID, + bookmarks_file=BOOKMARKS_PATH, + cache_file=CACHE_PATH, + start_dir=abspath(opts.directory), + sort_order_suggestion=opts.sort_order.split(',') + ).run() diff --git a/browser/__init__.py b/browser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/browser/config_constants.py b/browser/config_constants.py new file mode 100644 index 0000000..e635065 --- /dev/null +++ b/browser/config_constants.py @@ -0,0 +1,9 @@ +"""Configuration-ready constants.""" + +GALLERY_PER_ROW_DEFAULT = 5 +GTK_APP_ID = 'plomlompom.com.StablePixBrowser.App' +CACHE_PATH = 'cache.json' +BOOKMARKS_PATH = 'bookmarks.json' +IMG_DIR_DEFAULT = '.' +SORT_DEFAULT = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\ + 'model,prompt' diff --git a/browser/gallery.py b/browser/gallery.py new file mode 100644 index 0000000..76cd0e3 --- /dev/null +++ b/browser/gallery.py @@ -0,0 +1,798 @@ +"""Gallery class.""" + +from datetime import datetime, timedelta, timezone +from functools import cmp_to_key +from math import ceil, radians +from os import listdir +from os.path import abspath, getmtime, isdir, join as path_join, splitext +from typing import Callable, Optional, TypeAlias +from PIL import Image +from PIL.PngImagePlugin import PngImageFile +import gi # type: ignore + +from browser.config_constants import GALLERY_PER_ROW_DEFAULT +from browser.gtk_helpers import OR_V +from browser.json_dbs import BookmarksDb, CacheDb +from browser.gallery_config import SorterAndFilterer, SorterAndFiltererOrder +from browser.types import ( + AttrVals, AttrValsByVisibility, Cache, CachedImg, ItemsAttrs + ) +from stable.gen_params import GenParams, GEN_PARAMS, GEN_PARAMS_STR + +# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes +gi.require_version('Gtk', '4.0') +# pylint: disable=wrong-import-order,wrong-import-position +from gi.repository import ( # type: ignore # noqa: E402 + GLib, GObject, Gtk, Pango, PangoCairo) + + +BasicItemsAttrs: TypeAlias = dict[str, set[str]] + + +UPPER_DIR: str = '..' +GALLERY_SLOT_MARGIN: int = 6 +GALLERY_UPDATE_INTERVAL_MS: int = 50 +GALLERY_REDRAW_WAIT_MS: int = 200 +ACCEPTED_IMG_FILE_ENDINGS: set[str] = {'.png', '.PNG'} + + +class GalleryItem(GObject.GObject): + """Gallery representation of filesystem entry, base to DirItem, ImgItem.""" + _to_hash = ['name', 'full_path'] + + def __init__(self, path: str, name: str) -> None: + super().__init__() + self.name = name + self.full_path: str = path_join(path, self.name) + self.slot: GallerySlot + + def __hash__(self) -> int: + hashable_values: list[str | bool] = [] + 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.""" + + def __init__(self, path: str, name: str, is_parent: bool = False) -> None: + super().__init__(path, name) + if is_parent: + self.full_path = path + + +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: str, name: str, cache: Cache) -> None: + super().__init__(path, name) + self.subprompt1: str = '' + self.subprompt2: str = '' + mtime = getmtime(self.full_path) + dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + iso8601_str: str = dt.isoformat(timespec='microseconds') + self.last_mod_time: str = 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(), '') + else: + 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]) + + def set_metadata(self, cache: Cache) -> None: + """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', '') + if gen_params_as_str: + gen_params = GenParams.from_str(gen_params_as_str) + for k, v_ in gen_params.as_dict.items(): + setattr(self, k, v_) + cached: CachedImg = {} + for k in (k.lower() for k in GEN_PARAMS): + cached[k] = getattr(self, k) + cache[self.full_path] = {self.last_mod_time: cached} + + def bookmark(self, positive: bool = True) -> None: + """Set self.bookmark to positive, and update CSS class mark.""" + self.bookmarked = positive + self.slot.mark('bookmarked', positive) + + +class GallerySlotsGeometry: + """Collect variable sizes shared among all GallerySlots.""" + + def __init__(self) -> None: + self._margin: int = GALLERY_SLOT_MARGIN + assert 0 == self._margin % 2 # avoid ._margin != 2 * .side_margin + self.side_margin: int = self._margin // 2 + self.size: int = -1 + self.size_sans_margins: int = -1 + + def set_size(self, size: int) -> None: + """Not only set .size but also update .size_sans_margins.""" + self.size = size + self.size_sans_margins = self.size - self._margin + + +class GallerySlot(Gtk.Button): + """Slot in Gallery representing a GalleryItem.""" + + def __init__(self, + item: GalleryItem, + slots_geometry: GallerySlotsGeometry, + on_click_file: Optional[Callable] = None + ) -> None: + super().__init__() + self._geometry = slots_geometry + self.add_css_class('slot') + self.set_hexpand(True) + self.item = item + self.item.slot = self + if on_click_file: + self.connect('clicked', lambda _: on_click_file()) + + def mark(self, css_class: str, do_add: bool = True) -> None: + """Add or remove css_class from self.""" + if do_add: + self.add_css_class(css_class) + else: + self.remove_css_class(css_class) + + def ensure_slot_size(self) -> None: + """Call ._size_widget to size .props.child; if none, make empty one.""" + if self.get_child() is None: + self.set_child(Gtk.Label(label='+')) + self._size_widget() + + def _size_widget(self) -> None: + for s in ('bottom', 'top', 'start', 'end'): + setattr(self.get_child().props, f'margin_{s}', + self._geometry.side_margin) + self.get_child().set_size_request(self._geometry.size_sans_margins, + self._geometry.size_sans_margins) + + def update_widget(self, is_in_vp: bool) -> None: + """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class.""" + new_content: Optional[Gtk.Image | Gtk.Label] = None + if 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) + if self.item.with_others: + new_content.set_vexpand(True) + box = Gtk.Box(orientation=OR_V) + box.append(new_content) + msg = 'and one or more other images of this configuration' + box.append(Gtk.Label(label=msg)) + new_content = box + elif (not is_in_vp) and not isinstance(self.item, Gtk.Label): + new_content = Gtk.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) + self._size_widget() + if isinstance(self.item, ImgItem): + self.mark('bookmarked', self.item.bookmarked) + + +class _VerticalLabel(Gtk.DrawingArea): + """Label of vertical text (rotated -90°).""" + + def __init__(self, + text: str, + slots_geometry: GallerySlotsGeometry + ) -> None: + super().__init__() + self._text = text + self._slots_geometry = slots_geometry + test_layout = self.create_pango_layout() + test_layout.set_markup(text) + _, self._text_height = test_layout.get_pixel_size() + self.set_draw_func(self._on_draw) + + def _on_draw(self, + _, + cairo_ctx: Pango.Context, + __, + height: int + ) -> None: + """Create layout, rotate by 90°, size widget to measurements.""" + layout = self.create_pango_layout() + layout.set_markup(self._text) + layout.set_width(self._slots_geometry.size * Pango.SCALE) + layout.set_ellipsize(Pango.EllipsizeMode.MIDDLE) + text_width, _ = layout.get_pixel_size() + cairo_ctx.translate(0, text_width + (height - text_width)) + cairo_ctx.rotate(radians(-90)) + PangoCairo.show_layout(cairo_ctx, layout) + self.set_size_request(self._text_height, text_width) + + @property + def width(self) -> int: + """Return (rotated) ._text_height.""" + return self._text_height + + +class Gallery: + """Representation of GalleryItems below a directory.""" + update_config_box: Callable + + def __init__(self, + sort_order: SorterAndFiltererOrder, + on_hit_item: Callable, + on_selection_change: Callable, + bookmarks_db: BookmarksDb, + cache_db: CacheDb + ) -> None: + self._on_hit_item = on_hit_item + self._on_selection_change = on_selection_change + self._bookmarks_db, self._cache_db = bookmarks_db, cache_db + self._sort_order = sort_order + self._img_dir_path = '' + + self._shall_load = False + self._shall_build_grid = 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._slots_geometry = GallerySlotsGeometry() + + self.dir_entries: list[GalleryItem] = [] + self._basic_items_attrs: BasicItemsAttrs = {} + self.items_attrs: ItemsAttrs = {} + self.selected_idx = 0 + self.slots: list[GallerySlot] = [] + + self._grid = Gtk.Grid() + self._force_width, self._force_height = 0, 0 + scroller = Gtk.ScrolledWindow(propagate_natural_height=True) + self._col_headers_frame = Gtk.Fixed() + self._col_headers_grid = Gtk.Grid() + self.frame = Gtk.Box(orientation=OR_V) + self.frame.append(self._col_headers_frame) + self.frame.append(scroller) + # 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 + + def ensure_uptodate() -> bool: + if not self._img_dir_path: + return True + if self._shall_load: + self._shall_load = False + self._load_directory() + if self._shall_build_grid: + self._shall_build_grid = False + self._build_grid() + if self._shall_select: + self._shall_select = False + self._assert_selection() + if self._shall_redraw: + wait_time_passed = datetime.now() - self._start_redraw_wait + if wait_time_passed > redraw_wait_time: + self._shall_redraw = False + self._redraw_and_check_focus() + return True + + def handle_scroll(_) -> None: + self._start_redraw_wait = datetime.now() + self._shall_scroll_to_focus = False + self.request_update() # only request redraw + + 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: Optional[int] = None, + by_1st: Optional[bool] = None, + show_dirs: Optional[bool] = None, + recurse_dirs: Optional[bool] = None, + img_dir_path: Optional[str] = None, + ) -> 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')]: + 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.request_update(load=True) + else: + self.request_update(build_grid=True) + + @staticmethod + def _diff_prompts(prompts: list[str]) -> dict[str, tuple[str, str]]: + if not prompts: + return {} + + def find_longest_equal(prompts, j, matcher): + longest_total, temp_longest = '', '' + while j < len(prompts[0]): + if 'end' == matcher: + temp_longest = prompts[0][-j] + temp_longest + else: + temp_longest += prompts[0][j] + if len(temp_longest) > len(longest_total): + found_in_all = True + for prompt in prompts[1:]: + if ('start' == matcher + and not prompt.startswith(temp_longest)) or\ + ('end' == matcher + and not prompt.endswith(temp_longest)) or\ + ('in' == matcher + and temp_longest not in prompt): + found_in_all = False + break + if not found_in_all: + break + longest_total = temp_longest + j += 1 + return longest_total + + prefix = find_longest_equal(prompts, 0, 'start') + suffix = find_longest_equal(prompts, 1, matcher='end') + cores = [p[len(prefix):] for p in prompts] + if suffix: + for i, p in enumerate(cores): + cores[i] = p[:-len(suffix)] + longest_total = '' + for i in range(len(cores[0])): + temp_longest = find_longest_equal(cores, j=i, matcher='in') + if len(temp_longest) > len(longest_total): + longest_total = temp_longest + middle = longest_total + prompts_diff = {} + for i, p in enumerate(prompts): + remains = p[len(prefix):] if prefix else p + idx_middle = remains.index(middle) + first = remains[:idx_middle] if idx_middle else '' + remains = remains[idx_middle + len(middle):] + second = remains[:-len(suffix)] if suffix else remains + if first: + first = f'…{first}' if prefix else first + first = f'{first}…' if suffix or middle else first + if second: + second = f'…{second}' if prefix or middle else second + second = f'{second}…' if suffix else second + prompts_diff[p] = (first if first else '…', + second if second else '…') + return prompts_diff + + def _prep_items_attrs(self, entries: list[GalleryItem]) -> BasicItemsAttrs: + basic_items_attrs = {} + for attr_name in (s.name for s in self._sort_order): + vals: set[str] = set() + for entry in [e for e in entries if isinstance(e, ImgItem)]: + val = (getattr(entry, attr_name) + if hasattr(entry, attr_name) else None) + if val is not None: + vals.add(val) + basic_items_attrs[attr_name] = vals + return basic_items_attrs + + def _load_directory(self) -> None: + """(Re-)build .dir_entries from ._img_dir_path, ._basic_items_attrs.""" + self.dir_entries.clear() + bookmarks = self._bookmarks_db.as_copy() + cache = self._cache_db.as_ref() + + def read_directory(dir_path: str, make_parent: bool = False) -> None: + 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: list[str] = [] + to_set_metadata_on: list[ImgItem] = [] + 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) + + read_directory(self._img_dir_path, make_parent=True) + prompts_set: set[str] = set() + for entry in [e for e in self.dir_entries + if isinstance(e, ImgItem) and hasattr(e, 'prompt')]: + prompts_set.add(entry.prompt) + prompts_diff = self._diff_prompts(list(prompts_set)) + for entry in [e for e in self.dir_entries if isinstance(e, ImgItem)]: + entry.subprompt1 = prompts_diff[entry.prompt][0] + entry.subprompt2 = prompts_diff[entry.prompt][1] + if self._sort_order.by_name('prompt'): + self._sort_order.remove('prompt') + self._sort_order._list.append(SorterAndFilterer('subprompt1')) + self._sort_order._list.append(SorterAndFilterer('subprompt2')) + self._basic_items_attrs = self._prep_items_attrs(self.dir_entries) + ignorable_attrs = [] + for attr_name, attr_vals in self._basic_items_attrs.items(): + if len(attr_vals) < 2: + ignorable_attrs += [attr_name] + for attr_name in ignorable_attrs: + self._sort_order.remove(attr_name) + del self._basic_items_attrs[attr_name] + self._cache_db.write() + + @property + def selected_item(self) -> Optional[GalleryItem]: + """Return slot.item for slot at self.selected_idx.""" + return self.slots[self.selected_idx].item if self.slots else None + + def on_focus_slot(self, slot: GallerySlot) -> None: + """If GallerySlot focused, set .selected_idx to it.""" + self._set_selection(self.slots.index(slot)) + self.request_update(scroll_to_focus=True) + + def _assert_selection(self) -> None: + if self.slots: + self.slots[self.selected_idx].mark('selected', True) + self.slots[self.selected_idx].grab_focus() + + def _set_selection(self, new_idx: int) -> None: + """Set self.selected_idx, mark slot as 'selected', unmark old one.""" + # in ._build_grid(), 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_grid() 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 + self._assert_selection() + self._on_selection_change() + + def _build_grid(self) -> None: + """(Re-)build slot grid from .dir_entries, filters, layout settings.""" + old_selected_item: Optional[GalleryItem] = self.selected_item + + def update_items_attrs() -> None: + self.items_attrs.clear() + + def separate_items_attrs(basic_items_attrs) -> ItemsAttrs: + items_attrs: ItemsAttrs = {} + for attr_name, vals in basic_items_attrs.items(): + sorter = self._sort_order.by_name(attr_name) + items_attrs[attr_name] = {'incl': [], 'excl': []} + for v in vals: + passes_filter = sorter is None + if sorter: + passes_filter = sorter.filter_allows_value(v) + k = 'incl' if passes_filter else 'excl' + items_attrs[attr_name][k] += [v] + return items_attrs + + items_attrs_tmp_1 = separate_items_attrs(self._basic_items_attrs) + filtered = filter_entries(items_attrs_tmp_1) + reduced_basic_items_attrs = self._prep_items_attrs(filtered) + items_attrs_tmp_2 = separate_items_attrs(reduced_basic_items_attrs) + for attr_name in (s.name for s in self._sort_order): + final_values: AttrValsByVisibility = {'incl': [], 'semi': []} + final_values['excl'] = items_attrs_tmp_1[attr_name]['excl'] + for v in items_attrs_tmp_1[attr_name]['incl']: + k = ('incl' if v in items_attrs_tmp_2[attr_name]['incl'] + else 'semi') + final_values[k] += [v] + for category in ('incl', 'semi', 'excl'): + final_values[category].sort() + self.items_attrs[attr_name] = final_values + + def filter_entries(items_attrs: ItemsAttrs) -> list[GalleryItem]: + entries_filtered: list[GalleryItem] = [] + 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 not in items_attrs[attr_name]['incl']: + passes_filters = False + break + if passes_filters: + entries_filtered += [entry] + return entries_filtered + + def build(entries_filtered: list[GalleryItem]) -> None: + i_row_ref, i_slot_ref = [0], [0] + if self._grid.get_parent(): + self._fixed_frame.remove(self._grid) + self._grid = Gtk.Grid() + if self._col_headers_grid.get_parent(): + self._col_headers_frame.remove(self._col_headers_grid) + self._col_headers_grid = Gtk.Grid() + self.slots.clear() + self._fixed_frame.put(self._grid, 0, 0) + + def build_rows_by_attrs( + remaining: list[tuple[str, AttrVals]], + items_of_parent: list[GalleryItem], + ancestors: list[tuple[str, str]] + ) -> None: + if not items_of_parent: + return + attr_name, attr_values = remaining[0] + if 1 == len(remaining): + for i, attr in enumerate(ancestors): + txt = f'{attr[0]}: {attr[1]}' + vlabel = _VerticalLabel(txt, self._slots_geometry) + self._grid.attach(vlabel, i, i_row_ref[0], 1, 1) + row: list[Optional[GalleryItem]] + row = [None] * len(attr_values) + for gallery_item in items_of_parent: + val = getattr(gallery_item, attr_name) + idx_val_in_attr_values = attr_values.index(val) + if row[idx_val_in_attr_values]: + gallery_item.with_others = True + row[idx_val_in_attr_values] = gallery_item + for i_col, item in enumerate(row): + slot = GallerySlot( # build empty dummy if necessary + item if item else GalleryItem('', ''), + self._slots_geometry) + self.slots += [slot] + i_slot_ref[0] += 1 + self._grid.attach(slot, i_col + len(ancestors), + 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 + if attr_value == getattr(x, + attr_name)] + build_rows_by_attrs(remaining[1:], items_of_attr_value, + ancestors + [(attr_name, attr_value)]) + + if self._by_1st: + self._show_dirs = False + sort_attrs: list[tuple[str, AttrVals]] = [] + for sorter in reversed(self._sort_order): + vals: AttrVals = self.items_attrs[sorter.name]['incl'] + if len(vals) > 1: + sort_attrs += [(sorter.name, vals)] + if not sort_attrs: + s_name: str = self._sort_order[0].name + sort_attrs += [(s_name, self.items_attrs[s_name]['incl'])] + self._per_row = len(sort_attrs[-1][1]) + build_rows_by_attrs(sort_attrs, entries_filtered, []) + self._col_headers_frame.put(self._col_headers_grid, 0, 0) + self._col_headers_grid.attach(Gtk.Box(), 0, 0, 1, 1) + top_attr_name: str = sort_attrs[-1][0] + for i, val in enumerate(sort_attrs[-1][1]): + label = Gtk.Label(label=f'{top_attr_name}: {val}', + xalign=0, + ellipsize=Pango.EllipsizeMode.MIDDLE) + label.set_use_markup(True) + self._col_headers_grid.attach(label, i + 1, 0, 1, 1) + + else: + dir_entries_filtered_sorted: list[GalleryItem] = 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, self._slots_geometry, + self._on_hit_item) + self._grid.attach(slot, i_col, i_row, 1, 1) + self.slots += [slot] + i_col += 1 + self.update_config_box() + + update_items_attrs() + entries_filtered = filter_entries(self.items_attrs) + build(entries_filtered) + new_idx = 0 + if old_selected_item is not None: + for i, slot in enumerate(self.slots): + if hash(old_selected_item) == hash(slot.item): + new_idx = i + break + self._set_selection(new_idx) + + def request_update(self, + select: bool = False, + scroll_to_focus: bool = False, + build_grid: bool = False, + load: bool = False + ) -> None: + """Set ._shall_… to trigger updates on next relevant interval.""" + self._shall_redraw = True + self._shall_select |= select or scroll_to_focus or build_grid or load + self._shall_scroll_to_focus |= scroll_to_focus or build_grid or load + self._shall_build_grid |= build_grid or load + self._shall_load |= load + + def move_selection(self, + x_inc: Optional[int], + y_inc: Optional[int], + buf_end: Optional[int] + ) -> None: + """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: int = 0, height: int = 0) -> None: + """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 _redraw_and_check_focus(self) -> None: + """Draw gallery; possibly notice and first follow need to re-focus.""" + vp_width: int = (self._force_width if self._force_width + else self._viewport.get_width()) + vp_height: int = (self._force_height if self._force_height + else self._viewport.get_height()) + self._force_width, self._force_height = 0, 0 + vp_scroll: Gtk.Adjustment = self._viewport.get_vadjustment() + vp_top: int = vp_scroll.get_value() + vp_bottom: int = vp_top + vp_height + side_offset, i_vlabels = 0, 0 + if self._by_1st: + while True: + gal_widget: _VerticalLabel | GalleryItem + gal_widget = self._grid.get_child_at(i_vlabels, 0) + if isinstance(gal_widget, _VerticalLabel): + side_offset += gal_widget.width + else: + break + i_vlabels += 1 + max_slot_width: int = (vp_width - side_offset) // self._per_row + self._slots_geometry.set_size(min(vp_height, max_slot_width)) + if self._by_1st: + i_widgets = 0 + while True: + head_widget: Gtk.Box | Gtk.Label | None + head_widget = self._col_headers_grid.get_child_at(i_widgets, 0) + if 0 == i_widgets: + head_widget.set_size_request(side_offset, -1) + elif isinstance(head_widget, Gtk.Label): + head_widget.set_size_request(self._slots_geometry.size, -1) + else: + break + i_widgets += 1 + for idx, slot in enumerate(self.slots): + slot.ensure_slot_size() + vp_scroll.set_upper(self._slots_geometry.size * ceil(len(self.slots) + / self._per_row)) + if self._scroll_to_focus(vp_scroll, vp_top, vp_bottom): + return + for idx, slot in enumerate(self.slots): + in_vp, _, _ = self._position_to_viewport(idx, + vp_top, vp_bottom, True) + slot.update_widget(in_vp) + self._start_redraw_wait = datetime.now() + + def _position_to_viewport(self, + idx: int, + vp_top: int, + vp_bottom: int, + in_vp_greedy: bool = False + ) -> tuple[bool, int, int]: + slot_top: int = (idx // self._per_row) * self._slots_geometry.size + slot_bottom: int = slot_top + self._slots_geometry.size + if in_vp_greedy: + in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom) + else: + in_vp = (slot_top >= vp_top and slot_bottom <= vp_bottom) + return in_vp, slot_top, slot_bottom + + def _scroll_to_focus(self, + vp_scroll: Gtk.Scrollable, + vp_top: int, + vp_bottom: int + ) -> bool: + scroll_to_focus: bool = 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, 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 + - self._slots_geometry.size) + return True + return False + + def _sort_cmp(self, a: GalleryItem, b: GalleryItem) -> int: + """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 ImgItems (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 diff --git a/browser/gallery_config.py b/browser/gallery_config.py new file mode 100644 index 0000000..20f25ee --- /dev/null +++ b/browser/gallery_config.py @@ -0,0 +1,376 @@ +"""Gallery configuration, sorting, filtering widgets/logic.""" + +from re import search as re_search +from typing import Callable, Optional, Self +import gi # type: ignore + +from browser.config_constants import GALLERY_PER_ROW_DEFAULT +from browser.gtk_helpers import add_button, OR_H, OR_V +from browser.types import ItemsAttrs, AttrValsByVisibility +from stable.gen_params import ( + GEN_PARAMS, GEN_PARAMS_FLOAT, GEN_PARAMS_INT) + +# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes +gi.require_version('Gtk', '4.0') +gi.require_version('Gio', '2.0') +# pylint: disable=wrong-import-order,wrong-import-position +from gi.repository import Gio, GObject, Gtk, Pango # type: ignore # noqa: E402 + + +class SorterAndFilterer(GObject.GObject): + """Sort order box representation of sorting/filtering attribute.""" + widget: Gtk.Box + + def __init__(self, name: str) -> None: + super().__init__() + self.name = name + self.filter_text = '' + + def setup_on_bind(self, + widget: Gtk.Box, + on_filter_activate: Callable, + vals: AttrValsByVisibility, + ) -> None: + """Set up SorterAndFilterer label, values listing, filter entry.""" + self.widget = widget + # label + len_incl = len(vals['incl']) + len_semi_total: int = len_incl + len(vals['semi']) + len_total: int = len_semi_total + len(vals['excl']) + title = f'{self.name} ({len_incl}/{len_semi_total}/{len_total}) ' + self.widget.label.set_text(title) + # values listing + vals_listed: list[str] = [f'{v}' for v in vals['incl']] + vals_listed += [f'{v}' for v in vals['semi']] + 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 + + def filter_activate() -> None: + self.widget.filter_input.remove_css_class('temp') + self.filter_text = self.widget.filter_input.get_buffer().get_text() + on_filter_activate() + + filter_buffer = self.widget.filter_input.get_buffer() + filter_buffer.set_text(self.filter_text, -1) # triggers 'temp' class + self.widget.filter_input.remove_css_class('temp') # set, that's why … + self.widget.filter_input.connect('activate', + lambda _: filter_activate()) + filter_buffer.connect( + 'inserted_text', + lambda a, b, c, d: self.widget.filter_input.add_css_class('temp')) + filter_buffer.connect( + 'deleted_text', + lambda a, b, c: self.widget.filter_input.add_css_class('temp')) + + def filter_allows_value(self, value: str | int | float) -> bool: + """Return if value passes filter defined by .name and .filter_text.""" + number_attributes = (set(s.lower() for s in GEN_PARAMS_INT) | + set(s.lower() for s in GEN_PARAMS_FLOAT) | + {'bookmarked'}) + if value is None: + return False + if self.name not in number_attributes: + assert isinstance(value, str) + return bool(re_search(self.filter_text, value)) + assert isinstance(value, (int, float)) + use_float = self.name in {s.lower() for s in GEN_PARAMS_FLOAT} + numbers_or, unequal = (set(),) * 2 + less_than, less_or_equal, more_or_equal, more_than = (None,) * 4 + for constraint_string in self.filter_text.split(','): + toks = constraint_string.split() + if len(toks) == 1: + tok = toks[0] + if tok[0] in '<>!': # operator sans space after: split, re-try + if '=' == tok[1]: + toks = [tok[:2], tok[2:]] + else: + toks = [tok[:1], tok[1:]] + else: + pattern_number = float(tok) if use_float else int(tok) + numbers_or.add(pattern_number) + if len(toks) == 2: # assume operator followed by number + pattern_number = float(toks[1]) if use_float else int(toks[1]) + if toks[0] == '!=': + unequal.add(pattern_number) + elif toks[0] == '<': + if less_than is None or less_than >= pattern_number: + less_than = pattern_number + elif toks[0] == '<=': + if less_or_equal is None or less_or_equal > pattern_number: + less_or_equal = pattern_number + elif toks[0] == '>=': + if more_or_equal is None or more_or_equal < pattern_number: + more_or_equal = pattern_number + elif toks[0] == '>': + if more_than is None or more_than <= pattern_number: + more_than = pattern_number + if value in numbers_or: + return True + if len(numbers_or) > 0 and (less_than == less_or_equal == + more_or_equal == more_than): + return False + if value in unequal: + return False + return ((less_than is None or value < less_than) + and (less_or_equal is None or value <= less_or_equal) + and (more_or_equal is None or value >= more_or_equal) + and (more_than is None or value > more_than)) + + +class SorterAndFiltererOrder: + """Represents sorted list of SorterAndFilterer items.""" + + def __init__(self, as_list: list[SorterAndFilterer]) -> None: + self._list = as_list + + def __eq__(self, other) -> bool: + return self._list == other._list + + def __len__(self) -> int: + return len(self._list) + + def __getitem__(self, idx: int) -> SorterAndFilterer: + return self._list[idx] + + def __iter__(self): + return self._list.__iter__() + + @staticmethod + def _list_from_store(store: Gio.ListStore) -> list[SorterAndFilterer]: + order = [] + for i in range(store.get_n_items()): + order += [store.get_item(i)] + return order + + @classmethod + def from_suggestion(cls, suggestion: list[str]) -> Self: + """Create new, interpreting order of strings in suggestion.""" + names: list[str] = [p.lower() for p in GEN_PARAMS] + ['bookmarked'] + order: list[SorterAndFilterer] = [] + for name in names: + order += [SorterAndFilterer(name)] + new_order: list[SorterAndFilterer] = [] + do_reverse: bool = '-' in suggestion + for pattern in suggestion: + for sorter in [sorter for sorter in order + if sorter.name.startswith(pattern)]: + order.remove(sorter) + new_order += [sorter] + order = new_order + order + if do_reverse: + order.reverse() + return cls(order) + + @classmethod + def from_store(cls, store: Gio.ListStore) -> Self: + """Create new, mirroring order in store.""" + return cls(cls._list_from_store(store)) + + def by_name(self, name: str) -> Optional[SorterAndFilterer]: + """Return included SorterAndFilterer of name.""" + for s in [s for s in self._list if name == s.name]: + return s + return None + + def copy(self) -> Self: + """Create new, of equal order.""" + return self.__class__(self._list[:]) + + def sync_from(self, other_order: Self) -> None: + """Sync internal state from other order.""" + self._list = other_order._list + + def remove(self, sorter_name: str) -> None: + """Remove sorter of sorter_name from self.""" + candidate = self.by_name(sorter_name) + assert candidate is not None + self._list.remove(candidate) + + def update_from_store(self, store: Gio.ListStore) -> None: + """Update self from store.""" + self._list = self._list_from_store(store) + + def into_store(self, store: Gio.ListStore) -> None: + """Update store to represent self.""" + store.remove_all() + for sorter in self: + store.append(sorter) + + def switch_at(self, selected_idx: int, forward: bool) -> None: + """Switch elements at selected_idx and its neighbor.""" + selected: SorterAndFilterer = self[selected_idx] + other_idx: int = selected_idx + (1 if forward else -1) + other: SorterAndFilterer = self[other_idx] + self._list[other_idx] = selected + self._list[selected_idx] = other + + +class GalleryConfig(): + """Representation of sort and filtering settings.""" + _sort_sel = Gtk.SingleSelection + _set_recurse_changed: bool + _btn_apply: Gtk.Button + _btn_by_1st: Gtk.CheckButton + _btn_recurse: Gtk.CheckButton + _btn_per_row: Gtk.CheckButton + _btn_show_dirs: Gtk.CheckButton + _store: Gio.ListStore + + def __init__(self, + box: Gtk.Box, + sort_order: SorterAndFiltererOrder, + request_update: Callable, + update_settings: Callable, + items_attrs: ItemsAttrs, + ) -> None: + self.order = sort_order + self._tmp_order: Optional[SorterAndFiltererOrder] = None + self._gallery_request_update = request_update + self._gallery_update_settings = update_settings + self._gallery_items_attrs = items_attrs + + def setup_sorter_list_item(_, list_item: SorterAndFilterer) -> None: + 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_input = Gtk.Entry(placeholder_text='filter?') + hbox = Gtk.Box(orientation=OR_H) + hbox.append(item_widget.label) + hbox.append(item_widget.filter_input) + item_widget.append(hbox) + item_widget.append(item_widget.values) + list_item.set_child(item_widget) + + def bind_sorter_list_item(_, list_item: SorterAndFilterer) -> None: + def on_filter_activate(): + self._filter_changed = True + sorter: SorterAndFilterer = list_item.props.item + sorter.setup_on_bind(list_item.props.child, + on_filter_activate, + self._gallery_items_attrs[sorter.name]) + + def select_sort_order(_a, _b, _c) -> None: + self._sort_sel.props.selected_item.widget.get_parent().grab_focus() + + def toggle_recurse(_) -> None: + self._set_recurse_changed = not self._set_recurse_changed + self._btn_apply.set_sensitive(not self._set_recurse_changed) + + def toggle_by_1st(btn: Gtk.CheckButton) -> None: + 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) + + def apply_config() -> None: + if self._tmp_order: + self.order.sync_from(self._tmp_order) + self._tmp_order = None + self._gallery_request_update(build_grid=True) + if self._filter_changed: + self._gallery_request_update(build_grid=True) + 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(), + recurse_dirs=self._btn_recurse.get_active()) + self._gallery_request_update(select=True) + self._set_recurse_changed = False + self._filter_changed = False + + def full_reload() -> None: + apply_config() + self._gallery_request_update(load=True) + self._btn_apply.set_sensitive(True) + + self._filter_changed = False + self._set_recurse_changed = False + self._last_selected: Optional[Gtk.Widget] = None + + 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_reload = add_button(buttons_box, 'full reload', + lambda _: full_reload()) + + buttons_box = Gtk.Box(orientation=OR_H) + self._btn_apply = add_button(buttons_box, 'apply config', + lambda _: apply_config()) + self._btn_reload = 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', + toggle_recurse, checkbox=True) + + per_row_box = Gtk.Box(orientation=OR_H) + per_row_box.append(Gtk.Label(label='cols/row:')) + 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) + + box.append(self.sorter_listing) + box.append(dirs_box) + box.append(per_row_box) + box.append(buttons_box) + + def on_focus_sorter(self, focused: SorterAndFilterer) -> None: + """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: int) -> None: + """Move sort order selection by direction (-1 or +1).""" + min_idx, max_idx = 0, len(self.order) - 1 + cur_idx = self._sort_sel.props.selected + if (1 == direction and cur_idx < max_idx)\ + or (-1 == direction and cur_idx > min_idx): + self._sort_sel.props.selected = cur_idx + direction + + def move_sorter(self, direction: int) -> None: + """Move selected item in sort order view, ensure temporary state.""" + tmp_order = self._tmp_order if self._tmp_order else self.order.copy() + cur_idx = self._sort_sel.props.selected + if direction == -1 and cur_idx > 0: + tmp_order.switch_at(cur_idx, forward=False) + elif direction == 1 and cur_idx < (len(tmp_order) - 1): + tmp_order.switch_at(cur_idx, forward=True) + else: # to catch movement beyond limits + return + if not self._tmp_order: + self._tmp_order = tmp_order + for sorter in self._tmp_order: + sorter.widget.add_css_class('temp') + self.update_box(cur_idx + direction) + self._sort_sel.props.selected = cur_idx + direction + for i in range(self._store.get_n_items()): + sort_item: SorterAndFilterer = self._store.get_item(i) + sort_item.widget.add_css_class('temp') + + def update_box(self, cur_selection: int = 0) -> None: + """Rebuild sorter listing in box from .order, or alt_order if set.""" + sort_order = self._tmp_order if self._tmp_order else self.order + sort_order.into_store(self._store) + self._sort_sel.props.selected = cur_selection diff --git a/browser/gtk_app.py b/browser/gtk_app.py new file mode 100644 index 0000000..9f24249 --- /dev/null +++ b/browser/gtk_app.py @@ -0,0 +1,232 @@ +"""Main Gtk code.""" + +from typing import Optional +import gi # type: ignore + +from browser.gallery import ( + DirItem, Gallery, GalleryItem, GallerySlot, ImgItem) +from browser.gallery_config import GalleryConfig, SorterAndFiltererOrder +from browser.json_dbs import BookmarksDb, CacheDb +from browser.gtk_helpers import add_button, OR_H, OR_V +from stable.gen_params import GEN_PARAMS + +# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes +gi.require_version('Gtk', '4.0') +gi.require_version('Gdk', '4.0') +# pylint: disable=wrong-import-order,wrong-import-position +from gi.repository import Gdk, GLib, Gtk # type: ignore # noqa: E402 + + +CSS: str = ''' +.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; +} +''' + + +class GtkApp(Gtk.Application): + """Image browser application class.""" + + def __init__(self, + bookmarks_file: str, + cache_file: str, + start_dir: str, + sort_order_suggestion: list[str], + *args, **kwargs + ) -> None: + super().__init__(*args, **kwargs) + self.img_dir_absolute = start_dir + self.bookmarks_db = BookmarksDb(bookmarks_file) + self.cache_db = CacheDb(cache_file) + self.sort_order = SorterAndFiltererOrder.from_suggestion( + sort_order_suggestion) + + def do_activate(self, *args, **kwargs) -> None: + """Parse arguments, start window, keep it open.""" + win = MainWindow(self) + win.present() + self.hold() + + +class MainWindow(Gtk.Window): + """Image browser app top-level window.""" + + def __init__(self, app: GtkApp, **kwargs) -> None: + super().__init__(**kwargs) + self.app = app + self.gallery = Gallery( + sort_order=self.app.sort_order, + on_hit_item=self.hit_gallery_item, + on_selection_change=self.update_metadata_on_gallery_selection, + bookmarks_db=self.app.bookmarks_db, + cache_db=self.app.cache_db) + config_box = Gtk.Box(orientation=OR_V) + self.conf = GalleryConfig( + sort_order=self.app.sort_order, + box=config_box, + request_update=self.gallery.request_update, + update_settings=self.gallery.update_settings, + items_attrs=self.gallery.items_attrs) + self.gallery.update_config_box = self.conf.update_box + metadata_textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR, + editable=False) + self.metadata = metadata_textview.get_buffer() + self.idx_display = Gtk.Label() + + # layout: outer box, CSS, sizings + box_outer = Gtk.Box(orientation=OR_H) + self.set_child(box_outer) + css_provider = Gtk.CssProvider() + css_provider.load_from_data(CSS) + Gtk.StyleContext.add_provider_for_display( + self.get_display(), css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_USER) + metadata_textview.set_size_request(300, -1) + self.connect('notify::default-width', lambda _, __: self.on_resize()) + self.connect('notify::default-height', lambda _, __: self.on_resize()) + + # layout: sidebar + self.side_box = Gtk.Notebook.new() + self.side_box.append_page(metadata_textview, + Gtk.Label(label='metadata')) + self.side_box.append_page(config_box, Gtk.Label(label='config')) + box_outer.append(self.side_box) + + # layout: gallery viewer + viewer = Gtk.Box(orientation=OR_V) + self.navbar = Gtk.Box(orientation=OR_H) + add_button(self.navbar, 'sidebar', lambda _: self.toggle_side_box()) + self.navbar.append(self.idx_display) + viewer.append(self.navbar) + viewer.append(self.gallery.frame) + box_outer.append(viewer) + + # init key and focus 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_ref = [0] + self.connect('notify::focus-widget', + lambda _, __: self.on_focus_change()) + + # only now we're ready for actually running the gallery + GLib.idle_add(lambda: self.gallery.update_settings( + img_dir_path=self.app.img_dir_absolute)) + + def on_focus_change(self) -> None: + """Handle reactions on focus changes in .gallery and .conf.""" + focused: Optional[Gtk.Widget] = self.get_focus() + if not focused: + return + if isinstance(focused, GallerySlot): + self.gallery.on_focus_slot(focused) + elif focused.get_parent() == self.conf.sorter_listing: + self.conf.on_focus_sorter(focused) + + def on_resize(self) -> None: + """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: int = self.side_box.measure(OR_H, -1).natural + default_size: tuple[int, int] = self.get_default_size() + self.gallery.on_resize(default_size[0] - side_box_width, + default_size[1] - self.navbar.get_height()) + + def bookmark(self) -> None: + """Toggle bookmark on selected gallery item.""" + if not isinstance(self.gallery.selected_item, ImgItem): + return + bookmarks = self.app.bookmarks_db.as_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] + self.app.bookmarks_db.write() + self.conf.update_box() + + def hit_gallery_item(self) -> None: + """If current file selection is directory, reload into that one.""" + selected: Optional[GalleryItem] = self.gallery.selected_item + if isinstance(selected, DirItem): + self.gallery.update_settings(img_dir_path=selected.full_path) + + def toggle_side_box(self) -> None: + """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: int = self.side_box.measure(OR_H, -1).natural + self.gallery.on_resize(self.get_width() - side_box_width) + + def update_metadata_on_gallery_selection(self) -> None: + """Update .metadata about individual file, and .idx_display.""" + self.metadata.set_text('') + selected_item: Optional[GalleryItem] = self.gallery.selected_item + display_name = '(none)' + 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)) + display_name = selected_item.full_path + elif isinstance(selected_item, DirItem): + display_name = selected_item.full_path + total = len([s for s in self.gallery.slots + if isinstance(s.item, (DirItem, ImgItem))]) + n_selected: int = self.gallery.selected_idx + 1 + txt = f' {n_selected} of {total} – {display_name}' + self.idx_display.set_text(txt) + self.idx_display.set_use_markup(True) + + def handle_keypress(self, keyval: int) -> bool: + """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 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: + self.gallery.move_selection(-1, None, None) + elif Gdk.KEY_j == keyval: + self.gallery.move_selection(None, +1, None) + elif Gdk.KEY_k == keyval: + self.gallery.move_selection(None, -1, None) + elif Gdk.KEY_l == keyval: + self.gallery.move_selection(+1, None, None) + elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key_ref[0]: + self.gallery.move_selection(None, None, -1) + elif Gdk.KEY_w == keyval: + self.conf.move_selection(-1) + elif Gdk.KEY_W == keyval: + self.conf.move_sorter(-1) + elif Gdk.KEY_s == keyval: + self.conf.move_selection(1) + elif Gdk.KEY_S == keyval: + self.conf.move_sorter(1) + elif Gdk.KEY_b == keyval: + self.bookmark() + else: + self.prev_key_ref[0] = keyval + return False + return True diff --git a/browser/gtk_helpers.py b/browser/gtk_helpers.py new file mode 100644 index 0000000..030cbda --- /dev/null +++ b/browser/gtk_helpers.py @@ -0,0 +1,27 @@ +"""Helpers for working with GTK.""" + +from typing import Callable, Optional +import gi # type: ignore + +# gi.repository stuff at bottom, to avoid it forcing lots of E402 escapes +gi.require_version('Gtk', '4.0') +# pylint: disable=wrong-import-order,wrong-import-position +from gi.repository import Gtk # type: ignore # noqa: E402 + + +OR_H: int = Gtk.Orientation.HORIZONTAL +OR_V: int = Gtk.Orientation.VERTICAL + + +def add_button(parent: Gtk.Widget, + label: str, + on_click: Optional[Callable] = None, + checkbox: bool = False + ) -> Gtk.Button | Gtk.CheckButton: + """Helper to add Gtk.Button or .CheckButton to parent.""" + btn = (Gtk.CheckButton(label=label) if checkbox + else Gtk.Button(label=label)) + if on_click: + btn.connect('toggled' if checkbox else 'clicked', on_click) + parent.append(btn) + return btn diff --git a/browser/json_dbs.py b/browser/json_dbs.py new file mode 100644 index 0000000..084f63a --- /dev/null +++ b/browser/json_dbs.py @@ -0,0 +1,69 @@ +"""Classes for our DB files.""" + +from json import dump as json_dump, load as json_load +from os.path import exists as path_exists +from typing import TypeAlias + +from browser.types import Cache + + +Bookmarks: TypeAlias = list[str] +Db: TypeAlias = Cache | Bookmarks + + +class _JsonDb: + """Representation of our simple .json DB files.""" + _content: Db + + def __init__(self, path: str) -> None: + self._path = path + self._is_open = False + if not path_exists(path): + with open(path, 'w', encoding='utf8') as f: + json_dump({}, f) + + def _open(self) -> None: + 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) -> None: + self._is_open = False + self._content = {} + + def write(self) -> None: + """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() + + +class BookmarksDb(_JsonDb): + """Representation of Bookmarks DB files.""" + _content: Bookmarks + + def as_ref(self) -> Bookmarks: + """Return content at ._path as ref so that .write() stores changes.""" + self._open() + return self._content + + def as_copy(self) -> Bookmarks: + """Return content at ._path for read-only purposes.""" + self._open() + copy = self._content.copy() + self._close() + return copy + + +class CacheDb(_JsonDb): + """Representation of Cache DB files.""" + _content: Cache + + def as_ref(self) -> Cache: + """Return content at ._path as ref so that .write() stores changes.""" + self._open() + return self._content diff --git a/browser/types.py b/browser/types.py new file mode 100644 index 0000000..bbac2f6 --- /dev/null +++ b/browser/types.py @@ -0,0 +1,9 @@ +"""Types used among multiple modules.""" + +from typing import TypeAlias + +AttrVals: TypeAlias = list[str] +AttrValsByVisibility: TypeAlias = dict[str, AttrVals] +CachedImg: TypeAlias = dict[str, str | float | int] +Cache: TypeAlias = dict[str, dict[str, CachedImg]] +ItemsAttrs: TypeAlias = dict[str, AttrValsByVisibility]