# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 """Image part of vimiv.""" from random import shuffle from threading import Thread from gi.repository import GdkPixbuf, GLib, Gtk from vimiv.exceptions import StringConversionError from vimiv.fileactions import is_animation, is_svg from vimiv.helpers import get_float from vimiv.settings import settings class Image(Gtk.Image): """Image class for vimiv. Inherits from Gtk.Image and includes all actions that apply to it. Attributes: fit_image: user: Image is user zoomed. overzoom: Image is zoomed to fit respecting overzoom. horizontal: Image is zoomed to fit horizontally. vertical: Image is zoomed to fit vertically. fit: Image is zoomed to fit. zoom_percent: Percentage to zoom to compared to the original size. _app: The main vimiv class to interact with. _identifier: Used so GUI callbacks are only done if the image is equal _pixbuf_iter: Iter of displayed animation. _pixbuf_original: Original image. _size: Size of the displayed image as a tuple. _timer_id: Id of current animation timer. _faulty_image: Necessary evil for images that PixbufLoader cannot read. """ def __init__(self, app): """Set default values for attributes.""" super(Image, self).__init__() self._app = app # Settings and defaults self.fit_image = "overzoom" self._pixbuf_iter = GdkPixbuf.PixbufAnimationIter() self._pixbuf_original = GdkPixbuf.Pixbuf() self.zoom_percent = 1 self._identifier = 0 self._size = (1, 1) self._timer_id = 0 self._faulty_image = False # Connect signals self._app["transform"].connect("changed", self._on_image_changed) self._app["commandline"].search.connect("search-completed", self._on_search_completed) settings.connect("changed", self._on_settings_changed) def _update(self): """Show the final image.""" if not self._app.get_paths() or self._faulty_image: return # Scale image pbo_width = self._pixbuf_original.get_width() pbo_height = self._pixbuf_original.get_height() pbf_width = int(pbo_width * self.zoom_percent) pbf_height = int(pbo_height * self.zoom_percent) # Rescaling of svg if is_svg(self._app.get_path()) and settings["rescale_svg"].get_value(): pixbuf_final = GdkPixbuf.Pixbuf.new_from_file_at_scale( self._app.get_path(), -1, pbf_height, True) else: pixbuf_final = self._pixbuf_original.scale_simple( pbf_width, pbf_height, GdkPixbuf.InterpType.BILINEAR) self.set_from_pixbuf(pixbuf_final) # Update the statusbar self._app["statusbar"].update_info() def zoom_delta(self, zoom_in=True, step=1): """Zoom the image by delta percent. Args: zoom_in: If True zoom in, else zoom out. """ delta = 0.25 # Allow user steps step = self._app["eventhandler"].num_receive(step, True) if isinstance(step, str): try: step = get_float(step, allow_sign=True) except StringConversionError as e: self._app["statusbar"].message(str(e), "error") return fallback_zoom = self.zoom_percent if zoom_in: self.zoom_percent = self.zoom_percent * (1 + delta * step) else: self.zoom_percent = self.zoom_percent / (1 + delta * step) self._catch_unreasonable_zoom_and_update(fallback_zoom) self.fit_image = "user" def zoom_to(self, percent=0, fit="fit"): """Zoom to a given percentage. Args: percent: Percentage to zoom to. fit: How to fit image if percent is not given. """ fallback_zoom = self.zoom_percent # Catch user zooms percent = self._app["eventhandler"].num_receive(percent, True) # Given from commandline if isinstance(percent, str): try: percent = get_float(percent) except StringConversionError as e: self._app["statusbar"].message(str(e), "error") return self._size = self._get_available_size() # 0 means zoom to fit if percent: self.zoom_percent = percent self.fit_image = "user" else: self.zoom_percent = self.get_zoom_percent_to_fit(fit) self.fit_image = fit # Catch some unreasonable zooms self._catch_unreasonable_zoom_and_update(fallback_zoom) def move_index(self, forward=True, key=None, delta=1, force=False): """Move by delta in paths. Args: forward: If True move forwards. Else move backwards. key: If available, move by key. delta: Positions to move by. force: If True, move regardless of editing image. """ # Check if an image is opened or if it has been edited if not self._app.get_paths() or self._app["thumbnail"].toggled or \ self._app["manipulate"].check_for_edit(force): return # Apply done rotations and flips to file self._app["transform"].apply() # Check for prepended numbers and direction if key: delta *= self._app["eventhandler"].num_receive() if not forward: delta *= -1 self._app.update_index(delta) self.fit_image = "overzoom" # Reshuffle on wrap-around if settings["shuffle"].get_value() \ and self._app.get_index() is 0 and delta > 0: shuffle(self._app.get_paths()) # Load the image at path into self._pixbuf_* and show it self.load() def load(self): """Load an image using GdkPixbufLoader.""" path = self._app.get_path() # Remove old timers and reset scale if self._timer_id: self.zoom_percent = 1 self._pause_gif() # Load file try: self._load(path) except (PermissionError, FileNotFoundError): self._app.remove_path(path) self.move_pos(False) self._app["statusbar"].message("File not accessible", "error") def move_pos(self, forward=True, force=False): """Move to specific position in paths. Args: forward: If True move forwards. Else move backwards. force: If True, move regardless of editing image. """ # Check if image has been edited if self._app["manipulate"].check_for_edit(force): return max_pos = len(self._app.get_paths()) current = self._app.get_index() # Move to definition by keys or end/beg if forward: pos = self._app["eventhandler"].num_receive(max_pos) else: pos = self._app["eventhandler"].num_receive() # Catch range if pos < 0 or pos > max_pos: self._app["statusbar"].message("Unsupported index", "warning") return # Do the maths and move if self._app["thumbnail"].toggled: self._app["thumbnail"].move_to_pos(pos - 1) else: self.move_index(True, False, pos - current - 1) def get_scroll_scale(self): return self.zoom_percent / self.get_zoom_percent_to_fit() * 2 def get_zoom_percent(self): return self.zoom_percent * 100 def get_zoom_percent_to_fit(self, fit="fit"): """Get the zoom factor perfectly fitting the image to the window. Args: fit: How to fit image. Return: Zoom percentage. """ # Size of the file pbo_width = self._pixbuf_original.get_width() pbo_height = self._pixbuf_original.get_height() # Maximum size respecting overzoom max_width = pbo_width * settings["overzoom"].get_value() max_height = pbo_height * settings["overzoom"].get_value() # Get scales for "panorama" vs "portrait" image w_scale = pbo_width / self._size[0] h_scale = pbo_height / self._size[1] scale_width = True if w_scale > h_scale else False # Check if image fits completely with overzoom fits = max_width < self._size[0] and max_height < self._size[1] # Image fits completely even with overzoom and we do not want to fit if fits and fit in ["user", "overzoom"]: return settings["overzoom"].get_value() # Force horizontal fit or "panorama" image elif fit == "horizontal" or (scale_width and fit != "vertical"): return self._size[0] / pbo_width # Force vertical fit or "portrait" image return self._size[1] / pbo_height def _catch_unreasonable_zoom_and_update(self, fallback_zoom): """Catch unreasonable zooms otherwise update. Args: fallback_zoom: Zoom percentage to fall back to if the zoom percentage is unreasonable. """ orig_width = self._pixbuf_original.get_width() orig_height = self._pixbuf_original.get_height() new_width = orig_width * self.zoom_percent new_height = orig_height * self.zoom_percent min_width = max(16, orig_width * 0.05) min_height = max(16, orig_height * 0.05) max_width = min(self._app["window"].get_size()[0] * 10, self._pixbuf_original.get_width() * 20) max_height = min(self._app["window"].get_size()[1] * 10, self._pixbuf_original.get_height() * 20) # Image too small or too large if new_height < min_height or new_width < min_width \ or new_height > max_height or new_width > max_width: # Warn user if it was his fault if self.fit_image == "user": message = "Image cannot be zoomed this far" self._app["statusbar"].message(message, "warning") self.zoom_percent = fallback_zoom return self._update() def _play_gif(self): """Run the animation of a gif.""" self._pixbuf_original = self._pixbuf_iter.get_pixbuf() GLib.idle_add(self._update) if self._pixbuf_iter.advance(): # Clear old timer if self._timer_id: GLib.source_remove(self._timer_id) # Add new timer if the gif is not static delay = self._pixbuf_iter.get_delay_time() self._timer_id = GLib.timeout_add(delay, self._play_gif) \ if delay >= 0 else 0 def _pause_gif(self): """Pause a gif or show initial image.""" if self._timer_id: GLib.source_remove(self._timer_id) self._timer_id = 0 else: image = self._pixbuf_iter.get_pixbuf() self.set_from_pixbuf(image) def _get_available_size(self): """Receive size not occupied by other Widgets. Subtracts other widgets (manipulate, statusbar, library) from window size and returns the available size. Return: Tuple of available size. """ size = self._app["window"].get_size() if self._app["library"].grid.is_visible(): library_width = self._app["library"].get_size_request()[0] size = (size[0] - library_width, size[1]) if settings["display_bar"].get_value(): size = (size[0], size[1] - self._app["statusbar"].get_bar_height()) return size def _load(self, path): """Actual implementation to load an image from path.""" loader = GdkPixbuf.PixbufLoader() self._identifier += 1 if is_animation(path): loader.connect("area-prepared", self._set_image_anim) else: loader.connect("area-prepared", self._set_image_pixbuf) loader.connect("closed", self._finish_image_pixbuf, self._identifier) load_thread = Thread(target=self._load_thread, args=(loader, path), daemon=True) # Daemon is set to True so the program can exit with "q" immediately if # only a loading thread is left load_thread.start() def _load_thread(self, loader, path): # The try ... except wrapper and the _faulty_image attribute are used to # catch weird images that break GdkPixbufLoader but work otherwise # See https://github.com/karlch/vimiv/issues/49 for more information try: self._faulty_image = True with open(path, "rb") as f: image_bytes = f.read() loader.write(image_bytes) self._faulty_image = False loader.close() except GLib.GError: self._pixbuf_original = GdkPixbuf.Pixbuf.new_from_file(path) self._faulty_image = False self._set_image_pixbuf() GLib.idle_add(self._update) def _set_image_pixbuf(self, loader=None): if loader: self._pixbuf_original = loader.get_pixbuf() self._size = self._get_available_size() self.zoom_percent = self.get_zoom_percent_to_fit(self.fit_image) def _finish_image_pixbuf(self, loader, image_id): if self._identifier == image_id: GLib.idle_add(self._update) def _set_image_anim(self, loader): self._pixbuf_iter = loader.get_animation().get_iter() self._pixbuf_original = self._pixbuf_iter.get_pixbuf() self._size = self._get_available_size() self.zoom_percent = self.get_zoom_percent_to_fit(self.fit_image) if settings["play_animations"].get_value(): delay = self._pixbuf_iter.get_delay_time() self._timer_id = GLib.timeout_add(delay, self._play_gif) \ if delay >= 0 else 0 def get_pixbuf_original(self): return self._pixbuf_original.copy() def set_pixbuf(self, pixbuf): self._pixbuf_original = pixbuf self._update() def _on_image_changed(self, transform, change, arg): """Update image after a transformation. Args: transform: The transform object that emitted the signal. change: The type of transformation. arg: Argument for the transformation, e.g. cwise for rotate. """ if change == "rotate": self._pixbuf_original = \ self._pixbuf_original.rotate_simple(90 * arg) elif change == "flip": self._pixbuf_original = self._pixbuf_original.flip(arg) if self.fit_image != "user": self.zoom_to(0, self.fit_image) else: self._update() def _on_search_completed(self, search, new_pos, last_focused): if last_focused == "im": self._app["eventhandler"].set_num_str(new_pos + 1) self.move_pos() def _on_settings_changed(self, new_settings, setting): # Rescale image after setting overzoom if setting == "overzoom" and self.fit_image != "user": self.zoom_percent = self.get_zoom_percent_to_fit(self.fit_image) self._update() # Change play status of gifs elif setting == "play_animations": if self._app.get_paths() and is_animation(self._app.get_path()) \ and not self._app["thumbnail"].toggled: if settings["play_animations"].get_value(): self._play_gif() else: self._pause_gif()