From f3c25f36c08d7a3eef5b559666b6df5761748e9c Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sun, 21 Apr 2024 18:39:19 +0100 Subject: [PATCH 01/37] fix,refac: render: Improve image rendering - Fix: Also handle possible errors during the creation of image instances and setting image size. - Change: Split rendering of grid and non-grid images into separate functions, for simplification. - Add `render_grid_images()` for grid images. - Refactor `render_images()` for non-grid images only. - Change: Rename input `image` -> `source`. - Change: For grid image rendering, output `source` instead of `image._source`. --- src/termvisage/tui/render.py | 84 ++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 1b5be4a..57f3716 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -159,7 +159,6 @@ def not_skip(): ImageClass, image_style_specs.get(ImageClass.style, ""), ), - kwargs=dict(out_extras=False, log_faults=True), name="ImageRenderer", redirect_notifs=True, ) @@ -237,14 +236,13 @@ def manage_grid_renders(n_renderers: int): grid_render_out = (mp_Queue if multi else Queue)() renderers = [ (Process if multi else logging.Thread)( - target=render_images, + target=render_grid_images, args=( grid_render_in, grid_render_out, ImageClass, grid_style_specs.get(ImageClass.style, ""), ), - kwargs=dict(out_extras=True, log_faults=False), name="GridRenderer" + f"-{n}" * multi, redirect_notifs=True, ) @@ -421,34 +419,22 @@ def render_frames( clear_queue(output) -def render_images( - input: Union[Queue, mp_Queue], - output: Union[Queue, mp_Queue], +def render_grid_images( + input: Queue | mp_Queue, + output: Queue | mp_Queue, ImageClass: type, style_spec: str, - *, - out_extras: bool, - log_faults: bool, ): - """Renders images. + """Renders images for the grid. - Args: - out_extras: If True, details other than the render output and it's size are - also passed out. Intended to be executed in a subprocess or thread. """ while True: - if log_faults: - image, size, alpha, faulty = input.get() - else: - image, size, alpha = input.get() + source, size, alpha = input.get() - if not image: # Quitting + if not source: # Quitting break - image = ImageClass.from_file(image) - image.set_size(Size.AUTO, maxsize=size) - # Using `BaseImage` for padding will use more memory since all the # spaces will be in the render output string, and theoretically more time # with all the checks and string splitting & joining. @@ -456,30 +442,54 @@ def render_images( # string (as a list though) then generates and yields the complete lines # **as needed**. Trimmed padding lines are never generated at all. try: + image = ImageClass.from_file(source) + image.set_size(Size.AUTO, maxsize=size) output.put( ( - image._source, + source, f"{image:1.1{alpha}{style_spec}}", size, image.rendered_size, ) - if out_extras - else (f"{image:1.1{alpha}{style_spec}}", image.rendered_size) ) + except Exception: + output.put((source, None, size, None)) + + clear_queue(output) + + +def render_images( + input: Queue | mp_Queue, + output: Queue | mp_Queue, + ImageClass: type, + style_spec: str, +): + """Renders images. + + Intended to be executed in a subprocess or thread. + """ + while True: + source, size, alpha, faulty = input.get() + + if not source: # Quitting + break + + # Using `BaseImage` for padding will use more memory since all the + # spaces will be in the render output string, and theoretically more time + # with all the checks and string splitting & joining. + # While `ImageCanvas` is better since it only stores the main image render + # string (as a list though) then generates and yields the complete lines + # **as needed**. Trimmed padding lines are never generated at all. + try: + image = ImageClass.from_file(source) + image.set_size(Size.AUTO, maxsize=size) + output.put((f"{image:1.1{alpha}{style_spec}}", image.rendered_size)) except Exception as e: - output.put( - (image._source, None, size, image.rendered_size) - if out_extras - else (None, image.rendered_size) - ) - # *faulty* ensures a fault is logged only once per `Image` instance - if log_faults: - if not faulty: - logging.log_exception( - f"Failed to load or render {image._source!r}", - logger, - ) - notify.notify(str(e), level=notify.ERROR) + output.put((None, None)) + # `faulty` ensures a fault is logged only once per `Image` instance + if not faulty: + logging.log_exception(f"Failed to load or render {source!r}", logger) + notify.notify(str(e), level=notify.ERROR) clear_queue(output) From cf48dc1d1b6c90c0daae41174cd642a2e8da6cf2 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sun, 21 Apr 2024 19:06:16 +0100 Subject: [PATCH 02/37] refac: render: Refactor `manage_grid_renders()` - Change: Purge the input queue to the renderer(s) first. - Change: Rename variables: - `image_path` -> `source` - `image` -> `render` - `dir` -> `source_dirname` - `entry` -> `source_basename` - Change: Update comments. --- src/termvisage/tui/render.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 57f3716..0eef80c 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -270,19 +270,26 @@ def manage_grid_renders(n_renderers: int): if new_grid or grid_change.is_set(): # New grid grid_cache.clear() grid_change.clear() # Signal "cache cleared" - if not new_grid: # The starting `None` hasn't been gotten - while grid_render_queue.get(): - pass + + # Purge the in and out queues and update the loading indicator counter for q in (grid_render_in, grid_render_out): while True: try: q.get(timeout=0.005) - notify.stop_loading() except Empty: break + else: + notify.stop_loading() + + if not new_grid: # The grid delimeter hasn't been gotten + # Purge all items until the grid delimeter + while grid_render_queue.get(): + pass + else: + new_grid = False + cell_width = image_grid.cell_width grid_path = main.grid_path - new_grid = False if grid_change.is_set(): continue @@ -293,7 +300,7 @@ def manage_grid_renders(n_renderers: int): except Empty: pass else: - if not image_info: # Start of a new grid + if not image_info: # Grid delimeter new_grid = True continue grid_render_in.put(image_info) @@ -303,23 +310,21 @@ def manage_grid_renders(n_renderers: int): continue try: - image_path, image, size, rendered_size = grid_render_out.get( - timeout=0.02 - ) + source, render, size, rendered_size = grid_render_out.get(timeout=0.02) except Empty: pass else: - dir, entry = split(image_path) + source_dirname, source_basename = split(source) # The directory and cell-width checks are to filter out any remnants # that were still being rendered at the other end if ( not grid_change.is_set() - and dir == grid_path + and source_dirname == grid_path and size[0] + 2 == cell_width ): - grid_cache[entry] = ( - ImageCanvas(image.encode().split(b"\n"), size, rendered_size) - if image + grid_cache[source_basename] = ( + ImageCanvas(render.encode().split(b"\n"), size, rendered_size) + if render else faulty_image.render(size) ) if grid_active.is_set(): From 88aff6868cec34bc64543b25bd82b58c0bb217ff Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sun, 21 Apr 2024 23:16:23 +0100 Subject: [PATCH 03/37] feat,docs: config: Add thumbnailing options - Add: `thumbnail cache` and `thumbnail size` config options. --- docs/source/config.rst | 22 ++++++++++++++++++++++ src/termvisage/config.py | 10 ++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/source/config.rst b/docs/source/config.rst index 5284f8b..17f374c 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -191,6 +191,28 @@ These are top-level fields whose values control various settings of the viewer. * Overridden by :option:`--swap-win-size` and :option:`--no-swap-win-size`. * Affects *auto* :term:`cell ratio` computation. +.. confval:: thumbnail cache + :synopsis: The maximum amount of thumbnails that can be cached per time. + :type: integer + :valid: *x* >= ``0`` + :default: ``0`` + + If ``0``, the cache size is infinite i.e no eviction. Otherwise, older thumbnails + will be evicted to accommodate newer ones when the cache is full (i.e the specified + size limit is reached). + + .. note:: Overridden by :option:`--thumbnail-cache`. + +.. confval:: thumbnail size + :synopsis: Maxiumum thumbnail dimension. + :type: integer + :valid: ``32`` <= *x* <= ``256`` + :default: ``128`` + + Thumbnails generated will have a maximum of *x* pixels in the long dimension. + + .. note:: Overridden by :option:`--thumbnail-size`. + Keybindings ----------- diff --git a/src/termvisage/config.py b/src/termvisage/config.py index cb40f76..3c202b5 100644 --- a/src/termvisage/config.py +++ b/src/termvisage/config.py @@ -616,6 +616,16 @@ def update_context_nav( lambda x: isinstance(x, bool), "must be a boolean", ), + "thumbnail cache": Option( + 0, + lambda x: isinstance(x, int) and x >= 0, + "must be a non-negative integer", + ), + "thumbnail size": Option( + 128, + lambda x: isinstance(x, int) and 32 <= x <= 256, + "must be an integer between 32 and 256 (both inclusive)", + ), } config_options = ConfigOptions(config_options) From feea5302db9f1f7e054912dd03aaf046a622428d Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sun, 21 Apr 2024 23:20:49 +0100 Subject: [PATCH 04/37] feat: cli: Add thumbnailing options - Add: `--thumbnail-cache` and `--thumbnail-size` CLI options. --- src/termvisage/parsers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/termvisage/parsers.py b/src/termvisage/parsers.py index 02b74ec..a640c9e 100644 --- a/src/termvisage/parsers.py +++ b/src/termvisage/parsers.py @@ -593,6 +593,24 @@ def strip_markup(string: str) -> str: default=sys.getrecursionlimit() - 50, help=f"Maximum recursion depth (default: {sys.getrecursionlimit() - 50})", ) +tui_options.add_argument( + "--thumbnail-cache", + type=int, + metavar="N", + help=( + "Maximum amount of thumbnails that can be cached per time (``0`` -> infinite) " + "(default: :confval:`thumbnail cache` config)" + ), +) +tui_options.add_argument( + "--thumbnail-size", + type=int, + metavar="N", + help=( + "Maxiumum thumbnail dimension; thumbnails generated will have a maximum of " + "*N* pixels in the long dimension (default: :confval:`thumbnail size` config)" + ), +) # Performance perf_options = parser.add_argument_group("Performance Options") From c9d6b30808ef7ed87fc2655e11d9ad7f25b6fcbf Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 23 Apr 2024 07:32:22 +0100 Subject: [PATCH 05/37] chore: widget: Update comments in `Image.render()` --- src/termvisage/tui/widgets.py | 50 +++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 72307f9..2251725 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -270,33 +270,45 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: image = self._ti_image image.set_size(Size.AUTO, maxsize=size) - # Forced render + # Forced render / Large images + # does the image have more pixels than the maximum? + # AND has the image NOT been force-rendered, with a valid-sized canvas? if mul(*image.original_size) > tui_main.MAX_PIXELS and not ( + # has the image been force-rendered? self._ti_canv + # is the force-rendered canvas valid for the current widget render size? and ( - # will be resized later @ Rendering. + # the canvas itself will be resized later at the Rendering stage + # below self._ti_canv._ti_image_size == image.size - # can either be SolidCanvas (faulty) or ImageCanvas + # can either be `SolidCanvas` (faulty) or `ImageCanvas` if isinstance(self._ti_canv, ImageCanvas) - # but faulty shouldn't be resized to allow re-rendering after resize + # a *faulty* canvas shouldn't be resized, to allow re-rendering the + # image after a change in widget size else self._ti_canv.size == size ) + # is the image currently being rendered? or self._ti_rendering ): + # has the image been requested to be force-rendered? if self._ti_force_render: # AnimRendermanager or `.tui.main.animate_image()` deletes - # `_force_render` when the animation is done to avoid attribute + # `_ti_force_render` when the animation is done to avoid attribute # creation and deletion per frame - if image.is_animated and not tui_main.NO_ANIMATION: + if image.is_animated and not tui_main.NO_ANIMATION: # an animation? + # has the animation NOT started? if not (self._ti_frame or self._ti_anim_finished): self._ti_forced_anim_size_hash = hash(image.size) + # has the image render size changed? elif hash(image.size) != self._ti_forced_anim_size_hash: self._ti_force_render = False if context in self._ti_force_render_contexts: keys.enable_actions(context, "Force Render") return __class__._ti_large_image.render(size, focus) - else: + else: # a non-animation? + # acknowledge the force-render request and prevent it from being + # re-satisfied. del self._ti_force_render else: if context in self._ti_force_render_contexts: @@ -306,28 +318,31 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: if context in self._ti_force_render_contexts: keys.disable_actions(context, "Force Render") - # Grid cells + # Grid images if ( + # is the image grid in view? (next two lines) view.original_widget is image_grid_box and context != "full-grid-image" - # Grid render cell width adjusts when _maxcols_ < _cell_width_ + # Grid render cell width adjusts when `maxcols` < `cell_width` # `+2` cos `LineSquare` subtracts the columns for surrounding lines and size[0] + 2 == image_grid.cell_width ): canv = __class__._ti_grid_cache.get(basename(image._source)) - if not canv: + if not canv: # is the image not the grid cache? grid_render_queue.put((image._source, size, self._ti_alpha)) __class__._ti_grid_cache[basename(image._source)] = ... canv = __class__._ti_placeholder.render(size, focus) - elif canv is ...: + elif canv is ...: # is the image currently being rendered? canv = __class__._ti_placeholder.render(size, focus) return canv # Rendering + # For when the grid render cell width adjusts i.e when `maxcols` < `cell_width` + # + # is the image grid in view? if view.original_widget is image_grid_box and context != "full-grid-image": - # When the grid render cell width adjusts; when _maxcols_ < _cell_width_ try: canv = ImageCanvas( f"{image:1.1{self._ti_alpha}{self._ti_grid_style_spec}}" @@ -337,9 +352,11 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: ) except Exception: canv = __class__._ti_faulty_image.render(size, focus) + # is the image currently being animated (i.e an **ongoing** animation)? elif self._ti_frame: canv, repeat, frame_no = self._ti_frame - if canv._ti_image_size != image.size: # The canvas is always an ImageCanvas + # has the image render size changed? (the canvas is always an `ImageCanvas`) + if canv._ti_image_size != image.size: canv = ( placeholder if ( @@ -354,16 +371,19 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: getattr(tui_main.ImageClass, "clear", lambda: True)() else: canv.size = size + # has the image been rendered, with a valid-sized canvas? elif self._ti_canv and ( self._ti_canv._ti_image_size == image.size # Can either be SolidCanvas (faulty) or ImageCanvas if isinstance(self._ti_canv, ImageCanvas) - # but faulty shouldn't be resized to allow re-rendering after resize + # a *faulty* canvas shouldn't be resized, to allow re-rendering the + # image after a change in widget size else self._ti_canv.size == size ): self._ti_canv.size = size canv = self._ti_canv else: + # is it an unfinished (yet to start or ongoing) animation? if ( image.is_animated and not tui_main.NO_ANIMATION @@ -372,9 +392,11 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: if not self._ti_anim_ongoing: anim_render_queue.put((self, size, self._ti_force_render)) self._ti_anim_ongoing = True + # is it a non-animation NOT yet being rendered? elif not self._ti_rendering: self._ti_rendering = True image_render_queue.put((self, size, self._ti_alpha)) + canv = ( placeholder if ( From e164f1f4577e5625305a8ee7e4d1b703cb02c4fd Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 23 Apr 2024 14:04:56 +0100 Subject: [PATCH 06/37] refac: cli: Remove thumbnailing options - Remove: `--thumbnail-cache` and `--thumbnail-size` CLI options. Theese settings aren't the kind a user would typically want to change per session. --- docs/source/config.rst | 4 ---- src/termvisage/parsers.py | 18 ------------------ 2 files changed, 22 deletions(-) diff --git a/docs/source/config.rst b/docs/source/config.rst index 17f374c..659c08a 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -201,8 +201,6 @@ These are top-level fields whose values control various settings of the viewer. will be evicted to accommodate newer ones when the cache is full (i.e the specified size limit is reached). - .. note:: Overridden by :option:`--thumbnail-cache`. - .. confval:: thumbnail size :synopsis: Maxiumum thumbnail dimension. :type: integer @@ -211,8 +209,6 @@ These are top-level fields whose values control various settings of the viewer. Thumbnails generated will have a maximum of *x* pixels in the long dimension. - .. note:: Overridden by :option:`--thumbnail-size`. - Keybindings ----------- diff --git a/src/termvisage/parsers.py b/src/termvisage/parsers.py index a640c9e..02b74ec 100644 --- a/src/termvisage/parsers.py +++ b/src/termvisage/parsers.py @@ -593,24 +593,6 @@ def strip_markup(string: str) -> str: default=sys.getrecursionlimit() - 50, help=f"Maximum recursion depth (default: {sys.getrecursionlimit() - 50})", ) -tui_options.add_argument( - "--thumbnail-cache", - type=int, - metavar="N", - help=( - "Maximum amount of thumbnails that can be cached per time (``0`` -> infinite) " - "(default: :confval:`thumbnail cache` config)" - ), -) -tui_options.add_argument( - "--thumbnail-size", - type=int, - metavar="N", - help=( - "Maxiumum thumbnail dimension; thumbnails generated will have a maximum of " - "*N* pixels in the long dimension (default: :confval:`thumbnail size` config)" - ), -) # Performance perf_options = parser.add_argument_group("Performance Options") From 60b9a8fadf66da0f79f561e4070e699ad599c038 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 23 Apr 2024 14:18:18 +0100 Subject: [PATCH 07/37] refac: config: Update `thumbnail size` option - Change: Increase the upper bound from 256 to 512. - Change: Increase the default value from 128 to 256. --- docs/source/config.rst | 4 ++-- src/termvisage/config.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/config.rst b/docs/source/config.rst index 659c08a..d5067e8 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -204,8 +204,8 @@ These are top-level fields whose values control various settings of the viewer. .. confval:: thumbnail size :synopsis: Maxiumum thumbnail dimension. :type: integer - :valid: ``32`` <= *x* <= ``256`` - :default: ``128`` + :valid: ``32`` <= *x* <= ``512`` + :default: ``256`` Thumbnails generated will have a maximum of *x* pixels in the long dimension. diff --git a/src/termvisage/config.py b/src/termvisage/config.py index 3c202b5..196a852 100644 --- a/src/termvisage/config.py +++ b/src/termvisage/config.py @@ -622,9 +622,9 @@ def update_context_nav( "must be a non-negative integer", ), "thumbnail size": Option( - 128, - lambda x: isinstance(x, int) and 32 <= x <= 256, - "must be an integer between 32 and 256 (both inclusive)", + 256, + lambda x: isinstance(x, int) and 32 <= x <= 512, + "must be an integer between 32 and 512 (both inclusive)", ), } config_options = ConfigOptions(config_options) From e6669f5c649d461e41a65e4e171270598b669699 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Thu, 25 Apr 2024 08:12:18 +0100 Subject: [PATCH 08/37] refac: render: Improve grid render synchronization - Change: Replace `.tui.main.grid_change` with `.tui.render.grid_renderer_in_sync`. - Change: Reverse grid sync logic to allow blocking via `Event.wait()` instead of using tight loops. - Change: Rename `new_grid` -> `delimeted` in `manage_grid_renders()`. - Change: Update comments. --- src/termvisage/tui/keys.py | 29 +++++++++++++-------------- src/termvisage/tui/main.py | 22 ++++++++++----------- src/termvisage/tui/render.py | 38 +++++++++++++++++++++++++----------- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index e8512cb..6998422 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -16,6 +16,7 @@ from .. import __version__, logging from ..config import context_keys, expand_key from . import main +from .render import grid_render_queue, grid_renderer_in_sync from .widgets import ( ImageCanvas, bottom_bar, @@ -389,11 +390,9 @@ def resize(): cell_ratio = get_cell_ratio() if cell_ratio != _prev_cell_ratio: _prev_cell_ratio = cell_ratio - main.grid_render_queue.put(None) # Mark the start of a new grid - main.grid_change.set() - # Wait till GridRenderManager clears the cache - while main.grid_change.is_set(): - pass + grid_render_queue.put(None) # Send the grid delimeter + grid_renderer_in_sync.clear() + grid_renderer_in_sync.wait() adjust_bottom_bar() getattr(main.ImageClass, "clear", lambda: True)() or ImageCanvas.change() @@ -480,11 +479,11 @@ def maximize(): def cell_width_dec(): if image_grid.cell_width > 30: image_grid.cell_width -= 2 - main.grid_render_queue.put(None) # Mark the start of a new grid - main.grid_change.set() - # Wait till GridRenderManager clears the cache - while main.grid_change.is_set(): - pass + + grid_render_queue.put(None) # Send the grid delimeter + grid_renderer_in_sync.clear() + grid_renderer_in_sync.wait() + getattr(main.ImageClass, "clear", lambda: True)() @@ -492,11 +491,11 @@ def cell_width_dec(): def cell_width_inc(): if image_grid.cell_width < 50: image_grid.cell_width += 2 - main.grid_render_queue.put(None) # Mark the start of a new grid - main.grid_change.set() - # Wait till GridRenderManager clears the cache - while main.grid_change.is_set(): - pass + + grid_render_queue.put(None) # Send the grid delimeter + grid_renderer_in_sync.clear() + grid_renderer_in_sync.wait() + getattr(main.ImageClass, "clear", lambda: True)() diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index b1d6794..50af809 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -30,7 +30,7 @@ set_menu_actions, set_menu_count, ) -from .render import grid_render_queue +from .render import grid_render_queue, grid_renderer_in_sync from .widgets import ( Image, ImageCanvas, @@ -156,7 +156,7 @@ def display_images( continue else: set_context("global") - grid_active.clear() # Grid not in view + grid_active.clear() image_box._w.contents[1][0].contents[1] = ( placeholder, ("weight", 1, False), @@ -186,7 +186,7 @@ def display_images( # used as the next `menu_list` as is and to prevent `FileNotFoundError`s if not grid_scan_done.is_set(): grid_acknowledge.clear() - grid_active.clear() # Grid not in view + grid_active.clear() grid_acknowledge.wait() # To restore the menu on the way back @@ -237,7 +237,7 @@ def display_images( # `FileNotFoundError`s if grid_active.is_set() and not grid_scan_done.is_set(): grid_acknowledge.clear() - grid_active.clear() # Grid not in view + grid_active.clear() grid_acknowledge.wait() break @@ -260,7 +260,7 @@ def display_images( else: entry, value = items[pos] if isinstance(value, Image): - grid_active.clear() # Grid not in view + grid_active.clear() image_box._w.contents[1][0].contents[1] = (value, ("weight", 1, False)) image_box.set_title(entry) view.original_widget = image_box @@ -269,7 +269,7 @@ def display_images( animate_image(value) else: # Directory grid_acknowledge.clear() - grid_active.set() # Grid is in view + grid_active.set() next_grid.put((entry, contents[entry])) # No need to wait for acknowledgement since this is a new list instance @@ -280,11 +280,10 @@ def display_images( grid_path = abspath(entry) if contents[entry].get("/") and grid_path != last_non_empty_grid_path: - grid_render_queue.put(None) # Mark the start of a new grid - grid_change.set() - # Wait till GridRenderManager clears the cache - while grid_change.is_set(): - pass + grid_render_queue.put(None) # Send the grid delimeter + grid_renderer_in_sync.clear() + grid_renderer_in_sync.wait() + last_non_empty_grid_path = grid_path image_box.original_widget = placeholder # halt image and anim rendering image_grid_box.set_title(grid_path + "/") @@ -683,7 +682,6 @@ def update_screen(): # For grid scanning/display grid_acknowledge = Event() grid_active = Event() -grid_change = Event() grid_scan_done = Event() next_grid = Queue(1) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 0eef80c..5366370 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -228,7 +228,7 @@ def manage_grid_renders(n_renderers: int): Otherwise, it starts a single new thread to render the cells. """ from . import main - from .main import ImageClass, grid_active, grid_change, quitting, update_screen + from .main import ImageClass, grid_active, quitting, update_screen from .widgets import Image, ImageCanvas, image_grid multi = logging.MULTI and n_renderers > 0 @@ -253,8 +253,9 @@ def manage_grid_renders(n_renderers: int): cell_width = grid_path = None # Silence flake8's F821 faulty_image = Image._ti_faulty_image + delimited = False grid_cache = Image._ti_grid_cache - new_grid = False + in_sync = grid_renderer_in_sync try: while True: @@ -267,9 +268,9 @@ def manage_grid_renders(n_renderers: int): if quitting.is_set(): break - if new_grid or grid_change.is_set(): # New grid + if delimited or not in_sync.is_set(): grid_cache.clear() - grid_change.clear() # Signal "cache cleared" + in_sync.set() # Purge the in and out queues and update the loading indicator counter for q in (grid_render_in, grid_render_out): @@ -281,17 +282,17 @@ def manage_grid_renders(n_renderers: int): else: notify.stop_loading() - if not new_grid: # The grid delimeter hasn't been gotten + if not delimited: # Purge all items until the grid delimeter while grid_render_queue.get(): pass else: - new_grid = False + delimited = False cell_width = image_grid.cell_width grid_path = main.grid_path - if grid_change.is_set(): + if not in_sync.is_set(): continue if grid_active.is_set(): @@ -300,13 +301,13 @@ def manage_grid_renders(n_renderers: int): except Empty: pass else: - if not image_info: # Grid delimeter - new_grid = True + if not image_info: + delimited = True continue grid_render_in.put(image_info) notify.start_loading() - if grid_change.is_set(): + if not in_sync.is_set(): continue try: @@ -318,7 +319,7 @@ def manage_grid_renders(n_renderers: int): # The directory and cell-width checks are to filter out any remnants # that were still being rendered at the other end if ( - not grid_change.is_set() + in_sync.is_set() and source_dirname == grid_path and size[0] + 2 == cell_width ): @@ -503,6 +504,21 @@ def render_images( anim_render_queue = Queue() grid_render_queue = Queue() image_render_queue = Queue() +grid_renderer_in_sync = Event() + +# `GridRenderManager` is actually "in sync" initially. +# +# Removing these may result in ocassional deadlocks because at init, the thread +# may detect "out of sync" and try to prep for a new grid but **without a grid +# delimeter**. +# The deadlock is unpredictable and timing-dependent, as it only happens when +# `main.display_images()` signals "out of sync" **after** the thread has responded to +# the false initial "out of sync". Hence, the thread blocks on trying to get a grid +# delimeter. +# When another "out of sync" is signaled (with a grid delimeter), the thread takes +# the delimeter as for the previous "out of sync" and comes back around to block +# again since the event will be unset after consuming the delimeter. +grid_renderer_in_sync.set() # Updated from `.tui.init()` anim_style_specs = {"kitty": "+W", "iterm2": "+Wm1"} From 1d475297070708130c6f904304939792f0f5b261 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Thu, 25 Apr 2024 21:32:41 +0100 Subject: [PATCH 09/37] feat: render: Implement image grid thumbnailing - Add: Integrate thumbnailing into the image grid rendering process. - Add: Functions in `.tui.render`: - `manage_grid_thumbnails()` - `generate_grid_thumbnails()` - `delete_thumbnail()` for thumbnailing in the image grid. - Add: `mark_thumbnail_rendered()` in `manage_grid_renders()`. - Add: `.tui.main.THUMBNAIL_SIZE_PRODUCT`. - Add: `.tui.main.THUMBNAIL_CACHE_SIZE`. - Add: `GridThumbnailManager` thread and `GridThumbnailer` thread/subprocess. - Add: A `thumbnail` field to the data passed through every grid render queue. - Add: Synchronization primitives for thumbnailing, in `.tui.render`. --- src/termvisage/tui/__init__.py | 10 + src/termvisage/tui/keys.py | 20 +- src/termvisage/tui/main.py | 14 +- src/termvisage/tui/render.py | 326 ++++++++++++++++++++++++++++++++- src/termvisage/tui/widgets.py | 56 ++++-- 5 files changed, 396 insertions(+), 30 deletions(-) diff --git a/src/termvisage/tui/__init__.py b/src/termvisage/tui/__init__.py index 903cecc..cffcba2 100644 --- a/src/termvisage/tui/__init__.py +++ b/src/termvisage/tui/__init__.py @@ -41,6 +41,7 @@ def init( main.NO_ANIMATION = args.no_anim main.RECURSIVE = args.recursive main.SHOW_HIDDEN = args.all + main.THUMBNAIL_SIZE_PRODUCT = config_options.thumbnail_size**2 main.ImageClass = ImageClass main.loop = Loop( main_widget, palette, UrwidImageScreen(), unhandled_input=process_input @@ -52,6 +53,7 @@ def init( ) render.FRAME_DURATION = args.frame_duration render.REPEAT = args.repeat + render.THUMBNAIL_CACHE_SIZE = config_options.thumbnail_cache images.sort( key=lambda x: sort_key_lexi( @@ -87,6 +89,12 @@ def init( name="GridRenderManager", daemon=True, ) + grid_thumbnail_manager = logging.Thread( + target=render.manage_grid_thumbnails, + args=(config_options.thumbnail_size,), + name="GridThumbnailManager", + daemon=True, + ) image_render_manager = logging.Thread( target=render.manage_image_renders, name="ImageRenderManager", @@ -121,6 +129,7 @@ def init( menu_scanner.start() grid_scanner.start() + grid_thumbnail_manager.start() grid_render_manager.start() image_render_manager.start() anim_render_manager.start() @@ -129,6 +138,7 @@ def init( write_tty(f"{CSI}?1049h".encode()) # Switch to the alternate buffer next(main.displayer) main.loop.run() + grid_thumbnail_manager.join() grid_render_manager.join() render.image_render_queue.put((None,) * 3) image_render_manager.join() diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index 6998422..5c6116d 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -16,7 +16,12 @@ from .. import __version__, logging from ..config import context_keys, expand_key from . import main -from .render import grid_render_queue, grid_renderer_in_sync +from .render import ( + grid_render_queue, + grid_renderer_in_sync, + grid_thumbnail_queue, + grid_thumbnailer_in_sync, +) from .widgets import ( ImageCanvas, bottom_bar, @@ -390,6 +395,11 @@ def resize(): cell_ratio = get_cell_ratio() if cell_ratio != _prev_cell_ratio: _prev_cell_ratio = cell_ratio + + grid_thumbnail_queue.put(None) # Send the grid delimeter + grid_thumbnailer_in_sync.clear() + grid_thumbnailer_in_sync.wait() + grid_render_queue.put(None) # Send the grid delimeter grid_renderer_in_sync.clear() grid_renderer_in_sync.wait() @@ -480,6 +490,10 @@ def cell_width_dec(): if image_grid.cell_width > 30: image_grid.cell_width -= 2 + grid_thumbnail_queue.put(None) # Send the grid delimeter + grid_thumbnailer_in_sync.clear() + grid_thumbnailer_in_sync.wait() + grid_render_queue.put(None) # Send the grid delimeter grid_renderer_in_sync.clear() grid_renderer_in_sync.wait() @@ -492,6 +506,10 @@ def cell_width_inc(): if image_grid.cell_width < 50: image_grid.cell_width += 2 + grid_thumbnail_queue.put(None) # Send the grid delimeter + grid_thumbnailer_in_sync.clear() + grid_thumbnailer_in_sync.wait() + grid_render_queue.put(None) # Send the grid delimeter grid_renderer_in_sync.clear() grid_renderer_in_sync.wait() diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index 50af809..4fc2281 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -30,7 +30,12 @@ set_menu_actions, set_menu_count, ) -from .render import grid_render_queue, grid_renderer_in_sync +from .render import ( + grid_render_queue, + grid_renderer_in_sync, + grid_thumbnail_queue, + grid_thumbnailer_in_sync, +) from .widgets import ( Image, ImageCanvas, @@ -280,6 +285,10 @@ def display_images( grid_path = abspath(entry) if contents[entry].get("/") and grid_path != last_non_empty_grid_path: + grid_thumbnail_queue.put(None) # Send the grid delimeter + grid_thumbnailer_in_sync.clear() + grid_thumbnailer_in_sync.wait() + grid_render_queue.put(None) # Send the grid delimeter grid_renderer_in_sync.clear() grid_renderer_in_sync.wait() @@ -710,9 +719,10 @@ def update_screen(): loop: tui.Loop update_pipe: int -# # Corresponding to command-line args +# # Corresponding to (or derived directly from) command-line args and/or config options DEBUG: bool MAX_PIXELS: int NO_ANIMATION: bool RECURSIVE: bool SHOW_HIDDEN: bool +THUMBNAIL_SIZE_PRODUCT: int diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 5366370..7c3f613 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -4,9 +4,11 @@ import logging as _logging from multiprocessing import Event as mp_Event, Queue as mp_Queue -from os.path import split +from os import remove +from os.path import basename, split from queue import Empty, Queue -from threading import Event +from tempfile import mkdtemp, mkstemp +from threading import Event, Lock from typing import Union from term_image.image import Size @@ -16,6 +18,108 @@ from ..utils import clear_queue +def delete_thumbnail(thumbnail: str) -> bool: + try: + remove(thumbnail) + except OSError: # On Windows, a file in use cannot be deleted + logging.log_exception(f"Failed to delete thumbnail file {thumbnail!r}", logger) + return False + + return True + + +def generate_grid_thumbnails( + input: Queue | mp_Queue, + output: Queue | mp_Queue, + thumbnail_size: int, + not_generating: Event | mp_Event, +) -> None: + from os import fdopen, mkdir + from shutil import rmtree + + from PIL.Image import Resampling, open as Image_open + + THUMBNAIL_FRAME_SIZE = (thumbnail_size,) * 2 + BOX = Resampling.BOX + THUMBNAIL_MODES = {"RGB", "RGBA"} + + try: + THUMBNAIL_DIR = (TEMP_DIR := mkdtemp(prefix="termvisage-")) + "/thumbnails" + mkdir(THUMBNAIL_DIR) + except OSError: + logging.log_exception( + "Failed to create thumbnail directory", logger, fatal=True + ) + raise + + logging.log( + f"Created thumbnail directory {THUMBNAIL_DIR!r}", + logger, + _logging.DEBUG, + direct=False, + ) + + try: + while True: + not_generating.set() + try: + if not (source := input.get()): + break # Quitting + finally: + not_generating.clear() + + # Make source image into a thumbnail + try: + img = Image_open(source) + has_transparency = img.has_transparency_data + img.thumbnail(THUMBNAIL_FRAME_SIZE, BOX) + if img.mode not in THUMBNAIL_MODES: + with img: + img = img.convert("RGBA" if has_transparency else "RGB") + except Exception: + output.put((source, None)) + logging.log_exception( + f"Failed to generate thumbnail for {source!r}", logger + ) + continue + + # Create thumbnail file + try: + thumbnail_fd, thumbnail = mkstemp( + "", f"{basename(source)}-", THUMBNAIL_DIR + ) + except Exception: + output.put((source, None)) + logging.log_exception( + f"Failed to create thumbnail file for {source!r}", logger + ) + continue + + # Save thumbnail + with img, fdopen(thumbnail_fd, "wb") as thumbnail_file: + try: + img.save(thumbnail_file, "PNG") + except Exception: + output.put((source, None)) + thumbnail_file.close() # Close before deleting the file + delete_thumbnail(thumbnail) + logging.log_exception( + f"Failed to save thumbnail for {source!r}", logger + ) + continue + + output.put((source, thumbnail)) + finally: + try: + rmtree(TEMP_DIR, ignore_errors=True) + except OSError: + logging.log_exception( + f"Failed to delete thumbnail directory {THUMBNAIL_DIR!r}", logger + ) + + clear_queue(output) + + def manage_anim_renders() -> None: from .main import ImageClass, update_screen from .widgets import ImageCanvas, image_box @@ -231,6 +335,27 @@ def manage_grid_renders(n_renderers: int): from .main import ImageClass, grid_active, quitting, update_screen from .widgets import Image, ImageCanvas, image_grid + # NOTE: + # Always keep in mind that every directory entry is rendered only once per grid + # since results are cached, at least for now. + + def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: + with thumbnail_render_lock: + """ + # Better than `e[k] or d.pop(k, None)`. + thumbnail = ( + thumbnail_cache[source] + if source in thumbnail_cache + # Safe to pop since every source is rendered only once per grid. + # Need to pop because after this point, there's no efficient way + # to tie `thumbnail` to `source`. + else extra_thumbnail_cache.pop(source) + ) + """ + if source in extra_thumbnail_cache: + del extra_thumbnail_cache[source] + thumbnails_being_rendered.remove(thumbnail) + multi = logging.MULTI and n_renderers > 0 grid_render_in = (mp_Queue if multi else Queue)() grid_render_out = (mp_Queue if multi else Queue)() @@ -311,7 +436,9 @@ def manage_grid_renders(n_renderers: int): continue try: - source, render, size, rendered_size = grid_render_out.get(timeout=0.02) + source, thumbnail, render, size, rendered_size = grid_render_out.get( + timeout=0.02 + ) except Empty: pass else: @@ -330,16 +457,188 @@ def manage_grid_renders(n_renderers: int): ) if grid_active.is_set(): update_screen() + if THUMBNAIL_CACHE_SIZE and thumbnail: + mark_thumbnail_rendered(source, thumbnail) notify.stop_loading() finally: clear_queue(grid_render_in) for renderer in renderers: - grid_render_in.put((None,) * 3) + grid_render_in.put((None,) * 4) for renderer in renderers: renderer.join() clear_queue(grid_render_queue) +def manage_grid_thumbnails(thumbnail_size: int) -> None: + from .main import grid_active, quitting + + # NOTE: + # Always keep in mind that every directory entry is rendered only once per grid + # since results are cached, at least for now. + + def cache_thumbnail(source: str, thumbnail: str) -> None: + # Eviction, for finite cache size + if THUMBNAIL_CACHE_SIZE and len(thumbnail_cache) == THUMBNAIL_CACHE_SIZE: + # Evict and delete the first cached thumbnail not in the render pipeline + for other_source, other_thumbnail in thumbnail_cache.items(): + # `thumbnail_render_lock` is unnecessary here since it's just a + # membership test; the outcome is the same as when the lock is + # used but more efficient. + if other_thumbnail not in thumbnails_being_rendered: + delete_thumbnail(other_thumbnail) + del thumbnail_cache[other_source] + break + # If all are being rendered, evict the oldest and queue it up to be + # deleted later. + else: + with thumbnail_render_lock: + other_source = next(iter(thumbnail_cache)) # oldest entry + other_thumbnail = thumbnail_cache.pop(other_source) + extra_thumbnail_cache[other_source] = other_thumbnail + thumbnails_to_be_deleted.add(other_thumbnail) + + thumbnail_cache[source] = thumbnail # Cache the new thumbnail. + + multi = logging.MULTI + thumbnail_in = (mp_Queue if multi else Queue)() + thumbnail_out = (mp_Queue if multi else Queue)() + not_generating = (mp_Event if multi else Event)() + generator = (Process if multi else logging.Thread)( + target=generate_grid_thumbnails, + args=(thumbnail_in, thumbnail_out, thumbnail_size, not_generating), + name="GridThumbnailer", + redirect_notifs=True, + ) + generator.start() + not_generating.set() + + delimited = False + in_sync = grid_thumbnailer_in_sync + renderer_in_sync = grid_renderer_in_sync + thumbnails_to_be_deleted: set[str] = set() + # Stores `(size, alpha)`s (to be passed on to the renderer) in the same order in + # which `source`s are sent to the generator. + size_alpha_s: Queue[tuple[tuple[int, int], str | float | None]] = Queue() + + try: + while True: + while not ( + grid_active.wait(0.1) + or quitting.is_set() + or not thumbnail_out.empty() + or thumbnails_to_be_deleted + ): + pass + if quitting.is_set(): + break + + if delimited or not in_sync.is_set(): + in_sync.set() + renderer_in_sync.wait() + + if THUMBNAIL_CACHE_SIZE: + with thumbnail_render_lock: + extra_thumbnail_cache.clear() + thumbnails_being_rendered.clear() + + for thumbnail in thumbnails_to_be_deleted: + delete_thumbnail(thumbnail) + thumbnails_to_be_deleted.clear() + + # Purge the in queue and update the loading indicator counter + while True: + try: + thumbnail_in.get(timeout=0.005) + except Empty: + break + else: + notify.stop_loading() + + # Wait for the thumbnail being generated, if any + not_generating.wait() + + # Cache or delete already generated thumbnails in the out queue + # and update the loading indicator counter + while True: + try: + source, thumbnail = thumbnail_out.get(timeout=0.005) + except Empty: + break + else: + if ( + THUMBNAIL_CACHE_SIZE + and len(thumbnail_cache) == THUMBNAIL_CACHE_SIZE + ): + # Quicker than the eviction process + delete_thumbnail(thumbnail) + else: + cache_thumbnail(source, thumbnail) + notify.stop_loading() + + # It's okay since we've taken care of all thumbnails that were being + # generated. + clear_queue(size_alpha_s) + + if not delimited: + while grid_thumbnail_queue.get(): + pass + else: + delimited = False + + if not in_sync.is_set(): + continue + + if THUMBNAIL_CACHE_SIZE: + for thumbnail in thumbnails_to_be_deleted - thumbnails_being_rendered: + delete_thumbnail(thumbnail) + thumbnails_to_be_deleted.remove(thumbnail) + + if not in_sync.is_set(): + continue + + if grid_active.is_set(): + try: + image_info = grid_thumbnail_queue.get(timeout=0.02) + except Empty: + pass + else: + if not image_info: + delimited = True + continue + if thumbnail := thumbnail_cache.get(source := image_info[0]): + grid_render_queue.put((source, thumbnail, *image_info[2:])) + if THUMBNAIL_CACHE_SIZE: + with thumbnail_render_lock: + thumbnails_being_rendered.add(thumbnail) + else: + thumbnail_in.put(source) + size_alpha_s.put(image_info[2:]) + notify.start_loading() + + if not in_sync.is_set(): + continue + + try: + source, thumbnail = thumbnail_out.get(timeout=0.02) + except Empty: + pass + else: + size_alpha = size_alpha_s.get() + if in_sync.is_set(): + grid_render_queue.put((source, thumbnail, *size_alpha)) + if THUMBNAIL_CACHE_SIZE and thumbnail: + with thumbnail_render_lock: + thumbnails_being_rendered.add(thumbnail) + if thumbnail: + cache_thumbnail(source, thumbnail) + notify.stop_loading() + finally: + clear_queue(thumbnail_in) + thumbnail_in.put(None) + generator.join() + clear_queue(grid_thumbnail_queue) + + def render_frames( input: Union[Queue, mp_Queue], output: Union[Queue, mp_Queue], @@ -436,7 +735,7 @@ def render_grid_images( Intended to be executed in a subprocess or thread. """ while True: - source, size, alpha = input.get() + source, thumbnail, size, alpha = input.get() if not source: # Quitting break @@ -448,18 +747,19 @@ def render_grid_images( # string (as a list though) then generates and yields the complete lines # **as needed**. Trimmed padding lines are never generated at all. try: - image = ImageClass.from_file(source) + image = ImageClass.from_file(thumbnail or source) image.set_size(Size.AUTO, maxsize=size) output.put( ( source, + thumbnail, f"{image:1.1{alpha}{style_spec}}", size, image.rendered_size, ) ) except Exception: - output.put((source, None, size, None)) + output.put((source, thumbnail, None, size, None)) clear_queue(output) @@ -503,8 +803,16 @@ def render_images( logger = _logging.getLogger(__name__) anim_render_queue = Queue() grid_render_queue = Queue() +grid_thumbnail_queue = Queue() image_render_queue = Queue() grid_renderer_in_sync = Event() +grid_thumbnailer_in_sync = Event() +thumbnails_being_rendered: set[str] = set() +thumbnail_render_lock = Lock() +# Main thumbnail cache +thumbnail_cache: dict[str, str] = {} +# For evicted thumbnails still being rendered +extra_thumbnail_cache: dict[str, str] = {} # `GridRenderManager` is actually "in sync" initially. # @@ -519,6 +827,7 @@ def render_images( # the delimeter as for the previous "out of sync" and comes back around to block # again since the event will be unset after consuming the delimeter. grid_renderer_in_sync.set() +grid_thumbnailer_in_sync.set() # Updated from `.tui.init()` anim_style_specs = {"kitty": "+W", "iterm2": "+Wm1"} @@ -526,7 +835,8 @@ def render_images( image_style_specs = {"kitty": "+W", "iterm2": "+W"} # Set from `.tui.init()` -# # Corresponding to command-line args +# # Corresponding to command-line args and/or config options ANIM_CACHED: bool | int FRAME_DURATION: float REPEAT: int +THUMBNAIL_CACHE_SIZE: int diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 2251725..97ab927 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -28,7 +28,12 @@ from ..config import config_options, expand_key, navi from ..utils import KITTY_DELETE_CURSOR_IMAGES_b from . import keys, main as tui_main -from .render import anim_render_queue, grid_render_queue, image_render_queue +from .render import ( + anim_render_queue, + grid_render_queue, + grid_thumbnail_queue, + image_render_queue, +) # NOTE: Any new "private" attribute set on any subclass or instance of an urwid class # should be prepended with "_ti" to prevent clashes with names used by urwid itself. @@ -272,24 +277,33 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: # Forced render / Large images - # does the image have more pixels than the maximum? - # AND has the image NOT been force-rendered, with a valid-sized canvas? - if mul(*image.original_size) > tui_main.MAX_PIXELS and not ( - # has the image been force-rendered? - self._ti_canv - # is the force-rendered canvas valid for the current widget render size? - and ( - # the canvas itself will be resized later at the Rendering stage - # below - self._ti_canv._ti_image_size == image.size - # can either be `SolidCanvas` (faulty) or `ImageCanvas` - if isinstance(self._ti_canv, ImageCanvas) - # a *faulty* canvas shouldn't be resized, to allow re-rendering the - # image after a change in widget size - else self._ti_canv.size == size + if ( + # is the image NOT in a grid cell? + not ( + # is the image grid in view? (next two lines) + view.original_widget is image_grid_box + and context != "full-grid-image" + ) + # does the image have more pixels than `max pixels`? + and mul(*image.original_size) > tui_main.MAX_PIXELS + # has the image NOT been force-rendered, with a valid-sized canvas? + and not ( + # has the widget been force-rendered? + self._ti_canv + # is the force-rendered canvas valid for the current widget render size? + and ( + # the canvas itself will be resized later at the Rendering stage + # below + self._ti_canv._ti_image_size == image.size + # can either be `SolidCanvas` (faulty) or `ImageCanvas` + if isinstance(self._ti_canv, ImageCanvas) + # a *faulty* canvas shouldn't be resized, to allow re-rendering the + # image after a change in widget size + else self._ti_canv.size == size + ) + # is the image currently being rendered? + or self._ti_rendering ) - # is the image currently being rendered? - or self._ti_rendering ): # has the image been requested to be force-rendered? if self._ti_force_render: @@ -330,7 +344,11 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: ): canv = __class__._ti_grid_cache.get(basename(image._source)) if not canv: # is the image not the grid cache? - grid_render_queue.put((image._source, size, self._ti_alpha)) + ( + grid_thumbnail_queue + if mul(*image.original_size) > tui_main.THUMBNAIL_SIZE_PRODUCT + else grid_render_queue + ).put((image._source, None, size, self._ti_alpha)) __class__._ti_grid_cache[basename(image._source)] = ... canv = __class__._ti_placeholder.render(size, focus) elif canv is ...: # is the image currently being rendered? From 6b19913b83dc567798e513da05b24ac7b2812dbf Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 26 Apr 2024 15:01:11 +0100 Subject: [PATCH 10/37] feat,refac: tui: Improve grid thumbnailing - Add: `Image._ti_grid_thumbnailing_threshold`, a dynamic threshold above which images are thumbnailed i.e any image with more pixels than the threshold is rendered with a thumbnail. This threshold is updated upon terminal resize (for graphics-based styles and only if terminal cell size changes) and grid cell resize. - Add: `Image._ti_update_grid_thumbnailing_threshold()`. - Add: `.tui.keys._prev_cell_size`. - Change: Improve the grid thumbnailing criterion. - Now based on both the thumbnail size and grid cell size, not just the thumbnail size. - The new citerion prevents thumbnailing of images with more pixels than the thumbnail size but less than the grid cell size. It also ensures any image rendered with a thumbnail is larger than the grid cell size along at least one axis. - Change: Render thumbnails in the grid with `FIT` sizing instead of `AUTO` since any image now rendered with a thumbnail is known to be larger than the grid cell size along at least one axis, based on the new thumbnailing criterion. --- src/termvisage/tui/__init__.py | 11 ++++++++++- src/termvisage/tui/keys.py | 23 +++++++++++++++++++++-- src/termvisage/tui/render.py | 2 +- src/termvisage/tui/widgets.py | 18 ++++++++++++++++-- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/termvisage/tui/__init__.py b/src/termvisage/tui/__init__.py index cffcba2..5949b7d 100644 --- a/src/termvisage/tui/__init__.py +++ b/src/termvisage/tui/__init__.py @@ -9,7 +9,8 @@ from typing import Any, Dict, Iterable, Iterator, Tuple, Union import urwid -from term_image.utils import lock_tty, write_tty +from term_image.image import GraphicsImage +from term_image.utils import get_cell_size, lock_tty, write_tty from term_image.widget import UrwidImageScreen from .. import logging, notify @@ -29,6 +30,8 @@ def init( ImageClass: type, ) -> None: """Initializes the TUI""" + from . import keys + global active, initialized if args.debug: @@ -69,6 +72,11 @@ def init( specs = getattr(render, f"{name}_style_specs") specs[ImageClass.style] += f"c{style_args['compress']}" + if issubclass(ImageClass, GraphicsImage): + # `get_cell_size()` may sometimes return `None` on terminals that don't + # implement the `TIOCSWINSZ` ioctl command. Hence, the `or (1, 2)`. + keys._prev_cell_size = get_cell_size() or (1, 2) + Image._ti_alpha = ( "#" if args.no_alpha @@ -79,6 +87,7 @@ def init( ) ) Image._ti_grid_style_spec = render.grid_style_specs.get(ImageClass.style, "") + Image._ti_update_grid_thumbnailing_threshold(keys._prev_cell_size) # daemon, to avoid having to check if the main process has been interrupted menu_scanner = logging.Thread(target=scan_dir_menu, name="MenuScanner", daemon=True) diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index 5c6116d..c9015ce 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -11,7 +11,8 @@ import urwid from term_image import get_cell_ratio -from term_image.utils import get_terminal_size +from term_image.image import GraphicsImage +from term_image.utils import get_cell_size, get_terminal_size from .. import __version__, logging from ..config import context_keys, expand_key @@ -23,6 +24,7 @@ grid_thumbnailer_in_sync, ) from .widgets import ( + Image, ImageCanvas, bottom_bar, confirmation, @@ -389,7 +391,15 @@ def key_bar_rows(): def resize(): - global _prev_cell_ratio + global _prev_cell_ratio, _prev_cell_size + + if issubclass(main.ImageClass, GraphicsImage): + cell_size = get_cell_size() + # `get_cell_size()` may sometimes return `None` on terminals that don't + # implement the `TIOCSWINSZ` ioctl command. Hence, the `cell_size and`. + if cell_size and cell_size != _prev_cell_size: + _prev_cell_size = cell_size + Image._ti_update_grid_thumbnailing_threshold(cell_size) if main.grid_active.is_set(): cell_ratio = get_cell_ratio() @@ -500,6 +510,8 @@ def cell_width_dec(): getattr(main.ImageClass, "clear", lambda: True)() + Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) + @register_key(("image-grid", "Size+")) def cell_width_inc(): @@ -516,6 +528,8 @@ def cell_width_inc(): getattr(main.ImageClass, "clear", lambda: True)() + Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) + @register_key(("image-grid", "Open")) def maximize_cell(): @@ -751,3 +765,8 @@ def close(): # Used to guard clearing of grid render cache _prev_cell_ratio: float = 0.0 + +# Used to [re]compute the thumbnailing threshold. +# The default value is for text-based styles, for which this variable is never updated. +# Updated from `.tui.init()` and `resize()`, for graphics-based styles. +_prev_cell_size: tuple[int, int] = (1, 2) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 7c3f613..41acfbd 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -748,7 +748,7 @@ def render_grid_images( # **as needed**. Trimmed padding lines are never generated at all. try: image = ImageClass.from_file(thumbnail or source) - image.set_size(Size.AUTO, maxsize=size) + image.set_size(Size.FIT if thumbnail else Size.AUTO, maxsize=size) output.put( ( source, diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 97ab927..5ee0cbe 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -6,7 +6,7 @@ from math import ceil from operator import floordiv, mul, sub from os.path import basename -from typing import List, Optional, Tuple +from typing import ClassVar, List, Optional, Tuple import urwid from term_image.image import BaseImage, Size @@ -263,6 +263,8 @@ class Image(urwid.Widget): # Set from `.tui.init()` _ti_alpha = "" _ti_grid_style_spec = "" + # # Updated in `._ti_update_grid_thumbnailing_threshold()` + _ti_grid_thumbnailing_threshold: ClassVar[int] def __init__(self, image: BaseImage): self._ti_image = image @@ -346,7 +348,10 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: if not canv: # is the image not the grid cache? ( grid_thumbnail_queue - if mul(*image.original_size) > tui_main.THUMBNAIL_SIZE_PRODUCT + if ( + mul(*image.original_size) + > __class__._ti_grid_thumbnailing_threshold + ) else grid_render_queue ).put((image._source, None, size, self._ti_alpha)) __class__._ti_grid_cache[basename(image._source)] = ... @@ -429,6 +434,15 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: return canv + @classmethod + def _ti_update_grid_thumbnailing_threshold(cls, cell_size: tuple[int, int]) -> None: + grid_cell_width = image_grid.cell_width + grid_image_size = (grid_cell_width - 2, ceil(grid_cell_width / 2) - 2) + cls._ti_grid_thumbnailing_threshold = max( + tui_main.THUMBNAIL_SIZE_PRODUCT, + mul(*map(mul, grid_image_size, cell_size)), + ) + class ImageCanvas(urwid.Canvas): cacheable = False From 736e4eb43f9a7123f3b2f3bc3a7afa264a5eeb9a Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 26 Apr 2024 15:29:19 +0100 Subject: [PATCH 11/37] refac: tui: Deduplicate grid render refresh code - Add: `.render.refresh_grid_rendering()`. - Change: Call `refresh_grid_rendering()` in `.main` and `.keys` instead of repeating the same code all over. - Change: Correct typo in comments: `delimeter` -> `delimiter`. --- src/termvisage/tui/keys.py | 35 ++++------------------------------- src/termvisage/tui/main.py | 17 +++-------------- src/termvisage/tui/render.py | 22 ++++++++++++++++------ 3 files changed, 23 insertions(+), 51 deletions(-) diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index c9015ce..195f1be 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -17,12 +17,7 @@ from .. import __version__, logging from ..config import context_keys, expand_key from . import main -from .render import ( - grid_render_queue, - grid_renderer_in_sync, - grid_thumbnail_queue, - grid_thumbnailer_in_sync, -) +from .render import refresh_grid_rendering from .widgets import ( Image, ImageCanvas, @@ -405,14 +400,8 @@ def resize(): cell_ratio = get_cell_ratio() if cell_ratio != _prev_cell_ratio: _prev_cell_ratio = cell_ratio + refresh_grid_rendering() - grid_thumbnail_queue.put(None) # Send the grid delimeter - grid_thumbnailer_in_sync.clear() - grid_thumbnailer_in_sync.wait() - - grid_render_queue.put(None) # Send the grid delimeter - grid_renderer_in_sync.clear() - grid_renderer_in_sync.wait() adjust_bottom_bar() getattr(main.ImageClass, "clear", lambda: True)() or ImageCanvas.change() @@ -499,15 +488,7 @@ def maximize(): def cell_width_dec(): if image_grid.cell_width > 30: image_grid.cell_width -= 2 - - grid_thumbnail_queue.put(None) # Send the grid delimeter - grid_thumbnailer_in_sync.clear() - grid_thumbnailer_in_sync.wait() - - grid_render_queue.put(None) # Send the grid delimeter - grid_renderer_in_sync.clear() - grid_renderer_in_sync.wait() - + refresh_grid_rendering() getattr(main.ImageClass, "clear", lambda: True)() Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) @@ -517,15 +498,7 @@ def cell_width_dec(): def cell_width_inc(): if image_grid.cell_width < 50: image_grid.cell_width += 2 - - grid_thumbnail_queue.put(None) # Send the grid delimeter - grid_thumbnailer_in_sync.clear() - grid_thumbnailer_in_sync.wait() - - grid_render_queue.put(None) # Send the grid delimeter - grid_renderer_in_sync.clear() - grid_renderer_in_sync.wait() - + refresh_grid_rendering() getattr(main.ImageClass, "clear", lambda: True)() Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index 4fc2281..a817e83 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -30,12 +30,7 @@ set_menu_actions, set_menu_count, ) -from .render import ( - grid_render_queue, - grid_renderer_in_sync, - grid_thumbnail_queue, - grid_thumbnailer_in_sync, -) +from .render import refresh_grid_rendering from .widgets import ( Image, ImageCanvas, @@ -285,15 +280,9 @@ def display_images( grid_path = abspath(entry) if contents[entry].get("/") and grid_path != last_non_empty_grid_path: - grid_thumbnail_queue.put(None) # Send the grid delimeter - grid_thumbnailer_in_sync.clear() - grid_thumbnailer_in_sync.wait() - - grid_render_queue.put(None) # Send the grid delimeter - grid_renderer_in_sync.clear() - grid_renderer_in_sync.wait() - + refresh_grid_rendering() last_non_empty_grid_path = grid_path + image_box.original_widget = placeholder # halt image and anim rendering image_grid_box.set_title(grid_path + "/") view.original_widget = image_grid_box diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 41acfbd..1ecbd6b 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -28,6 +28,16 @@ def delete_thumbnail(thumbnail: str) -> bool: return True +def refresh_grid_rendering() -> None: + grid_thumbnail_queue.put(None) # Send the grid delimiter + grid_thumbnailer_in_sync.clear() + grid_thumbnailer_in_sync.wait() + + grid_render_queue.put(None) # Send the grid delimiter + grid_renderer_in_sync.clear() + grid_renderer_in_sync.wait() + + def generate_grid_thumbnails( input: Queue | mp_Queue, output: Queue | mp_Queue, @@ -408,7 +418,7 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: notify.stop_loading() if not delimited: - # Purge all items until the grid delimeter + # Purge all items until the grid delimiter while grid_render_queue.get(): pass else: @@ -818,14 +828,14 @@ def render_images( # # Removing these may result in ocassional deadlocks because at init, the thread # may detect "out of sync" and try to prep for a new grid but **without a grid -# delimeter**. +# delimiter**. # The deadlock is unpredictable and timing-dependent, as it only happens when # `main.display_images()` signals "out of sync" **after** the thread has responded to # the false initial "out of sync". Hence, the thread blocks on trying to get a grid -# delimeter. -# When another "out of sync" is signaled (with a grid delimeter), the thread takes -# the delimeter as for the previous "out of sync" and comes back around to block -# again since the event will be unset after consuming the delimeter. +# delimiter. +# When another "out of sync" is signaled (with a grid delimiter), the thread takes +# the delimiter as for the previous "out of sync" and comes back around to block +# again since the event will be unset after consuming the delimiter. grid_renderer_in_sync.set() grid_thumbnailer_in_sync.set() From 6ef2a51a0cb697d03dd643bda18a56f2d80a86e1 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 26 Apr 2024 16:05:16 +0100 Subject: [PATCH 12/37] refac: tui: Improve grid render refresh on resize - Change: Refresh grid rendering regardless of whether the grid is active or not. - Change: For graphics-based styles, refresh grid rendering based on change in terminal cell size. Cell ratio is now only for text-based styles. --- src/termvisage/tui/keys.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index 195f1be..2a816a5 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -394,9 +394,9 @@ def resize(): # implement the `TIOCSWINSZ` ioctl command. Hence, the `cell_size and`. if cell_size and cell_size != _prev_cell_size: _prev_cell_size = cell_size + refresh_grid_rendering() Image._ti_update_grid_thumbnailing_threshold(cell_size) - - if main.grid_active.is_set(): + else: cell_ratio = get_cell_ratio() if cell_ratio != _prev_cell_ratio: _prev_cell_ratio = cell_ratio @@ -737,6 +737,7 @@ def close(): _prev_view_widget: urwid.Widget | None = None # Used to guard clearing of grid render cache +# Updated from `resize()`, for text-based styles. _prev_cell_ratio: float = 0.0 # Used to [re]compute the thumbnailing threshold. From df4e72302772d1fa35ab1f54c9fd9b2df6a0f149 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 26 Apr 2024 16:20:32 +0100 Subject: [PATCH 13/37] refac: tui.render: Improve grid thumbnail quality - Change: Convert non-RGB[A] source images to RGB[A] before resizng. --- src/termvisage/tui/render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 1ecbd6b..58815ad 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -82,10 +82,10 @@ def generate_grid_thumbnails( try: img = Image_open(source) has_transparency = img.has_transparency_data - img.thumbnail(THUMBNAIL_FRAME_SIZE, BOX) if img.mode not in THUMBNAIL_MODES: with img: img = img.convert("RGBA" if has_transparency else "RGB") + img.thumbnail(THUMBNAIL_FRAME_SIZE, BOX) except Exception: output.put((source, None)) logging.log_exception( From 1f9faa0ddc57b5c9a341da94e9fc19dd94f518ca Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 26 Apr 2024 16:24:44 +0100 Subject: [PATCH 14/37] fix: render: Finalize thumbnail images properly - Fix: Finalize PIL images for thumbnails when thumbnail file creation fails. --- src/termvisage/tui/render.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 58815ad..6d94610 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -103,6 +103,7 @@ def generate_grid_thumbnails( logging.log_exception( f"Failed to create thumbnail file for {source!r}", logger ) + img.close() continue # Save thumbnail From f5363ebfc31b7c0c93df5b7742181234c28a0a97 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 26 Apr 2024 16:35:21 +0100 Subject: [PATCH 15/37] feat,docs: cli,config: Enable/Disable thumbnailing - Add: `thumbnail` config option. - Add: `--thumbnail` and `--no-thumbnail` CLI options. --- docs/source/config.rst | 22 ++++++++++++++++++++++ src/termvisage/config.py | 5 +++++ src/termvisage/parsers.py | 10 ++++++++++ 3 files changed, 37 insertions(+) diff --git a/docs/source/config.rst b/docs/source/config.rst index d5067e8..78c8017 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -191,6 +191,22 @@ These are top-level fields whose values control various settings of the viewer. * Overridden by :option:`--swap-win-size` and :option:`--no-swap-win-size`. * Affects *auto* :term:`cell ratio` computation. +.. confval:: thumbnail + :synopsis: Enable or disable thumbnail generation for the image grid. + :type: boolean + :valid: ``true``, ``false`` + :default: ``true`` + + If ``true``, thumbnails are generated for some images (based on their size), cached + on disk and cleaned up upon exit. Otherwise, all images in the grid are rendered + directly from the original image files. + + .. note:: + + - Overridden by :option:`--thumbnail` and :option:`--no-thumbnail`. + - Thumbnails are generated **on demand** i.e a thumbnail will be generated for + an image only if its grid cell has come into view at least once. + .. confval:: thumbnail cache :synopsis: The maximum amount of thumbnails that can be cached per time. :type: integer @@ -201,6 +217,9 @@ These are top-level fields whose values control various settings of the viewer. will be evicted to accommodate newer ones when the cache is full (i.e the specified size limit is reached). + .. note:: Unused if :confval:`thumbnail` is ``false`` or :option:`--no-thumbnail` + is specified. + .. confval:: thumbnail size :synopsis: Maxiumum thumbnail dimension. :type: integer @@ -209,6 +228,9 @@ These are top-level fields whose values control various settings of the viewer. Thumbnails generated will have a maximum of *x* pixels in the long dimension. + .. note:: Unused if :confval:`thumbnail` is ``false`` or :option:`--no-thumbnail` + is specified. + Keybindings ----------- diff --git a/src/termvisage/config.py b/src/termvisage/config.py index 196a852..691c344 100644 --- a/src/termvisage/config.py +++ b/src/termvisage/config.py @@ -616,6 +616,11 @@ def update_context_nav( lambda x: isinstance(x, bool), "must be a boolean", ), + "thumbnail": Option( + True, + lambda x: isinstance(x, bool), + "must be a boolean", + ), "thumbnail cache": Option( 0, lambda x: isinstance(x, int) and x >= 0, diff --git a/src/termvisage/parsers.py b/src/termvisage/parsers.py index 02b74ec..1420cb0 100644 --- a/src/termvisage/parsers.py +++ b/src/termvisage/parsers.py @@ -593,6 +593,16 @@ def strip_markup(string: str) -> str: default=sys.getrecursionlimit() - 50, help=f"Maximum recursion depth (default: {sys.getrecursionlimit() - 50})", ) +tui_options.add_argument( + "--thumbnail", + action=BooleanOptionalAction, + default=None, + help=( + "Enable or disable thumbnail generation for the image grid; if enabled, " + "thumbnails are cached on disk and cleaned up upon exit " + "(default: :confval:`thumbnail` config)" + ), +) # Performance perf_options = parser.add_argument_group("Performance Options") From 919419060a69157a8f043be7cc9b7845dc3f19ce Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sat, 27 Apr 2024 00:04:52 +0100 Subject: [PATCH 16/37] feat,refac: tui: Implement thumbnailing en/disable - Add: `.main.THUMBNAIL`. - Change: Guard thumbnailing-related operations with `.main.THUMBNAIL`. - Change: Apply "max pixels" to grid rendering when thumbnailing is disabled. --- src/termvisage/tui/__init__.py | 23 ++++++++++++++--------- src/termvisage/tui/keys.py | 9 ++++++--- src/termvisage/tui/main.py | 1 + src/termvisage/tui/render.py | 14 ++++++++++---- src/termvisage/tui/widgets.py | 6 ++++-- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/termvisage/tui/__init__.py b/src/termvisage/tui/__init__.py index 5949b7d..b76236b 100644 --- a/src/termvisage/tui/__init__.py +++ b/src/termvisage/tui/__init__.py @@ -44,6 +44,7 @@ def init( main.NO_ANIMATION = args.no_anim main.RECURSIVE = args.recursive main.SHOW_HIDDEN = args.all + main.THUMBNAIL = args.thumbnail main.THUMBNAIL_SIZE_PRODUCT = config_options.thumbnail_size**2 main.ImageClass = ImageClass main.loop = Loop( @@ -87,7 +88,8 @@ def init( ) ) Image._ti_grid_style_spec = render.grid_style_specs.get(ImageClass.style, "") - Image._ti_update_grid_thumbnailing_threshold(keys._prev_cell_size) + if main.THUMBNAIL: + Image._ti_update_grid_thumbnailing_threshold(keys._prev_cell_size) # daemon, to avoid having to check if the main process has been interrupted menu_scanner = logging.Thread(target=scan_dir_menu, name="MenuScanner", daemon=True) @@ -98,12 +100,13 @@ def init( name="GridRenderManager", daemon=True, ) - grid_thumbnail_manager = logging.Thread( - target=render.manage_grid_thumbnails, - args=(config_options.thumbnail_size,), - name="GridThumbnailManager", - daemon=True, - ) + if main.THUMBNAIL: + grid_thumbnail_manager = logging.Thread( + target=render.manage_grid_thumbnails, + args=(config_options.thumbnail_size,), + name="GridThumbnailManager", + daemon=True, + ) image_render_manager = logging.Thread( target=render.manage_image_renders, name="ImageRenderManager", @@ -138,7 +141,8 @@ def init( menu_scanner.start() grid_scanner.start() - grid_thumbnail_manager.start() + if main.THUMBNAIL: + grid_thumbnail_manager.start() grid_render_manager.start() image_render_manager.start() anim_render_manager.start() @@ -147,7 +151,8 @@ def init( write_tty(f"{CSI}?1049h".encode()) # Switch to the alternate buffer next(main.displayer) main.loop.run() - grid_thumbnail_manager.join() + if main.THUMBNAIL: + grid_thumbnail_manager.join() grid_render_manager.join() render.image_render_queue.put((None,) * 3) image_render_manager.join() diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index 2a816a5..8c18a52 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -395,7 +395,8 @@ def resize(): if cell_size and cell_size != _prev_cell_size: _prev_cell_size = cell_size refresh_grid_rendering() - Image._ti_update_grid_thumbnailing_threshold(cell_size) + if main.THUMBNAIL: + Image._ti_update_grid_thumbnailing_threshold(cell_size) else: cell_ratio = get_cell_ratio() if cell_ratio != _prev_cell_ratio: @@ -491,7 +492,8 @@ def cell_width_dec(): refresh_grid_rendering() getattr(main.ImageClass, "clear", lambda: True)() - Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) + if main.THUMBNAIL: + Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) @register_key(("image-grid", "Size+")) @@ -501,7 +503,8 @@ def cell_width_inc(): refresh_grid_rendering() getattr(main.ImageClass, "clear", lambda: True)() - Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) + if main.THUMBNAIL: + Image._ti_update_grid_thumbnailing_threshold(_prev_cell_size) @register_key(("image-grid", "Open")) diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index a817e83..afa51e8 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -714,4 +714,5 @@ def update_screen(): NO_ANIMATION: bool RECURSIVE: bool SHOW_HIDDEN: bool +THUMBNAIL: bool THUMBNAIL_SIZE_PRODUCT: int diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 6d94610..9755c8b 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -16,6 +16,7 @@ from .. import logging, notify from ..logging_multi import Process from ..utils import clear_queue +from . import main def delete_thumbnail(thumbnail: str) -> bool: @@ -29,9 +30,10 @@ def delete_thumbnail(thumbnail: str) -> bool: def refresh_grid_rendering() -> None: - grid_thumbnail_queue.put(None) # Send the grid delimiter - grid_thumbnailer_in_sync.clear() - grid_thumbnailer_in_sync.wait() + if main.THUMBNAIL: + grid_thumbnail_queue.put(None) # Send the grid delimiter + grid_thumbnailer_in_sync.clear() + grid_thumbnailer_in_sync.wait() grid_render_queue.put(None) # Send the grid delimiter grid_renderer_in_sync.clear() @@ -468,8 +470,12 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: ) if grid_active.is_set(): update_screen() - if THUMBNAIL_CACHE_SIZE and thumbnail: + + # There's no need to check `.tui.main.THUMBNAIL` since + # `thumbnail` is always `None` when thumbnailing is disabled. + if thumbnail and THUMBNAIL_CACHE_SIZE: mark_thumbnail_rendered(source, thumbnail) + notify.stop_loading() finally: clear_queue(grid_render_in) diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 5ee0cbe..3cf6dbd 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -280,11 +280,12 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: # Forced render / Large images if ( - # is the image NOT in a grid cell? not ( # is the image grid in view? (next two lines) view.original_widget is image_grid_box and context != "full-grid-image" + # is thumbnailing enabled? + and tui_main.THUMBNAIL ) # does the image have more pixels than `max pixels`? and mul(*image.original_size) > tui_main.MAX_PIXELS @@ -348,7 +349,8 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: if not canv: # is the image not the grid cache? ( grid_thumbnail_queue - if ( + if tui_main.THUMBNAIL + and ( mul(*image.original_size) > __class__._ti_grid_thumbnailing_threshold ) From 4b989d39c1044f1fc604561849e302817f4b9ada Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sat, 27 Apr 2024 00:10:02 +0100 Subject: [PATCH 17/37] chore: tui.keys,tui.render: Update comments --- src/termvisage/tui/keys.py | 17 +++++++++++------ src/termvisage/tui/render.py | 5 +++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index 8c18a52..ebc97b6 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -303,8 +303,8 @@ def set_confirmation( main.set_context("confirmation") # `Image` widgets don't support overlay. - # Always reset by or "confirmation::Cancel" - # but _confirm()_ must reset `view.original_widget` on it's own. + # Always reset by "confirmation::Confirm" or "confirmation::Cancel" + # but *confirm* must reset `view.original_widget` on it's own. _prev_view_widget = view.original_widget view.original_widget = urwid.LineBox( placeholder, _prev_view_widget.title_widget.text.strip(" "), "left" @@ -732,18 +732,23 @@ def close(): key_bar._ti_collapsed = True expand._ti_shown = True -# Use in the "confirmation" context. Set by `set_confirmation()` +# Used in the "confirmation" context. +# +# Updated by `set_confirmation()`. _confirm: tuple[FunctionType, tuple[Any, ...]] | None = None _cancel: tuple[FunctionType, tuple[Any, ...]] | None = None # Used for overlays _prev_view_widget: urwid.Widget | None = None -# Used to guard clearing of grid render cache -# Updated from `resize()`, for text-based styles. +# Used to guard grid render refresh upon terminal resize, for text-based styles. +# +# Updated from `resize()`. _prev_cell_ratio: float = 0.0 -# Used to [re]compute the thumbnailing threshold. +# Used to [re]compute the grid thumbnailing threshold. +# Also used to guard grid render refresh on terminal resize, for graphics-based styles. +# # The default value is for text-based styles, for which this variable is never updated. # Updated from `.tui.init()` and `resize()`, for graphics-based styles. _prev_cell_size: tuple[int, int] = (1, 2) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 9755c8b..e4557d0 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -456,10 +456,11 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: pass else: source_dirname, source_basename = split(source) - # The directory and cell-width checks are to filter out any remnants - # that were still being rendered at the other end if ( in_sync.is_set() + # The directory and cell-width checks are to filter out any + # remnants that were caught up in the renderer(s) while syncing + # grid rendering. and source_dirname == grid_path and size[0] + 2 == cell_width ): From 70a57bc954230f05533284e537a8e7f2148484eb Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sat, 27 Apr 2024 01:12:42 +0100 Subject: [PATCH 18/37] feat: tui: Make "max pixels" optional for the grid - Add: `--no-max-pixels-grid` command-line option. _ Add: `.main.NO_MAX_PIXELS_GRID`. - Change: Do not apply "max pixels" to grid rendering when thumbnailing is disabled and `--no-max-pixels-grid` is specified. --- src/termvisage/parsers.py | 8 ++++++++ src/termvisage/tui/__init__.py | 1 + src/termvisage/tui/main.py | 1 + src/termvisage/tui/widgets.py | 8 ++++++-- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/termvisage/parsers.py b/src/termvisage/parsers.py index 1420cb0..9ccbc59 100644 --- a/src/termvisage/parsers.py +++ b/src/termvisage/parsers.py @@ -620,6 +620,14 @@ def strip_markup(string: str) -> str: action="store_true", help="Apply :option:`--max-pixels` in CLI mode", ) +perf_options.add_argument( + "--no-max-pixels-grid", + action="store_true", + help=( + "Do not apply :option:`--max-pixels` to the image grid in TUI mode when " + "thumbnailing is disabled." + ), +) perf_options.add_argument( "--multi", action=BooleanOptionalAction, diff --git a/src/termvisage/tui/__init__.py b/src/termvisage/tui/__init__.py index b76236b..4ab0d59 100644 --- a/src/termvisage/tui/__init__.py +++ b/src/termvisage/tui/__init__.py @@ -42,6 +42,7 @@ def init( main.DEBUG = args.debug main.MAX_PIXELS = args.max_pixels main.NO_ANIMATION = args.no_anim + main.NO_MAX_PIXELS_GRID = args.no_max_pixels_grid main.RECURSIVE = args.recursive main.SHOW_HIDDEN = args.all main.THUMBNAIL = args.thumbnail diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index afa51e8..6a5a0e5 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -712,6 +712,7 @@ def update_screen(): DEBUG: bool MAX_PIXELS: int NO_ANIMATION: bool +NO_MAX_PIXELS_GRID: bool RECURSIVE: bool SHOW_HIDDEN: bool THUMBNAIL: bool diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 3cf6dbd..d2e93ef 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -284,8 +284,12 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: # is the image grid in view? (next two lines) view.original_widget is image_grid_box and context != "full-grid-image" - # is thumbnailing enabled? - and tui_main.THUMBNAIL + and ( + # is thumbnailing enabled? + tui_main.THUMBNAIL + # does "max pixels" NOT apply to the image grid? + or tui_main.NO_MAX_PIXELS_GRID + ) ) # does the image have more pixels than `max pixels`? and mul(*image.original_size) > tui_main.MAX_PIXELS From 735c192cc1d5c6622fdf43f6a20b2c094ae44c6f Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sat, 27 Apr 2024 01:28:12 +0100 Subject: [PATCH 19/37] docs: cli,config: Update option descriptions - Add: Mention `--no-max-pixels-grid` where neccessary. - Change: Update the descriptions of `max pixels` and `thumbnail` config options. - Change: Update the description of `--max-pixels` command-line opton. --- docs/source/cli.rst | 21 +++++++++++++-------- docs/source/config.rst | 25 ++++++++++++++++--------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index a8ffed1..2708356 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -45,16 +45,21 @@ Options and Arguments :option:`--original-size` is used if not larger than the :term:`available size`, else :option:`--fit`. -.. [#] Any image having more pixels than the specified maximum will be: +.. [#] Any image having more pixels than the specified value will be: - - skipped, in CLI mode, if :option:`--max-pixels-cli` is specified. - - replaced, in TUI mode, with a placeholder when displayed but can still be - explicitly made to display. + - **skipped**, in CLI mode, if :option:`--max-pixels-cli` is specified. + - **replaced**, in the image grid in TUI mode, with a placeholder only if thumbnail + generation is disabled (via :confval:`thumbnail` or :option:`--no-thumbnail`) + and :option:`--no-max-pixels-grid` is **not** specified. + - **replaced**, in other contexts in TUI mode, with a placeholder but can still be + forced to display. - Note that increasing this should not have any effect on general performance - (i.e navigation, etc) but the larger an image is, the more the time and memory - it'll take to render it. Thus, a large image might delay the rendering of other - images to be rendered immediately after it. + .. important:: + + Increasing this should have little to no effect on general + performance (i.e navigation, etc) but the larger an image is, the more the + time and memory it'll take to render it. Thus, a large image might delay the + rendering of other images to be rendered immediately after it. .. [#] Any event with a level lower than the specified one is not reported. diff --git a/docs/source/config.rst b/docs/source/config.rst index 78c8017..9c11966 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -137,17 +137,22 @@ These are top-level fields whose values control various settings of the viewer. Any image having more pixels than the specified value will be: - * skipped, in CLI mode, if :option:`--max-pixels-cli` is specified. - * replaced, in TUI mode, with a placeholder when displayed but can still be forced - to display or viewed externally. - - Note that increasing this should not have any effect on general performance (i.e - navigation, etc) but the larger an image is, the more the time and memory it'll take - to render it. Thus, a large image might delay the rendering of other images to be - rendered immediately after it. + * **skipped**, in CLI mode, if :option:`--max-pixels-cli` is specified. + * **replaced**, in the image grid in TUI mode, with a placeholder only if + thumbnailing is disabled (via :confval:`thumbnail` or :option:`--no-thumbnail`) + and :option:`--no-max-pixels-grid` is **not** specified. + * **replaced**, in other contexts in TUI mode, with a placeholder but can still be + forced to display. .. note:: Overridden by :option:`--max-pixels`. + .. important:: + + Increasing this should have little to no effect on general + performance (i.e navigation, etc) but the larger an image is, the more the + time and memory it'll take to render it. Thus, a large image might delay the + rendering of other images to be rendered immediately after it. + .. confval:: multi :synopsis: Enable (if supported) or disable multiprocessing. :type: boolean @@ -192,7 +197,7 @@ These are top-level fields whose values control various settings of the viewer. * Affects *auto* :term:`cell ratio` computation. .. confval:: thumbnail - :synopsis: Enable or disable thumbnail generation for the image grid. + :synopsis: Enable or disable thumbnailing for the image grid. :type: boolean :valid: ``true``, ``false`` :default: ``true`` @@ -207,6 +212,8 @@ These are top-level fields whose values control various settings of the viewer. - Thumbnails are generated **on demand** i.e a thumbnail will be generated for an image only if its grid cell has come into view at least once. + .. seealso:: :option:`--no-max-pixels-grid`. + .. confval:: thumbnail cache :synopsis: The maximum amount of thumbnails that can be cached per time. :type: integer From f20e31ed28d6fbec7d917d55ca345aab505b9518 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Sat, 27 Apr 2024 09:35:20 +0100 Subject: [PATCH 20/37] chore: Add thumbnail options to the sample config - Add: `thumbnail`, `thumbnail cache` and `thumbnail size` to the sample default config file. --- default-termvisage.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/default-termvisage.json b/default-termvisage.json index d2ca77e..9d6ffd0 100644 --- a/default-termvisage.json +++ b/default-termvisage.json @@ -12,6 +12,9 @@ "query timeout": 0.1, "style": "auto", "swap win size": false, + "thumbnail": true, + "thumbnail cache": 0, + "thumbnail size": 256, "keys": { "navigation": { "Left": [ From b3aec3fff0c2ac3fe4a7e752b24bc26b2f063a76 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Mon, 29 Apr 2024 23:09:08 +0100 Subject: [PATCH 21/37] Revert 'Make "max pixels" optional for the grid' This reverts commit 70a57bc954230f05533284e537a8e7f2148484eb. --- src/termvisage/parsers.py | 8 -------- src/termvisage/tui/__init__.py | 1 - src/termvisage/tui/main.py | 1 - src/termvisage/tui/widgets.py | 8 ++------ 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/termvisage/parsers.py b/src/termvisage/parsers.py index 9ccbc59..1420cb0 100644 --- a/src/termvisage/parsers.py +++ b/src/termvisage/parsers.py @@ -620,14 +620,6 @@ def strip_markup(string: str) -> str: action="store_true", help="Apply :option:`--max-pixels` in CLI mode", ) -perf_options.add_argument( - "--no-max-pixels-grid", - action="store_true", - help=( - "Do not apply :option:`--max-pixels` to the image grid in TUI mode when " - "thumbnailing is disabled." - ), -) perf_options.add_argument( "--multi", action=BooleanOptionalAction, diff --git a/src/termvisage/tui/__init__.py b/src/termvisage/tui/__init__.py index 4ab0d59..b76236b 100644 --- a/src/termvisage/tui/__init__.py +++ b/src/termvisage/tui/__init__.py @@ -42,7 +42,6 @@ def init( main.DEBUG = args.debug main.MAX_PIXELS = args.max_pixels main.NO_ANIMATION = args.no_anim - main.NO_MAX_PIXELS_GRID = args.no_max_pixels_grid main.RECURSIVE = args.recursive main.SHOW_HIDDEN = args.all main.THUMBNAIL = args.thumbnail diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index 6a5a0e5..afa51e8 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -712,7 +712,6 @@ def update_screen(): DEBUG: bool MAX_PIXELS: int NO_ANIMATION: bool -NO_MAX_PIXELS_GRID: bool RECURSIVE: bool SHOW_HIDDEN: bool THUMBNAIL: bool diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index d2e93ef..3cf6dbd 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -284,12 +284,8 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: # is the image grid in view? (next two lines) view.original_widget is image_grid_box and context != "full-grid-image" - and ( - # is thumbnailing enabled? - tui_main.THUMBNAIL - # does "max pixels" NOT apply to the image grid? - or tui_main.NO_MAX_PIXELS_GRID - ) + # is thumbnailing enabled? + and tui_main.THUMBNAIL ) # does the image have more pixels than `max pixels`? and mul(*image.original_size) > tui_main.MAX_PIXELS From f2c0c02d7c22f9cc3bbbd5961ee7c935ad3e4850 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Mon, 29 Apr 2024 23:19:00 +0100 Subject: [PATCH 22/37] docs: args,config: Update "max pixels" description - Change: Update the descriptions of `max pixels` config option and `--max-pixels` CL option. - Change: Remove references to `--no-max-pixels-grid`. --- docs/source/cli.rst | 8 +++----- docs/source/config.rst | 10 +++------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 2708356..a04d24f 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -48,11 +48,9 @@ Options and Arguments .. [#] Any image having more pixels than the specified value will be: - **skipped**, in CLI mode, if :option:`--max-pixels-cli` is specified. - - **replaced**, in the image grid in TUI mode, with a placeholder only if thumbnail - generation is disabled (via :confval:`thumbnail` or :option:`--no-thumbnail`) - and :option:`--no-max-pixels-grid` is **not** specified. - - **replaced**, in other contexts in TUI mode, with a placeholder but can still be - forced to display. + - **replaced**, in TUI mode, with a placeholder (filled with exclamation marks) + but can be forced to display using the **"Force Render"** action in contexts + with full-sized image views. .. important:: diff --git a/docs/source/config.rst b/docs/source/config.rst index 9c11966..1624935 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -138,11 +138,9 @@ These are top-level fields whose values control various settings of the viewer. Any image having more pixels than the specified value will be: * **skipped**, in CLI mode, if :option:`--max-pixels-cli` is specified. - * **replaced**, in the image grid in TUI mode, with a placeholder only if - thumbnailing is disabled (via :confval:`thumbnail` or :option:`--no-thumbnail`) - and :option:`--no-max-pixels-grid` is **not** specified. - * **replaced**, in other contexts in TUI mode, with a placeholder but can still be - forced to display. + * **replaced**, in TUI mode, with a placeholder (filled with exclamation marks) + but can be forced to display using the **"Force Render"** action in contexts + with full-sized image views. .. note:: Overridden by :option:`--max-pixels`. @@ -212,8 +210,6 @@ These are top-level fields whose values control various settings of the viewer. - Thumbnails are generated **on demand** i.e a thumbnail will be generated for an image only if its grid cell has come into view at least once. - .. seealso:: :option:`--no-max-pixels-grid`. - .. confval:: thumbnail cache :synopsis: The maximum amount of thumbnails that can be cached per time. :type: integer From a6ee0033566642e9ca66210f6a65f1ff2c64e40d Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 30 Apr 2024 00:59:50 +0100 Subject: [PATCH 23/37] refac,docs: args,config: Make "max pixels" opt-in - Change: Include zero in the value range for `max pixels` config option and `--max-pixels` CL option. A value of zero implies the absence of a maximum pixel-count i.e all images are rendered regardless of their resolution. - Change: Change the default value of `max pixels` config option to zero. - Change: Update the documentation of the `max pixels` config option and `--max-pixels` CL option. - Update the description, value range and default value. - Remove the admonition about performance implications. - Change: Remove the footnote for the `--max-pixels` CL option; the repetition is unnecessary. Turns out displaying all images regardless of resolution results in a better out-of-the-box user experience as that's the expectation of literally every user (other than myself :facepalm:). Also, the so-called "performance implications" of displaying high-res images turns to be really not as bad as I had it pictured in my mind. Thanks to [@qrockz](https://github.com/qrockz) and [@DreamMaoMao](https://github.com/DreamMaoMao) for helping me make the right decision :smiley:. Refs: #12. --- docs/source/cli.rst | 14 -------------- docs/source/config.rst | 20 +++++++------------- src/termvisage/cli.py | 2 +- src/termvisage/config.py | 6 +++--- src/termvisage/parsers.py | 4 ++-- src/termvisage/tui/widgets.py | 6 +++--- 6 files changed, 16 insertions(+), 36 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index a04d24f..671b198 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -45,20 +45,6 @@ Options and Arguments :option:`--original-size` is used if not larger than the :term:`available size`, else :option:`--fit`. -.. [#] Any image having more pixels than the specified value will be: - - - **skipped**, in CLI mode, if :option:`--max-pixels-cli` is specified. - - **replaced**, in TUI mode, with a placeholder (filled with exclamation marks) - but can be forced to display using the **"Force Render"** action in contexts - with full-sized image views. - - .. important:: - - Increasing this should have little to no effect on general - performance (i.e navigation, etc) but the larger an image is, the more the - time and memory it'll take to render it. Thus, a large image might delay the - rendering of other images to be rendered immediately after it. - .. [#] Any event with a level lower than the specified one is not reported. .. [#] 0 -> worst quality; smallest data size, 95 -> best quality; largest data size. diff --git a/docs/source/config.rst b/docs/source/config.rst index 1624935..057d7d9 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -130,27 +130,21 @@ These are top-level fields whose values control various settings of the viewer. Adjusts the height of the :ref:`notification bar `. .. confval:: max pixels - :synopsis: The maximum amount of pixels in images to be displayed in the TUI. + :synopsis: The maximum pixel-count for images that should be rendered. :type: integer - :valid: *x* > ``0`` - :default: ``4194304`` (2 ** 22) + :valid: *x* >= ``0`` + :default: ``0`` - Any image having more pixels than the specified value will be: + If zero, all images will be rendered normally, regardless of their resolution. + Otherwise, any image having more pixels than the specified value will be: * **skipped**, in CLI mode, if :option:`--max-pixels-cli` is specified. * **replaced**, in TUI mode, with a placeholder (filled with exclamation marks) - but can be forced to display using the **"Force Render"** action in contexts - with full-sized image views. + but can be forced to render using the **"Force Render"** :ref:`action ` + in :ref:`contexts` with a full-sized image view. .. note:: Overridden by :option:`--max-pixels`. - .. important:: - - Increasing this should have little to no effect on general - performance (i.e navigation, etc) but the larger an image is, the more the - time and memory it'll take to render it. Thus, a large image might delay the - rendering of other images to be rendered immediately after it. - .. confval:: multi :synopsis: Enable (if supported) or disable multiprocessing. :type: boolean diff --git a/src/termvisage/cli.py b/src/termvisage/cli.py index 8717648..64505a6 100644 --- a/src/termvisage/cli.py +++ b/src/termvisage/cli.py @@ -948,7 +948,7 @@ def main() -> None: show_name = len(args.sources) > 1 for entry in images: image = entry[1]._ti_image - if args.max_pixels_cli and mul(*image._original_size) > args.max_pixels: + if args.max_pixels_cli and 0 < args.max_pixels < mul(*image._original_size): log( f"Has more than the maximum pixel-count, skipping: {entry[0]!r}", logger, diff --git a/src/termvisage/config.py b/src/termvisage/config.py index 691c344..a57d4dc 100644 --- a/src/termvisage/config.py +++ b/src/termvisage/config.py @@ -592,9 +592,9 @@ def update_context_nav( "must be an integer between 0 and 5 (both inclusive)", ), "max pixels": Option( - 2**22, # 2048x2048 - lambda x: isinstance(x, int) and x > 0, - "must be an integer greater than zero", + 0, + lambda x: isinstance(x, int) and x >= 0, + "must be a non-negative integer", ), "multi": Option( True, diff --git a/src/termvisage/parsers.py b/src/termvisage/parsers.py index 1420cb0..80100c3 100644 --- a/src/termvisage/parsers.py +++ b/src/termvisage/parsers.py @@ -611,8 +611,8 @@ def strip_markup(string: str) -> str: type=int, metavar="N", help=( - "Maximum amount of pixels in images to be displayed " - "(default: :confval:`max pixels` config) [#]_" + "The maximum pixel-count for images that should be rendered " + "(default: :confval:`max pixels` config)" ), ) perf_options.add_argument( diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 3cf6dbd..726ccd8 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -280,15 +280,15 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: # Forced render / Large images if ( - not ( + # is there a maximum pixel-count and is the image's pixel-count higher? + 0 < tui_main.MAX_PIXELS < mul(*image.original_size) + and not ( # is the image grid in view? (next two lines) view.original_widget is image_grid_box and context != "full-grid-image" # is thumbnailing enabled? and tui_main.THUMBNAIL ) - # does the image have more pixels than `max pixels`? - and mul(*image.original_size) > tui_main.MAX_PIXELS # has the image NOT been force-rendered, with a valid-sized canvas? and not ( # has the widget been force-rendered? From 54950d2c4354ee960484bc5db575aba1713657bd Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 30 Apr 2024 01:08:33 +0100 Subject: [PATCH 24/37] refac,docs: render,config: Minor refactor/reword - Change: Simply comparisons involving `.render.THUMBNAIL_CACHE_SIZE` using chained comparisons. - Change: Correct terminology: image "size" -> image "resolution". --- docs/source/config.rst | 6 +++--- src/termvisage/tui/render.py | 7 ++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/source/config.rst b/docs/source/config.rst index 057d7d9..ced4061 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -194,9 +194,9 @@ These are top-level fields whose values control various settings of the viewer. :valid: ``true``, ``false`` :default: ``true`` - If ``true``, thumbnails are generated for some images (based on their size), cached - on disk and cleaned up upon exit. Otherwise, all images in the grid are rendered - directly from the original image files. + If ``true``, thumbnails are generated for some images (based on their resolution), + cached on disk and cleaned up upon exit. Otherwise, all images in the grid are + rendered directly from the original image files. .. note:: diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index e4557d0..688e4b2 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -496,7 +496,7 @@ def manage_grid_thumbnails(thumbnail_size: int) -> None: def cache_thumbnail(source: str, thumbnail: str) -> None: # Eviction, for finite cache size - if THUMBNAIL_CACHE_SIZE and len(thumbnail_cache) == THUMBNAIL_CACHE_SIZE: + if 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache): # Evict and delete the first cached thumbnail not in the render pipeline for other_source, other_thumbnail in thumbnail_cache.items(): # `thumbnail_render_lock` is unnecessary here since it's just a @@ -583,10 +583,7 @@ def cache_thumbnail(source: str, thumbnail: str) -> None: except Empty: break else: - if ( - THUMBNAIL_CACHE_SIZE - and len(thumbnail_cache) == THUMBNAIL_CACHE_SIZE - ): + if 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache): # Quicker than the eviction process delete_thumbnail(thumbnail) else: From 03358cb0fe3b62835ee0ea113b5b58d9f9c9a66e Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 30 Apr 2024 01:24:54 +0100 Subject: [PATCH 25/37] refac,docs: args: Remove `--max-pixels-cli` The option is no longer neccessary since the *max pixels* setting is now opt-in. --- docs/source/config.rst | 4 ++-- src/termvisage/cli.py | 2 +- src/termvisage/parsers.py | 5 ----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/source/config.rst b/docs/source/config.rst index ced4061..e7a8f73 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -138,8 +138,8 @@ These are top-level fields whose values control various settings of the viewer. If zero, all images will be rendered normally, regardless of their resolution. Otherwise, any image having more pixels than the specified value will be: - * **skipped**, in CLI mode, if :option:`--max-pixels-cli` is specified. - * **replaced**, in TUI mode, with a placeholder (filled with exclamation marks) + * **skipped** in CLI mode. + * **replaced** in TUI mode, with a placeholder (filled with exclamation marks) but can be forced to render using the **"Force Render"** :ref:`action ` in :ref:`contexts` with a full-sized image view. diff --git a/src/termvisage/cli.py b/src/termvisage/cli.py index 64505a6..9d8d142 100644 --- a/src/termvisage/cli.py +++ b/src/termvisage/cli.py @@ -948,7 +948,7 @@ def main() -> None: show_name = len(args.sources) > 1 for entry in images: image = entry[1]._ti_image - if args.max_pixels_cli and 0 < args.max_pixels < mul(*image._original_size): + if 0 < args.max_pixels < mul(*image._original_size): log( f"Has more than the maximum pixel-count, skipping: {entry[0]!r}", logger, diff --git a/src/termvisage/parsers.py b/src/termvisage/parsers.py index 80100c3..5912e3f 100644 --- a/src/termvisage/parsers.py +++ b/src/termvisage/parsers.py @@ -615,11 +615,6 @@ def strip_markup(string: str) -> str: "(default: :confval:`max pixels` config)" ), ) -perf_options.add_argument( - "--max-pixels-cli", - action="store_true", - help="Apply :option:`--max-pixels` in CLI mode", -) perf_options.add_argument( "--multi", action=BooleanOptionalAction, From 4ecb8cff6f681fb0b39a679bfc8e9c0e1d9b90b6 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 30 Apr 2024 03:07:25 +0100 Subject: [PATCH 26/37] refac: tui: No "max pixels" in "full-grid-image" - Change: Always render images in the "full-grid-image" context, regardless of resolution. - Change: Remove "full-grid-image::Force Render" action. - Change: Update the description of the `max pixels` config option. I fail to see a need for the *max pixels* setting in this context since full-sized views of images can/will not typically be displayed in rapid succession. Refs: https://github.com/AnonymouX47/termvisage/issues/12#issuecomment-2081394428 --- default-termvisage.json | 4 ---- docs/source/config.rst | 7 ++++--- src/termvisage/config.py | 5 ----- src/termvisage/tui/keys.py | 12 ------------ src/termvisage/tui/widgets.py | 6 +++--- 5 files changed, 7 insertions(+), 27 deletions(-) diff --git a/default-termvisage.json b/default-termvisage.json index 9d6ffd0..8379565 100644 --- a/default-termvisage.json +++ b/default-termvisage.json @@ -140,10 +140,6 @@ "Back": [ "esc", "\u238b" - ], - "Force Render": [ - "F", - "\u21e7F" ] }, "confirmation": { diff --git a/docs/source/config.rst b/docs/source/config.rst index e7a8f73..c58da93 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -139,9 +139,10 @@ These are top-level fields whose values control various settings of the viewer. Otherwise, any image having more pixels than the specified value will be: * **skipped** in CLI mode. - * **replaced** in TUI mode, with a placeholder (filled with exclamation marks) - but can be forced to render using the **"Force Render"** :ref:`action ` - in :ref:`contexts` with a full-sized image view. + * **replaced** in TUI mode (except in the ``full-grid-image`` + :ref:`context `), with a placeholder (filled with exclamation marks) + but can be forced to render using the ``Force Render`` :ref:`action ` + in :ref:`contexts ` with a full-sized image view. .. note:: Overridden by :option:`--max-pixels`. diff --git a/src/termvisage/config.py b/src/termvisage/config.py index a57d4dc..2e15882 100644 --- a/src/termvisage/config.py +++ b/src/termvisage/config.py @@ -706,11 +706,6 @@ def update_context_nav( }, "full-grid-image": { "Back": ["esc", "\u238b", "Back to grid view"], - "Force Render": [ - "F", - "\u21e7F", - "Force an image, with more pixels than the set maximum, to be displayed", - ], }, "confirmation": { "Confirm": ["enter", "\u23ce", ""], diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index ebc97b6..476ca1c 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -536,18 +536,6 @@ def set_image_grid_actions(): disable_actions("image-grid", "Open", "Size-", "Size+") -# full-grid-image -@register_key(("full-grid-image", "Force Render")) -def force_render_maximized_cell(): - # Will re-render immediately after processing input, since caching has been disabled - # for `Image` widgets. - image_w = image_box._w.contents[1][0].contents[1][0] - if image_w._ti_image.is_animated: - main.animate_image(image_w, True) - else: - image_w._ti_force_render = True - - # full-image, full-grid-image @register_key(("full-image", "Restore"), ("full-grid-image", "Back")) def restore(): diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 726ccd8..667d05f 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -248,7 +248,7 @@ class Image(urwid.Widget): _ti_placeholder = urwid.SolidFill(".") _ti_force_render = False - _ti_force_render_contexts = {"image", "full-image", "full-grid-image"} + _ti_force_render_contexts = {"image", "full-image"} _ti_forced_anim_size_hash = None _ti_frame = None @@ -282,10 +282,10 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: if ( # is there a maximum pixel-count and is the image's pixel-count higher? 0 < tui_main.MAX_PIXELS < mul(*image.original_size) + and context != "full-grid-image" and not ( - # is the image grid in view? (next two lines) + # is the image grid in view? ("full-grid-image" already ruled out) view.original_widget is image_grid_box - and context != "full-grid-image" # is thumbnailing enabled? and tui_main.THUMBNAIL ) From 723583b362da0932b5d5d9824fdec62a0fa1f419 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 30 Apr 2024 03:24:57 +0100 Subject: [PATCH 27/37] feat: tui: Distinguish high-res images in the grid - Add: A "high-res" pallete entry to the TUI. - Add: A *title_attr* parameter to the `.widgets.LineSquare` constructor. Makes it possible to set any display attribute for the square's title. - Change: Change the title and border color for grid cells containing images having pixel counts above *max pixels* to yellow, in order to highlight them to the user. - Change: Update the description of the `max pixels` config option. Refs: https://github.com/AnonymouX47/termvisage/issues/12#issuecomment-2081394428 --- docs/source/config.rst | 3 +++ src/termvisage/tui/__init__.py | 1 + src/termvisage/tui/main.py | 10 ++++++++-- src/termvisage/tui/widgets.py | 6 ++++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/source/config.rst b/docs/source/config.rst index c58da93..cecc90d 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -144,6 +144,9 @@ These are top-level fields whose values control various settings of the viewer. but can be forced to render using the ``Force Render`` :ref:`action ` in :ref:`contexts ` with a full-sized image view. + In the ``image-grid`` :ref:`context `, such images have a **yellow** + border (when selected) and title. + .. note:: Overridden by :option:`--max-pixels`. .. confval:: multi diff --git a/src/termvisage/tui/__init__.py b/src/termvisage/tui/__init__.py index b76236b..edfca5f 100644 --- a/src/termvisage/tui/__init__.py +++ b/src/termvisage/tui/__init__.py @@ -205,4 +205,5 @@ def process_input(self, keys): ("error", "", "", "", "bold", "#ff0000"), ("warning", "", "", "", "#ff0000,bold", ""), ("notif context", "", "", "", "#0000ff,bold", ""), + ("high-res", "", "", "", "#a07f00", ""), ] diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index afa51e8..2d2c26b 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -496,12 +496,18 @@ def scan_dir_grid() -> None: if kind is EntryKind.IMAGE: grid_list.append(item) image_w = item[1] + image = image_w._ti_image + border_attr = ( + "high-res" + if 0 < MAX_PIXELS < mul(*image.original_size) + else "focused box" + ) grid_contents.append( ( urwid.AttrMap( - LineSquare(image_w, basename(image_w._ti_image.source)), + LineSquare(image_w, basename(image.source), border_attr), "unfocused box", - "focused box", + border_attr, ), image_grid.options(), ) diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 667d05f..3ddc099 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -540,12 +540,14 @@ class LineSquare(WidgetDecoration, WidgetWrap): no_cache = ["render", "rows"] _sizing = frozenset((urwid.FLOW,)) - def __init__(self, widget, title=""): + def __init__(self, widget, title="", title_attr=None): title_w = Text(title and f" {title} ", wrap="ellipsis") top_w = Columns( [ (PACK, Text("┌")), - Columns([(PACK, AttrMap(title_w, "default")), Divider("─")]), + Columns( + [(PACK, AttrMap(title_w, title_attr or "default")), Divider("─")] + ), (PACK, Text("┐")), ] ) From 9336dae0039c4a3638da60d9802b26cb9e92f52c Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 30 Apr 2024 03:39:19 +0100 Subject: [PATCH 28/37] feat: tui: Force-rendering in the "menu" context - Change: Add "menu::Force Render" action. --- default-termvisage.json | 4 ++++ src/termvisage/config.py | 5 +++++ src/termvisage/tui/keys.py | 4 +++- src/termvisage/tui/widgets.py | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/default-termvisage.json b/default-termvisage.json index 8379565..3dadb45 100644 --- a/default-termvisage.json +++ b/default-termvisage.json @@ -77,6 +77,10 @@ "backspace", "\u27f5 " ], + "Force Render": [ + "F", + "\u21e7F" + ], "Delete": [ "d", "d" diff --git a/src/termvisage/config.py b/src/termvisage/config.py index 2e15882..514f327 100644 --- a/src/termvisage/config.py +++ b/src/termvisage/config.py @@ -660,6 +660,11 @@ def update_context_nav( "Prev": ["up", "", "Select the next item on the list"], "Next": ["down", "", "Select the previous item on the list"], "Back": ["backspace", "\u27f5 ", "Return to the previous directory"], + "Force Render": [ + "F", + "\u21e7F", + "Force an image, with more pixels than the set maximum, to be displayed", + ], "Delete": ["d", "d", "Delete selected image"], "Switch Pane": ["tab", "\u21b9", "Switch to image pane"], "Page Up": ["page up", "", "Jump up one page"], diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index 476ca1c..6cd3626 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -578,7 +578,9 @@ def next_image(): set_menu_count() -@register_key(("image", "Force Render"), ("full-image", "Force Render")) +@register_key( + ("menu", "Force Render"), ("image", "Force Render"), ("full-image", "Force Render") +) def force_render(): # Will re-render immediately after processing input, since caching has been disabled # for `Image` widgets. diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 3ddc099..15058b4 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -248,7 +248,7 @@ class Image(urwid.Widget): _ti_placeholder = urwid.SolidFill(".") _ti_force_render = False - _ti_force_render_contexts = {"image", "full-image"} + _ti_force_render_contexts = {"menu", "image", "full-image"} _ti_forced_anim_size_hash = None _ti_frame = None From 9ced040f77b68236bdc75f57053ef5b965f60a95 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Tue, 30 Apr 2024 03:50:35 +0100 Subject: [PATCH 29/37] refac: tui,config: Reorder "Force Render" actions - Change: Move the "Force Render" action in the "menu", "image" and "full-image" contexts to after all other non-naviagtion actions since its priority has decreased now that *max pixels* is opt-in. --- default-termvisage.json | 24 ++++++++++++------------ src/termvisage/config.py | 12 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/default-termvisage.json b/default-termvisage.json index 3dadb45..57237ef 100644 --- a/default-termvisage.json +++ b/default-termvisage.json @@ -77,10 +77,6 @@ "backspace", "\u27f5 " ], - "Force Render": [ - "F", - "\u21e7F" - ], "Delete": [ "d", "d" @@ -88,13 +84,13 @@ "Switch Pane": [ "tab", "\u21b9" - ] - }, - "image": { + ], "Force Render": [ "F", "\u21e7F" - ], + ] + }, + "image": { "Maximize": [ "f", "f" @@ -106,6 +102,10 @@ "Switch Pane": [ "tab", "\u21b9" + ], + "Force Render": [ + "F", + "\u21e7F" ] }, "image-grid": { @@ -131,13 +131,13 @@ "esc", "\u238b" ], - "Force Render": [ - "F", - "\u21e7F" - ], "Delete": [ "d", "d" + ], + "Force Render": [ + "F", + "\u21e7F" ] }, "full-grid-image": { diff --git a/src/termvisage/config.py b/src/termvisage/config.py index 514f327..ab39e85 100644 --- a/src/termvisage/config.py +++ b/src/termvisage/config.py @@ -660,13 +660,13 @@ def update_context_nav( "Prev": ["up", "", "Select the next item on the list"], "Next": ["down", "", "Select the previous item on the list"], "Back": ["backspace", "\u27f5 ", "Return to the previous directory"], + "Delete": ["d", "d", "Delete selected image"], + "Switch Pane": ["tab", "\u21b9", "Switch to image pane"], "Force Render": [ "F", "\u21e7F", "Force an image, with more pixels than the set maximum, to be displayed", ], - "Delete": ["d", "d", "Delete selected image"], - "Switch Pane": ["tab", "\u21b9", "Switch to image pane"], "Page Up": ["page up", "", "Jump up one page"], "Page Down": ["page down", "", "Jump down one page"], "Top": ["home", "", "Jump to the top of the list"], @@ -675,14 +675,14 @@ def update_context_nav( "image": { "Prev": ["left", "", "Move to the previous image"], "Next": ["right", "", "Move to the next image"], + "Maximize": ["f", "f", "Maximize the current image"], + "Delete": ["d", "d", "Delete current image"], + "Switch Pane": ["tab", "\u21b9", "Switch to list pane"], "Force Render": [ "F", "\u21e7F", "Force an image, with more pixels than the set maximum, to be displayed", ], - "Maximize": ["f", "f", "Maximize the current image"], - "Delete": ["d", "d", "Delete current image"], - "Switch Pane": ["tab", "\u21b9", "Switch to list pane"], }, "image-grid": { "Open": ["enter", "\u23ce", "Maximize the selected image"], @@ -702,12 +702,12 @@ def update_context_nav( "Restore": ["esc", "\u238b", "Exit maximized view"], "Prev": ["left", "", "Move to the previous image"], "Next": ["right", "", "Move to the next image"], + "Delete": ["d", "d", "Delete current image"], "Force Render": [ "F", "\u21e7F", "Force an image, with more pixels than the set maximum, to be displayed", ], - "Delete": ["d", "d", "Delete current image"], }, "full-grid-image": { "Back": ["esc", "\u238b", "Back to grid view"], From ec101379b9ef4e077c3528ebafe44a437ae27d3e Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Wed, 1 May 2024 22:40:35 +0100 Subject: [PATCH 30/37] feat,refac,docs: render: Thumbnail deduplication - Add: Thumbnail deduplication to grid image rendering. Source images with **exactly** identical thumbnails share the same thumbnail file. - Add: `thumbnail_sources` to keep track of sources sharing a thumbnail file. - Add: A *deduplicated* field to the output thumbnail queue items. - Add: Deduplication feature to the `thumbnail` config option docs. - Change: Name thumbnail files based on a hash of their content instead of the source file name, in order to implement/aid deduplication. - Change: No longer guard thumbnail cleanup with `THUMNAIL_CACHE_SIZE` as deduplication also necessitates it. - Change: Refactor `generate_grid_thumbnails()`. - Change: Refactor `manage_grid_thumbnails()`. - Change: Re-implement `mark_thumbnail_rendered()` in `manage_grid_renders()`. - Change: Re-implement thumbnail eviction. - Now always evicts the oldest thumbnail with the least amount of linked sources. - The process is now simpler and more straightforward. --- docs/source/config.rst | 2 + src/termvisage/tui/render.py | 269 +++++++++++++++++++++++++---------- 2 files changed, 192 insertions(+), 79 deletions(-) diff --git a/docs/source/config.rst b/docs/source/config.rst index cecc90d..fbb8046 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -207,6 +207,8 @@ These are top-level fields whose values control various settings of the viewer. - Overridden by :option:`--thumbnail` and :option:`--no-thumbnail`. - Thumbnails are generated **on demand** i.e a thumbnail will be generated for an image only if its grid cell has come into view at least once. + - Thumbnails are **deduplicated** i.e only one thumbnail is cached and used for + different images having exactly identical thumbnails. .. confval:: thumbnail cache :synopsis: The maximum amount of thumbnails that can be cached per time. diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 688e4b2..7b0fde5 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging as _logging -from multiprocessing import Event as mp_Event, Queue as mp_Queue +from collections import defaultdict +from multiprocessing import Event as mp_Event, Lock as mp_Lock, Queue as mp_Queue from os import remove -from os.path import basename, split +from os.path import split from queue import Empty, Queue from tempfile import mkdtemp, mkstemp from threading import Event, Lock @@ -45,15 +46,25 @@ def generate_grid_thumbnails( output: Queue | mp_Queue, thumbnail_size: int, not_generating: Event | mp_Event, + deduplication_lock: Lock | mp_Lock, ) -> None: - from os import fdopen, mkdir - from shutil import rmtree + from glob import iglob + from os import fdopen, mkdir, scandir + from shutil import copyfile, rmtree + from sys import hash_info from PIL.Image import Resampling, open as Image_open THUMBNAIL_FRAME_SIZE = (thumbnail_size,) * 2 BOX = Resampling.BOX THUMBNAIL_MODES = {"RGB", "RGBA"} + # No of nibbles (hex digits) in the platform-specific hash integer type + HEX_HASH_WIDTH = hash_info.width // 4 # 4 bits -> 1 hex digit + # The max value for the unsigned counterpart of the platform-specific hash + # integer type + UINT_HASH_WIDTH_MAX = (1 << hash_info.width) - 1 + + deduplicated_to_be_deleted: set[str] = set() try: THUMBNAIL_DIR = (TEMP_DIR := mkdtemp(prefix="termvisage-")) + "/thumbnails" @@ -80,6 +91,13 @@ def generate_grid_thumbnails( finally: not_generating.clear() + if deduplicated_to_be_deleted: + # Retain only the files that still exist; + # remove files that have been deleted. + deduplicated_to_be_deleted.intersection_update( + entry.path for entry in scandir(THUMBNAIL_DIR) + ) + # Make source image into a thumbnail try: img = Image_open(source) @@ -89,39 +107,75 @@ def generate_grid_thumbnails( img = img.convert("RGBA" if has_transparency else "RGB") img.thumbnail(THUMBNAIL_FRAME_SIZE, BOX) except Exception: - output.put((source, None)) + output.put((source, None, None)) logging.log_exception( f"Failed to generate thumbnail for {source!r}", logger ) continue + img_bytes = img.tobytes() + # The hash is interpreted as an unsigned integer, represented in hex and + # zero-extended to fill up the platform-specific hash integer width. + img_hash = f"{hash(img_bytes) & UINT_HASH_WIDTH_MAX:0{HEX_HASH_WIDTH}x}" + # Create thumbnail file try: - thumbnail_fd, thumbnail = mkstemp( - "", f"{basename(source)}-", THUMBNAIL_DIR - ) + thumbnail_fd, thumbnail = mkstemp("", f"{img_hash}-", THUMBNAIL_DIR) except Exception: - output.put((source, None)) + output.put((source, None, None)) logging.log_exception( f"Failed to create thumbnail file for {source!r}", logger ) + del img_bytes # Possibly relatively large img.close() continue - # Save thumbnail - with img, fdopen(thumbnail_fd, "wb") as thumbnail_file: - try: - img.save(thumbnail_file, "PNG") - except Exception: - output.put((source, None)) - thumbnail_file.close() # Close before deleting the file - delete_thumbnail(thumbnail) - logging.log_exception( - f"Failed to save thumbnail for {source!r}", logger - ) - continue + # Deduplication + deduplicated = None + with deduplication_lock: + for other_thumbnail in iglob( + f"{THUMBNAIL_DIR}/{img_hash}-*", root_dir=THUMBNAIL_DIR + ): + if ( + other_thumbnail == thumbnail + or other_thumbnail in deduplicated_to_be_deleted + ): + continue + + with Image_open(other_thumbnail) as other_img: + if other_img.tobytes() != img_bytes: + continue + + try: + copyfile(other_thumbnail, thumbnail) + except Exception: + logging.log_exception( + f"Failed to deduplicate {other_thumbnail!r} for " + "{source!r}", + logger, + ) + else: + deduplicated_to_be_deleted.add(deduplicated := other_thumbnail) + + break + + del img_bytes # Possibly relatively large - output.put((source, thumbnail)) + # Save thumbnail, if deduplication didn't work out + if not deduplicated: + with img, fdopen(thumbnail_fd, "wb") as thumbnail_file: + try: + img.save(thumbnail_file, "PNG") + except Exception: + output.put((source, None, None)) + thumbnail_file.close() # Close before deleting the file + delete_thumbnail(thumbnail) + logging.log_exception( + f"Failed to save thumbnail for {source!r}", logger + ) + continue + + output.put((source, thumbnail, deduplicated)) finally: try: rmtree(TEMP_DIR, ignore_errors=True) @@ -354,20 +408,20 @@ def manage_grid_renders(n_renderers: int): def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: with thumbnail_render_lock: - """ - # Better than `e[k] or d.pop(k, None)`. - thumbnail = ( - thumbnail_cache[source] - if source in thumbnail_cache - # Safe to pop since every source is rendered only once per grid. - # Need to pop because after this point, there's no efficient way - # to tie `thumbnail` to `source`. - else extra_thumbnail_cache.pop(source) - ) - """ + if len(sources := thumbnails_being_rendered[thumbnail]) == 1: + del thumbnails_being_rendered[thumbnail] + else: + sources.remove(source) + # *source* may be in both caches in the case of thumbnail deduplication. + # In such a case, since a source is never rendered more than once + # between grid render syncs, then *thumbnail* is the deduplicated thumbnail + # in the extra cache. if source in extra_thumbnail_cache: + # Safe to remove since every source is never rendered more than once + # between grid render syncs. + # Need to remove at this point because afterwards, there's no efficient + # way to tie `thumbnail` to `source`. del extra_thumbnail_cache[source] - thumbnails_being_rendered.remove(thumbnail) multi = logging.MULTI and n_renderers > 0 grid_render_in = (mp_Queue if multi else Queue)() @@ -474,7 +528,7 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: # There's no need to check `.tui.main.THUMBNAIL` since # `thumbnail` is always `None` when thumbnailing is disabled. - if thumbnail and THUMBNAIL_CACHE_SIZE: + if thumbnail: mark_thumbnail_rendered(source, thumbnail) notify.stop_loading() @@ -494,36 +548,82 @@ def manage_grid_thumbnails(thumbnail_size: int) -> None: # Always keep in mind that every directory entry is rendered only once per grid # since results are cached, at least for now. - def cache_thumbnail(source: str, thumbnail: str) -> None: + def cache_thumbnail(source: str, thumbnail: str, deduplicated: str | None) -> None: # Eviction, for finite cache size - if 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache): - # Evict and delete the first cached thumbnail not in the render pipeline - for other_source, other_thumbnail in thumbnail_cache.items(): - # `thumbnail_render_lock` is unnecessary here since it's just a - # membership test; the outcome is the same as when the lock is - # used but more efficient. - if other_thumbnail not in thumbnails_being_rendered: - delete_thumbnail(other_thumbnail) - del thumbnail_cache[other_source] - break - # If all are being rendered, evict the oldest and queue it up to be - # deleted later. - else: + if not deduplicated and 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache): + # Evict the oldest thumbnail with the least amount of linked sources. + other_thumbnail = min( + thumbnail_sources, + key=lambda thumbnail: len(thumbnail_sources[thumbnail]), + ) + # `thumbnail_render_lock` is unnecessary for just a membership test on + # `thumbnails_being_rendered`; the outcome is the same as with the lock + # but without is less costly. + if other_thumbnail in thumbnails_being_rendered: with thumbnail_render_lock: - other_source = next(iter(thumbnail_cache)) # oldest entry - other_thumbnail = thumbnail_cache.pop(other_source) - extra_thumbnail_cache[other_source] = other_thumbnail + # Copy only the linked sources that are in the render pipeline + # into the extra cache. + for other_source in thumbnails_being_rendered[other_thumbnail]: + extra_thumbnail_cache[other_source] = other_thumbnail + # Remove all linked sources from the main cache. + for other_source in thumbnail_sources[other_thumbnail]: + del thumbnail_cache[other_source] + # Queue it up to be deleted later. thumbnails_to_be_deleted.add(other_thumbnail) - - thumbnail_cache[source] = thumbnail # Cache the new thumbnail. + else: + with deduplication_lock: + delete_thumbnail(other_thumbnail) + for other_source in thumbnail_sources[other_thumbnail]: + # `thumbnail_render_lock` is unnecessary here since + # `other_thumbnail` is not in the render pipeline. + del thumbnail_cache[other_source] + del thumbnail_sources[other_thumbnail] + + thumbnail_cache[source] = thumbnail # Link *source* to *thumbnail*. + + # Deduplication + if deduplicated and ( + # has the deduplicated thumbnail NOT been evicted? (next two lines) + not THUMBNAIL_CACHE_SIZE + or deduplicated in thumbnail_sources + ): + # Unlink *deduplicated* from the sources linked to it and link *thumbnail* + # to them, along with *source*. + deduplicated_sources = thumbnail_sources.pop(deduplicated) + thumbnail_sources[thumbnail] = (*deduplicated_sources, source) + + with thumbnail_render_lock: + # Link to *thumbnail*, the sources linked to *deduplicated*. + for deduplicated_source in deduplicated_sources: + thumbnail_cache[deduplicated_source] = thumbnail + + if deduplicated in thumbnails_being_rendered: + # Copy sources linked to *deduplicated* and in the render pipeline + # into the extra cache. + for deduplicated_source in thumbnails_being_rendered[deduplicated]: + extra_thumbnail_cache[deduplicated_source] = deduplicated + # Queue *deduplicated* up to be deleted later. + thumbnails_to_be_deleted.add(deduplicated) + else: + with deduplication_lock: + delete_thumbnail(deduplicated) + else: + thumbnail_sources[thumbnail] = (source,) multi = logging.MULTI thumbnail_in = (mp_Queue if multi else Queue)() thumbnail_out = (mp_Queue if multi else Queue)() not_generating = (mp_Event if multi else Event)() + deduplication_lock = (mp_Lock if multi else Lock)() generator = (Process if multi else logging.Thread)( target=generate_grid_thumbnails, - args=(thumbnail_in, thumbnail_out, thumbnail_size, not_generating), + args=( + thumbnail_in, + thumbnail_out, + thumbnail_size, + not_generating, + deduplication_lock, + ), name="GridThumbnailer", redirect_notifs=True, ) @@ -554,14 +654,9 @@ def cache_thumbnail(source: str, thumbnail: str) -> None: in_sync.set() renderer_in_sync.wait() - if THUMBNAIL_CACHE_SIZE: - with thumbnail_render_lock: - extra_thumbnail_cache.clear() - thumbnails_being_rendered.clear() - - for thumbnail in thumbnails_to_be_deleted: - delete_thumbnail(thumbnail) - thumbnails_to_be_deleted.clear() + with thumbnail_render_lock: + extra_thumbnail_cache.clear() + thumbnails_being_rendered.clear() # Purge the in queue and update the loading indicator counter while True: @@ -579,17 +674,25 @@ def cache_thumbnail(source: str, thumbnail: str) -> None: # and update the loading indicator counter while True: try: - source, thumbnail = thumbnail_out.get(timeout=0.005) + source, thumbnail, deduplicated = thumbnail_out.get( + timeout=0.005 + ) except Empty: break else: - if 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache): + if not deduplicated and ( + 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache) + ): # Quicker than the eviction process delete_thumbnail(thumbnail) else: - cache_thumbnail(source, thumbnail) + cache_thumbnail(source, thumbnail, deduplicated) notify.stop_loading() + for thumbnail in thumbnails_to_be_deleted: + delete_thumbnail(thumbnail) + thumbnails_to_be_deleted.clear() + # It's okay since we've taken care of all thumbnails that were being # generated. clear_queue(size_alpha_s) @@ -603,10 +706,17 @@ def cache_thumbnail(source: str, thumbnail: str) -> None: if not in_sync.is_set(): continue - if THUMBNAIL_CACHE_SIZE: - for thumbnail in thumbnails_to_be_deleted - thumbnails_being_rendered: - delete_thumbnail(thumbnail) - thumbnails_to_be_deleted.remove(thumbnail) + if thumbnails_to_be_deleted: + # `thumbnail_render_lock` is unnecessary here since + # `thumbnails_being_rendered` is only **read** just **once**; the + # outcome is the same as with the lock but without is less costly. + thumbnails_to_delete = ( + thumbnails_to_be_deleted - thumbnails_being_rendered.keys() + ) + for thumbnail in thumbnails_to_delete: + with deduplication_lock: + delete_thumbnail(thumbnail) + thumbnails_to_be_deleted -= thumbnails_to_delete if not in_sync.is_set(): continue @@ -622,9 +732,8 @@ def cache_thumbnail(source: str, thumbnail: str) -> None: continue if thumbnail := thumbnail_cache.get(source := image_info[0]): grid_render_queue.put((source, thumbnail, *image_info[2:])) - if THUMBNAIL_CACHE_SIZE: - with thumbnail_render_lock: - thumbnails_being_rendered.add(thumbnail) + with thumbnail_render_lock: + thumbnails_being_rendered[thumbnail].add(source) else: thumbnail_in.put(source) size_alpha_s.put(image_info[2:]) @@ -634,18 +743,18 @@ def cache_thumbnail(source: str, thumbnail: str) -> None: continue try: - source, thumbnail = thumbnail_out.get(timeout=0.02) + source, thumbnail, deduplicated = thumbnail_out.get(timeout=0.02) except Empty: pass else: size_alpha = size_alpha_s.get() if in_sync.is_set(): grid_render_queue.put((source, thumbnail, *size_alpha)) - if THUMBNAIL_CACHE_SIZE and thumbnail: + if thumbnail: with thumbnail_render_lock: - thumbnails_being_rendered.add(thumbnail) + thumbnails_being_rendered[thumbnail].add(source) if thumbnail: - cache_thumbnail(source, thumbnail) + cache_thumbnail(source, thumbnail, deduplicated) notify.stop_loading() finally: clear_queue(thumbnail_in) @@ -822,12 +931,14 @@ def render_images( image_render_queue = Queue() grid_renderer_in_sync = Event() grid_thumbnailer_in_sync = Event() -thumbnails_being_rendered: set[str] = set() thumbnail_render_lock = Lock() +thumbnail_sources: dict[str, tuple[str]] = {} # Main thumbnail cache thumbnail_cache: dict[str, str] = {} -# For evicted thumbnails still being rendered +# For evicted and deduplicated thumbnails still being rendered extra_thumbnail_cache: dict[str, str] = {} +# Each value contains the sources currently using the thumbnail +thumbnails_being_rendered: defaultdict[str, set] = defaultdict(set) # `GridRenderManager` is actually "in sync" initially. # From 0e006502803048a4ec9f4fdf2770dcc953a9c29d Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Thu, 2 May 2024 01:16:24 +0100 Subject: [PATCH 31/37] fix: render: Fix thumbnail eviction - Fix: Use `len(thumbnail_sources)` instead of `len(thumbnail_cache)` to get the number of thumbnails because there can be more sources than thumbnails as thumbnails are now deduplicated and shared among sources. The number of active thumbnail files no longer exceeds the cache size. --- src/termvisage/tui/render.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 7b0fde5..f6f909c 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -550,7 +550,7 @@ def manage_grid_thumbnails(thumbnail_size: int) -> None: def cache_thumbnail(source: str, thumbnail: str, deduplicated: str | None) -> None: # Eviction, for finite cache size - if not deduplicated and 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache): + if not deduplicated and 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_sources): # Evict the oldest thumbnail with the least amount of linked sources. other_thumbnail = min( thumbnail_sources, @@ -681,7 +681,7 @@ def cache_thumbnail(source: str, thumbnail: str, deduplicated: str | None) -> No break else: if not deduplicated and ( - 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_cache) + 0 < THUMBNAIL_CACHE_SIZE == len(thumbnail_sources) ): # Quicker than the eviction process delete_thumbnail(thumbnail) From e76476af3a5cf37dac247de148909580443ec089 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Thu, 2 May 2024 01:47:37 +0100 Subject: [PATCH 32/37] refac: render: Simplify grid image rendering - Add: *alpha* parameter to `render_grid_images()`. - Change: `GridRenderManager` no longer accepts the image canvas size and the alpha spec via `grid_render_queue`. - The alpha spec is constant. - `GridRenderManager` is capable of deriving the image canvas size on its own. In fact, already did derive the grid cell width which is all that's required to compute the image canvas size. - Change: `GridRenderer`s no longer accept the alpha spec via it's input queue. Instead, they now accepts it as an arg since it's constant. - Change: Rename some related/affected variables in `manage_grid_renders()`, `manage_grid_thumbnails()` and `render_grid_images()`, and remove some others. --- src/termvisage/tui/render.py | 57 +++++++++++++++++------------------ src/termvisage/tui/widgets.py | 2 +- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index f6f909c..5ba2ed8 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -433,6 +433,7 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: grid_render_in, grid_render_out, ImageClass, + Image._ti_alpha, grid_style_specs.get(ImageClass.style, ""), ), name="GridRenderer" + f"-{n}" * multi, @@ -443,7 +444,7 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: for renderer in renderers: renderer.start() - cell_width = grid_path = None # Silence flake8's F821 + grid_image_size = grid_path = None # Silence flake8's F821 faulty_image = Image._ti_faulty_image delimited = False grid_cache = Image._ti_grid_cache @@ -481,30 +482,32 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: else: delimited = False - cell_width = image_grid.cell_width grid_path = main.grid_path + grid_cell_width = image_grid.cell_width + grid_image_size = (grid_cell_width - 2, grid_cell_width // 2 - 2) + del grid_cell_width if not in_sync.is_set(): continue if grid_active.is_set(): try: - image_info = grid_render_queue.get(timeout=0.02) + source_and_thumbnail = grid_render_queue.get(timeout=0.02) except Empty: pass else: - if not image_info: + if not source_and_thumbnail: delimited = True continue - grid_render_in.put(image_info) + grid_render_in.put((*source_and_thumbnail, grid_image_size)) notify.start_loading() if not in_sync.is_set(): continue try: - source, thumbnail, render, size, rendered_size = grid_render_out.get( - timeout=0.02 + source, thumbnail, render, canvas_size, rendered_size = ( + grid_render_out.get(timeout=0.02) ) except Empty: pass @@ -516,12 +519,14 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: # remnants that were caught up in the renderer(s) while syncing # grid rendering. and source_dirname == grid_path - and size[0] + 2 == cell_width + and canvas_size == grid_image_size ): grid_cache[source_basename] = ( - ImageCanvas(render.encode().split(b"\n"), size, rendered_size) + ImageCanvas( + render.encode().split(b"\n"), canvas_size, rendered_size + ) if render - else faulty_image.render(size) + else faulty_image.render(canvas_size) ) if grid_active.is_set(): update_screen() @@ -535,7 +540,7 @@ def mark_thumbnail_rendered(source: str, thumbnail: str) -> None: finally: clear_queue(grid_render_in) for renderer in renderers: - grid_render_in.put((None,) * 4) + grid_render_in.put((None,) * 3) for renderer in renderers: renderer.join() clear_queue(grid_render_queue) @@ -634,9 +639,6 @@ def cache_thumbnail(source: str, thumbnail: str, deduplicated: str | None) -> No in_sync = grid_thumbnailer_in_sync renderer_in_sync = grid_renderer_in_sync thumbnails_to_be_deleted: set[str] = set() - # Stores `(size, alpha)`s (to be passed on to the renderer) in the same order in - # which `source`s are sent to the generator. - size_alpha_s: Queue[tuple[tuple[int, int], str | float | None]] = Queue() try: while True: @@ -693,10 +695,6 @@ def cache_thumbnail(source: str, thumbnail: str, deduplicated: str | None) -> No delete_thumbnail(thumbnail) thumbnails_to_be_deleted.clear() - # It's okay since we've taken care of all thumbnails that were being - # generated. - clear_queue(size_alpha_s) - if not delimited: while grid_thumbnail_queue.get(): pass @@ -723,20 +721,21 @@ def cache_thumbnail(source: str, thumbnail: str, deduplicated: str | None) -> No if grid_active.is_set(): try: - image_info = grid_thumbnail_queue.get(timeout=0.02) + source_and_thumbnail = grid_thumbnail_queue.get(timeout=0.02) except Empty: pass else: - if not image_info: + if not source_and_thumbnail: delimited = True continue - if thumbnail := thumbnail_cache.get(source := image_info[0]): - grid_render_queue.put((source, thumbnail, *image_info[2:])) + if thumbnail := thumbnail_cache.get( + source := source_and_thumbnail[0] + ): + grid_render_queue.put((source, thumbnail)) with thumbnail_render_lock: thumbnails_being_rendered[thumbnail].add(source) else: thumbnail_in.put(source) - size_alpha_s.put(image_info[2:]) notify.start_loading() if not in_sync.is_set(): @@ -747,9 +746,8 @@ def cache_thumbnail(source: str, thumbnail: str, deduplicated: str | None) -> No except Empty: pass else: - size_alpha = size_alpha_s.get() if in_sync.is_set(): - grid_render_queue.put((source, thumbnail, *size_alpha)) + grid_render_queue.put((source, thumbnail)) if thumbnail: with thumbnail_render_lock: thumbnails_being_rendered[thumbnail].add(source) @@ -852,6 +850,7 @@ def render_grid_images( input: Queue | mp_Queue, output: Queue | mp_Queue, ImageClass: type, + alpha: str, style_spec: str, ): """Renders images for the grid. @@ -859,7 +858,7 @@ def render_grid_images( Intended to be executed in a subprocess or thread. """ while True: - source, thumbnail, size, alpha = input.get() + source, thumbnail, canvas_size = input.get() if not source: # Quitting break @@ -872,18 +871,18 @@ def render_grid_images( # **as needed**. Trimmed padding lines are never generated at all. try: image = ImageClass.from_file(thumbnail or source) - image.set_size(Size.FIT if thumbnail else Size.AUTO, maxsize=size) + image.set_size(Size.FIT if thumbnail else Size.AUTO, maxsize=canvas_size) output.put( ( source, thumbnail, f"{image:1.1{alpha}{style_spec}}", - size, + canvas_size, image.rendered_size, ) ) except Exception: - output.put((source, thumbnail, None, size, None)) + output.put((source, thumbnail, None, canvas_size, None)) clear_queue(output) diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 15058b4..5fba64d 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -355,7 +355,7 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: > __class__._ti_grid_thumbnailing_threshold ) else grid_render_queue - ).put((image._source, None, size, self._ti_alpha)) + ).put((image._source, None)) __class__._ti_grid_cache[basename(image._source)] = ... canv = __class__._ti_placeholder.render(size, focus) elif canv is ...: # is the image currently being rendered? From 440d98745abbeddf71237c328ed191efbf503f34 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Thu, 2 May 2024 01:52:54 +0100 Subject: [PATCH 33/37] refac: tui.widgets: Simplify grid cell height comp - Change: Use `cell_width // 2` instead of `ceil(cell_width / 2)` as cell height since `cell_width` is known to always be even. --- src/termvisage/tui/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termvisage/tui/widgets.py b/src/termvisage/tui/widgets.py index 5fba64d..e0e4f40 100644 --- a/src/termvisage/tui/widgets.py +++ b/src/termvisage/tui/widgets.py @@ -439,7 +439,7 @@ def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas: @classmethod def _ti_update_grid_thumbnailing_threshold(cls, cell_size: tuple[int, int]) -> None: grid_cell_width = image_grid.cell_width - grid_image_size = (grid_cell_width - 2, ceil(grid_cell_width / 2) - 2) + grid_image_size = (grid_cell_width - 2, grid_cell_width // 2 - 2) cls._ti_grid_thumbnailing_threshold = max( tui_main.THUMBNAIL_SIZE_PRODUCT, mul(*map(mul, grid_image_size, cell_size)), From a50bf004184052da55711588c78a85ef70c6705d Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Thu, 2 May 2024 23:57:33 +0100 Subject: [PATCH 34/37] refac: tui.render: Rename a function - Change: Rename `refresh_grid_rendering()` -> `resync_grid_rendering()`. --- src/termvisage/tui/keys.py | 10 +++++----- src/termvisage/tui/main.py | 4 ++-- src/termvisage/tui/render.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index 6cd3626..dcce028 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -17,7 +17,7 @@ from .. import __version__, logging from ..config import context_keys, expand_key from . import main -from .render import refresh_grid_rendering +from .render import resync_grid_rendering from .widgets import ( Image, ImageCanvas, @@ -394,14 +394,14 @@ def resize(): # implement the `TIOCSWINSZ` ioctl command. Hence, the `cell_size and`. if cell_size and cell_size != _prev_cell_size: _prev_cell_size = cell_size - refresh_grid_rendering() + resync_grid_rendering() if main.THUMBNAIL: Image._ti_update_grid_thumbnailing_threshold(cell_size) else: cell_ratio = get_cell_ratio() if cell_ratio != _prev_cell_ratio: _prev_cell_ratio = cell_ratio - refresh_grid_rendering() + resync_grid_rendering() adjust_bottom_bar() getattr(main.ImageClass, "clear", lambda: True)() or ImageCanvas.change() @@ -489,7 +489,7 @@ def maximize(): def cell_width_dec(): if image_grid.cell_width > 30: image_grid.cell_width -= 2 - refresh_grid_rendering() + resync_grid_rendering() getattr(main.ImageClass, "clear", lambda: True)() if main.THUMBNAIL: @@ -500,7 +500,7 @@ def cell_width_dec(): def cell_width_inc(): if image_grid.cell_width < 50: image_grid.cell_width += 2 - refresh_grid_rendering() + resync_grid_rendering() getattr(main.ImageClass, "clear", lambda: True)() if main.THUMBNAIL: diff --git a/src/termvisage/tui/main.py b/src/termvisage/tui/main.py index 2d2c26b..66959d2 100644 --- a/src/termvisage/tui/main.py +++ b/src/termvisage/tui/main.py @@ -30,7 +30,7 @@ set_menu_actions, set_menu_count, ) -from .render import refresh_grid_rendering +from .render import resync_grid_rendering from .widgets import ( Image, ImageCanvas, @@ -280,7 +280,7 @@ def display_images( grid_path = abspath(entry) if contents[entry].get("/") and grid_path != last_non_empty_grid_path: - refresh_grid_rendering() + resync_grid_rendering() last_non_empty_grid_path = grid_path image_box.original_widget = placeholder # halt image and anim rendering diff --git a/src/termvisage/tui/render.py b/src/termvisage/tui/render.py index 5ba2ed8..d7be8e2 100644 --- a/src/termvisage/tui/render.py +++ b/src/termvisage/tui/render.py @@ -30,7 +30,7 @@ def delete_thumbnail(thumbnail: str) -> bool: return True -def refresh_grid_rendering() -> None: +def resync_grid_rendering() -> None: if main.THUMBNAIL: grid_thumbnail_queue.put(None) # Send the grid delimiter grid_thumbnailer_in_sync.clear() From 611c93120040812730037cf97d18996ca2649c27 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 3 May 2024 00:33:27 +0100 Subject: [PATCH 35/37] fix: tui.keys: Fix grid render resync upon resize - Fix: Do not resync grid image rendering upon terminal resize if the grid is not active. Fixes a deadlock when the terminal is resized and cell size changes while the grid is not active. Broken in 6ef2a51a0cb697d03dd643bda18a56f2d80a86e1. --- src/termvisage/tui/keys.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/termvisage/tui/keys.py b/src/termvisage/tui/keys.py index dcce028..fed7442 100644 --- a/src/termvisage/tui/keys.py +++ b/src/termvisage/tui/keys.py @@ -394,14 +394,16 @@ def resize(): # implement the `TIOCSWINSZ` ioctl command. Hence, the `cell_size and`. if cell_size and cell_size != _prev_cell_size: _prev_cell_size = cell_size - resync_grid_rendering() if main.THUMBNAIL: Image._ti_update_grid_thumbnailing_threshold(cell_size) + if main.grid_active.is_set(): + resync_grid_rendering() else: cell_ratio = get_cell_ratio() if cell_ratio != _prev_cell_ratio: _prev_cell_ratio = cell_ratio - resync_grid_rendering() + if main.grid_active.is_set(): + resync_grid_rendering() adjust_bottom_bar() getattr(main.ImageClass, "clear", lambda: True)() or ImageCanvas.change() From 047378e1b7e792af3d5e7f7eb3c022ed404380c2 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 3 May 2024 01:18:05 +0100 Subject: [PATCH 36/37] chore: Update the sample default config file - Change: Update the value of `max pixels`. --- default-termvisage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/default-termvisage.json b/default-termvisage.json index 57237ef..89243f5 100644 --- a/default-termvisage.json +++ b/default-termvisage.json @@ -7,7 +7,7 @@ "grid renderers": 1, "log file": "~/.local/state/termvisage/termvisage.log", "max notifications": 2, - "max pixels": 4194304, + "max pixels": 0, "multi": true, "query timeout": 0.1, "style": "auto", From 328d504a4666878873ed807820b2c1d84e1eaa34 Mon Sep 17 00:00:00 2001 From: AnonymouX47 Date: Fri, 3 May 2024 01:23:17 +0100 Subject: [PATCH 37/37] chore: Update CHANGELOG for #13 --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e30d9..e435c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed - tui: Crash on image grid view ([c64f195]). -- cli,tui: Sorting of top-level (command line) entries ([9ea0572]) +- cli,tui: Sorting of top-level (command line) entries ([9ea0572]). +### Added +- tui: Thumbnail generation (with deduplication) and caching for the image grid ([#13]). + - config: `thumbnail`, `thumbnail cache` and `thumbnail size` config options. + - args: `--thumbnail/--no-thumbnail` command-line option. +- tui: `Force Render` action to the `menu` context ([#13]). + +### Changed +- cli,tui: Revamped the *max pixels* setting ([#13]). + - It is now **opt-in** i.e by default, all images are now rendered regardless of resolution. + - config: Changed the default value of the `max pixels` config option to `0` (disabled). + - tui: It no longer applies in the `full-grid-image` context. + - tui: In the `image-grid` context, images with more pixels than *max pixels* (**if non-zero**) are now distinguished by a yellow title and border. + +### Removed +- args: `--max-pixels-cli` command-line option ([#13]). + +[#13]: https://github.com/AnonymouX47/termvisage/pull/13 [c64f195]: https://github.com/AnonymouX47/termvisage/commit/c64f195a79557fdf5a9323db907a5716a12d6440 [9ea0572]: https://github.com/AnonymouX47/termvisage/commit/9ea0572e6db35984a4ae0af1691edfd179e5d393