From: Christian Heller Date: Sat, 7 Sep 2024 19:53:04 +0000 (+0200) Subject: Greatly refactor, and rely more on Gtk navigation rather than DIY. X-Git-Url: https://plomlompom.com/repos/%7B%7B%20web_path%20%7D%7D/decks/static/condition?a=commitdiff_plain;h=9fb32434e94dcd203129cdcb2097dc9baf90315b;p=stable_plom Greatly refactor, and rely more on Gtk navigation rather than DIY. --- diff --git a/browser.py b/browser.py index 37e4e60..d67fd21 100755 --- a/browser.py +++ b/browser.py @@ -1,4 +1,5 @@ #!/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 @@ -19,8 +20,15 @@ CACHE_PATH = 'cache.json' 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__() @@ -28,6 +36,7 @@ class SortLabelItem(GObject.GObject): class FileItem(GObject.GObject): + """Gallery representation of filesystem entry, base to DirItem, ImgItem.""" def __init__(self, path, info): super().__init__() @@ -37,6 +46,7 @@ class FileItem(GObject.GObject): class DirItem(FileItem): + """Gallery representation of filesystem entry for directory.""" def __init__(self, path, info, is_parent=False): super().__init__(path, info) @@ -46,6 +56,7 @@ class DirItem(FileItem): class ImgItem(FileItem): + """Gallery representation of filesystem entry for image file.""" def __init__(self, path, info, cache): super().__init__(path, info) @@ -60,8 +71,9 @@ class ImgItem(FileItem): 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) @@ -73,9 +85,21 @@ class ImgItem(FileItem): 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(): @@ -85,7 +109,7 @@ class Window(Gtk.ApplicationWindow): 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) @@ -97,18 +121,18 @@ class Window(Gtk.ApplicationWindow): 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 @@ -158,6 +182,10 @@ class Window(Gtk.ApplicationWindow): 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()) @@ -170,28 +198,44 @@ class Window(Gtk.ApplicationWindow): 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 @@ -204,20 +248,9 @@ class Window(Gtk.ApplicationWindow): 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 @@ -228,22 +261,38 @@ class Window(Gtk.ApplicationWindow): 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 @@ -260,7 +309,6 @@ class Window(Gtk.ApplicationWindow): 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 @@ -275,157 +323,208 @@ class Window(Gtk.ApplicationWindow): # 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)