- def select_sort_order(_a, _b, _c) -> None:
- self._sort_sel.props.selected_item.widget.get_parent().grab_focus()
-
- 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: 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() -> None:
- if self._tmp_order:
- self.order.sync_from(self._tmp_order)
- self._tmp_order = None
- self._gallery_request_update(build_grid=True)
- if self._filter_changed:
- self._gallery_request_update(build_grid=True)
- self._gallery_update_settings(
- per_row=self._btn_per_row.get_value_as_int(),
- by_1st=self._btn_by_1st.get_active(),
- show_dirs=self._btn_show_dirs.get_active(),
- recurse_dirs=self._btn_recurse.get_active())
- self._gallery_request_update(select=True)
- self._set_recurse_changed = False
- self._filter_changed = False
-
- def full_reload() -> None:
- apply_config()
- self._gallery_request_update(load=True)
- self._btn_apply.set_sensitive(True)
-
- self._filter_changed = False
- self._set_recurse_changed = False
- self._last_selected: Optional[Gtk.Widget] = None
-
- self._store = Gio.ListStore(item_type=SorterAndFilterer)
- self._sort_sel = Gtk.SingleSelection.new(self._store)
- self._sort_sel.connect('selection-changed', select_sort_order)
- fac = Gtk.SignalListItemFactory()
- fac.connect('setup', setup_sorter_list_item)
- 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_reload = _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())
- self._btn_reload = _add_button(buttons_box, 'full reload',
- lambda _: full_reload())
-
- dirs_box = Gtk.Box(orientation=OR_H)
- dirs_box.append(Gtk.Label(label='directories:'))
- self._btn_show_dirs = _add_button(dirs_box, 'show', checkbox=True)
- self._btn_recurse = _add_button(dirs_box, 'recurse',
- toggle_recurse, checkbox=True)
-
- per_row_box = Gtk.Box(orientation=OR_H)
- per_row_box.append(Gtk.Label(label='cols/row:'))
- self._btn_by_1st = _add_button(per_row_box, 'by 1st sorter',
- toggle_by_1st, checkbox=True)
- self._btn_per_row = Gtk.SpinButton.new_with_range(
- GALLERY_PER_ROW_DEFAULT, 9, 1)
- per_row_box.append(self._btn_per_row)
-
- box.append(self.sorter_listing)
- box.append(dirs_box)
- box.append(per_row_box)
- box.append(buttons_box)
-
- 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._last_selected = focused.get_first_child()
- self._last_selected.values.set_visible(True)
- for i in range(self._sort_sel.get_n_items()):
- if self._sort_sel.get_item(i).widget == self._last_selected:
- self._sort_sel.props.selected = i
- break
-
- 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
- if (1 == direction and cur_idx < max_idx)\
- or (-1 == direction and cur_idx > min_idx):
- self._sort_sel.props.selected = cur_idx + direction
-
- def move_sorter(self, direction: int) -> None:
- """Move selected item in sort order view, ensure temporary state."""
- tmp_order = self._tmp_order if self._tmp_order else self.order.copy()
- cur_idx = self._sort_sel.props.selected
- if direction == -1 and cur_idx > 0:
- tmp_order.switch_at(cur_idx, forward=False)
- elif direction == 1 and cur_idx < (len(tmp_order) - 1):
- tmp_order.switch_at(cur_idx, forward=True)
- else: # to catch movement beyond limits
- return
- if not self._tmp_order:
- self._tmp_order = tmp_order
- for sorter in self._tmp_order:
- sorter.widget.add_css_class('temp')
- self.update_box(cur_idx + direction)
- self._sort_sel.props.selected = cur_idx + direction
- for i in range(self._store.get_n_items()):
- sort_item: SorterAndFilterer = self._store.get_item(i)
- sort_item.widget.add_css_class('temp')
-
- def update_box(self, cur_selection: int = 0) -> None:
- """Rebuild sorter listing in box from .order, or alt_order if set."""
- sort_order = self._tmp_order if self._tmp_order else self.order
- sort_order.into_store(self._store)
- self._sort_sel.props.selected = cur_selection
-
-
-class VerticalLabel(Gtk.DrawingArea):
- """Label of vertical text (rotated -90°)."""
-
- def __init__(self,
- text: str,
- slots_geometry: GallerySlotsGeometry
- ) -> None:
- super().__init__()
- self._text = text
- self._slots_geometry = slots_geometry
- test_layout = self.create_pango_layout()
- test_layout.set_markup(text)
- _, self._text_height = test_layout.get_pixel_size()
- self.set_draw_func(self._on_draw)
-
- 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)
- layout.set_width(self._slots_geometry.size * Pango.SCALE)
- layout.set_ellipsize(Pango.EllipsizeMode.MIDDLE)
- text_width, _ = layout.get_pixel_size()
- cairo_ctx.translate(0, text_width + (height - text_width))
- cairo_ctx.rotate(radians(-90))
- PangoCairo.show_layout(cairo_ctx, layout)
- self.set_size_request(self._text_height, text_width)
-
- @property
- def width(self) -> int:
- """Return (rotated) ._text_height."""
- return self._text_height
-
-
-class Gallery:
- """Representation of GalleryItems below a directory."""
- update_config_box: Callable
-
- def __init__(self,
- sort_order: SorterAndFiltererOrder,
- on_hit_item: Callable,
- on_selection_change: Callable,
- bookmarks_db: BookmarksDb,
- cache_db: CacheDb
- ) -> None:
- self._on_hit_item = on_hit_item
- self._on_selection_change = on_selection_change
- self._bookmarks_db, self._cache_db = bookmarks_db, cache_db
- self._sort_order = sort_order
- self._img_dir_path = ''
-
- self._shall_load = False
- self._shall_build_grid = False
- self._shall_redraw = False
- self._shall_scroll_to_focus = False
- self._shall_select = False
-
- self._show_dirs = False
- self._recurse_dirs = False
- self._by_1st = False
- self._per_row = GALLERY_PER_ROW_DEFAULT
- self._slots_geometry = GallerySlotsGeometry()
-
- self.dir_entries: list[GalleryItem] = []
- self._basic_items_attrs: BasicItemsAttrs = {}
- self.items_attrs: ItemsAttrs = {}
- self.selected_idx = 0
- self.slots: list[GallerySlot] = []
-
- self._grid = Gtk.Grid()
- self._force_width, self._force_height = 0, 0
- scroller = Gtk.ScrolledWindow(propagate_natural_height=True)
- self._col_headers_frame = Gtk.Fixed()
- self._col_headers_grid = Gtk.Grid()
- self.frame = Gtk.Box(orientation=OR_V)
- self.frame.append(self._col_headers_frame)
- self.frame.append(scroller)
- # We want our viewport at always maximum possible size (so we can
- # safely calculate what's in it and what not), even if the gallery
- # would be smaller. Therefore we frame the gallery in an expanding
- # Fixed, to stretch out the viewport even if the gallery is small.
- self._fixed_frame = Gtk.Fixed(hexpand=True, vexpand=True)
- scroller.set_child(self._fixed_frame)
- self._viewport = self._fixed_frame.get_parent()
- self._viewport.set_scroll_to_focus(False) # prefer our own handling
-
- def ensure_uptodate() -> bool:
- if not self._img_dir_path:
- return True
- if self._shall_load:
- self._shall_load = False
- self._load_directory()
- if self._shall_build_grid:
- self._shall_build_grid = False
- self._build_grid()
- if self._shall_select:
- self._shall_select = False
- self._assert_selection()
- if self._shall_redraw:
- wait_time_passed = datetime.now() - self._start_redraw_wait
- if wait_time_passed > redraw_wait_time:
- self._shall_redraw = False
- self._redraw_and_check_focus()
- return True
-
- def handle_scroll(_) -> None:
- self._start_redraw_wait = datetime.now()
- self._shall_scroll_to_focus = False
- self.request_update() # only request redraw
-
- redraw_wait_time = timedelta(milliseconds=GALLERY_REDRAW_WAIT_MS)
- self._start_redraw_wait = datetime.now() - redraw_wait_time
- scroller.get_vadjustment().connect('value-changed', handle_scroll)
- GLib.timeout_add(GALLERY_UPDATE_INTERVAL_MS, ensure_uptodate)
-
- 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,
- ) -> None:
- """Set Gallery setup fields, request appropriate updates."""
- for val, attr_name in [(per_row, '_per_row'),
- (by_1st, '_by_1st'),
- (show_dirs, '_show_dirs'),
- (recurse_dirs, '_recurse_dirs'),
- (img_dir_path, '_img_dir_path')]:
- if val is not None and getattr(self, attr_name) != val:
- setattr(self, attr_name, val)
- if attr_name in {'_recurse_dirs', '_img_dir_path'}:
- self.request_update(load=True)
- else:
- self.request_update(build_grid=True)
-
- @staticmethod
- def _diff_prompts(prompts: list[str]) -> dict[str, tuple[str, str]]:
- if not prompts:
- return {}
-
- def find_longest_equal(prompts, j, matcher):
- longest_total, temp_longest = '', ''
- while j < len(prompts[0]):
- if 'end' == matcher:
- temp_longest = prompts[0][-j] + temp_longest
- else:
- temp_longest += prompts[0][j]
- if len(temp_longest) > len(longest_total):
- found_in_all = True
- for prompt in prompts[1:]:
- if ('start' == matcher
- and not prompt.startswith(temp_longest)) or\
- ('end' == matcher
- and not prompt.endswith(temp_longest)) or\
- ('in' == matcher
- and temp_longest not in prompt):
- found_in_all = False
- break
- if not found_in_all:
- break
- longest_total = temp_longest
- j += 1
- return longest_total
-
- prefix = find_longest_equal(prompts, 0, 'start')
- suffix = find_longest_equal(prompts, 1, matcher='end')
- cores = [p[len(prefix):] for p in prompts]
- if suffix:
- for i, p in enumerate(cores):
- cores[i] = p[:-len(suffix)]
- longest_total = ''
- for i in range(len(cores[0])):
- temp_longest = find_longest_equal(cores, j=i, matcher='in')
- if len(temp_longest) > len(longest_total):
- longest_total = temp_longest
- middle = longest_total
- prompts_diff = {}
- for i, p in enumerate(prompts):
- remains = p[len(prefix):] if prefix else p
- idx_middle = remains.index(middle)
- first = remains[:idx_middle] if idx_middle else ''
- remains = remains[idx_middle + len(middle):]
- second = remains[:-len(suffix)] if suffix else remains
- if first:
- first = f'…{first}' if prefix else first
- first = f'{first}…' if suffix or middle else first
- if second:
- second = f'…{second}' if prefix or middle else second
- second = f'{second}…' if suffix else second
- prompts_diff[p] = (first if first else '…',
- second if second else '…')
- return prompts_diff
-
- def _prep_items_attrs(self, entries: list[GalleryItem]) -> BasicItemsAttrs:
- basic_items_attrs = {}
- for attr_name in (s.name for s in self._sort_order):
- vals: set[str] = set()
- for entry in [e for e in entries if isinstance(e, ImgItem)]:
- val = (getattr(entry, attr_name)
- if hasattr(entry, attr_name) else None)
- if val is not None:
- vals.add(val)
- basic_items_attrs[attr_name] = vals
- return basic_items_attrs
-
- def _load_directory(self) -> None:
- """(Re-)build .dir_entries from ._img_dir_path, ._basic_items_attrs."""
- self.dir_entries.clear()
- bookmarks = self._bookmarks_db.as_copy()
- cache = self._cache_db.as_ref()
-
- def read_directory(dir_path: str, make_parent: bool = False) -> None:
- if make_parent:
- parent_dir = DirItem(abspath(path_join(dir_path, UPPER_DIR)),
- UPPER_DIR, is_parent=True)
- self.dir_entries += [parent_dir]
- dirs_to_enter: list[str] = []
- to_set_metadata_on: list[ImgItem] = []
- dir_entries = list(listdir(dir_path))
- for i, filename in enumerate(dir_entries):
- msg = f'loading {dir_path}: entry {i+1}/{len(dir_entries)}'
- print(msg, end='\r')
- full_path = path_join(dir_path, filename)
- if isdir(full_path):
- self.dir_entries += [DirItem(dir_path, filename)]
- dirs_to_enter += [full_path]
- continue
- _, ext = splitext(filename)
- if ext not in ACCEPTED_IMG_FILE_ENDINGS:
- continue
- img_item = ImgItem(dir_path, filename, cache)
- if img_item.full_path in bookmarks:
- img_item.bookmarked = True
- if not img_item.has_metadata:
- to_set_metadata_on += [img_item]
- self.dir_entries += [img_item]
- print('')
- for i, item in enumerate(to_set_metadata_on):
- msg = f'setting metadata: {i+1}/{len(to_set_metadata_on)}'
- print(msg, end='\r')
- item.set_metadata(cache)
- msg = '' if to_set_metadata_on else '(no metadata to set)'
- print(msg)
- if dirs_to_enter and self._recurse_dirs:
- prefix = f'entering directories below {dir_path}: directory '
- for i, path in enumerate(dirs_to_enter):
- print(f'{prefix}{i+1}/{len(dirs_to_enter)}')
- read_directory(path)
-
- read_directory(self._img_dir_path, make_parent=True)
- prompts_set: set[str] = set()
- for entry in [e for e in self.dir_entries
- if isinstance(e, ImgItem) and hasattr(e, 'prompt')]:
- prompts_set.add(entry.prompt)
- prompts_diff = self._diff_prompts(list(prompts_set))
- for entry in [e for e in self.dir_entries if isinstance(e, ImgItem)]:
- entry.subprompt1 = prompts_diff[entry.prompt][0]
- entry.subprompt2 = prompts_diff[entry.prompt][1]
- if self._sort_order.by_name('prompt'):
- self._sort_order.remove('prompt')
- self._sort_order._list.append(SorterAndFilterer('subprompt1'))
- self._sort_order._list.append(SorterAndFilterer('subprompt2'))
- self._basic_items_attrs = self._prep_items_attrs(self.dir_entries)
- ignorable_attrs = []
- for attr_name, attr_vals in self._basic_items_attrs.items():
- if len(attr_vals) < 2:
- ignorable_attrs += [attr_name]
- for attr_name in ignorable_attrs:
- self._sort_order.remove(attr_name)
- del self._basic_items_attrs[attr_name]
- self._cache_db.write()
-
- @property
- 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: 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 _assert_selection(self) -> None:
- if self.slots:
- self.slots[self.selected_idx].mark('selected', True)
- self.slots[self.selected_idx].grab_focus()
-
- def _set_selection(self, new_idx: int) -> None:
- """Set self.selected_idx, mark slot as 'selected', unmark old one."""
- # in ._build_grid(), directly before we are called, no slot will be
- # CSS-marked 'selected', so .mark('selected', False) would tolerably
- # happen without effect; where called from ._build_grid() however, an
- # old .selected_idx might point beyond _any_ of the new .slots, the
- # IndexError of which we still want to avoid
- if self.selected_idx < len(self.slots):
- self.slots[self.selected_idx].mark('selected', False)
- self.selected_idx = new_idx
- self._assert_selection()
- self._on_selection_change()
-
- def _build_grid(self) -> None:
- """(Re-)build slot grid from .dir_entries, filters, layout settings."""
- old_selected_item: Optional[GalleryItem] = self.selected_item
-
- def update_items_attrs() -> None:
- self.items_attrs.clear()
-
- def separate_items_attrs(basic_items_attrs) -> ItemsAttrs:
- items_attrs: ItemsAttrs = {}
- for attr_name, vals in basic_items_attrs.items():
- sorter = self._sort_order.by_name(attr_name)
- items_attrs[attr_name] = {'incl': [], 'excl': []}
- for v in vals:
- passes_filter = sorter is None
- if sorter:
- passes_filter = sorter.filter_allows_value(v)
- k = 'incl' if passes_filter else 'excl'
- items_attrs[attr_name][k] += [v]
- return items_attrs
-
- items_attrs_tmp_1 = separate_items_attrs(self._basic_items_attrs)
- filtered = filter_entries(items_attrs_tmp_1)
- reduced_basic_items_attrs = self._prep_items_attrs(filtered)
- items_attrs_tmp_2 = separate_items_attrs(reduced_basic_items_attrs)
- for attr_name in (s.name for s in self._sort_order):
- 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']
- else 'semi')
- final_values[k] += [v]
- for category in ('incl', 'semi', 'excl'):
- final_values[category].sort()
- self.items_attrs[attr_name] = final_values
-
- def filter_entries(items_attrs: ItemsAttrs) -> list[GalleryItem]:
- entries_filtered: list[GalleryItem] = []
- for entry in self.dir_entries:
- if (not self._show_dirs) and isinstance(entry, DirItem):
- continue
- passes_filters = True
- for attr_name in (s.name for s in self._sort_order):
- if isinstance(entry, ImgItem):
- val = (getattr(entry, attr_name)
- if hasattr(entry, attr_name) else None)
- if val not in items_attrs[attr_name]['incl']:
- passes_filters = False
- break
- if passes_filters:
- entries_filtered += [entry]
- return entries_filtered
-
- def build(entries_filtered: list[GalleryItem]) -> None:
- i_row_ref, i_slot_ref = [0], [0]
- if self._grid.get_parent():
- self._fixed_frame.remove(self._grid)
- self._grid = Gtk.Grid()
- if self._col_headers_grid.get_parent():
- self._col_headers_frame.remove(self._col_headers_grid)
- self._col_headers_grid = Gtk.Grid()
- self.slots.clear()
- self._fixed_frame.put(self._grid, 0, 0)
-
- 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]
- if 1 == len(remaining):
- for i, attr in enumerate(ancestors):
- txt = f'<b>{attr[0]}</b>: {attr[1]}'
- vlabel = VerticalLabel(txt, self._slots_geometry)
- self._grid.attach(vlabel, i, i_row_ref[0], 1, 1)
- row: list[Optional[GalleryItem]]
- row = [None] * len(attr_values)
- 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]:
- 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
- self._grid.attach(slot, i_col + len(ancestors),
- i_row_ref[0], 1, 1)
- i_row_ref[0] += 1
- return
- for attr_value in attr_values:
- items_of_attr_value = [x for x in items_of_parent
- if attr_value == getattr(x,
- attr_name)]
- build_rows_by_attrs(remaining[1:], items_of_attr_value,
- ancestors + [(attr_name, attr_value)])
-
- if self._by_1st:
- self._show_dirs = False
- sort_attrs: list[tuple[str, AttrVals]] = []
- for sorter in reversed(self._sort_order):
- vals: AttrVals = self.items_attrs[sorter.name]['incl']
- if len(vals) > 1:
- sort_attrs += [(sorter.name, vals)]
- if not sort_attrs:
- s_name: str = self._sort_order[0].name
- sort_attrs += [(s_name, self.items_attrs[s_name]['incl'])]
- self._per_row = len(sort_attrs[-1][1])
- build_rows_by_attrs(sort_attrs, entries_filtered, [])
- self._col_headers_frame.put(self._col_headers_grid, 0, 0)
- self._col_headers_grid.attach(Gtk.Box(), 0, 0, 1, 1)
- top_attr_name: str = sort_attrs[-1][0]
- for i, val in enumerate(sort_attrs[-1][1]):
- label = Gtk.Label(label=f'<b>{top_attr_name}</b>: {val}',
- xalign=0,
- ellipsize=Pango.EllipsizeMode.MIDDLE)
- label.set_use_markup(True)
- self._col_headers_grid.attach(label, i + 1, 0, 1, 1)
-
- else:
- dir_entries_filtered_sorted: list[GalleryItem] = sorted(
- entries_filtered, key=cmp_to_key(self._sort_cmp))
- i_row, i_col = 0, 0
- for i, item in enumerate(dir_entries_filtered_sorted):
- if self._per_row == i_col:
- i_col = 0
- i_row += 1
- slot = GallerySlot(item, self._slots_geometry,
- self._on_hit_item)
- self._grid.attach(slot, i_col, i_row, 1, 1)
- self.slots += [slot]
- i_col += 1
- self.update_config_box()
-
- update_items_attrs()
- entries_filtered = filter_entries(self.items_attrs)
- build(entries_filtered)
- new_idx = 0
- if old_selected_item is not None:
- for i, slot in enumerate(self.slots):
- if hash(old_selected_item) == hash(slot.item):
- new_idx = i
- break
- self._set_selection(new_idx)
-
- def request_update(self,
- select: bool = False,
- scroll_to_focus: bool = False,
- build_grid: bool = False,
- load: bool = False
- ) -> None:
- """Set ._shall_… to trigger updates on next relevant interval."""
- self._shall_redraw = True
- self._shall_select |= select or scroll_to_focus or build_grid or load
- self._shall_scroll_to_focus |= scroll_to_focus or build_grid or load
- self._shall_build_grid |= build_grid or load
- self._shall_load |= load
-
- 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:
- new_idx = self.selected_idx - self._per_row
- elif 1 == y_inc and self.selected_idx <= max_idx - self._per_row:
- new_idx = self.selected_idx + self._per_row
- elif -1 == x_inc and self.selected_idx > 0:
- new_idx = self.selected_idx - 1
- elif 1 == x_inc and self.selected_idx < max_idx:
- new_idx = self.selected_idx + 1
- elif 1 == buf_end:
- new_idx = max_idx
- elif -1 == buf_end:
- new_idx = min_idx
- else:
- return
- self._set_selection(new_idx)
-
- 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) -> None:
- """Draw gallery; possibly notice and first follow need to re-focus."""
- vp_width: int = (self._force_width if self._force_width
- else self._viewport.get_width())
- vp_height: int = (self._force_height if self._force_height
- else self._viewport.get_height())
- self._force_width, self._force_height = 0, 0
- vp_scroll: Gtk.Adjustment = self._viewport.get_vadjustment()
- vp_top: int = vp_scroll.get_value()
- vp_bottom: int = vp_top + vp_height
- side_offset, i_vlabels = 0, 0
- if self._by_1st:
- while True:
- gal_widget: VerticalLabel | GalleryItem
- gal_widget = self._grid.get_child_at(i_vlabels, 0)
- if isinstance(gal_widget, VerticalLabel):
- side_offset += gal_widget.width
- else:
- break
- i_vlabels += 1
- max_slot_width: int = (vp_width - side_offset) // self._per_row
- self._slots_geometry.set_size(min(vp_height, max_slot_width))
- if self._by_1st:
- i_widgets = 0
- while True:
- head_widget: Gtk.Box | Gtk.Label | None
- head_widget = self._col_headers_grid.get_child_at(i_widgets, 0)
- if 0 == i_widgets:
- head_widget.set_size_request(side_offset, -1)
- elif isinstance(head_widget, Gtk.Label):
- head_widget.set_size_request(self._slots_geometry.size, -1)
- else:
- break
- i_widgets += 1
- for idx, slot in enumerate(self.slots):
- slot.ensure_slot_size()
- vp_scroll.set_upper(self._slots_geometry.size * ceil(len(self.slots)
- / self._per_row))
- if self._scroll_to_focus(vp_scroll, vp_top, vp_bottom):
- return
- for idx, slot in enumerate(self.slots):
- in_vp, _, _ = self._position_to_viewport(idx,
- vp_top, vp_bottom, True)
- slot.update_widget(in_vp)
- self._start_redraw_wait = datetime.now()
-
- def _position_to_viewport(self,
- idx: int,
- vp_top: int,
- vp_bottom: int,
- in_vp_greedy: bool = False
- ) -> tuple[bool, int, int]:
- slot_top: int = (idx // self._per_row) * self._slots_geometry.size
- slot_bottom: int = slot_top + self._slots_geometry.size
- if in_vp_greedy:
- in_vp = (slot_bottom >= vp_top and slot_top <= vp_bottom)
- else:
- 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: Gtk.Scrollable,
- vp_top: int,
- vp_bottom: int
- ) -> bool:
- scroll_to_focus: bool = self._shall_scroll_to_focus
- self._shall_redraw, self._shall_scroll_to_focus = False, False
- if scroll_to_focus:
- in_vp, slot_top, slot_bottom = self._position_to_viewport(
- self.selected_idx, vp_top, vp_bottom)
- if not in_vp:
- self._shall_redraw, self._shall_scroll_to_focus = True, True
- if slot_top < vp_top:
- vp_scroll.set_value(slot_top)
- else:
- vp_scroll.set_value(slot_bottom
- - self._slots_geometry.size)
- return True
- return False
-
- 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:
- cmp_upper_dir = f' {UPPER_DIR}'
- if isinstance(a, DirItem) and a.name == cmp_upper_dir:
- return -1
- if isinstance(b, DirItem) and b.name == cmp_upper_dir:
- return +1
- if isinstance(a, DirItem) and not isinstance(b, DirItem):
- return -1
- if isinstance(b, DirItem) and not isinstance(a, DirItem):
- return +1
- # apply ._sort_order within DirItems and ImgItems (separately)
- ret = 0
- for key in [sorter.name for sorter in self._sort_order]:
- a_cmp = None
- b_cmp = None
- if hasattr(a, key):
- a_cmp = getattr(a, key)
- if hasattr(b, key):
- b_cmp = getattr(b, key)
- if a_cmp is None and b_cmp is None:
- continue
- if a_cmp is None:
- ret = -1
- elif b_cmp is None:
- ret = +1
- elif a_cmp > b_cmp:
- ret = +1
- elif a_cmp < b_cmp:
- ret = -1
- return ret
-
-
-class MainWindow(Gtk.Window):
- """Image browser app top-level window."""
-
- def __init__(self, app: Application, **kwargs) -> None:
- super().__init__(**kwargs)
- self.app = app
- self.gallery = Gallery(
- sort_order=self.app.sort_order,
- on_hit_item=self.hit_gallery_item,
- on_selection_change=self.update_metadata_on_gallery_selection,
- bookmarks_db=self.app.bookmarks_db,
- cache_db=self.app.cache_db)
- config_box = Gtk.Box(orientation=OR_V)
- 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
- metadata_textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR,
- editable=False)
- self.metadata = metadata_textview.get_buffer()
- self.idx_display = Gtk.Label()
-
- # layout: outer box, CSS, sizings
- box_outer = Gtk.Box(orientation=OR_H)
- self.set_child(box_outer)
- css_provider = Gtk.CssProvider()
- css_provider.load_from_data(CSS)
- Gtk.StyleContext.add_provider_for_display(
- self.get_display(), css_provider,
- Gtk.STYLE_PROVIDER_PRIORITY_USER)
- metadata_textview.set_size_request(300, -1)
- self.connect('notify::default-width', lambda _, __: self.on_resize())
- self.connect('notify::default-height', lambda _, __: self.on_resize())
-
- # layout: sidebar
- self.side_box = Gtk.Notebook.new()
- self.side_box.append_page(metadata_textview,
- Gtk.Label(label='metadata'))
- self.side_box.append_page(config_box, Gtk.Label(label='config'))
- box_outer.append(self.side_box)
-
- # layout: gallery viewer
- viewer = Gtk.Box(orientation=OR_V)
- self.navbar = Gtk.Box(orientation=OR_H)
- _add_button(self.navbar, 'sidebar', lambda _: self.toggle_side_box())
- self.navbar.append(self.idx_display)
- viewer.append(self.navbar)
- viewer.append(self.gallery.frame)
- box_outer.append(viewer)
-
- # init key and focus control
- key_ctl = Gtk.EventControllerKey(
- propagation_phase=Gtk.PropagationPhase.CAPTURE)
- key_ctl.connect('key-pressed',
- lambda _, kval, _0, _1: self.handle_keypress(kval))
- self.add_controller(key_ctl)
- self.prev_key_ref = [0]
- self.connect('notify::focus-widget',
- lambda _, __: self.on_focus_change())
-
- # only now we're ready for actually running the gallery
- GLib.idle_add(lambda: self.gallery.update_settings(
- img_dir_path=self.app.img_dir_absolute))
-
- def on_focus_change(self) -> None:
- """Handle reactions on focus changes in .gallery and .conf."""
- focused: Optional[Gtk.Widget] = self.get_focus()
- if not focused:
- return
- if isinstance(focused, GallerySlot):
- self.gallery.on_focus_slot(focused)
- elif focused.get_parent() == self.conf.sorter_listing:
- self.conf.on_focus_sorter(focused)
-
- 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
- # for the unchanging navbar .get_height is sufficient.
- side_box_width: int = self.side_box.measure(OR_H, -1).natural
- default_size: tuple[int, int] = self.get_default_size()
- self.gallery.on_resize(default_size[0] - side_box_width,
- default_size[1] - self.navbar.get_height())
-
- 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_ref()
- if self.gallery.selected_item.bookmarked:
- self.gallery.selected_item.bookmark(False)
- bookmarks.remove(self.gallery.selected_item.full_path)
- else:
- self.gallery.selected_item.bookmark(True)
- bookmarks += [self.gallery.selected_item.full_path]
- self.app.bookmarks_db.write()
- self.conf.update_box()
-
- def hit_gallery_item(self) -> None:
- """If current file selection is directory, reload into that one."""
- selected: Optional[GalleryItem] = self.gallery.selected_item
- if isinstance(selected, DirItem):
- self.gallery.update_settings(img_dir_path=selected.full_path)
-
- 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
- # measurement happens too late for our needs.
- side_box_width: int = 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) -> None:
- """Update .metadata about individual file, and .idx_display."""
- self.metadata.set_text('')
- selected_item: Optional[GalleryItem] = self.gallery.selected_item
- display_name = '(none)'
- 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}'
- bookmarked = 'BOOKMARK' if selected_item.bookmarked else ''
- self.metadata.set_text(
- '\n'.join([title, bookmarked] + params_strs))
- display_name = selected_item.full_path
- elif isinstance(selected_item, DirItem):
- display_name = selected_item.full_path
- total = len([s for s in self.gallery.slots
- if isinstance(s.item, (DirItem, ImgItem))])
- n_selected: int = self.gallery.selected_idx + 1
- txt = f' {n_selected} of {total} – <b>{display_name}</b>'
- self.idx_display.set_text(txt)
- self.idx_display.set_use_markup(True)
-
- 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
- if Gdk.KEY_Return == keyval and isinstance(self.get_focus(),
- GallerySlot):
- self.hit_gallery_item()
- elif Gdk.KEY_G == keyval:
- self.gallery.move_selection(None, None, 1)
- elif Gdk.KEY_h == keyval:
- self.gallery.move_selection(-1, None, None)
- elif Gdk.KEY_j == keyval:
- self.gallery.move_selection(None, +1, None)
- elif Gdk.KEY_k == keyval:
- self.gallery.move_selection(None, -1, None)
- elif Gdk.KEY_l == keyval:
- self.gallery.move_selection(+1, None, None)
- elif Gdk.KEY_g == keyval and Gdk.KEY_g == self.prev_key_ref[0]:
- self.gallery.move_selection(None, None, -1)
- elif Gdk.KEY_w == keyval:
- self.conf.move_selection(-1)
- elif Gdk.KEY_W == keyval:
- self.conf.move_sorter(-1)
- elif Gdk.KEY_s == keyval:
- self.conf.move_selection(1)
- elif Gdk.KEY_S == keyval:
- self.conf.move_sorter(1)
- elif Gdk.KEY_b == keyval:
- self.bookmark()
- else:
- self.prev_key_ref[0] = keyval
- return False
- return True
-
-
-main_app = Application(application_id='plomlompom.com.StablePixBrowser.App')
-main_app.run()