enkan 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. enkan-2.0.2/LICENSE +21 -0
  2. enkan-2.0.2/PKG-INFO +12 -0
  3. enkan-2.0.2/README.md +185 -0
  4. enkan-2.0.2/enkan/__init__.py +0 -0
  5. enkan-2.0.2/enkan/__main__.py +43 -0
  6. enkan-2.0.2/enkan/cache/HistoryManager.py +71 -0
  7. enkan-2.0.2/enkan/cache/ImageCacheManager.py +203 -0
  8. enkan-2.0.2/enkan/cache/LRUCache.py +54 -0
  9. enkan-2.0.2/enkan/cache/PreloadQueue.py +73 -0
  10. enkan-2.0.2/enkan/cache/__init__.py +0 -0
  11. enkan-2.0.2/enkan/cli.py +90 -0
  12. enkan-2.0.2/enkan/constants.py +33 -0
  13. enkan-2.0.2/enkan/mySlideshow/ProviderChoooseDialog.py +56 -0
  14. enkan-2.0.2/enkan/mySlideshow/ZoomPan.py +323 -0
  15. enkan-2.0.2/enkan/mySlideshow/__init__.py +0 -0
  16. enkan-2.0.2/enkan/mySlideshow/mySlideshow.py +858 -0
  17. enkan-2.0.2/enkan/mySlideshow/start_slideshow.py +31 -0
  18. enkan-2.0.2/enkan/plugables/ImageLoaders.py +63 -0
  19. enkan-2.0.2/enkan/plugables/ImageProviders.py +169 -0
  20. enkan-2.0.2/enkan/plugables/__init__.py +0 -0
  21. enkan-2.0.2/enkan/tree/Grafting.py +128 -0
  22. enkan-2.0.2/enkan/tree/Tree.py +264 -0
  23. enkan-2.0.2/enkan/tree/TreeBuilder.py +379 -0
  24. enkan-2.0.2/enkan/tree/TreeNode.py +80 -0
  25. enkan-2.0.2/enkan/tree/__init__.py +0 -0
  26. enkan-2.0.2/enkan/tree/tree_logic.py +258 -0
  27. enkan-2.0.2/enkan/utils/Defaults.py +151 -0
  28. enkan-2.0.2/enkan/utils/Filters.py +80 -0
  29. enkan-2.0.2/enkan/utils/InputProcessor.py +335 -0
  30. enkan-2.0.2/enkan/utils/__init__.py +0 -0
  31. enkan-2.0.2/enkan/utils/argparse_setup.py +114 -0
  32. enkan-2.0.2/enkan/utils/logging.py +17 -0
  33. enkan-2.0.2/enkan/utils/myStack.py +63 -0
  34. enkan-2.0.2/enkan/utils/tests.py +204 -0
  35. enkan-2.0.2/enkan/utils/utils.py +342 -0
  36. enkan-2.0.2/enkan.egg-info/PKG-INFO +12 -0
  37. enkan-2.0.2/enkan.egg-info/SOURCES.txt +41 -0
  38. enkan-2.0.2/enkan.egg-info/dependency_links.txt +1 -0
  39. enkan-2.0.2/enkan.egg-info/entry_points.txt +2 -0
  40. enkan-2.0.2/enkan.egg-info/requires.txt +4 -0
  41. enkan-2.0.2/enkan.egg-info/top_level.txt +1 -0
  42. enkan-2.0.2/pyproject.toml +28 -0
  43. enkan-2.0.2/setup.cfg +4 -0
enkan-2.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 John Sullivan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
enkan-2.0.2/PKG-INFO ADDED
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: enkan
3
+ Version: 2.0.2
4
+ Summary: A not-so-simple slideshow application
5
+ Author-email: John Sullivan <benzo8@gmail.com>
6
+ Requires-Python: >=3.11
7
+ License-File: LICENSE
8
+ Requires-Dist: matplotlib>=3.10.5
9
+ Requires-Dist: pillow>=11.3.0
10
+ Requires-Dist: python-vlc>=3.0.21203
11
+ Requires-Dist: tqdm>=4.67.1
12
+ Dynamic: license-file
enkan-2.0.2/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # enkan
2
+
3
+ A not-so-simple slideshow application for building rich, weighted photo and video playlists that you can drive with a lean Tkinter UI. enkan reads structured text input, builds a tree of sources, and serves images (and optional video) according to the weighting rules you specify.
4
+
5
+ Of course, enkan can show you images completely at random, but its real power comes as you delve deeper into weighting and grafting, giving you complete control over the balance of images you see.
6
+
7
+ ## Requirements
8
+
9
+ - Python ≥3.11
10
+ - Pillow
11
+ - python-vlc (for video support)
12
+ - matplotlib (for tree visualization)
13
+ - tqdm (progress bars)
14
+
15
+ Optional:
16
+
17
+ - customtkinter (enhanced GUI appearance)
18
+
19
+ ## Installation
20
+
21
+ ### From PyPI (recommended)
22
+
23
+ ```bash
24
+ pip install enkan
25
+ ```
26
+
27
+ ### From source
28
+
29
+ ```bash
30
+ git clone https://github.com/yourusername/enkan.git
31
+ cd enkan
32
+ pip install -e .
33
+ ```
34
+
35
+ If you install into a fresh environment, remember to install VLC separately so that `python-vlc` can find the native libraries.
36
+
37
+ ## Features
38
+
39
+ - Weighted and balanced image selection modes
40
+ - Tree-based directory organization with grafting
41
+ - EXIF orientation support
42
+ - Image caching for performance
43
+ - Video playback support (via VLC)
44
+ - Interactive GUI with zoom/pan
45
+ - Rotation persistence to EXIF
46
+
47
+ ## Quick Start
48
+
49
+ 1. Collect your image (and optional video) folders.
50
+ 2. Create a text file (for example `shows/summer-show.txt`) that lists the folders you want to include. You can mix folders, individual files, nested `.txt` files, and inline weighting rules.
51
+ 3. Launch enkan and point it at the file:
52
+
53
+ ```bash
54
+ enkan --input_file shows/summer-show.txt --run
55
+ ```
56
+
57
+ enkan will parse the file, build an in-memory tree, and open the slideshow window. Use the keyboard or on-screen controls to navigate. Add `--auto 8` to advance every eight seconds, or `--random` to drop into completely random mode.
58
+
59
+ ## `.txt` Input Files
60
+
61
+ Text files let you describe complex shows declaratively. General rules:
62
+
63
+ - One path per non-empty line. Lines starting with `#` are comments.
64
+ - Windows paths can use `\\` or `/`. Surround a path with quotes if it contains spaces or commas.
65
+ - You can reference other `.txt`, `.lst`, or `.tree` files. enkan resolves them recursively.
66
+ - Place any number of square-bracket modifiers before the path to alter weighting, grouping, or behaviour.
67
+ - "Level" refers to the tree depth, which mirrors the folder depth (unless grafted), starting from root = 1
68
+
69
+ ### Common Modifiers
70
+
71
+ | Syntax | Meaning |
72
+ | --- | --- |
73
+ | `[r]` | Set the entire show to run in fully random mode. |
74
+ | `[+]keyword` | Only include files whose path contains `keyword`. Repeat for multiple must-match terms. |
75
+ | `[-]keyword` | Exclude any path containing `keyword`. If you give an absolute file or folder, that path is skipped entirely. |
76
+ | `[NN%]` | Multiply a branch's weight by `NN%` relative to its siblings (e.g. `[150%]` gives that branch 1.5x the default share). |
77
+ | `[NN]` | For individual images, repeat the image `NN` times in the weighted pool. Useful for spotlighting a favourite shot. |
78
+ | `[%NN%]` | Reserve `NN%` of the parent branch's share for this node and divide the remainder among siblings. |
79
+ | `[bN]` | Switch the weighting mode at `level "N"` to balanced (`b`). Example: `[b2]` keeps siblings at level 2 evenly balanced. |
80
+ | `[wN[,slope]]` | Switch the weighting mode at `level "N"` to weighted (`w`), using descendant counts to apportion remaining percentage. Negative slopes favour smaller folders, positive slopes flatten toward balanced. |
81
+ | `[gN]` | Graft this branch up to tree level `N`, letting you bubble deep folders to a shallower menu. |
82
+ | `[>group-name]` | Assign the line to a named group so you can share grafting, proportion, or mode changes. Combine with a `*` line to define the group (see below). |
83
+ | `[f]` | Treat a directory as a flat bucket: gather every image under it into one node instead of mirroring the folder hierarchy. |
84
+ | `[v]` / `[nv]` | Force-enable or disable video for this branch (overrides the default or CLI flag). |
85
+ | `[m]` / `[nm]` | Force the slideshow to mute or unmute when media from this branch plays. |
86
+ | `[/]` | Do not recurse beyond this directory; only its direct files are considered. |
87
+
88
+ Modifiers can appear in any order so long as they precede the path, for example:
89
+
90
+ ``` bash
91
+ [%40%][b3]D:\Media\Family Archive
92
+ [150%][v]E:\Clips\Action Cams
93
+ [>portraits][g3]F:\Photos\Portrait Sessions
94
+ ```
95
+
96
+ ### Global Defaults and Groups
97
+
98
+ Use a line whose path is just `*` to define global or group-level behaviour:
99
+
100
+ ``` bash
101
+ [b1w2]* # Balanced top level, weighted from level 2 downward
102
+ [v][nm]* # Default to video enabled and audio unmuted
103
+ [>portraits][g3][%30%][w4,-20]* # Define the "portraits" group once
104
+ [>portraits]F:\Photos\Portrait Sessions # Apply the group to a folder
105
+ ```
106
+
107
+ A group definition stores graft level, proportion, and mode modifiers. Any line tagged with the same `[>group]` inherits those settings.
108
+
109
+ ### Nesting Other Files
110
+
111
+ - Pointing at another `.txt` file in a line imports everything from that file at the current position. (Only the Global Defaults from the top-level file are honoured.)
112
+ - `.lst` files are either:
113
+ - plain CSV lines (`absolute\path\to\image.jpg,weight`). They are useful when you already have a hand-curated weighted list
114
+ - new-line delimited lists of files (`absolute\path\to\image.jpg`), as produced by irfanView, et al
115
+ - `.tree` files are binary snapshots produced by `--outputtree`. enkan reuses them if the embedded version matches; otherwise it falls back to the sibling `.txt` / `.lst` source.
116
+
117
+ ## CLI Reference
118
+
119
+ | Option | Purpose |
120
+ | --- | --- |
121
+ | `-i`, `--input_file` | One or more `.txt`, `.lst` or `.tree` files, or folder and/or file paths (including [modifiers] if desired) to process. |
122
+ | `--run` | Explicitly launch the slideshow (optional when you omit `--output*`). |
123
+ | `--outputlist` | Write a `.lst` file next to the inputs instead of launching the GUI. |
124
+ | `--outputtree` | Persist the computed tree to a `.tree` file for fast reloads. |
125
+ | `--mode` | Provide a global mode string such as `b1w2` to override file defaults. |
126
+ | `--random` | Start in fully random mode (same as `[r]` in a file). |
127
+ | `--auto N` | Advance automatically every `N` seconds. |
128
+ | `--no-recurse` | Treat every supplied folder as non-recursive. |
129
+ | `--ignore-below-bottom` | Ignore files in folders below lowest balance level. |
130
+ | `--video` / `--no-video` | Force-enable or disable video globally. |
131
+ | `--no-mute` | Keep audio tracks unmuted (video default is muted). |
132
+ | `--quiet` | Suppress progress output. |
133
+ | `--no-background` | Run loaders in the foreground (useful when debugging). |
134
+ | `--test N` | Run `N` randomised draws and report the observed distribution. Combine with `--histo` for a matplotlib histogram. |
135
+ | `--printtree` | Emit a text representation of the computed tree. |
136
+ | `--testdepth`, `--histo`, `--debug` | Extra diagnostics for tuning your weighting setup. |
137
+
138
+ ## Hotkeys (active during slideshow)
139
+
140
+ | Key | Function |
141
+ | --- | --- |
142
+ | Space | Next image |
143
+ | Left | Back through history |
144
+ | Right | Forward through history |
145
+ | Up | Next sequential image |
146
+ | Down | Previous sequential image |
147
+ | N | Toggle information line |
148
+ | C | Toggle Random Mode - overrides weightings |
149
+ | S | Toggle Slideshow Mode - limit slideshow to subfolder containing current image |
150
+ | N | Toggle Navigation Mode - switch between Branch and Folder. See below for details on Navigation Mode and Parent Mode|
151
+ | P | Enter Parent Mode - move up tree and limit slideshow to images in folders below current parent |
152
+ | I | Step backwards through Parent Mode (if possible) |
153
+ | U | Reset Parent Mode |
154
+ | W | Select Weighted/Balanced mode |
155
+ | B | Select Folder Burst Mode - pick 5 images from the current folder, then randomly select the next folder and repeat |
156
+ | L | Select Liner / Sequential Mode |
157
+ | Ctrl-B | Reset Folder Burst Mode - pick a new folder and begin Folder Burst mode again |
158
+ | R | Rotate image clockwise by 90 degrees |
159
+ | Ctrl-R | Try to write current orientation to image EXIF data |
160
+ | = / + / - | Zoom image |
161
+ | 0 | Reset Zoom |
162
+ | Shift-Cursor | Move viewport around zoomed image |
163
+ | A | Toggle Auto-Advance (Timed) Mode |
164
+
165
+ ## Navigation and Parent Mode
166
+
167
+ enkan can navigate in two modes:
168
+
169
+ - Branch - Parent Mode moves up and down the branches of the tree
170
+ - Folder - Parent Mode moves up and down the folder structure of the disk. If the folder you move to is not in the tree, enkan will need to read all the files below the current folder, whether they are in you input files or not. This can take a lot of time.
171
+
172
+ ## Working With Lists and Trees
173
+
174
+ - Run `enkan -i show.txt --outputlist` to capture the fully expanded list (including virtual nodes and weights) into `show.lst`.
175
+ - Run `enkan -i show.txt --outputtree` to produce `show.tree`, a binary cache you can ship with a release for faster loading.
176
+ - Combine `--printtree` and `--test` while iterating on your `.txt` files to confirm that proportions and grafting behave the way you expect.
177
+
178
+ ## Tips
179
+
180
+ - Keep your `.txt` files in source control alongside the media curations—they capture the intent of the show far better than flat lists.
181
+ - Use groups to coordinate related folders (for example all portrait shoots) without repeating the same graft and mode modifiers on every line.
182
+ - When emphasising a single standout image, prefer an absolute modifier like `[25]` on the image line instead of inflating nearby branches.
183
+ - Large libraries benefit from building a `.tree` once and reusing it until the folder structure changes; enkan automatically regenerates it if the pickle version does not match.
184
+
185
+ Have fun!
File without changes
@@ -0,0 +1,43 @@
1
+ # -----------------------------------------------------------------------------
2
+ # # A Python script to create a slideshow from a list of files, with various
3
+ # modes and features including weighted, balanced, and random selection.
4
+ #
5
+ # Author: John Sullivan
6
+ # Created: 2023-2025
7
+ # License: MIT License
8
+ # Copyright: (c) 2023-2025 John Sullivan
9
+ #
10
+ # Description:
11
+ # This script builds a slideshow from directories or lists of images,
12
+ # supporting advanced weighting, folder balancing, and navigation features.
13
+ # It uses Tkinter for the GUI and Pillow for image handling.
14
+ #
15
+ # Usage:
16
+ # enkan --input_file <file_or_folder> [options]
17
+ #
18
+ # Dependencies:
19
+ # - Python 3.8+
20
+ # - Pillow
21
+ # - tqdm
22
+ #
23
+ # -----------------------------------------------------------------------------
24
+
25
+ import logging
26
+ from enkan.utils.argparse_setup import get_arg_parser
27
+ from enkan.utils.logging import configure_logging
28
+ from enkan.cli import main_with_args
29
+
30
+ def main() -> None:
31
+ """Console script / module entry point."""
32
+ args = get_arg_parser().parse_args()
33
+ configure_logging(debug=getattr(args, "debug", False), quiet=getattr(args, "quiet", False))
34
+ try:
35
+ main_with_args(args)
36
+ except Exception as e:
37
+ logging.getLogger("enkan").error("Fatal: %s", e)
38
+ # No traceback shown unless debug enabled
39
+ if getattr(args, "debug", False):
40
+ raise
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1,71 @@
1
+ from collections import deque
2
+ from enkan import constants
3
+
4
+ class HistoryManager:
5
+ """
6
+ A single-line history manager with back/forward navigation.
7
+ Maintains a deque of visited paths with a current index pointer.
8
+ """
9
+
10
+ def __init__(self, max_length=constants.HISTORY_QUEUE_LENGTH):
11
+ self.history = deque(maxlen=max_length)
12
+ self.current_index = -1 # -1 = no current item
13
+ self.max_length = max_length
14
+
15
+ def add(self, path):
16
+ """
17
+ Add a new path. Clears forward history if the user
18
+ has navigated backward and is now moving forward naturally.
19
+ """
20
+ # Remove everything after the current index (future)
21
+ while len(self.history) - 1 > self.current_index:
22
+ self.history.pop()
23
+
24
+ # Add new path
25
+ self.history.append(path)
26
+ self.current_index = len(self.history) - 1
27
+
28
+ def remove(self, path):
29
+ """
30
+ Remove a path from history, adjusting current_index if needed.
31
+ If the current item is removed, current_index is moved to the previous item.
32
+ """
33
+ try:
34
+ idx = self.history.index(path)
35
+ except ValueError:
36
+ return # Path not in history
37
+
38
+ self.history.remove(path)
39
+
40
+ # Adjust current_index
41
+ if idx < self.current_index:
42
+ self.current_index -= 1
43
+ elif idx == self.current_index:
44
+ # Move to the previous item, or -1 if none exist
45
+ self.current_index = max(0, self.current_index - 1) if self.history else -1
46
+
47
+ def back(self):
48
+ if self.current_index > 0:
49
+ self.current_index -= 1
50
+ return self.history[self.current_index]
51
+ return None
52
+
53
+ def forward_step(self):
54
+ if self.current_index < len(self.history) - 1:
55
+ self.current_index += 1
56
+ return self.history[self.current_index]
57
+ return None
58
+
59
+ def current(self):
60
+ """Return the current item (or None if empty)."""
61
+ if self.current_index == -1:
62
+ return None
63
+ return self.history[self.current_index]
64
+
65
+ def clear(self):
66
+ self.history.clear()
67
+ self.current_index = -1
68
+
69
+ def __repr__(self):
70
+ line = list(self.history)
71
+ return f"HistoryManager(line={line}, current_index={self.current_index})"
@@ -0,0 +1,203 @@
1
+ import threading
2
+ import logging
3
+
4
+ from PIL import Image
5
+
6
+ from .LRUCache import LRUCache
7
+ from .PreloadQueue import PreloadQueue
8
+ from .HistoryManager import HistoryManager
9
+ from enkan.plugables.ImageLoaders import ImageLoaders
10
+ from enkan.utils.utils import is_videofile
11
+ from enkan import constants
12
+
13
+ logger: logging.Logger = logging.getLogger("enkan.cache")
14
+
15
+ class ImageCacheManager:
16
+ """
17
+ Combines LRUCache, PreloadQueue, and HistoryManager
18
+ to manage images and videos for the slideshow.
19
+ Supports background preloading of images.
20
+ """
21
+
22
+ def __init__(self, image_provider, current_image_index, background_preload=True):
23
+ self.lru_cache = LRUCache(constants.CACHE_SIZE)
24
+ self.preload_queue = PreloadQueue(constants.PRELOAD_QUEUE_LENGTH)
25
+ self.history_manager = HistoryManager(constants.HISTORY_QUEUE_LENGTH)
26
+ self.image_provider = image_provider
27
+ self.image_loader = ImageLoaders()
28
+ self.current_image_index = current_image_index
29
+ self.background_preload = background_preload
30
+
31
+ self._lock = threading.Lock()
32
+ self._refill_thread = None
33
+ self._provider_lock = threading.Lock()
34
+
35
+ # Initial preload (async if background=True)
36
+ if self.background_preload:
37
+ self._background_refill()
38
+ else:
39
+ self._preload_refill()
40
+
41
+ # -----------------------
42
+ # Preload
43
+ # -----------------------
44
+
45
+ def _provider_next(self) -> str:
46
+ """Thread-safe wrapper around the image provider."""
47
+ with self._provider_lock:
48
+ return next(self.image_provider)
49
+
50
+ def _preload_refill(self) -> None:
51
+ """Fill preload queue fully."""
52
+ while len(self.preload_queue) < self.preload_queue.max_size:
53
+ try:
54
+ image_path: str = self._provider_next()
55
+ except StopIteration:
56
+ break # For linear/sequential provider; random providers never stop
57
+ if image_path is None:
58
+ break
59
+ if image_path in self.preload_queue:
60
+ continue
61
+ image_obj: Image.Image = self._load_image(image_path)
62
+ self.preload_queue.push(image_path, image_obj)
63
+ logger.debug(f"Preloaded: {image_path}")
64
+
65
+ def _background_refill(self):
66
+ """Start a background refill thread if not already running."""
67
+ with self._lock:
68
+ if self._refill_thread and self._refill_thread.is_alive():
69
+ logger.debug("Background refill aborted. Already refilling.")
70
+ return # Already refilling
71
+ self._refill_thread = threading.Thread(
72
+ target=self._preload_refill, daemon=True
73
+ )
74
+ self._refill_thread.start()
75
+ logger.debug("Background refill started.")
76
+
77
+ # -----------------------
78
+ # Image Loading
79
+ # -----------------------
80
+
81
+ def _load_image(self, image_path: str) -> Image.Image | None:
82
+ if is_videofile(image_path):
83
+ return None
84
+ image_obj: Image.Image = self.lru_cache.get(image_path)
85
+ if image_obj is None:
86
+ logger.debug(f"Loading from Disk: {image_path}")
87
+ image_obj: Image.Image = self.image_loader.load_image(image_path)
88
+ self.lru_cache.put(image_path, image_obj)
89
+ else:
90
+ logger.debug(f"Loaded from LRUCache: {image_path}")
91
+ return image_obj
92
+
93
+ # -----------------------
94
+ # Public API
95
+ # -----------------------
96
+
97
+ def get_next(
98
+ self,
99
+ image_path: str = None,
100
+ record_history: bool = True
101
+ ) -> tuple[str | None, Image.Image | None]:
102
+ """
103
+ Retrieve the next (image_path, image_obj).
104
+ If image_path is None, pop from PreloadQueue.
105
+ Updates history if record_history=True.
106
+ """
107
+ image_obj: Image.Image = None
108
+ source: str = None
109
+
110
+ # Step 1: Pop from preload if no explicit path is given
111
+ if image_path is None:
112
+ popped = self.preload_queue.pop()
113
+ if popped:
114
+ image_path, image_obj = next(iter(popped.items()))
115
+ source = "PreloadQueue"
116
+ else:
117
+ try:
118
+ image_path = self._provider_next()
119
+ except StopIteration:
120
+ logger.debug("No image provided (empty provider).")
121
+ return None, None
122
+ image_obj = self._load_image(image_path)
123
+ source = "Disk"
124
+ else:
125
+ image_obj = self.lru_cache.get(image_path)
126
+ if image_obj:
127
+ source = "LRUCache"
128
+ else:
129
+ image_obj = self._load_image(image_path)
130
+ source = "Disk"
131
+
132
+ # Step 2: Conditionally update history
133
+ if record_history:
134
+ self.history_manager.add(image_path)
135
+
136
+ # Step 3: Refill preload (in background if enabled)
137
+ if self.background_preload:
138
+ self._background_refill()
139
+ else:
140
+ self._preload_refill()
141
+
142
+ # Step 4: Debug output
143
+ logger.debug(f"Loaded: {image_path} (from {source})")
144
+
145
+ return image_path, image_obj
146
+
147
+ def back(self):
148
+ path = self.history_manager.back()
149
+ if path is None:
150
+ return None, None
151
+ return path, self._load_image(path)
152
+
153
+ def forward(self):
154
+ path = self.history_manager.forward_step()
155
+ if path is None:
156
+ return None, None
157
+ return path, self._load_image(path)
158
+
159
+ def current(self):
160
+ path = self.history_manager.current()
161
+ if path is None:
162
+ return None, None
163
+ return path, self._load_image(path)
164
+
165
+ def reset(self):
166
+ """Clear all caches and history."""
167
+ self.lru_cache.clear()
168
+ self.preload_queue.clear()
169
+ self.history_manager.clear()
170
+ logger.debug("Cache, preload queue, and history cleared.")
171
+
172
+ if self.background_preload:
173
+ self._background_refill()
174
+ else:
175
+ self._preload_refill()
176
+
177
+ def reset_provider(self) -> bool:
178
+ """Reset provider-specific state and clear pending preloads."""
179
+ reset_callable = getattr(self.image_provider, "reset_burst", None)
180
+ if not callable(reset_callable):
181
+ return False
182
+ with self._provider_lock:
183
+ reset_callable()
184
+ self.preload_queue.clear()
185
+ if self.background_preload:
186
+ self._background_refill()
187
+ else:
188
+ self._preload_refill()
189
+ logger.debug("Provider state reset; preload queue cleared.")
190
+ return True
191
+
192
+ def invalidate(self, image_path: str) -> None:
193
+ """Remove a specific path from caches and preload queue."""
194
+ self.lru_cache.pop(image_path)
195
+ self.preload_queue.discard(image_path)
196
+
197
+ def __repr__(self):
198
+ return (
199
+ f"ImageCacheManager("
200
+ f"cache={self.lru_cache}, "
201
+ f"preload={self.preload_queue}, "
202
+ f"history={self.history_manager})"
203
+ )
@@ -0,0 +1,54 @@
1
+ from collections import OrderedDict
2
+
3
+ class LRUCache:
4
+ """
5
+ A simple LRU (Least Recently Used) cache implemented using OrderedDict.
6
+ Keys are promoted to the front on access or insertion.
7
+ """
8
+
9
+ def __init__(self, max_size: int):
10
+ self.cache = OrderedDict()
11
+ self.max_size = max_size
12
+
13
+ def put(self, key, value):
14
+ if key in self.cache:
15
+ # Update and promote
16
+ self.cache.move_to_end(key, last=False)
17
+ self.cache[key] = value
18
+ else:
19
+ self.cache[key] = value
20
+ self.cache.move_to_end(key, last=False)
21
+ if len(self.cache) > self.max_size:
22
+ self.cache.popitem(last=True) # Remove oldest
23
+
24
+ def get(self, key):
25
+ if key not in self.cache:
26
+ return None
27
+ self.cache.move_to_end(key, last=False)
28
+ return self.cache[key]
29
+
30
+ def pop(self, key):
31
+ return self.cache.pop(key, None)
32
+
33
+ def clear(self):
34
+ """Remove all items from the cache."""
35
+ self.cache.clear()
36
+
37
+ def __contains__(self, key):
38
+ return key in self.cache
39
+
40
+ def __getitem__(self, key):
41
+ return self.get(key)
42
+
43
+ def __setitem__(self, key, value):
44
+ self.put(key, value)
45
+
46
+ def items(self):
47
+ """Return items in most-recently-used order."""
48
+ return list(self.cache.items())
49
+
50
+ def __len__(self):
51
+ return len(self.cache)
52
+
53
+ def __repr__(self):
54
+ return f"LRUCache({list(self.cache.items())})"
@@ -0,0 +1,73 @@
1
+ from collections import deque
2
+
3
+ class PreloadQueue:
4
+ """
5
+ A FIFO queue of preloaded images with a maximum size.
6
+ Each element is a {path: image_obj} dict.
7
+ """
8
+
9
+ def __init__(self, max_size: int):
10
+ self.queue = deque(maxlen=max_size)
11
+ self.lookup = set() # For fast membership checks
12
+ self.max_size = max_size
13
+
14
+ def push(self, path, image_obj):
15
+ """
16
+ Push a new (path, image_obj) to the bottom of the queue.
17
+ If the queue is full, the oldest item is automatically dropped.
18
+ """
19
+ if path in self.lookup:
20
+ return False # Avoid duplicates
21
+
22
+ # If queue is full, remove oldest
23
+ if len(self.queue) == self.max_size:
24
+ oldest = self.queue.popleft()
25
+ self.lookup.difference_update(oldest.keys())
26
+
27
+ self.queue.append({path: image_obj})
28
+ self.lookup.add(path)
29
+ return True
30
+
31
+ def pop(self):
32
+ """
33
+ Pop and return the top (oldest) item in the queue.
34
+ Returns None if the queue is empty.
35
+ """
36
+ if not self.queue:
37
+ return None
38
+ item = self.queue.popleft()
39
+ self.lookup.difference_update(item.keys())
40
+ return item
41
+
42
+ def discard(self, path):
43
+ """
44
+ Remove a specific path from the queue if present.
45
+ Returns True if the path was removed, else False.
46
+ """
47
+ if path not in self.lookup:
48
+ return False
49
+ self.lookup.discard(path)
50
+ new_queue = deque(maxlen=self.max_size)
51
+ for item in self.queue:
52
+ if path not in item:
53
+ new_queue.append(item)
54
+ self.queue = new_queue
55
+ return True
56
+
57
+ def clear(self):
58
+ """Remove all items from the queue."""
59
+ self.queue.clear()
60
+ self.lookup.clear()
61
+
62
+ def __contains__(self, path):
63
+ return path in self.lookup
64
+
65
+ def __len__(self):
66
+ return len(self.queue)
67
+
68
+ def items(self):
69
+ """Return a list of all queued items in order."""
70
+ return list(self.queue)
71
+
72
+ def __repr__(self):
73
+ return f"PreloadQueue({list(self.queue)})"
File without changes