#!/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'<b>{v}</b>' for v in vals['incl']]
- vals_listed += [f'<s>{v}</s>' for v in vals['semi']]
- vals_listed += [f'<b><s>{v}</s></b>' 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'<b>{attr[0]}</b>: {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'<b>{top_attr_name}</b>: {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} – <b>{display_name}</b>'
- 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()
--- /dev/null
+"""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'
--- /dev/null
+"""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'<b>{attr[0]}</b>: {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'<b>{top_attr_name}</b>: {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
--- /dev/null
+"""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'<b>{v}</b>' for v in vals['incl']]
+ vals_listed += [f'<s>{v}</s>' for v in vals['semi']]
+ vals_listed += [f'<b><s>{v}</s></b>' 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
--- /dev/null
+"""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} – <b>{display_name}</b>'
+ 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
--- /dev/null
+"""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
--- /dev/null
+"""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
--- /dev/null
+"""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]