#!/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 exiftool import ExifToolHelper # type: ignore
OR_H = Gtk.Orientation.HORIZONTAL
OR_V = Gtk.Orientation.VERTICAL
+DEBUGGING_CSS = """
+:focus { outline: none; box-shadow: none; background: blue; }
+flowboxchild:selected { outline: none; box-shadow: none; background: green; }
+flowboxchild:hover{ outline: none; box-shadow: none; background: yellow; }
+flowboxchild:active { outline: none; box-shadow: none; background: red; }
+"""
class SortLabelItem(GObject.GObject):
+ """Sort order list representation of sorter label."""
def __init__(self, name):
super().__init__()
class FileItem(GObject.GObject):
+ """Gallery representation of filesystem entry, base to DirItem, ImgItem."""
def __init__(self, path, info):
super().__init__()
class DirItem(FileItem):
+ """Gallery representation of filesystem entry for directory."""
def __init__(self, path, info, is_parent=False):
super().__init__(path, info)
class ImgItem(FileItem):
+ """Gallery representation of filesystem entry for image file."""
def __init__(self, path, info, cache):
super().__init__(path, info)
for k in cached.keys():
setattr(self, k, cached[k])
- def set_metadata(self, et, cache):
- for d in et.get_tags([self.full_path], ['Comment']):
+ def set_metadata(self, exif_tool, cache):
+ """Set instance attributes from 'Comment' EXIF tag, write to cache."""
+ for d in exif_tool.get_tags([self.full_path], ['Comment']):
for k, v in d.items():
if k.endswith('Comment'):
gen_params = GenParams.from_str(v)
cache[self.full_path] = {self.last_mod_time: cached}
-class Window(Gtk.ApplicationWindow):
-
- def __init__(self, **kwargs):
+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
+ per_row: int
+ metadata: Gtk.TextBuffer
+ sort_order: list
+ sort_store: Gtk.ListStore
+ sort_selection: Gtk.SingleSelection
+ prev_key: list
+
+ def __init__(self, _app, **kwargs):
super().__init__(**kwargs)
def init_navbar():
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)
+ add_button('reload', lambda _: self.load_directory(), 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)
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))
+ 'selected-children-changed',
+ lambda _: self.update_file_selection())
+ self.gallery.connect(
+ 'child-activated', lambda _, __: self.hit_file_selection())
gallery_scroller = Gtk.ScrolledWindow(
child=self.gallery, propagate_natural_height=True)
gallery_scroller.get_vadjustment().connect(
- 'value-changed', lambda _: self.redraw_gallery_items())
+ 'value-changed', lambda _: self.update_gallery_view())
# 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
+ # bind self.gallery to a model; seems necessary to pre-stretch the
+ # gallery_scroller's viewport for our first calculation (in the
+ # first run of update_gallery) of what images to load into it
self.gallery.append(Gtk.Box(hexpand=True, vexpand=True))
return gallery_scroller
self.add_controller(key_ctl)
self.prev_key = [0]
+ self.img_dir_absolute = abspath(IMG_DIR)
+ self.block_once_hit_file_selection = False
+ self.block_file_selection_updates = False
+
viewer = Gtk.Box(orientation=OR_V)
viewer.append(init_navbar())
viewer.append(init_gallery_widgets())
self.props.child = box_outer
init_key_control()
- self.img_dir_absolute = abspath(IMG_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)
+ self.load_directory(update_gallery=False)
+ self.update_sort_order(update_gallery=False)
+ GLib.idle_add(self.update_gallery)
+
+ # # useful for debugging
+ # css_provider = Gtk.CssProvider()
+ # css_provider.load_from_data(DEBUGGING_CSS)
+ # Gtk.StyleContext.add_provider_for_display(
+ # self.get_display(), css_provider,
+ # Gtk.STYLE_PROVIDER_PRIORITY_USER)
# various gallery management tasks
- def sort(self):
+ @property
+ def gallery_viewport_geometry(self):
+ """Return gallery viewport's width, height, top."""
+ viewport = self.gallery.get_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 update_gallery(self, suggested_selection=None, sort=True):
+ """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:
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
+ if cmp_upper_dir in (a.name, b.name):
+ return cmp_upper_dir == b.name
elif isinstance(a, DirItem):
return False
elif isinstance(b, DirItem):
return True
+ # apply self.sort_order within DirItems and FileItems (separately)
for key in self.sort_order:
a_cmp = None
b_cmp = None
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
+ vp_width, vp_height, _ = self.gallery_viewport_geometry
max_slot_width = vp_width / self.per_row - 6
slot = Gtk.Box()
slot.item = file_item
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.content.connect('clicked', self.on_click_file)
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()
- self.redraw_gallery_items()
-
- def redraw_gallery_items(self):
- _, vp_height, vp_top = self.viewport_geometry
+ if sort:
+ self.gallery_store.sort(sorter)
+ 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)
+ item_path = gallery_item_at_i.props.child.item.full_path
+ if gallery_item_at_i is None:
+ break
+ 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()
+ self.update_gallery_view()
+
+ def update_gallery_view(self):
+ """Load/unload gallery's file images based on viewport visibility."""
+ _, vp_height, vp_top = self.gallery_viewport_geometry
vp_bottom = vp_top + vp_height
- for i in range(self.gallery_store_filtered.props.n_items):
+ 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):
continue
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
# 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):
- 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]
- title = f'{item.full_path}'
- self.metadata.set_text('\n'.join([title] + params_strs))
- else:
+ def hit_file_selection(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
+ if isinstance(selected, DirItem):
+ self.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}'
+ self.metadata.set_text('\n'.join([title] + params_strs))
+ return
self.metadata.set_text('')
+ sync_fbox_selection_to_gallery_selection()
+ update_metadata_on_file()
+
+ def update_sort_order(self, cur_selection=0, update_gallery=True):
+ """Rebuild self.sort_store from self.sort_order."""
+ self.sort_store.remove_all()
+ for s in self.sort_order:
+ self.sort_store.append(SortLabelItem(s))
+ self.sort_selection.props.selected = cur_selection
+ old_selection = self.gallery_selection.props.selected_item
+ if update_gallery:
+ self.update_gallery(old_selection)
+
# navbar callables
- def reload_dir(self, rebuild_gallery=True):
+ def load_directory(self, update_gallery=True):
+ """Load into gallery directory at self.img_dir_absolute."""
+
+ def read_directory_into_gallery_items(cache):
+ directory = Gio.File.new_for_path(self.img_dir_absolute)
+ query_attrs = 'standard::name,time::*'
+ parent_path = abspath(path_join(self.img_dir_absolute, 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)
+ 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 to_set_metadata_on:
+ item.set_metadata(et, cache)
+
+ old_selection = self.gallery_selection.props.selected_item
+ self.block_file_selection_updates = True
self.gallery_store.remove_all()
- self.dir = Gio.File.new_for_path(self.img_dir_absolute)
+ self.block_file_selection_updates = False
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,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)
- 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 = self.dir.enumerate_children(
- query_attrs, Gio.FileQueryInfoFlags.NONE, None)
- 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 to_set_metadata_on:
- item.set_metadata(et, cache)
+ read_directory_into_gallery_items(cache)
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()
+ if update_gallery:
+ self.update_gallery(old_selection)
def toggle_side_box(self):
- self.side_box.props.visible = not self.side_box.props.visible
- self.rebuild_gallery()
+ """Toggle window sidebox visible/invisible."""
+ self.side_box.props.visible = not self.side_box.get_visible()
+ self.update_gallery(self.gallery_selection.props.selected_item,
+ sort=False)
def reset_include_dirs(self, button):
+ """By button's .active, in-/exclude directories from gallery view."""
self.include_dirs = button.props.active
- self.reload_dir()
+ self.load_directory()
- def inc_per_row(self, inc):
- if self.per_row + inc > 0:
- self.per_row += inc
- self.rebuild_gallery()
+ 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(self.gallery_selection.props.selected_item,
+ sort=False)
- # 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()
+ # movement
def move_sort(self, direction):
- current_i = self.sort_selection.props.selected
- selected = self.sort_order[current_i]
- if direction == -1 and current_i > 0:
- prev_i = current_i - 1
+ """In sort order list, move selected item up (-1) or down (+1)."""
+ cur_idx = self.sort_selection.props.selected
+ selected = self.sort_order[cur_idx]
+ if direction == -1 and cur_idx > 0:
+ prev_i = cur_idx - 1
old_prev = self.sort_order[prev_i]
self.sort_order[prev_i] = selected
- self.sort_order[current_i] = old_prev
- elif direction == 1 and current_i < (len(self.sort_order) - 1):
- next_i = current_i + 1
+ self.sort_order[cur_idx] = old_prev
+ elif direction == 1 and cur_idx < (len(self.sort_order) - 1):
+ next_i = cur_idx + 1
old_next = self.sort_order[next_i]
self.sort_order[next_i] = selected
- self.sort_order[current_i] = old_next
+ self.sort_order[cur_idx] = old_next
else:
return
- self.update_sort_list(current_i + direction)
+ self.update_sort_order(cur_idx + direction)
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
+ """Move sort order selection by direction (-1 or +1)."""
+ min_idx, max_idx = 0, len(self.sort_order) - 1
+ cur_idx = self.sort_selection.props.selected
+ if (1 == direction and cur_idx < max_idx)\
+ 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 handle_keypress(self, keyval):
+ """Handle keys, and if, return True to declare key handling done."""
if Gdk.KEY_G == keyval:
- self.move_selection_in_directory(None, -1)
+ self.move_selection_in_gallery(None, None, 1)
+ elif Gdk.KEY_h == keyval:
+ self.move_selection_in_gallery(-1, None, None)
elif Gdk.KEY_j == keyval:
- self.move_selection_in_directory(1, None)
+ self.move_selection_in_gallery(None, +1, None)
elif Gdk.KEY_k == keyval:
- self.move_selection_in_directory(-1, None)
+ self.move_selection_in_gallery(None, -1, None)
+ elif Gdk.KEY_l == keyval:
+ self.move_selection_in_gallery(+1, None, None)
elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]:
- self.move_selection_in_directory(None, 0)
- elif Gdk.KEY_n == keyval:
+ self.move_selection_in_gallery(None, None, -1)
+ elif Gdk.KEY_w == keyval:
self.move_selection_in_sort_order(-1)
- elif Gdk.KEY_N == keyval:
+ elif Gdk.KEY_W == keyval:
self.move_sort(-1)
- elif Gdk.KEY_m == keyval:
+ elif Gdk.KEY_s == keyval:
self.move_selection_in_sort_order(1)
- elif Gdk.KEY_M == keyval:
+ elif Gdk.KEY_S == keyval:
self.move_sort(1)
else:
self.prev_key[0] = keyval
+ return False
return True
-def on_activate(app_):
- win = Window(application=app_)
- win.present()
+class Application(Gtk.Application):
+ """Image browser application class."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.connect('activate', self.on_activate)
+
+ def on_activate(self, app_):
+ """Start window and keep it open."""
+ win = MainWindow(app_)
+ win.present()
+ self.hold()
-app = Gtk.Application(application_id='plomlompom.com.StablePixBrowser.App')
-app.connect('activate', on_activate)
+app = Application(application_id='plomlompom.com.StablePixBrowser.App')
app.run(None)