#!/usr/bin/env python3
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 operator import attrgetter
from exiftool import ExifToolHelper # type: ignore
import gi # type: ignore
gi.require_version('Gtk', '4.0')
gi.require_version('Gdk', '4.0')
-gi.require_version('GdkPixbuf', '2.0')
gi.require_version('Gio', '2.0')
# pylint: disable=wrong-import-position
-from gi.repository import Gdk, GdkPixbuf, Gtk, Gio, GObject # type: ignore # noqa: E402
+from gi.repository import Gdk, Gtk, Gio, GObject, GLib # noqa: E402
# pylint: disable=no-name-in-module
from stable.gen_params import (GenParams, # noqa: E402
GEN_PARAMS, GEN_PARAMS_STR) # noqa: E402
-
IMG_DIR = '.'
+UPPER_DIR = '..'
CACHE_PATH = 'cache.json'
+OR_H = Gtk.Orientation.HORIZONTAL
+OR_V = Gtk.Orientation.VERTICAL
+
class SortLabelItem(GObject.GObject):
def __init__(self, path, info, is_parent=False):
super().__init__(path, info)
- self.name = ' ..' if is_parent else f' {self.name}/'
+ self.name = f' {UPPER_DIR}' if is_parent else f' {self.name}/'
if is_parent:
self.full_path = path
def __init__(self, **kwargs):
super().__init__(**kwargs)
- def add_button(label_, on_click, parent_box):
- btn = Gtk.Button(label=label_)
- btn.connect('clicked', on_click)
- parent_box.append(btn)
-
- keyboard_control = Gtk.EventControllerKey()
- keyboard_control.connect('key-pressed', self.handle_keypress, self)
- self.prev_key = ['']
- self.add_controller(keyboard_control)
-
- self.metadata = Gtk.Label(xalign=0)
- self.label_nothing_to_show = Gtk.Label(label='nothing to show')
-
- box_files_selection = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- add_button('folder_view', self.toggle_folder_view, box_files_selection)
- add_button('reload', lambda _: self.reload_dir(), box_files_selection)
- self.box_sort_order = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_files_selection.append(self.box_sort_order)
-
- # self.fbox = Gtk.FlowBox(orientation=Gtk.Orientation.VERTICAL)
- # self.fbox.set_max_children_per_line(3)
- # self.fbox.set_selection_mode(Gtk.SelectionMode.NONE)
-
- self.viewer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.viewer.append(box_files_selection)
- # self.viewer.append(self.fbox)
- self.viewer.append(self.metadata)
- self.viewer.append(self.label_nothing_to_show)
-
- self.dir_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.list_store = Gio.ListStore(item_type=FileItem)
- self.selection = Gtk.SingleSelection.new(self.list_store)
- self.selection.connect('selection-changed', self.update_selected)
- factory = Gtk.SignalListItemFactory()
- factory.connect('setup', lambda _, i: i.set_child(Gtk.Label(xalign=0)))
- factory.connect('bind',
- lambda _, i: i.props.child.set_text(i.props.item.name))
- self.selector = Gtk.ListView(model=self.selection, factory=factory)
- self.selector.connect('activate', self.on_selector_activate)
- scrolled = Gtk.ScrolledWindow(child=self.selector, vexpand=True,
- propagate_natural_width=True)
- self.dir_box.append(scrolled)
-
- self.dir_box.append(Gtk.Label(label='** sort order **'))
- self.sort_order = [p.lower() for p in GEN_PARAMS]
- self.sort_order += ['last_mod_time', 'name']
- self.sort_store = Gio.ListStore(item_type=SortLabelItem)
- self.sort_selection = Gtk.SingleSelection.new(self.sort_store)
- selector = Gtk.ListView(model=self.sort_selection, factory=factory)
- self.dir_box.append(selector)
-
- box_outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_outer.append(self.dir_box)
- box_outer.append(self.viewer)
+ def init_navbar():
+ def add_button(label_, on_click, parent_box):
+ btn = Gtk.Button(label=label_)
+ btn.connect('clicked', on_click)
+ parent_box.append(btn)
+ navbar = Gtk.Box(orientation=OR_H)
+ add_button('folder_view', lambda _: self.toggle_side_box(), navbar)
+ add_button('reload', lambda _: self.reload_dir(), navbar)
+ navbar.append(Gtk.Label(label=' per row: '))
+ 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)
+ navbar.append(btn)
+ return navbar
+
+ def init_gallery_widgets():
+ self.gallery = Gtk.FlowBox(orientation=OR_H)
+ self.gallery.connect(
+ 'child-activated',
+ lambda _, x: self.hit_gallery_item(x.props.child.item))
+ gallery_scroller = Gtk.ScrolledWindow(
+ child=self.gallery, propagate_natural_height=True)
+ gallery_scroller.get_vadjustment().connect(
+ 'value-changed', lambda _: self.redraw_gallery_items())
+ # attach a maximally expanded dummy that will be destroyed once we
+ # bind self.gallery to a model; don't know why exactly, but this
+ # seems necessary to have the viewport report a proper (rather
+ # than too small) size when its queried by the first run of
+ # self.rebuild_gallery, which influences what images we're gonna
+ # load then
+ self.gallery.append(Gtk.Box(hexpand=True, vexpand=True))
+ return gallery_scroller
+
+ def init_metadata_box():
+ text_view = Gtk.TextView()
+ text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ text_view.set_size_request(200, -1)
+ self.metadata = text_view.get_buffer()
+ metadata_box = Gtk.Box(orientation=OR_V)
+ metadata_box.append(Gtk.Label(label='** metadata **'))
+ metadata_box.append(text_view)
+ return metadata_box
+
+ def init_sort_orderer():
+ self.sort_order = [p.lower() for p in GEN_PARAMS]
+ self.sort_order += ['last_mod_time', 'name']
+ self.sort_store = Gio.ListStore(item_type=SortLabelItem)
+ self.sort_selection = Gtk.SingleSelection.new(self.sort_store)
+ factory = Gtk.SignalListItemFactory()
+ factory.connect('setup',
+ lambda _, i: i.set_child(Gtk.Label(xalign=0)))
+ factory.connect(
+ 'bind',
+ lambda _, i: i.props.child.set_text(i.props.item.name))
+ selector = Gtk.ListView(model=self.sort_selection, factory=factory)
+ sort_box = Gtk.Box(orientation=OR_V)
+ sort_box.append(Gtk.Label(label='** sort order **'))
+ sort_box.append(selector)
+ return sort_box
+
+ def init_gallery_content():
+ self.gallery_store = Gio.ListStore(item_type=FileItem)
+ list_filter = Gtk.CustomFilter.new(
+ lambda x: self.include_dirs or isinstance(x, ImgItem))
+ 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.per_row = 3
+
+ def init_key_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 = [0]
+
+ viewer = Gtk.Box(orientation=OR_V)
+ viewer.append(init_navbar())
+ viewer.append(init_gallery_widgets())
+ self.side_box = Gtk.Box(orientation=OR_V)
+ self.side_box.append(init_sort_orderer())
+ self.side_box.append(init_metadata_box())
+ box_outer = Gtk.Box(orientation=OR_H)
+ box_outer.append(self.side_box)
+ box_outer.append(viewer)
self.props.child = box_outer
- self.item_img, self.item_dir = None, None
- self.unsorted_dirs, self.unsorted_files = [], []
+ init_key_control()
self.img_dir_absolute = abspath(IMG_DIR)
- self.reload_dir()
- self.update_sort_list()
-
- def on_selector_activate(self, _, __):
- if isinstance(self.selection.props.selected_item, DirItem):
- self.item_dir = self.selection.props.selected_item
- self.img_dir_absolute = self.item_dir.full_path
- self.item_img, self.item_dir = None, None
- self.unsorted_dirs, self.unsorted_files = [], []
- self.reload_dir()
+ init_gallery_content()
+ self.reload_dir(rebuild_gallery=False)
+ self.update_sort_list(rebuild_gallery=False)
+ # self.gallery.grab_focus()
+ GLib.idle_add(self.rebuild_gallery)
- def toggle_folder_view(self, _):
- self.dir_box.props.visible = not self.dir_box.props.visible
+ # various gallery management tasks
def sort(self):
- # self.fbox.remove_all()
- self.list_store.remove_all()
- for key in self.sort_order:
- self.unsorted_files.sort(key=attrgetter(key))
- if key in {'name', 'last_mod_time'}:
- self.unsorted_dirs.sort(key=attrgetter(key))
- for file_item in [self.parent_dir_item]\
- + self.unsorted_dirs + self.unsorted_files:
- self.list_store.append(file_item)
- for self_item in (self.item_dir, self.item_img):
- if self_item:
- for pos, item in enumerate(self.list_store):
- if item.full_path == self_item.full_path:
- self.selection.set_selected(pos)
- return
+ def sorter(a, b):
+ if self.include_dirs:
+ if isinstance(a, DirItem) and isinstance(b, DirItem):
+ cmp_upper_dir = f' {UPPER_DIR}'
+ if cmp_upper_dir == a.name:
+ return False
+ if cmp_upper_dir == b.name:
+ return True
+ elif isinstance(a, DirItem):
+ return False
+ elif isinstance(b, DirItem):
+ return True
+ for key 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:
+ return True
+ if a_cmp is None:
+ return False
+ return a_cmp > b_cmp
+ self.gallery_store.sort(sorter)
+
+ @property
+ def viewport_geometry(self):
+ viewport = self.gallery.props.parent
+ vp_height = viewport.get_height()
+ vp_width = viewport.get_width()
+ vp_top = viewport.get_vadjustment().get_value()
+ return vp_width, vp_height, vp_top
+
+ def rebuild_gallery(self):
+
+ def init_gallery_slot(file_item):
+ vp_width, vp_height, _ = self.viewport_geometry
+ max_slot_width = vp_width / self.per_row - 6
+ slot = Gtk.Box()
+ slot.item = file_item
+ slot.props.hexpand = True
+ slot.size = min(vp_height, max_slot_width)
+ slot.set_size_request(slot.size, slot.size)
+ if isinstance(file_item, ImgItem):
+ slot.content = Gtk.Label(label='?')
+ else:
+ slot.content = Gtk.Button(label=file_item.name)
+ slot.content.connect(
+ 'clicked', lambda _: self.hit_gallery_item(file_item))
+ slot.content.set_size_request(slot.size, slot.size)
+ slot.append(slot.content)
+ return slot
+
+ self.gallery.set_min_children_per_line(self.per_row)
+ self.gallery.set_max_children_per_line(self.per_row)
+ self.gallery.bind_model(self.gallery_selection, init_gallery_slot)
self.update_selected()
- # for file_item in self.unsorted_files:
- # pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(file_item.full_path, 128, 128, True)
- # img = Gtk.Image()
- # img.set_from_pixbuf(pixbuf)
- # box = Gtk.Box()
- # box.img = img
- # self.fbox.append(box)
+ self.redraw_gallery_items()
+
+ def redraw_gallery_items(self):
+ _, vp_height, vp_top = self.viewport_geometry
+ vp_bottom = vp_top + vp_height
+ for i in range(self.gallery_store_filtered.props.n_items):
+ slot = self.gallery.get_child_at_index(i).props.child
+ if isinstance(slot.item, DirItem):
+ continue
+ slot_top = (i // self.per_row) * slot.size
+ slot_bottom = slot_top + slot.size
+ in_viewport = (slot_bottom >= vp_top and slot_top <= vp_bottom)
+ if in_viewport:
+ if not isinstance(slot.content, Gtk.Image):
+ slot.remove(slot.content)
+ slot.content = Gtk.Image.new_from_file(slot.item.full_path)
+ slot.content.set_size_request(slot.size, slot.size)
+ slot.append(slot.content)
+ elif isinstance(slot.content, Gtk.Image):
+ slot.remove(slot.content)
+ slot.content = Gtk.Label(label='?')
+ slot.append(slot.content)
+
+ # # DEBUG: mere statistics
+ # from datetime import datetime
+ # n_imgs, n_buttons, n_others = 0, 0, 0
+ # for i in range(self.gallery_store_filtered.props.n_items):
+ # child = self.gallery.get_child_at_index(i).props.child
+ # grandchild = child.get_first_child()
+ # if isinstance(grandchild, Gtk.Image):
+ # n_imgs += 1
+ # elif isinstance(grandchild, Gtk.Button):
+ # n_buttons += 1
+ # else:
+ # n_others += 1
+ # print(datetime.now(), "DEBUG", n_imgs, n_buttons, n_others)
+
+ def hit_gallery_item(self, activated):
+ self.redraw_gallery_items()
+ if isinstance(activated, DirItem):
+ self.img_dir_absolute = activated.full_path
+ self.reload_dir()
def update_selected(self, *_args):
- if isinstance(self.selection.props.selected_item, ImgItem):
- self.item_img = self.selection.props.selected_item
- self.item_dir = None
- self.reload_image()
- else:
- self.item_dir = self.selection.props.selected_item
- self.selector.scroll_to(self.selection.props.selected,
- Gtk.ListScrollFlags.NONE, None)
-
- def reload_image(self):
- self.viewer.remove(self.viewer.get_last_child())
- if self.item_img:
- params_strs = [f'{k}: ' + str(getattr(self.item_img, k.lower()))
+ idx = self.gallery_selection.props.selected
+ slot = self.gallery.get_child_at_index(idx).props.child
+ self.gallery.props.parent.scroll_to(slot)
+ # slot.grab_focus()
+ if isinstance(self.gallery_selection.props.selected_item, ImgItem):
+ item = self.gallery_selection.props.selected_item
+ params_strs = [f'{k}: ' + str(getattr(item, k.lower()))
for k in GEN_PARAMS]
- offset = len(self.unsorted_dirs) + 1
- position = self.selection.props.selected + 1 - offset
- total = len(self.unsorted_files)
- title = f'{self.item_img.full_path} ({position} of {total})'
- self.metadata.props.label = '\n'.join([title] + params_strs)
- pic = Gtk.Picture.new_for_filename(self.item_img.full_path)
- pic.props.halign = Gtk.Align.START
- self.viewer.append(pic)
+ title = f'{item.full_path}'
+ self.metadata.set_text('\n'.join([title] + params_strs))
else:
- self.metadata.props.label = None
- self.viewer.append(self.label_nothing_to_show)
+ self.metadata.set_text('')
- def reload_dir(self):
+ # navbar callables
+
+ def reload_dir(self, rebuild_gallery=True):
+ self.gallery_store.remove_all()
self.dir = Gio.File.new_for_path(self.img_dir_absolute)
- old_dir_path = self.item_dir.full_path if self.item_dir else ''
- old_img_path = self.item_img.full_path if self.item_img else ''
if not path_exists(CACHE_PATH):
with open(CACHE_PATH, 'w', encoding='utf8') as f:
json_dump({}, f)
with open(CACHE_PATH, 'r', encoding='utf8') as f:
cache = json_load(f)
- query_attrs = 'standard::name,standard::type,time::*'
- self.item_img, self.item_dir = None, None
- parent_path = abspath(path_join(self.img_dir_absolute, '..'))
+ query_attrs = 'standard::name,time::*'
+ parent_path = abspath(path_join(self.img_dir_absolute, UPPER_DIR))
parent_dir = self.dir.get_parent()
parent_dir_info = parent_dir.query_info(
query_attrs, Gio.FileQueryInfoFlags.NONE, None)
- self.parent_dir_item = DirItem(
+ parent_dir_item = DirItem(
parent_path, parent_dir_info, is_parent=True)
- self.unsorted_dirs, self.unsorted_files = [], []
+ self.gallery_store.append(parent_dir_item)
+ query_attrs = query_attrs + ',standard::content-type'
enumerator = self.dir.enumerate_children(
query_attrs, Gio.FileQueryInfoFlags.NONE, None)
- for info in [info for info in enumerator
- if info.get_file_type() == Gio.FileType.DIRECTORY]:
- item = DirItem(self.img_dir_absolute, info)
- if old_dir_path == item.full_path:
- self.item_dir = item
- self.unsorted_dirs += [item]
- enumerator = self.dir.enumerate_children(
- query_attrs + ',standard::content-type',
- Gio.FileQueryInfoFlags.NONE, None)
- for info in [info for info in enumerator
- if info.get_file_type() == Gio.FileType.REGULAR
- and info.get_content_type().startswith('image/')]:
- item = ImgItem(self.img_dir_absolute, info, cache)
- if old_img_path == item.full_path:
- self.item_img = item
- self.unsorted_files += [item]
+ to_set_metadata_on = []
+ for info in enumerator:
+ if self.include_dirs\
+ and info.get_file_type() == Gio.FileType.DIRECTORY:
+ self.gallery_store.append(DirItem(self.img_dir_absolute, info))
+ elif info.get_content_type()\
+ and info.get_content_type().startswith('image/'):
+ item = ImgItem(self.img_dir_absolute, info, cache)
+ if '' == item.model:
+ to_set_metadata_on += [item]
+ self.gallery_store.append(item)
with ExifToolHelper() as et:
- for item in [item for item
- in self.unsorted_files if '' == item.model]:
+ for item in to_set_metadata_on:
item.set_metadata(et, cache)
- self.sort()
with open(CACHE_PATH, 'w', encoding='utf8') as f:
json_dump(cache, f)
+ self.sort()
+ self.gallery_selection.props.selected = 0
+ if rebuild_gallery:
+ self.rebuild_gallery()
- def move_selection_in_sort_order(self, direction):
- self.move_selection(self.sort_selection, direction, None,
- 0, len(self.sort_order) - 1)
+ def toggle_side_box(self):
+ self.side_box.props.visible = not self.side_box.props.visible
+ self.rebuild_gallery()
- def move_selection_in_directory(self, direction, absolute_position):
- max_index = len(self.unsorted_files + self.unsorted_dirs)
- if len(self.unsorted_files) > 2:
- min_index = len(self.unsorted_dirs) + 1
- else:
- min_index = 0
- self.move_selection(self.selection, direction, absolute_position,
- min_index, max_index)
+ def reset_include_dirs(self, button):
+ self.include_dirs = button.props.active
+ self.reload_dir()
- def move_selection(self, selection, increment, absolute_position,
- min_index, max_index):
- cur_index = selection.props.selected
- if 0 == absolute_position:
- selection.props.selected = min_index
- elif -1 == absolute_position:
- selection.props.selected = max_index
- elif (1 == increment and cur_index < max_index)\
- or (-1 == increment and cur_index > min_index):
- selection.props.selected = cur_index + increment
+ def inc_per_row(self, inc):
+ if self.per_row + inc > 0:
+ self.per_row += inc
+ self.rebuild_gallery()
+
+ # key-bound movements
+
+ def update_sort_list(self, start_position=0, rebuild_gallery=True):
+ self.sort_store.remove_all()
+ for s in self.sort_order:
+ self.sort_store.append(SortLabelItem(s))
+ self.sort_selection.props.selected = start_position
+ self.sort()
+ if rebuild_gallery:
+ self.rebuild_gallery()
def move_sort(self, direction):
current_i = self.sort_selection.props.selected
return
self.update_sort_list(current_i + direction)
- def update_sort_list(self, start_position=0):
- self.sort_store.remove_all()
- for s in self.sort_order:
- self.sort_store.append(SortLabelItem(s))
- self.sort_selection.props.selected = start_position
- self.sort()
+ def move_selection_in_sort_order(self, direction):
+ self.move_selection(self.sort_selection, direction, None,
+ 0, len(self.sort_order) - 1)
+
+ def move_selection_in_directory(self, direction, absolute_position):
+ self.move_selection(self.gallery_selection,
+ direction, absolute_position,
+ 0, self.gallery_store_filtered.props.n_items - 1)
+ self.update_selected(None, self.gallery_selection.props.selected, None)
+
+ def move_selection(self, selection, increment, absolute_position,
+ min_index, max_index):
+ cur_index = selection.props.selected
+ if 0 == absolute_position:
+ selection.props.selected = min_index
+ elif -1 == absolute_position:
+ selection.props.selected = max_index
+ elif (1 == increment and cur_index < max_index)\
+ or (-1 == increment and cur_index > min_index):
+ selection.props.selected = cur_index + increment
- def handle_keypress(self, _keyval, keycode, state, _user_data, _win):
- if ord('G') == keycode:
+ def handle_keypress(self, keyval):
+ if Gdk.KEY_G == keyval:
self.move_selection_in_directory(None, -1)
- elif ord('j') == keycode:
+ elif Gdk.KEY_j == keyval:
self.move_selection_in_directory(1, None)
- elif ord('k') == keycode:
+ elif Gdk.KEY_k == keyval:
self.move_selection_in_directory(-1, None)
- elif ord('g') == keycode and 'g' == self.prev_key[0]:
+ elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]:
self.move_selection_in_directory(None, 0)
- elif ord('n') == keycode:
+ elif Gdk.KEY_n == keyval:
self.move_selection_in_sort_order(-1)
- elif ord('N') == keycode:
+ elif Gdk.KEY_N == keyval:
self.move_sort(-1)
- elif ord('m') == keycode:
+ elif Gdk.KEY_m == keyval:
self.move_selection_in_sort_order(1)
- elif ord('M') == keycode:
+ elif Gdk.KEY_M == keyval:
self.move_sort(1)
else:
- self.prev_key[0] = chr(keycode)
+ self.prev_key[0] = keyval
+ return True
def on_activate(app_):