From: Christian Heller Date: Thu, 31 Oct 2024 06:14:05 +0000 (+0100) Subject: Browser.py: Typify. X-Git-Url: https://plomlompom.com/repos/%7B%7B%20web_path%20%7D%7D/decks/%7B%7Bprefix%7D%7D/task?a=commitdiff_plain;h=7a08da2c3b81db57b656f93ac4c2aeddfc299475;p=stable_plom Browser.py: Typify. --- diff --git a/browser.py b/browser.py index 57dc8ab..95504a5 100755 --- a/browser.py +++ b/browser.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Browser for image files.""" from json import dump as json_dump, load as json_load +from typing import TypeAlias, Callable, Optional from functools import cmp_to_key from re import search as re_search from os import listdir @@ -24,22 +25,31 @@ from stable.gen_params import (GenParams, GEN_PARAMS_FLOAT, # noqa: E402 GEN_PARAMS_INT, GEN_PARAMS_STR, # noqa: E402 GEN_PARAMS) # noqa: E402 -IMG_DIR_DEFAULT = '.' -SORT_DEFAULT = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\ +AttrVals: TypeAlias = list[str] +AttrValsByVisibility: TypeAlias = dict[str, AttrVals] +ItemsAttrs: TypeAlias = dict[str, AttrValsByVisibility] +Cache: TypeAlias = dict[str, dict[str, dict[str, str | float | int]]] +Bookmarks: TypeAlias = list[str] +Db: TypeAlias = Cache | Bookmarks +FilterInputs: TypeAlias = dict[str, str] + + +IMG_DIR_DEFAULT: str = '.' +SORT_DEFAULT: str = 'width,height,bookmarked,scheduler,seed,guidance,n_steps,'\ 'model,prompt' -UPPER_DIR = '..' -CACHE_PATH = 'cache.json' -BOOKMARKS_PATH = 'bookmarks.json' -GALLERY_SLOT_MARGIN = 6 -GALLERY_PER_ROW_DEFAULT = 5 -GALLERY_UPDATE_INTERVAL_MS = 50 -GALLERY_REDRAW_WAIT_MS = 200 -ACCEPTED_IMG_FILE_ENDINGS = {'.png', '.PNG'} - -OR_H = Gtk.Orientation.HORIZONTAL -OR_V = Gtk.Orientation.VERTICAL - -CSS = """ +UPPER_DIR: str = '..' +CACHE_PATH: str = 'cache.json' +BOOKMARKS_PATH: str = 'bookmarks.json' +GALLERY_SLOT_MARGIN: int = 6 +GALLERY_PER_ROW_DEFAULT: int = 5 +GALLERY_UPDATE_INTERVAL_MS: int = 50 +GALLERY_REDRAW_WAIT_MS: int = 200 +ACCEPTED_IMG_FILE_ENDINGS: set[str] = {'.png', '.PNG'} + +OR_H: int = Gtk.Orientation.HORIZONTAL +OR_V: int = Gtk.Orientation.VERTICAL + +CSS: str = ''' .temp { background: #aaaa00; } .bookmarked { background: #000000; } .selected { background: #008800; } @@ -54,10 +64,14 @@ button.slot { border-left-width: 0; border-right-width: 0; } -""" +''' -def _add_button(parent, label, on_click=None, checkbox=False): +def _add_button(parent: Gtk.Widget, + label: str, + on_click: Optional[Callable] = None, + checkbox: bool = False + ) -> Gtk.Button | Gtk.CheckButton: """Helper to add Gtk.Button or .CheckButton to parent.""" btn = (Gtk.CheckButton(label=label) if checkbox else Gtk.Button(label=label)) @@ -67,29 +81,29 @@ def _add_button(parent, label, on_click=None, checkbox=False): return btn -class JsonDB: +class JsonDb: """Representation of our simple .json DB files.""" - def __init__(self, path): + def __init__(self, path: str) -> None: self._path = path - self._content = {} + self._content: Db = {} self._is_open = False if not path_exists(path): with open(path, 'w', encoding='utf8') as f: json_dump({}, f) - def _open(self): + def _open(self) -> None: if self._is_open: raise Exception('DB already open') with open(self._path, 'r', encoding='utf8') as f: self._content = json_load(f) self._is_open = True - def _close(self): + def _close(self) -> None: self._is_open = False self._content = {} - def write(self): + def write(self) -> None: """Write to ._path what's in ._content.""" if not self._is_open: raise Exception('DB not open') @@ -97,29 +111,69 @@ class JsonDB: json_dump(self._content, f) self._close() - def as_dict_copy(self): + def as_copy(self) -> Db: """Return content at ._path for read-only purposes.""" self._open() dict_copy = self._content.copy() self._close() return dict_copy - def as_dict_ref(self): + def as_ref(self) -> Db: """Return content at ._path as ref so that .write() stores changes.""" self._open() return self._content +class Application(Gtk.Application): + """Image browser application class.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + parser = ArgumentParser() + parser.add_argument('directory', default=IMG_DIR_DEFAULT, nargs='?') + parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT) + opts = parser.parse_args() + self.img_dir_absolute = abspath(opts.directory) + self.bookmarks_db = JsonDb(BOOKMARKS_PATH) + self.cache_db = JsonDb(CACHE_PATH) + sort_suggestion = opts.sort_order.split(',') + names = [p.lower() for p in GEN_PARAMS] + ['bookmarked'] + self.sort_order = [] + for name in names: + self.sort_order += [SorterAndFilterer(name)] + new_sort_order = [] + do_reverse = '-' in sort_suggestion + for pattern in sort_suggestion: + for sorter in [sorter for sorter in self.sort_order + if sorter.name.startswith(pattern)]: + self.sort_order.remove(sorter) + new_sort_order += [sorter] + self.sort_order = new_sort_order + self.sort_order + if do_reverse: + self.sort_order.reverse() + + def do_activate(self, *args, **kwargs) -> None: + """Parse arguments, start window, keep it open.""" + win = MainWindow(self) + win.present() + self.hold() + + class SorterAndFilterer(GObject.GObject): """Sort order box representation of sorting/filtering attribute.""" widget: Gtk.Box label: Gtk.Label - def __init__(self, name): + def __init__(self, name: str) -> None: super().__init__() self.name = name - def setup_on_bind(self, widget, on_filter_activate, filter_text, vals): + def setup_on_bind(self, + widget: Gtk.Widget, + on_filter_activate: Callable, + filter_text: str, + vals: dict[str, str], + ) -> None: """Set up SorterAndFilterer label, values listing, filter entry.""" self.widget = widget # label @@ -146,15 +200,180 @@ class SorterAndFilterer(GObject.GObject): lambda a, b, c: self.widget.filter.add_css_class('temp')) +class GalleryItem(GObject.GObject): + """Gallery representation of filesystem entry, base to DirItem, ImgItem.""" + _to_hash = ['name', 'full_path'] + + def __init__(self, path: str, name: str) -> None: + super().__init__() + self.name = name + self.full_path = path_join(path, self.name) + self.slot: GallerySlot + + def __hash__(self) -> int: + hashable_values = [] + for attr_name in self._to_hash: + hashable_values += [getattr(self, attr_name)] + return hash(tuple(hashable_values)) + + +class DirItem(GalleryItem): + """Gallery representation of filesystem entry for directory.""" + + def __init__(self, path: str, name: str, is_parent: bool = False) -> None: + super().__init__(path, name) + if is_parent: + self.full_path = path + + +class ImgItem(GalleryItem): + """Gallery representation of filesystem entry for image file.""" + _to_hash = (['name', 'full_path', 'last_mod_time', 'bookmarked', + 'with_others'] + + [k.lower() for k in GEN_PARAMS]) + + def __init__(self, path: str, name: str, cache: Cache) -> None: + super().__init__(path, name) + mtime = getmtime(self.full_path) + dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + iso8601_str = dt.isoformat(timespec='microseconds') + self.last_mod_time = iso8601_str.replace('+00:00', 'Z') + self.bookmarked = False + self.with_others = False + self.has_metadata = False + for param_name in GEN_PARAMS: + if param_name in GEN_PARAMS_STR: + setattr(self, param_name.lower(), '') + else: + setattr(self, param_name.lower(), 0) + if self.full_path in cache: + if self.last_mod_time in cache[self.full_path]: + self.has_metadata = True + cached = cache[self.full_path][self.last_mod_time] + for k in cached.keys(): + setattr(self, k, cached[k]) + + def set_metadata(self, cache: Cache) -> None: + """Set attrs from file's GenParams PNG chunk, update into cache.""" + img = Image.open(self.full_path) + if isinstance(img, PngImageFile): + gen_params_as_str = img.text.get('generation_parameters', '') + if gen_params_as_str: + gen_params = GenParams.from_str(gen_params_as_str) + for k, v_ in gen_params.as_dict.items(): + setattr(self, k, v_) + cached = {} + for k in (k.lower() for k in GEN_PARAMS): + cached[k] = getattr(self, k) + cache[self.full_path] = {self.last_mod_time: cached} + + def bookmark(self, positive: bool = True) -> None: + """Set self.bookmark to positive, and update CSS class mark.""" + self.bookmarked = positive + self.slot.mark('bookmarked', positive) + + +class GallerySlotsGeometry: + """Collect variable sizes shared among all GallerySlots.""" + + def __init__(self) -> None: + self._margin = GALLERY_SLOT_MARGIN + assert 0 == self._margin % 2 # avoid ._margin != 2 * .side_margin + self.side_margin = self._margin // 2 + self.size, self.size_sans_margins = -1, -1 + + def set_size(self, size: int) -> None: + """Not only set .size but also update .size_sans_margins.""" + self.size = size + self.size_sans_margins = self.size - self._margin + + +class GallerySlot(Gtk.Button): + """Slot in Gallery representing a GalleryItem.""" + + def __init__(self, + item: GalleryItem, + slots_geometry: GallerySlotsGeometry, + on_click_file: Optional[Callable] = None + ) -> None: + super().__init__() + self._geometry = slots_geometry + self.add_css_class('slot') + self.set_hexpand(True) + self.item = item + self.item.slot = self + if on_click_file: + self.connect('clicked', lambda _: on_click_file()) + + def mark(self, css_class: str, do_add: bool = True) -> None: + """Add or remove css_class from self.""" + if do_add: + self.add_css_class(css_class) + else: + self.remove_css_class(css_class) + + def ensure_slot_size(self) -> None: + """Call ._size_widget to size .props.child; if none, make empty one.""" + if self.get_child() is None: + self.set_child(Gtk.Label(label='+')) + self._size_widget() + + def _size_widget(self) -> None: + for s in ('bottom', 'top', 'start', 'end'): + setattr(self.get_child().props, f'margin_{s}', + self._geometry.side_margin) + self.get_child().set_size_request(self._geometry.size_sans_margins, + self._geometry.size_sans_margins) + + def update_widget(self, is_in_vp: bool) -> None: + """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class.""" + new_content = None + if isinstance(self.item, ImgItem): + if is_in_vp and not isinstance(self.item, Gtk.Image): + new_content = Gtk.Image.new_from_file(self.item.full_path) + if self.item.with_others: + new_content.set_vexpand(True) + box = Gtk.Box(orientation=OR_V) + box.append(new_content) + msg = 'and one or more other images of this configuration' + box.append(Gtk.Label(label=msg)) + new_content = box + elif (not is_in_vp) and not isinstance(self.item, Gtk.Label): + new_content = Gtk.Label(label='?') + elif (isinstance(self.item, DirItem) + and self.get_child().props.label == '+'): + new_content = Gtk.Label(label=self.item.name) + if new_content: + self.set_child(new_content) + self._size_widget() + if isinstance(self.item, ImgItem): + self.mark('bookmarked', self.item.bookmarked) + + class GalleryConfig(): """Representation of sort and filtering settings.""" - _gallery_request_update = None - _gallery_items_attrs = None - _gallery_update_settings = None - - def __init__(self, sort_order): + _sort_sel = Gtk.SingleSelection + _set_recurse_changed: bool + _btn_apply: Gtk.Button + _btn_by_1st: Gtk.CheckButton + _btn_recurse: Gtk.CheckButton + _btn_per_row: Gtk.CheckButton + _btn_show_dirs: Gtk.CheckButton + _store: Gio.ListStore + + def __init__(self, + box: Gtk.Box, + sort_order: list[SorterAndFilterer], + request_update: Callable, + update_settings: Callable, + items_attrs: ItemsAttrs, + ) -> None: + self.order = sort_order + self._gallery_request_update = request_update + self._gallery_update_settings = update_settings + self._gallery_items_attrs = items_attrs - def setup_sorter_list_item(_, list_item): + def setup_sorter_list_item(_, list_item: SorterAndFilterer) -> None: item_widget = Gtk.Box(orientation=OR_V) item_widget.values = Gtk.Label( visible=False, max_width_chars=35, @@ -168,9 +387,9 @@ class GalleryConfig(): item_widget.append(item_widget.values) list_item.set_child(item_widget) - def bind_sorter_list_item(_, list_item): + def bind_sorter_list_item(_, list_item: SorterAndFilterer) -> None: - def on_filter_activate(entry): + def on_filter_activate(entry: Gtk.Box) -> None: entry.remove_css_class('temp') text = entry.get_buffer().get_text() if '' != text.rstrip(): @@ -184,20 +403,20 @@ class GalleryConfig(): self.filter_inputs.get(sorter.name, ''), self._gallery_items_attrs[sorter.name]) - def select_sort_order(_a, _b, _c): + def select_sort_order(_a, _b, _c) -> None: self._sort_sel.props.selected_item.widget.get_parent().grab_focus() - def toggle_recurse(_): + def toggle_recurse(_) -> None: self._set_recurse_changed = not self._set_recurse_changed self._btn_apply.set_sensitive(not self._set_recurse_changed) - def toggle_by_1st(btn): + def toggle_by_1st(btn: Gtk.CheckButton) -> None: self._btn_per_row.set_sensitive(not btn.props.active) self._btn_show_dirs.set_sensitive(not btn.props.active) if btn.props.active: self._btn_show_dirs.set_active(False) - def apply_config(): + def apply_config() -> None: new_order = [] for i in range(self._store.get_n_items()): sorter = self._store.get_item(i) @@ -217,16 +436,15 @@ class GalleryConfig(): self._set_recurse_changed = False self._filter_inputs_changed = False - def full_reload(): + def full_reload() -> None: apply_config() self._gallery_request_update(load=True) self._btn_apply.set_sensitive(True) - self.order = sort_order - self.filter_inputs = {} + self.filter_inputs: FilterInputs = {} self._filter_inputs_changed = False self._set_recurse_changed = False - self._last_selected = None + self._last_selected: Optional[Gtk.Widget] = None self._store = Gio.ListStore(item_type=SorterAndFilterer) self._sort_sel = Gtk.SingleSelection.new(self._store) @@ -236,6 +454,12 @@ class GalleryConfig(): fac.connect('bind', bind_sorter_list_item) self.sorter_listing = Gtk.ListView(model=self._sort_sel, factory=fac) + buttons_box = Gtk.Box(orientation=OR_H) + self._btn_apply = _add_button(buttons_box, 'apply config', + lambda _: apply_config()) + self._btn_relaod = _add_button(buttons_box, 'full reload', + lambda _: full_reload()) + buttons_box = Gtk.Box(orientation=OR_H) self._btn_apply = _add_button(buttons_box, 'apply config', lambda _: apply_config()) @@ -256,39 +480,12 @@ class GalleryConfig(): GALLERY_PER_ROW_DEFAULT, 9, 1) per_row_box.append(self._btn_per_row) - self.box = Gtk.Box(orientation=OR_V) - self.box.append(self.sorter_listing) - self.box.append(dirs_box) - self.box.append(per_row_box) - self.box.append(buttons_box) - - @classmethod - def from_suggestion(cls, suggestion_fused): - """Parse suggestion_fused for/into initial sort order to build on.""" - suggestion = suggestion_fused.split(',') - names = [p.lower() for p in GEN_PARAMS] + ['bookmarked'] - sort_order = [] - for name in names: - sort_order += [SorterAndFilterer(name)] - new_sort_order = [] - do_reverse = '-' in suggestion - for pattern in suggestion: - for sorter in [sorter for sorter in sort_order - if sorter.name.startswith(pattern)]: - sort_order.remove(sorter) - new_sort_order += [sorter] - sort_order = new_sort_order + sort_order - if do_reverse: - sort_order.reverse() - return cls(sort_order) + box.append(self.sorter_listing) + box.append(dirs_box) + box.append(per_row_box) + box.append(buttons_box) - def bind_gallery(self, request_update, update_settings, items_attrs): - """Connect to Gallery interfaces where necessary.""" - self._gallery_request_update = request_update - self._gallery_update_settings = update_settings - self._gallery_items_attrs = items_attrs - - def on_focus_sorter(self, focused): + def on_focus_sorter(self, focused: SorterAndFilterer) -> None: """If sorter focused, select focused, move display of values there.""" if self._last_selected: self._last_selected.values.set_visible(False) @@ -299,7 +496,7 @@ class GalleryConfig(): self._sort_sel.props.selected = i break - def move_selection(self, direction): + def move_selection(self, direction: int) -> None: """Move sort order selection by direction (-1 or +1).""" min_idx, max_idx = 0, len(self.order) - 1 cur_idx = self._sort_sel.props.selected @@ -307,7 +504,7 @@ class GalleryConfig(): or (-1 == direction and cur_idx > min_idx): self._sort_sel.props.selected = cur_idx + direction - def move_sorter(self, direction): + def move_sorter(self, direction: int) -> None: """Move selected item in sort order view, ensure temporary state.""" tmp_sort_order = [] for i in range(self._store.get_n_items()): @@ -332,7 +529,10 @@ class GalleryConfig(): sort_item = self._store.get_item(i) sort_item.widget.add_css_class('temp') - def update_box(self, alt_order=None, cur_selection=0): + def update_box(self, + alt_order: Optional[list[SorterAndFilterer]] = None, + cur_selection: int = 0 + ) -> None: """Rebuild sorter listing in box from .order, or alt_order if set.""" sort_order = alt_order if alt_order else self.order self._store.remove_all() @@ -341,156 +541,13 @@ class GalleryConfig(): self._sort_sel.props.selected = cur_selection -class GallerySlot(Gtk.Button): - """Slot in Gallery representing a GalleryItem.""" - - def __init__(self, item, slots_geometry, on_click_file=None): - super().__init__() - self._geometry = slots_geometry - self.add_css_class('slot') - self.set_hexpand(True) - self.item = item - self.item.slot = self - if on_click_file: - self.connect('clicked', lambda _: on_click_file()) - - def mark(self, css_class, 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 ensure_slot_size(self): - """Call ._size_widget to size .props.child; if none, make empty one.""" - if self.get_child() is None: - self.set_child(Gtk.Label(label='+')) - self._size_widget() - - def _size_widget(self): - for s in ('bottom', 'top', 'start', 'end'): - setattr(self.get_child().props, f'margin_{s}', - self._geometry.side_margin) - self.get_child().set_size_request(self._geometry.size_sans_margins, - self._geometry.size_sans_margins) - - def update_widget(self, is_in_vp): - """(Un-)load slot, for Imgs if (not) is_in_vp, update CSS class.""" - new_content = None - if isinstance(self.item, ImgItem): - if is_in_vp and not isinstance(self.item, Gtk.Image): - new_content = Gtk.Image.new_from_file(self.item.full_path) - if self.item.with_others: - new_content.set_vexpand(True) - box = Gtk.Box(orientation=OR_V) - box.append(new_content) - msg = 'and one or more other images of this configuration' - box.append(Gtk.Label(label=msg)) - new_content = box - elif (not is_in_vp) and not isinstance(self.item, Gtk.Label): - new_content = Gtk.Label(label='?') - elif (isinstance(self.item, DirItem) - and self.get_child().props.label == '+'): - new_content = Gtk.Label(label=self.item.name) - if new_content: - self.set_child(new_content) - self._size_widget() - if isinstance(self.item, ImgItem): - self.mark('bookmarked', self.item.bookmarked) - - -class GallerySlotsGeometry: - """Collect variable sizes shared among all GallerySlots.""" - - def __init__(self): - self._margin = GALLERY_SLOT_MARGIN - assert 0 == self._margin % 2 # avoid ._margin != 2 * .side_margin - self.side_margin = self._margin // 2 - self.size, self.size_sans_margins = -1, -1 - - def set_size(self, size): - """Not only set .size but also update .size_sans_margins.""" - self.size = size - self.size_sans_margins = self.size - self._margin - - -class GalleryItem(GObject.GObject): - """Gallery representation of filesystem entry, base to DirItem, ImgItem.""" - slot: GallerySlot - _to_hash = ['name', 'full_path'] - - def __init__(self, path, name): - super().__init__() - self.name = name - self.full_path = path_join(path, self.name) - - def __hash__(self): - hashable_values = [] - for attr_name in self._to_hash: - hashable_values += [getattr(self, attr_name)] - return hash(tuple(hashable_values)) - - -class DirItem(GalleryItem): - """Gallery representation of filesystem entry for directory.""" - - def __init__(self, path, name, is_parent=False): - super().__init__(path, name) - if is_parent: - self.full_path = path - - -class ImgItem(GalleryItem): - """Gallery representation of filesystem entry for image file.""" - _to_hash = (['name', 'full_path', 'last_mod_time', 'bookmarked', - 'with_others'] - + [k.lower() for k in GEN_PARAMS]) - - def __init__(self, path, name, cache): - super().__init__(path, name) - mtime = getmtime(self.full_path) - dt = datetime.fromtimestamp(mtime, tz=timezone.utc) - iso8601_str = dt.isoformat(timespec='microseconds') - self.last_mod_time = iso8601_str.replace('+00:00', 'Z') - self.bookmarked = False - self.with_others = False - self.has_metadata = False - for param_name in GEN_PARAMS: - if param_name in GEN_PARAMS_STR: - setattr(self, param_name.lower(), '') - else: - setattr(self, param_name.lower(), 0) - if self.full_path in cache: - if self.last_mod_time in cache[self.full_path]: - self.has_metadata = True - cached = cache[self.full_path][self.last_mod_time] - for k in cached.keys(): - setattr(self, k, cached[k]) - - def set_metadata(self, cache): - """Set attrs from file's GenParams PNG chunk, update into cache.""" - img = Image.open(self.full_path) - if isinstance(img, PngImageFile): - gen_params_as_str = img.text.get('generation_parameters', '') - if gen_params_as_str: - gen_params = GenParams.from_str(gen_params_as_str) - for k, v_ in gen_params.as_dict.items(): - setattr(self, k, v_) - cached = {} - for k in (k.lower() for k in GEN_PARAMS): - cached[k] = getattr(self, k) - cache[self.full_path] = {self.last_mod_time: cached} - - def bookmark(self, positive=True): - """Set self.bookmark to positive, and update CSS class mark.""" - self.bookmarked = positive - self.slot.mark('bookmarked', positive) - - class VerticalLabel(Gtk.DrawingArea): """Label of vertical text (rotated -90°).""" - def __init__(self, text, slots_geometry): + def __init__(self, + text: str, + slots_geometry: GallerySlotsGeometry + ) -> None: super().__init__() self._text = text self._slots_geometry = slots_geometry @@ -499,7 +556,12 @@ class VerticalLabel(Gtk.DrawingArea): _, self._text_height = test_layout.get_pixel_size() self.set_draw_func(self._on_draw) - def _on_draw(self, _, cairo_ctx, __, height): + def _on_draw(self, + _, + cairo_ctx: Pango.Context, + __, + height: int + ) -> None: """Create layout, rotate by 90°, size widget to measurements.""" layout = self.create_pango_layout() layout.set_markup(self._text) @@ -512,22 +574,26 @@ class VerticalLabel(Gtk.DrawingArea): self.set_size_request(self._text_height, text_width) @property - def width(self): + def width(self) -> int: """Return (rotated) ._text_height.""" return self._text_height class Gallery: """Representation of GalleryItems below a directory.""" - - def __init__(self, on_hit_item, on_grid_built, on_selection_change, - bookmarks_db, cache_db): + update_config_box: Callable + + def __init__(self, + on_hit_item: Callable, + on_selection_change: Callable, + bookmarks_db: JsonDb, + cache_db: JsonDb + ) -> None: self._on_hit_item = on_hit_item - self._on_grid_built = on_grid_built self._on_selection_change = on_selection_change self._bookmarks_db, self._cache_db = bookmarks_db, cache_db - self._sort_order = [] - self._filter_inputs = {} + self._sort_order: list[SorterAndFilterer] = [] + self._filter_inputs: FilterInputs = {} self._img_dir_path = None self._shall_load = False @@ -542,10 +608,10 @@ class Gallery: self._per_row = GALLERY_PER_ROW_DEFAULT self._slots_geometry = GallerySlotsGeometry() - self.dir_entries = [] - self.items_attrs = {} + self.dir_entries: list[GalleryItem] = [] + self.items_attrs: ItemsAttrs = {} self.selected_idx = 0 - self.slots = None + self.slots: list[GallerySlot] = [] self._grid = None self._force_width, self._force_height = 0, 0 @@ -564,7 +630,7 @@ class Gallery: self._viewport = self._fixed_frame.get_parent() self._viewport.set_scroll_to_focus(False) # prefer our own handling - def ensure_uptodate(): + def ensure_uptodate() -> bool: if self._img_dir_path is None: return True if self._shall_load: @@ -579,7 +645,7 @@ class Gallery: self._redraw_and_check_focus() return True - def handle_scroll(_): + def handle_scroll(_) -> None: self._start_redraw_wait = datetime.now() self._shall_scroll_to_focus = False self._shall_redraw = True @@ -589,9 +655,15 @@ class Gallery: scroller.get_vadjustment().connect('value-changed', handle_scroll) GLib.timeout_add(GALLERY_UPDATE_INTERVAL_MS, ensure_uptodate) - def update_settings(self, per_row=None, by_1st=None, show_dirs=None, - recurse_dirs=None, img_dir_path=None, sort_order=None, - filter_inputs=None): + def update_settings(self, + per_row: Optional[int] = None, + by_1st: Optional[bool] = None, + show_dirs: Optional[bool] = None, + recurse_dirs: Optional[bool] = None, + img_dir_path: Optional[str] = None, + sort_order: Optional[list[SorterAndFilterer]] = None, + filter_inputs: Optional[FilterInputs] = None + ) -> None: """Set Gallery setup fields, request appropriate updates.""" for val, attr_name in [(per_row, '_per_row'), (by_1st, '_by_1st'), @@ -607,7 +679,7 @@ class Gallery: else: self.request_update(build=True) - def _load_directory(self): + def _load_directory(self) -> None: """Rewrite .dir_entries from ._img_dir_path, trigger rebuild.""" def read_directory(dir_path, make_parent=False): @@ -649,23 +721,23 @@ class Gallery: self._shall_load = False self.dir_entries = [] - bookmarks = self._bookmarks_db.as_dict_copy() - cache = self._cache_db.as_dict_ref() + bookmarks = self._bookmarks_db.as_copy() + cache = self._cache_db.as_ref() read_directory(self._img_dir_path, make_parent=True) self._cache_db.write() self.request_update(build=True) @property - def selected_item(self): + def selected_item(self) -> Optional[GalleryItem]: """Return slot.item for slot at self.selected_idx.""" return self.slots[self.selected_idx].item if self.slots else None - def on_focus_slot(self, slot): + def on_focus_slot(self, slot: GallerySlot) -> None: """If GallerySlot focused, set .selected_idx to it.""" self._set_selection(self.slots.index(slot)) self.request_update(scroll_to_focus=True) - def _set_selection(self, new_idx): + def _set_selection(self, new_idx: int) -> None: """Set self.selected_idx, mark slot as 'selected', unmark old one.""" self._shall_select = False # in ._build(), directly before we are called, no slot will be @@ -681,7 +753,7 @@ class Gallery: self.slots[self.selected_idx].grab_focus() self._on_selection_change() - def _passes_filter(self, attr_name, val): + def _passes_filter(self, attr_name: str, val: str) -> bool: number_attributes = (set(s.lower() for s in GEN_PARAMS_INT) | set(s.lower() for s in GEN_PARAMS_FLOAT) | {'bookmarked'}) @@ -753,14 +825,16 @@ class Gallery: return False return True - def _build(self): + def _build(self) -> None: """(Re-)build slot grid from .dir_entries, filters, layout settings.""" - def build_items_attrs(): + def build_items_attrs() -> None: self.items_attrs.clear() - def collect_and_split_attr_vals(entries): - items_attrs_tmp = {} + def collect_and_split_attr_vals( + entries: list[GalleryItem] + ) -> ItemsAttrs: + items_attrs_tmp: ItemsAttrs = {} for attr_name in (s.name for s in self._sort_order): items_attrs_tmp[attr_name] = {'incl': [], 'excl': []} vals = set() @@ -780,7 +854,7 @@ class Gallery: filtered_entries = filter_entries(items_attrs_tmp_1) items_attrs_tmp_2 = collect_and_split_attr_vals(filtered_entries) for attr_name in (s.name for s in self._sort_order): - final_values = {'incl': [], 'semi': []} + final_values: AttrValsByVisibility = {'incl': [], 'semi': []} final_values['excl'] = items_attrs_tmp_1[attr_name]['excl'] for v in items_attrs_tmp_1[attr_name]['incl']: k = ('incl' if v in items_attrs_tmp_2[attr_name]['incl'] @@ -790,7 +864,7 @@ class Gallery: final_values[category].sort() self.items_attrs[attr_name] = final_values - def filter_entries(items_attrs): + def filter_entries(items_attrs: ItemsAttrs) -> list[GalleryItem]: entries_filtered = [] for entry in self.dir_entries: if (not self._show_dirs) and isinstance(entry, DirItem): @@ -807,10 +881,14 @@ class Gallery: entries_filtered += [entry] return entries_filtered - def build_grid(entries_filtered): + def build_grid(entries_filtered: list[GalleryItem]) -> None: i_row_ref, i_slot_ref = [0], [0] - def build_rows_by_attrs(remaining, items_of_parent, ancestors): + def build_rows_by_attrs( + remaining: list[tuple[str, AttrVals]], + items_of_parent: list[GalleryItem], + ancestors: list[tuple[str, str]] + ) -> None: if not items_of_parent: return attr_name, attr_values = remaining[0] @@ -818,20 +896,23 @@ class Gallery: for i, attr in enumerate(ancestors): vlabel = VerticalLabel(f'{attr[0]}: {attr[1]}', self._slots_geometry) + assert self._grid is not None self._grid.attach(vlabel, i, i_row_ref[0], 1, 1) + row: list[Optional[GalleryItem]] row = [None] * len(attr_values) - for item in items_of_parent: - val = getattr(item, attr_name) + for gallery_item in items_of_parent: + val = getattr(gallery_item, attr_name) idx_val_in_attr_values = attr_values.index(val) if row[idx_val_in_attr_values]: - item.with_others = True - row[idx_val_in_attr_values] = item + gallery_item.with_others = True + row[idx_val_in_attr_values] = gallery_item for i_col, item in enumerate(row): slot = GallerySlot( # build empty dummy if necessary item if item else GalleryItem('', ''), self._slots_geometry) self.slots += [slot] i_slot_ref[0] += 1 + assert self._grid is not None self._grid.attach(slot, i_col + len(ancestors), i_row_ref[0], 1, 1) i_row_ref[0] += 1 @@ -847,6 +928,7 @@ class Gallery: self._fixed_frame.remove(self._grid) if self._col_headers_grid: self._col_headers_frame.remove(self._col_headers_grid) + self._col_headers_grid = None self.slots = [] self._grid = Gtk.Grid() self._fixed_frame.put(self._grid, 0, 0) @@ -864,6 +946,7 @@ class Gallery: build_rows_by_attrs(sort_attrs, entries_filtered, []) self._col_headers_grid = Gtk.Grid() self._col_headers_frame.put(self._col_headers_grid, 0, 0) + assert self._col_headers_grid is not None self._col_headers_grid.attach(Gtk.Box(), 0, 0, 1, 1) top_attr_name = sort_attrs[-1][0] for i, val in enumerate(sort_attrs[-1][1]): @@ -883,10 +966,11 @@ class Gallery: i_row += 1 slot = GallerySlot(item, self._slots_geometry, self._on_hit_item) + assert self._grid is not None self._grid.attach(slot, i_col, i_row, 1, 1) self.slots += [slot] i_col += 1 - self._on_grid_built() + self.update_config_box() self._shall_build = False old_selected_item = self.selected_item @@ -901,8 +985,12 @@ class Gallery: break self._set_selection(new_idx) - def request_update(self, select=False, scroll_to_focus=False, build=False, - load=False): + def request_update(self, + select: bool = False, + scroll_to_focus: bool = False, + build: bool = False, + load: bool = False + ) -> None: """Set ._shall_… to trigger updates on next relevant interval.""" self._shall_redraw = True if scroll_to_focus or build or select: @@ -914,7 +1002,11 @@ class Gallery: if load: self._shall_load = True - def move_selection(self, x_inc, y_inc, buf_end): + def move_selection(self, + x_inc: Optional[int], + y_inc: Optional[int], + buf_end: Optional[int] + ) -> None: """Move .selection, update its dependencies, redraw gallery.""" min_idx, max_idx = 0, len(self.slots) - 1 if -1 == y_inc and self.selected_idx >= self._per_row: @@ -933,12 +1025,12 @@ class Gallery: return self._set_selection(new_idx) - def on_resize(self, width=0, height=0): + def on_resize(self, width: int = 0, height: int = 0) -> None: """Force redraw and scroll-to-focus into new geometry.""" self._force_width, self._force_height = width, height self.request_update(scroll_to_focus=True) - def _redraw_and_check_focus(self): + def _redraw_and_check_focus(self) -> None: """Draw gallery; possibly notice and first follow need to re-focus.""" vp_width = (self._force_width if self._force_width else self._viewport.get_width()) @@ -951,6 +1043,7 @@ class Gallery: side_offset, i_vlabels = 0, 0 if self._by_1st: while True: + assert self._grid is not None widget = self._grid.get_child_at(i_vlabels, 0) if isinstance(widget, VerticalLabel): side_offset += widget.width @@ -982,8 +1075,12 @@ class Gallery: slot.update_widget(in_vp) self._start_redraw_wait = datetime.now() - def _position_to_viewport( - self, idx, vp_top, vp_bottom, in_vp_greedy=False): + def _position_to_viewport(self, + idx: int, + vp_top: int, + vp_bottom: int, + in_vp_greedy: bool = False + ) -> tuple[bool, int, int]: slot_top = (idx // self._per_row) * self._slots_geometry.size slot_bottom = slot_top + self._slots_geometry.size if in_vp_greedy: @@ -992,7 +1089,11 @@ class Gallery: in_vp = (slot_top >= vp_top and slot_bottom <= vp_bottom) return in_vp, slot_top, slot_bottom - def _scroll_to_focus(self, vp_scroll, vp_top, vp_bottom): + def _scroll_to_focus(self, + vp_scroll: Gtk.Scrollable, + vp_top: int, + vp_bottom: int + ) -> bool: scroll_to_focus = self._shall_scroll_to_focus self._shall_redraw, self._shall_scroll_to_focus = False, False if scroll_to_focus: @@ -1008,7 +1109,7 @@ class Gallery: return True return False - def _sort_cmp(self, a, b): + def _sort_cmp(self, a: GalleryItem, b: GalleryItem) -> int: """Sort [a, b] by user-set sort order, and putting directories first""" # ensure ".." and all DirItems at start of order if self._show_dirs: @@ -1049,25 +1150,25 @@ class MainWindow(Gtk.Window): prev_key: list topbar: Gtk.Label - def __init__(self, app, **kwargs): + def __init__(self, app: Application, **kwargs) -> None: super().__init__(**kwargs) self.app = app - def init_navbar(): + def init_navbar() -> Gtk.Box: navbar = Gtk.Box(orientation=OR_H) _add_button(navbar, 'sidebar', lambda _: self.toggle_side_box()) self.topbar = Gtk.Label() navbar.append(self.topbar) return navbar - def init_metadata_box(): + def init_metadata_box() -> Gtk.TextView: text_view = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR, editable=False) text_view.set_size_request(300, -1) self.metadata = text_view.get_buffer() return text_view - def init_key_control(): + def init_key_control() -> None: key_ctl = Gtk.EventControllerKey( propagation_phase=Gtk.PropagationPhase.CAPTURE) key_ctl.connect('key-pressed', @@ -1075,7 +1176,7 @@ class MainWindow(Gtk.Window): self.add_controller(key_ctl) self.prev_key = [0] - def setup_css(): + def setup_css() -> None: css_provider = Gtk.CssProvider() css_provider.load_from_data(CSS) Gtk.StyleContext.add_provider_for_display( @@ -1084,7 +1185,6 @@ class MainWindow(Gtk.Window): self.gallery = Gallery( on_hit_item=self.hit_gallery_item, - on_grid_built=self.app.conf.update_box, on_selection_change=self.update_metadata_on_gallery_selection, bookmarks_db=self.app.bookmarks_db, cache_db=self.app.cache_db) @@ -1097,7 +1197,8 @@ class MainWindow(Gtk.Window): self.side_box = Gtk.Notebook.new() self.side_box.append_page(init_metadata_box(), Gtk.Label(label='metadata')) - self.side_box.append_page(self.app.conf.box, Gtk.Label(label='config')) + config_box = Gtk.Box(orientation=OR_V) + self.side_box.append_page(config_box, Gtk.Label(label='config')) box_outer = Gtk.Box(orientation=OR_H) box_outer.append(self.side_box) box_outer.append(viewer) @@ -1108,26 +1209,29 @@ class MainWindow(Gtk.Window): init_key_control() self.connect('notify::focus-widget', lambda _, __: self.on_focus_change()) - self.app.conf.bind_gallery( + self.conf = GalleryConfig( + sort_order=self.app.sort_order, + box=config_box, request_update=self.gallery.request_update, update_settings=self.gallery.update_settings, items_attrs=self.gallery.items_attrs) + self.gallery.update_config_box = self.conf.update_box GLib.idle_add(lambda: self.gallery.update_settings( img_dir_path=self.app.img_dir_absolute, - sort_order=self.app.conf.order[:], - filter_inputs=self.app.conf.filter_inputs.copy())) + sort_order=self.conf.order[:], + filter_inputs=self.conf.filter_inputs.copy())) - def on_focus_change(self): + def on_focus_change(self) -> None: """Handle reactions on focus changes in .gallery and .conf.""" focused = self.get_focus() if not focused: return if isinstance(focused, GallerySlot): self.gallery.on_focus_slot(focused) - elif focused.get_parent() == self.app.conf.sorter_listing: - self.app.conf.on_focus_sorter(focused) + elif focused.get_parent() == self.conf.sorter_listing: + self.conf.on_focus_sorter(focused) - def on_resize(self): + def on_resize(self) -> None: """On window resize, do .gallery.on_resize towards its new geometry.""" if self.get_width() > 0: # So we don't call this on initial resize. # NB: We .measure side_box because its width is changing, whereas @@ -1137,11 +1241,12 @@ class MainWindow(Gtk.Window): self.gallery.on_resize(default_size[0] - side_box_width, default_size[1] - self.navbar.get_height()) - def bookmark(self): + def bookmark(self) -> None: """Toggle bookmark on selected gallery item.""" if not isinstance(self.gallery.selected_item, ImgItem): return - bookmarks = self.app.bookmarks_db.as_dict_ref() + bookmarks = self.app.bookmarks_db.as_ref() + assert isinstance(bookmarks, list) if self.gallery.selected_item.bookmarked: self.gallery.selected_item.bookmark(False) bookmarks.remove(self.gallery.selected_item.full_path) @@ -1149,15 +1254,15 @@ class MainWindow(Gtk.Window): self.gallery.selected_item.bookmark(True) bookmarks += [self.gallery.selected_item.full_path] self.app.bookmarks_db.write() - self.app.conf.update_box() + self.conf.update_box() - def hit_gallery_item(self): + def hit_gallery_item(self) -> None: """If current file selection is directory, reload into that one.""" selected = self.gallery.selected_item if isinstance(selected, DirItem): self.gallery.update_settings(img_dir_path=selected.full_path) - def toggle_side_box(self): + def toggle_side_box(self) -> None: """Toggle window sidebox visible/invisible.""" self.side_box.props.visible = not self.side_box.get_visible() # Calculate new viewport directly, because GTK's respective viewport @@ -1165,7 +1270,7 @@ class MainWindow(Gtk.Window): side_box_width = self.side_box.measure(OR_H, -1).natural self.gallery.on_resize(self.get_width() - side_box_width) - def update_metadata_on_gallery_selection(self): + def update_metadata_on_gallery_selection(self) -> None: """Update .metadata about individual file, .topbar also on idx/total""" self.metadata.set_text('') selected_item = self.gallery.selected_item @@ -1188,7 +1293,7 @@ class MainWindow(Gtk.Window): self.topbar.set_text(txt) self.topbar.set_use_markup(True) - def handle_keypress(self, keyval): + def handle_keypress(self, keyval: int) -> bool: """Handle keys if not in Gtk.Entry, return True if key handling done""" if isinstance(self.get_focus().get_parent(), Gtk.Entry): return False @@ -1208,13 +1313,13 @@ class MainWindow(Gtk.Window): elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key[0]: self.gallery.move_selection(None, None, -1) elif Gdk.KEY_w == keyval: - self.app.conf.move_selection(-1) + self.conf.move_selection(-1) elif Gdk.KEY_W == keyval: - self.app.conf.move_sorter(-1) + self.conf.move_sorter(-1) elif Gdk.KEY_s == keyval: - self.app.conf.move_selection(1) + self.conf.move_selection(1) elif Gdk.KEY_S == keyval: - self.app.conf.move_sorter(1) + self.conf.move_sorter(1) elif Gdk.KEY_b == keyval: self.bookmark() else: @@ -1223,26 +1328,5 @@ class MainWindow(Gtk.Window): return True -class Application(Gtk.Application): - """Image browser application class.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - parser = ArgumentParser() - parser.add_argument('directory', default=IMG_DIR_DEFAULT, nargs='?') - parser.add_argument('-s', '--sort-order', default=SORT_DEFAULT) - opts = parser.parse_args() - self.img_dir_absolute = abspath(opts.directory) - self.conf = GalleryConfig.from_suggestion(opts.sort_order) - self.bookmarks_db = JsonDB(BOOKMARKS_PATH) - self.cache_db = JsonDB(CACHE_PATH) - - def do_activate(self, *args, **kwargs): - """Parse arguments, start window, keep it open.""" - win = MainWindow(self) - win.present() - self.hold() - - main_app = Application(application_id='plomlompom.com.StablePixBrowser.App') main_app.run()