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