From ca6c2adc4f92bb4ad7f20a1ce6f9d69f222e23cb Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Wed, 28 Jun 2023 01:23:49 -0400 Subject: [PATCH 01/11] Add thumbnail.save configuration option. If this option is set to False, then do not save generated thumbnails to the disk. --- vimiv/api/settings.py | 1 + vimiv/utils/thumbnail_manager.py | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index 9f77d7409..637b735e6 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -469,6 +469,7 @@ class thumbnail: # pylint: disable=invalid-name """Namespace for thumbnail related settings.""" size = ThumbnailSizeSetting("thumbnail.size", 128, desc="Size of thumbnails") + save = BoolSetting("thumbnail.save", True, desc="Save thumbnails to disk") class slideshow: # pylint: disable=invalid-name diff --git a/vimiv/utils/thumbnail_manager.py b/vimiv/utils/thumbnail_manager.py index a547f0f93..d074006d0 100644 --- a/vimiv/utils/thumbnail_manager.py +++ b/vimiv/utils/thumbnail_manager.py @@ -22,6 +22,7 @@ from PyQt5.QtGui import QIcon, QPixmap, QImage import vimiv +from vimiv import api from vimiv.utils import xdg, imagereader, Pool @@ -131,6 +132,23 @@ def _get_thumbnail_filename(self, path: str) -> str: def _get_source_mtime(path: str) -> int: return int(os.path.getmtime(path)) + def _save_thumbnail(self, image: QImage, thumbnail_path: str) -> None: + """Save the thumbnail file to the disk. + Args: + image: The QImage representing the thumbnail. + thumbnail_path: Path to which the thumbnail is stored. + Returns: + None. + """ + # First create temporary file and then move it. This avoids + # problems with concurrent access of the thumbnail cache, since + # "move" is an atomic operation + handle, tmp_filename = tempfile.mkstemp(dir=self._manager.directory) + os.close(handle) + os.chmod(tmp_filename, 0o600) + image.save(tmp_filename, format="png") + os.replace(tmp_filename, thumbnail_path) + def _create_thumbnail(self, path: str, thumbnail_path: str) -> QPixmap: """Create thumbnail for an image. @@ -153,14 +171,8 @@ def _create_thumbnail(self, path: str, thumbnail_path: str) -> QPixmap: return self._manager.fail_pixmap for key, value in attributes.items(): image.setText(key, value) - # First create temporary file and then move it. This avoids - # problems with concurrent access of the thumbnail cache, since - # "move" is an atomic operation - handle, tmp_filename = tempfile.mkstemp(dir=self._manager.directory) - os.close(handle) - os.chmod(tmp_filename, 0o600) - image.save(tmp_filename, format="png") - os.replace(tmp_filename, thumbnail_path) + if api.settings.thumbnail.save: + self._save_thumbnail(image, thumbnail_path) return QPixmap(image) def _get_thumbnail_attributes(self, path: str, image: QImage) -> Dict[str, str]: From 052f8881b52d351597f208cd27de1e5804e5d298 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Wed, 28 Jun 2023 20:42:53 -0400 Subject: [PATCH 02/11] Add ranges for thumbnail loading. In order to reduce memory usage, the new 'thumbnail.max_ahead' and 'thumbnail.max_behind' can be used to specify a limit to the number of thumbnails after the current selection that can be loaded. Thumbnails outside of this range will be unloaded, and will be loaded again when they enter the range. Setting either of these variables to 0 will remove the loading limit, to restore previous behavior. --- vimiv/api/settings.py | 2 + vimiv/gui/thumbnail.py | 88 ++++++++++++++++++++++++++++---- vimiv/utils/thumbnail_manager.py | 5 +- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index 637b735e6..7bfb4b2cd 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -470,6 +470,8 @@ class thumbnail: # pylint: disable=invalid-name size = ThumbnailSizeSetting("thumbnail.size", 128, desc="Size of thumbnails") save = BoolSetting("thumbnail.save", True, desc="Save thumbnails to disk") + max_behind = IntSetting("thumbnail.max_behind", 50, desc="Maximum number of thumbnails to render behind the currently selected one.") + max_ahead = IntSetting("thumbnail.max_ahead", 50, desc="Maximum number of thumbnails to render ahead of the currently selected one.") class slideshow: # pylint: disable=invalid-name diff --git a/vimiv/gui/thumbnail.py b/vimiv/gui/thumbnail.py index 19b9f161f..514c38feb 100644 --- a/vimiv/gui/thumbnail.py +++ b/vimiv/gui/thumbnail.py @@ -83,6 +83,7 @@ def __init__(self) -> None: widgets.ScrollWheelCumulativeMixin.__init__(self, self._scroll_wheel_callback) QListWidget.__init__(self) + self._rendered_paths = set() self._paths: List[str] = [] fail_pixmap = create_pixmap( @@ -143,6 +144,67 @@ def n_rows(self) -> int: """Return the number of rows.""" return math.ceil(self.count() / self.n_columns()) + def _first_index(self) -> int: + """Return the index of the first thumbnail to be rendered.""" + result: int + if api.settings.thumbnail.max_behind.value == 0: + result = 0 + else: + result = max(0, + self.current_index() - api.settings.thumbnail.max_behind.value) + return result + + def _last_index(self) -> int: + """Return the index of the last thumbnail to be rendered.""" + result: int + if len(self._paths) == 0: + result = 0 + elif api.settings.thumbnail.max_ahead.value == 0: + result = len(self._paths) - 1 + else: + result = min(len(self._paths) - 1, + self.current_index() + api.settings.thumbnail.max_ahead.value) + return result + + def _index_range(self) -> tuple[int, int]: + """Return a tuple with the first index to be rendered, and last, plus one to match a typical Python range.""" + return (self._first_index(), self._last_index() + 1) + + def _prune_index(self, index) -> None: + """Unload the icon associated with a particular path index.""" + item = self.item(index) + if item is not None: # Otherwise it has been deleted in the meanwhile + item.setIcon(ThumbnailItem.default_icon()) + self._rendered_paths.discard(self._paths[index]) + + def _prune_icons_behind(self) -> None: + """Prune indexes before the current index.""" + for index in range(0, self._first_index()): + self._prune_index(index) + + def _prune_icons_ahead(self) -> None: + """Prune indexes after the current index.""" + for index in range(self._last_index() + 1, len(self._paths)): + self._prune_index(index) + + def _prune_icons(self) -> None: + """Prune indexes before and after the current index.""" + self._prune_icons_behind() + self._prune_icons_ahead() + + def _update_icon_position(self) -> None: + """Adjust displayed range of icons to current position.""" + first_index, last_index = self._index_range() + desired_paths = [] + indices = [] + for p, i in zip(self._paths[first_index:last_index], range(first_index, last_index)): + if p not in self._rendered_paths: + self._rendered_paths.add(p) + desired_paths.append(p) + indices.append(i) + self._manager.create_thumbnails_async(indices, desired_paths) + self._prune_icons() + def item(self, index: int) -> "ThumbnailItem": return cast(ThumbnailItem, super().item(index)) @@ -169,6 +231,8 @@ def _on_new_images_opened(self, paths: List[str]): del self._paths[idx] # Remove as the index also changes in the QListWidget if not self.takeItem(idx): _logger.error("Error removing thumbnail for '%s'", path) + else: + self._rendered_paths.remove(path) size_hint = QSize(self.item_size(), self.item_size()) for i, path in enumerate(paths): if path not in self._paths: # Add new path @@ -176,7 +240,11 @@ def _on_new_images_opened(self, paths: List[str]): ThumbnailItem(self, i, size_hint=size_hint) self.item(i).marked = path in api.mark.paths # Ensure correct highlighting self._paths = paths - self._manager.create_thumbnails_async(paths) + first_index, last_index = self._index_range() + self._manager.create_thumbnails_async(range(first_index, last_index), + paths[first_index:last_index]) + for p in paths[first_index:last_index]: + self._rendered_paths.add(p) _logger.debug("... update completed") @utils.slot @@ -238,21 +306,21 @@ def open_selected(self): api.signals.load_images.emit([self.current()]) api.modes.IMAGE.enter() - @api.keybindings.register("b", "scroll page-up", mode=api.modes.THUMBNAIL) - @api.keybindings.register("f", "scroll page-down", mode=api.modes.THUMBNAIL) + @api.keybindings.register(("b", ""), "scroll page-up", mode=api.modes.THUMBNAIL) + @api.keybindings.register(("f", ""), "scroll page-down", mode=api.modes.THUMBNAIL) @api.keybindings.register( "u", "scroll half-page-up", mode=api.modes.THUMBNAIL ) @api.keybindings.register( "d", "scroll half-page-down", mode=api.modes.THUMBNAIL ) - @api.keybindings.register("k", "scroll up", mode=api.modes.THUMBNAIL) - @api.keybindings.register("j", "scroll down", mode=api.modes.THUMBNAIL) + @api.keybindings.register(("k", ""), "scroll up", mode=api.modes.THUMBNAIL) + @api.keybindings.register(("j", ""), "scroll down", mode=api.modes.THUMBNAIL) @api.keybindings.register( - ("h", ""), "scroll left", mode=api.modes.THUMBNAIL + ("h", "", ""), "scroll left", mode=api.modes.THUMBNAIL ) @api.keybindings.register( - ("l", ""), "scroll right", mode=api.modes.THUMBNAIL + ("l", "", ""), "scroll right", mode=api.modes.THUMBNAIL ) @api.commands.register(mode=api.modes.THUMBNAIL) def scroll( # type: ignore[override] @@ -295,8 +363,8 @@ def _scroll_updown( last_in_col -= self.n_columns() return min(current + self.n_columns() * step, last_in_col) - @api.keybindings.register("gg", "goto 1", mode=api.modes.THUMBNAIL) - @api.keybindings.register("G", "goto -1", mode=api.modes.THUMBNAIL) + @api.keybindings.register(("gg", ""), "goto 1", mode=api.modes.THUMBNAIL) + @api.keybindings.register(("G", ""), "goto -1", mode=api.modes.THUMBNAIL) @api.commands.register(mode=api.modes.THUMBNAIL) def goto(self, index: Optional[int], count: Optional[int] = None): """Select specific thumbnail in current filelist. @@ -375,6 +443,7 @@ def _select_index(self, index: int, emit: bool = True) -> None: self.setCurrentRow(index) if emit: synchronize.signals.new_thumbnail_path_selected.emit(self._paths[index]) + self._update_icon_position() def _on_size_changed(self, value: int): _logger.debug("Setting size to %d", value) @@ -446,6 +515,7 @@ def resizeEvent(self, event): """Update resize event to keep selected thumbnail centered.""" super().resizeEvent(event) self.scrollTo(self.currentIndex()) + self._update_icon_position() def _scroll_wheel_callback(self, steps_x, steps_y): """Callback function used by the scroll wheel mixin for mouse scrolling.""" diff --git a/vimiv/utils/thumbnail_manager.py b/vimiv/utils/thumbnail_manager.py index d074006d0..6fec4d057 100644 --- a/vimiv/utils/thumbnail_manager.py +++ b/vimiv/utils/thumbnail_manager.py @@ -72,14 +72,15 @@ def __init__(self, fail_pixmap: QPixmap, large: bool = True): xdg.makedirs(self.directory, self.fail_directory) self.fail_pixmap = fail_pixmap - def create_thumbnails_async(self, paths: List[str]) -> None: + def create_thumbnails_async(self, indices: List[int], paths: List[str]) -> None: """Start ThumbnailsCreator for each path to create thumbnails. Args: + indices: The corresponding index of the thumbnail for each path. paths: Paths to create thumbnails for. """ self.pool.clear() - for i, path in enumerate(paths): + for i, path in zip(indices, paths): self.pool.start(ThumbnailCreator(i, path, self)) From 858d85ad61a4c1f361b9fc202ee4e9922a975b17 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Thu, 29 Jun 2023 14:28:30 -0400 Subject: [PATCH 03/11] Retain thumbnails beneath a certain limit. The 'thumbnail.max_count' configuration setting prevents thumbnails from being unloaded if they are under this count, even if they fall outside the range specified by 'thumbnail.max_ahead' and 'thumbnail.max_behind'. If this value is 0, it is ignored. ignored. --- vimiv/api/settings.py | 2 +- vimiv/gui/thumbnail.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index 7bfb4b2cd..d8fa61eb9 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -472,7 +472,7 @@ class thumbnail: # pylint: disable=invalid-name save = BoolSetting("thumbnail.save", True, desc="Save thumbnails to disk") max_behind = IntSetting("thumbnail.max_behind", 50, desc="Maximum number of thumbnails to render behind the currently selected one.") max_ahead = IntSetting("thumbnail.max_ahead", 50, desc="Maximum number of thumbnails to render ahead of the currently selected one.") - + max_count = IntSetting("thumbnail.max_count", 200, desc="Maximum number of thumbnails to render in general.") class slideshow: # pylint: disable=invalid-name """Namespace for slideshow related settings.""" diff --git a/vimiv/gui/thumbnail.py b/vimiv/gui/thumbnail.py index 514c38feb..947545251 100644 --- a/vimiv/gui/thumbnail.py +++ b/vimiv/gui/thumbnail.py @@ -173,7 +173,10 @@ def _index_range(self) -> tuple[int, int]: def _prune_index(self, index) -> None: """Unload the icon associated with a particular path index.""" item = self.item(index) - if item is not None: # Otherwise it has been deleted in the meanwhile + # Otherwise it has been deleted in the meanwhile + if item is not None and\ + (api.settings.thumbnail.max_count.value == 0 or\ + len(self._rendered_paths) > api.settings.thumbnail.max_count.value): item.setIcon(ThumbnailItem.default_icon()) self._rendered_paths.discard(self._paths[index]) @@ -199,7 +202,6 @@ def _update_icon_position(self) -> None: indices = [] for p, i in zip(self._paths[first_index:last_index], range(first_index, last_index)): if p not in self._rendered_paths: - self._rendered_paths.add(p) desired_paths.append(p) indices.append(i) self._manager.create_thumbnails_async(indices, desired_paths) @@ -243,8 +245,6 @@ def _on_new_images_opened(self, paths: List[str]): first_index, last_index = self._index_range() self._manager.create_thumbnails_async(range(first_index, last_index), paths[first_index:last_index]) - for p in paths[first_index:last_index]: - self._rendered_paths.add(p) _logger.debug("... update completed") @utils.slot @@ -258,6 +258,7 @@ def _on_thumbnail_created(self, index: int, icon: QIcon): item = self.item(index) if item is not None: # Otherwise it has been deleted in the meanwhile item.setIcon(icon) + self._rendered_paths.add(self._paths[index]) @pyqtSlot(int, list, api.modes.Mode, bool) def _on_new_search( From edffdaa447892a70193c2234af17533827d9d94b Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Thu, 29 Jun 2023 14:37:05 -0400 Subject: [PATCH 04/11] Default behavior for thumbnail 'max_ahead' 'max_behind' and 'max_count' --- vimiv/api/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index d8fa61eb9..f9ef5d4f8 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -470,9 +470,9 @@ class thumbnail: # pylint: disable=invalid-name size = ThumbnailSizeSetting("thumbnail.size", 128, desc="Size of thumbnails") save = BoolSetting("thumbnail.save", True, desc="Save thumbnails to disk") - max_behind = IntSetting("thumbnail.max_behind", 50, desc="Maximum number of thumbnails to render behind the currently selected one.") - max_ahead = IntSetting("thumbnail.max_ahead", 50, desc="Maximum number of thumbnails to render ahead of the currently selected one.") - max_count = IntSetting("thumbnail.max_count", 200, desc="Maximum number of thumbnails to render in general.") + max_behind = IntSetting("thumbnail.max_behind", 0, desc="Maximum number of thumbnails to render behind the currently selected one.") + max_ahead = IntSetting("thumbnail.max_ahead", 0, desc="Maximum number of thumbnails to render ahead of the currently selected one.") + max_count = IntSetting("thumbnail.max_count", 0, desc="Maximum number of thumbnails to render in general.") class slideshow: # pylint: disable=invalid-name """Namespace for slideshow related settings.""" From fc9284c369eae76b142014b6bc2a384ca5e3aa87 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Thu, 29 Jun 2023 14:47:45 -0400 Subject: [PATCH 05/11] Spaces instead of tabs. --- vimiv/gui/thumbnail.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vimiv/gui/thumbnail.py b/vimiv/gui/thumbnail.py index 947545251..ae7cce230 100644 --- a/vimiv/gui/thumbnail.py +++ b/vimiv/gui/thumbnail.py @@ -173,10 +173,10 @@ def _index_range(self) -> tuple[int, int]: def _prune_index(self, index) -> None: """Unload the icon associated with a particular path index.""" item = self.item(index) - # Otherwise it has been deleted in the meanwhile + # Otherwise it has been deleted in the meanwhile if item is not None and\ - (api.settings.thumbnail.max_count.value == 0 or\ - len(self._rendered_paths) > api.settings.thumbnail.max_count.value): + (api.settings.thumbnail.max_count.value == 0 or\ + len(self._rendered_paths) > api.settings.thumbnail.max_count.value): item.setIcon(ThumbnailItem.default_icon()) self._rendered_paths.discard(self._paths[index]) From 99315bb87fd6aefef22b665178f2f9657917b044 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Thu, 29 Jun 2023 14:54:20 -0400 Subject: [PATCH 06/11] Correctly handle thumbnail reordering. --- vimiv/gui/thumbnail.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vimiv/gui/thumbnail.py b/vimiv/gui/thumbnail.py index ae7cce230..90c96bad6 100644 --- a/vimiv/gui/thumbnail.py +++ b/vimiv/gui/thumbnail.py @@ -226,6 +226,7 @@ def _on_new_images_opened(self, paths: List[str]): _logger.debug("No new images to load") return _logger.debug("Updating thumbnails...") + self._rendered_paths.clear() removed = set(self._paths) - set(paths) for path in removed: _logger.debug("Removing existing thumbnail '%s'", path) @@ -233,8 +234,6 @@ def _on_new_images_opened(self, paths: List[str]): del self._paths[idx] # Remove as the index also changes in the QListWidget if not self.takeItem(idx): _logger.error("Error removing thumbnail for '%s'", path) - else: - self._rendered_paths.remove(path) size_hint = QSize(self.item_size(), self.item_size()) for i, path in enumerate(paths): if path not in self._paths: # Add new path From 9c70a56b273c588c4329b581f80f1a880644f68b Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Thu, 29 Jun 2023 19:52:40 -0400 Subject: [PATCH 07/11] Prune icons ahead of current from end instead of beginning. --- vimiv/gui/thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vimiv/gui/thumbnail.py b/vimiv/gui/thumbnail.py index 90c96bad6..3f3c5fddc 100644 --- a/vimiv/gui/thumbnail.py +++ b/vimiv/gui/thumbnail.py @@ -187,7 +187,7 @@ def _prune_icons_behind(self) -> None: def _prune_icons_ahead(self) -> None: """Prune indexes after the current index.""" - for index in range(self._last_index() + 1, len(self._paths)): + for index in reversed(range(self._last_index() + 1, len(self._paths))): self._prune_index(index) def _prune_icons(self) -> None: From ff155aabe00becf8cd50f03ef10c57a1f6f8e9f3 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Thu, 29 Jun 2023 19:53:35 -0400 Subject: [PATCH 08/11] Add the 'passthrough' sorting settings. For sort 'image_order' and 'directory_order', the passthrough setting can be used to sort images in the order they were first encountered by the software, whether passed from the command line, or from directory monitoring. --- vimiv/api/settings.py | 5 ++++ vimiv/api/working_directory.py | 47 ++++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index f9ef5d4f8..fa176c405 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -325,6 +325,7 @@ class OrderSetting(Setting): "alphabetical": str, "natural": natural_sort, "recently-modified": os.path.getmtime, + "passthrough": None } STR_ORDER_TYPES = "alphabetical", "natural" @@ -347,6 +348,8 @@ def convert(self, value: str) -> str: def sort(self, values: Iterable[str]) -> List[str]: """Sort values according to the current ordering.""" + if self.value == "passthrough": + return values ordering = self._get_ordering() return sorted(values, key=ordering, reverse=sort.reverse.value) @@ -474,6 +477,7 @@ class thumbnail: # pylint: disable=invalid-name max_ahead = IntSetting("thumbnail.max_ahead", 0, desc="Maximum number of thumbnails to render ahead of the currently selected one.") max_count = IntSetting("thumbnail.max_count", 0, desc="Maximum number of thumbnails to render in general.") + class slideshow: # pylint: disable=invalid-name """Namespace for slideshow related settings.""" @@ -613,3 +617,4 @@ class sort: # pylint: disable=invalid-name False, desc="Randomly shuffle images and ignoring all other sort settings", ) + diff --git a/vimiv/api/working_directory.py b/vimiv/api/working_directory.py index 04a864cc2..01cabf5df 100644 --- a/vimiv/api/working_directory.py +++ b/vimiv/api/working_directory.py @@ -61,7 +61,7 @@ def _on_im_changed(self, new_images, added, removed): import os from typing import cast, List, Tuple -from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher +from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, pyqtSlot from vimiv.api import settings, signals, status from vimiv.utils import files, slot, log, throttled @@ -92,7 +92,8 @@ class WorkingDirectoryHandler(QFileSystemWatcher): Attributes: _dir: The current working directory. - _images: Images in the current working directory. + _original_images: Originally specified layout of images in the current working directory. + _images: images in the current working directory. _directories: Directories in the current working directory. """ @@ -105,6 +106,7 @@ class WorkingDirectoryHandler(QFileSystemWatcher): def __init__(self) -> None: super().__init__() self._dir = "" + self._original_images: List[str] = [] self._images: List[str] = [] self._directories: List[str] = [] @@ -113,6 +115,7 @@ def __init__(self) -> None: settings.sort.directory_order.changed.connect(self._reorder_directory) settings.sort.reverse.changed.connect(self._reorder_directory) settings.sort.ignore_case.changed.connect(self._reorder_directory) + signals.load_images.connect(self._load_parameter_images) self.directoryChanged.connect(self._reload_directory) self.fileChanged.connect(self._on_file_changed) @@ -161,14 +164,18 @@ def _on_monitor_fs_changed(self, value: bool) -> None: def _load_directory(self, directory: str) -> None: """Load supported files for new directory.""" self._dir = directory - self._images, self._directories = self._get_content(directory) - self.loaded.emit(self._images, self._directories) + images, directories = self._get_content(directory) + self._load_original_images(images) + self._images = list(self._original_images) + self.loaded.emit(self._original_images, self._directories) @throttled(delay_ms=WAIT_TIME_MS) def _reload_directory(self, _path: str) -> None: """Load new supported files when directory content has changed.""" _logger.debug("Reloading working directory") - self._emit_changes(*self._get_content(self._dir)) + images, directories = self._get_content(self._dir) + self._load_original_images(images) + self._emit_changes(*self._order_paths(self._original_images, directories)) @slot def _on_new_image(self, path: str) -> None: @@ -216,8 +223,8 @@ def _emit_changes(self, images: List[str], directories: List[str]) -> None: self.images_changed.emit(images, added, removed) # Total filelist has changed, relevant for the library if images != self._images or directories != self._directories: - self._images = images - self._directories = directories + self._images = list(images) + self._directories = list(directories) self.changed.emit(images, directories) def _get_content(self, directory: str) -> Tuple[List[str], List[str]]: @@ -231,11 +238,35 @@ def _get_content(self, directory: str) -> Tuple[List[str], List[str]]: paths = files.listdir(directory, show_hidden=show_hidden) return self._order_paths(*files.supported(paths)) + @pyqtSlot(list) + def _load_parameter_images(self, images: list[str]): + old_original_images = self._original_images + self._original_images = [] + self._original_images.extend(images) + for i in old_original_images: + if i not in self._original_images: + self._original_images.append(i) + new_original_images = [] + for i in self._original_images: + if i in old_original_images: + new_original_images.append(i) + self._original_images = new_original_images + + def _load_original_images(self, images: list[str]): + for i in images: + if i not in self._original_images: + self._original_images.append(i) + new_original_images = [] + for i in self._original_images: + if i in images: + new_original_images.append(i) + self._original_images = new_original_images + @slot def _reorder_directory(self) -> None: """Reorder current files / directories.""" _logger.debug("Reloading working directory") - self._emit_changes(*self._order_paths(self._images, self._directories)) + self._emit_changes(*self._order_paths(self._original_images, self._directories)) @staticmethod def _order_paths(images: List[str], dirs: List[str]) -> Tuple[List[str], List[str]]: From 19858a9fa94fa372b53cb08996a354a5ba745311 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Tue, 4 Jul 2023 01:30:10 -0400 Subject: [PATCH 09/11] Add the `image.id_by_extension` configuration option vimiv typically uses imghdr to scan input files for valid images. This potentially results in a great deal of startup disk IO for large lists of files. When `image.id_by_extension` is enabled, the extension of the file will naively be believed to represent the correct image format. --- vimiv/api/settings.py | 5 +++++ vimiv/utils/files.py | 25 ++++++++++++++++++++++++- vimiv/utils/imagereader.py | 9 ++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index fa176c405..682acd882 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -450,6 +450,11 @@ class image: # pylint: disable=invalid-name True, desc="Require holding the control modifier for zooming with the mouse wheel", ) + id_by_extension = BoolSetting( + "image.id_by_extension", + False, + desc="Instead of scanning the image to determine that it's a compatible format, assume the extension accurately represents the format.", + ) class library: # pylint: disable=invalid-name diff --git a/vimiv/utils/files.py b/vimiv/utils/files.py index 68702e529..028c4bc1f 100644 --- a/vimiv/utils/files.py +++ b/vimiv/utils/files.py @@ -13,11 +13,26 @@ from PyQt5.QtGui import QImageReader +from vimiv import api from vimiv.utils import imagereader ImghdrTestFuncT = Callable[[bytes, Optional[BinaryIO]], bool] +image_format_names = set(('.rgb', + '.gif', + '.pbm', + '.pgm', + '.ppm', + '.tiff', + '.rast', + '.xbm', + '.jpeg', + '.jpg', + '.bmp', + '.png', + '.webp', + '.exr')) def listdir(directory: str, show_hidden: bool = False) -> List[str]: """Wrapper around os.listdir. @@ -121,7 +136,14 @@ def is_image(filename: str) -> bool: filename: Name of file to check. """ try: - return os.path.isfile(filename) and imghdr.what(filename) is not None + if not os.path.isfile(filename): + return False + elif api.settings.image.id_by_extension: + return os.path.splitext(filename)[1].lower() in image_format_names + elif imghdr.what(filename) is not None: + return True + else: + return False except OSError: return False @@ -164,6 +186,7 @@ def test(h: bytes, f: Optional[BinaryIO]) -> Optional[str]: imghdr.tests.remove(test) return None + image_format_names.add(name) imghdr.tests.insert(add_image_format.index, test) # type: ignore add_image_format.index += 1 # type: ignore diff --git a/vimiv/utils/imagereader.py b/vimiv/utils/imagereader.py index ba7f9aca8..f2bdb0cd8 100644 --- a/vimiv/utils/imagereader.py +++ b/vimiv/utils/imagereader.py @@ -8,10 +8,12 @@ import abc from typing import Dict, Callable +from os.path import splitext from PyQt5.QtCore import Qt from PyQt5.QtGui import QImageReader, QPixmap, QImage +from vimiv import api from .files import imghdr external_handler: Dict[str, Callable[[str], QPixmap]] = {} @@ -109,7 +111,12 @@ def get_reader(path: str) -> BaseReader: """Retrieve the appropriate image reader class for path.""" error = ValueError(f"'{path}' cannot be read as image") try: - file_format = imghdr.what(path) + if api.settings.image.id_by_extension: + file_format = splitext(path)[1].lower()[1:] + if file_format == 'jpg': + file_format = 'jpeg' + else: + file_format = imghdr.what(path) except OSError: raise error if file_format is None: From 61e05d13ea4e798dbeefe9b10019d4cb74146874 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Tue, 4 Jul 2023 03:20:43 -0400 Subject: [PATCH 10/11] Add the `image.imghdr_fallback` option When this option is set to true, if the result of attempting to load an image using a reader determined by file extension fails, imghdr will be used in its place as a last ditch effort to find a working reader. --- vimiv/api/settings.py | 5 +++++ vimiv/utils/imagereader.py | 32 ++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index 682acd882..56e5941a3 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -455,6 +455,11 @@ class image: # pylint: disable=invalid-name False, desc="Instead of scanning the image to determine that it's a compatible format, assume the extension accurately represents the format.", ) + imghdr_fallback = BoolSetting( + "image.imghdr_fallback", + True, + desc="If identifying a file by extension (when image.id_by_extension is set) files to open a file, try identifying it using imghdr before giving up." + ) class library: # pylint: disable=invalid-name diff --git a/vimiv/utils/imagereader.py b/vimiv/utils/imagereader.py index f2bdb0cd8..e063d9198 100644 --- a/vimiv/utils/imagereader.py +++ b/vimiv/utils/imagereader.py @@ -64,8 +64,20 @@ def __init__(self, path: str, file_format: str): self._handler = QImageReader(path, file_format.encode()) self._handler.setAutoTransform(True) if not self._handler.canRead(): - # TODO - raise ValueError(f"'{path}' cannot be read as image") + if api.settings.image.id_by_extension and\ + api.settings.image.imghdr_fallback: + try: + self.file_format = imghdr.what(path) + self._handler = QImageReader(path, self.file_format.encode()) + self._handler.setAutoTransform(True) + except OSError: + raise ValueError(f"'{path}' cannot be read as image") + if not self._handler.canRead(): + # TODO + raise ValueError(f"'{path}' cannot be read as image") + else: + # TODO + raise ValueError(f"'{path}' cannot be read as image") @classmethod def supports(cls, file_format: str) -> bool: @@ -100,8 +112,20 @@ def supports(cls, file_format: str) -> bool: return file_format in external_handler def get_pixmap(self) -> QPixmap: - handler = external_handler[self.file_format] - return handler(self.path) + try: + return external_handler[self.file_format](self.path) + except ValueError: + if api.settings.image.id_by_extension and\ + api.settings.image.imghdr_fallback: + try: + self.file_format = imghdr.what(self.path) + except OSError: + raise ValueError(f"'{self.path}' cannot be read as image") + try: + return external_handler[self.file_format](self.path) + except ValueError: + qtreader = QtReader(self.path, self.file_format) + return qtreader.get_pixmap() READERS = [QtReader, ExternalReader] From afde8cf8f25b8f1a56088663642a9a1eeb685d18 Mon Sep 17 00:00:00 2001 From: buzzingwires Date: Tue, 4 Jul 2023 03:54:24 -0400 Subject: [PATCH 11/11] Make sure image_format_names has the leading dot in its entries. --- vimiv/utils/files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vimiv/utils/files.py b/vimiv/utils/files.py index 028c4bc1f..c93cab219 100644 --- a/vimiv/utils/files.py +++ b/vimiv/utils/files.py @@ -186,7 +186,8 @@ def test(h: bytes, f: Optional[BinaryIO]) -> Optional[str]: imghdr.tests.remove(test) return None - image_format_names.add(name) + global image_format_names + image_format_names.add(''.join(('.', name))) imghdr.tests.insert(add_image_format.index, test) # type: ignore add_image_format.index += 1 # type: ignore @@ -200,7 +201,6 @@ def test_svg(h: bytes, _f: Optional[BinaryIO]) -> bool: add_image_format("svg", test_svg) - def test_ico(h: bytes, _f: Optional[BinaryIO]) -> bool: return h.startswith(bytes.fromhex("00000100"))