#!/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
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; }
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))
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')
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
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,
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():
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)
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)
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())
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)
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
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()):
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()
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
_, 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)
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
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
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:
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
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'),
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):
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
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'})
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()
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']
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):
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]
for i, attr in enumerate(ancestors):
vlabel = VerticalLabel(f'<b>{attr[0]}</b>: {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
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)
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]):
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
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:
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:
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())
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
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:
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:
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:
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',
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(
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)
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)
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
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)
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
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
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
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:
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()