pic-select 1.0.1__py3-none-any.whl

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.
pic_select/__init__.py ADDED
File without changes
pic_select/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .pic_select import start
2
+
3
+
4
+
5
+ def main():
6
+ start()
7
+
8
+ if __name__ == "__main__":
9
+ main()
pic_select/gui/app.py ADDED
@@ -0,0 +1,115 @@
1
+ import tkinter as tk
2
+
3
+ from ..logic.input.UserInput import UserInput
4
+ from ..logic.input.userInputHandler import UserInputHandler
5
+ from ..logic.load.loader import Loader
6
+ from ..logic.load.loaderThread import LoaderThread
7
+ from ..logic.selector import Selector
8
+
9
+ from .frames.displayFrame import DisplayFrame
10
+ from .frames.displays.imageViewFrame import ImageViewFrame
11
+
12
+ from .selectFile import ask_file_name
13
+
14
+
15
+
16
+ _PADDING_PERCENT = 0.15
17
+
18
+ _MIN_WINDOW_DIM = int(25 / _PADDING_PERCENT) + 1
19
+
20
+
21
+
22
+ class App(tk.Tk):
23
+
24
+ def __init__(
25
+ self,
26
+ title: str,
27
+ input_handler: UserInputHandler,
28
+ loader: Loader,
29
+ loader_thread: LoaderThread,
30
+ selector: Selector,
31
+ fullscreen = True):
32
+ super().__init__()
33
+
34
+ self.title(title)
35
+
36
+ self.minsize(_MIN_WINDOW_DIM, _MIN_WINDOW_DIM)
37
+ self.state("zoomed")
38
+ self.focus_force()
39
+ self.lift()
40
+
41
+ self.bind("<Left>", self._on_left_arrow)
42
+ self.bind("<Right>", self._on_right_arrow)
43
+ self.bind("<space>", self._on_space)
44
+ self.bind("<Control-c>", self._on_strg_c)
45
+ self.bind("<Control-C>", self._on_strg_c)
46
+ self.bind("<Control-a>", self._on_strg_a)
47
+ self.bind("<Control-A>", self._on_strg_a)
48
+ self.bind("<Control-t>", self._on_strg_t)
49
+ self.bind("<Control-T>", self._on_strg_t)
50
+ self.bind("<Control-o>", self._on_strg_o)
51
+ self.bind("<Control-O>", self._on_strg_o)
52
+ self.bind("<Escape>", self._on_escape)
53
+ self.bind("<F11>", self._on_f_11)
54
+
55
+ self._is_fullscreen = fullscreen
56
+ if self._is_fullscreen:
57
+ self.enter_fullscreen()
58
+
59
+ self._input_handler = input_handler
60
+
61
+ self._display_frame = DisplayFrame(master=self, loader_controller=loader_thread, selector=selector)
62
+ self._display_frame.pack(fill="both", expand=True)
63
+ self._display_frame.setup_displays(selector=selector, loader=loader)
64
+ self._display_frame.show_select_image()
65
+
66
+ @property
67
+ def display_frame(self) -> DisplayFrame:
68
+ return self._display_frame
69
+
70
+ @property
71
+ def image_view_frame(self) -> ImageViewFrame:
72
+ return self._display_frame.image_view_frame
73
+
74
+ def _on_right_arrow(self, event) -> None:
75
+ self._input_handler.handle_input(input=UserInput.FORWARD)
76
+
77
+ def _on_left_arrow(self, event) -> None:
78
+ self._input_handler.handle_input(input=UserInput.BACKWARD)
79
+
80
+ def _on_space(self, event) -> None:
81
+ self._input_handler.handle_input(input=UserInput.SELECT)
82
+
83
+ def _on_strg_c(self, evetn) -> None:
84
+ self._input_handler.handle_input(input=UserInput.COPY)
85
+
86
+ def _on_strg_a(self, event) -> None:
87
+ self._input_handler.handle_input(input=UserInput.SELECT_ALL)
88
+
89
+ def _on_escape(self, event) -> None:
90
+ self._input_handler.handle_input(input=UserInput.UNSELECT_ALL)
91
+
92
+ def _on_strg_t(self, event) -> None:
93
+ self.quit()
94
+
95
+ def _on_f_11(self, event) -> None:
96
+ self.toggle_fullscreen()
97
+
98
+ def _on_strg_o(self, event) -> None:
99
+ file = ask_file_name(parent=self)
100
+ self._input_handler.handle_input(input=UserInput.OPEN, file=file)
101
+
102
+ def toggle_fullscreen(self) -> None:
103
+ self._is_fullscreen = not self._is_fullscreen
104
+ self.attributes("-fullscreen", self._is_fullscreen)
105
+
106
+ def enter_fullscreen(self) -> None:
107
+ self.attributes("-fullscreen", True)
108
+ self._is_fullscreen = True
109
+
110
+ def exit_fullscreen(self) -> None:
111
+ self.attributes("-fullscreen", False)
112
+ self._is_fullscreen = False
113
+
114
+ def quit(self) -> None:
115
+ self.destroy()
@@ -0,0 +1,35 @@
1
+ import tkinter as tk
2
+ from PIL import ImageTk
3
+
4
+ from .paddedImageFrame import PaddedImageFrame
5
+
6
+
7
+
8
+ class BorderedImageFrame(tk.Frame):
9
+
10
+ def __init__(self, master: tk.Widget, image: ImageTk.PhotoImage, padding: int, borderwidth: int):
11
+ full_width = image.width() + (padding * 2) + (borderwidth * 2)
12
+ full_height = image.height() + (padding * 2) + (borderwidth * 2)
13
+
14
+ super().__init__(master, width = full_width, height = full_height, borderwidth=0, highlightthickness=0)
15
+
16
+ self._border_frame = tk.Frame(master=self,
17
+ background="blue",
18
+ width=full_width,
19
+ height=full_height,
20
+ borderwidth=0,
21
+ highlightthickness=0)
22
+
23
+ self._padded_frame = PaddedImageFrame(master=self._border_frame, image=image, padding=padding)
24
+
25
+ self._padded_frame.place(x=borderwidth, y=borderwidth)
26
+ self._border_frame.place(x=0, y=0)
27
+
28
+ self.pack_propagate(False)
29
+
30
+
31
+
32
+
33
+
34
+
35
+
@@ -0,0 +1,61 @@
1
+ import tkinter as tk
2
+ from PIL import Image, ImageTk
3
+ from typing import Tuple
4
+ import math
5
+
6
+ from .borderedImageFrame import BorderedImageFrame
7
+
8
+
9
+
10
+ _GAP_SIZE = 5
11
+ _BORDER_SIZE = 7
12
+
13
+
14
+
15
+ class ImageFrame(tk.Frame):
16
+
17
+ def __init__(self, master: tk.Widget, display_size: Tuple[int, int], image: Image.Image, selected: bool = False):
18
+ super().__init__(master=master, width=display_size[0], height=display_size[1])
19
+
20
+ image_bounds = display_size[0] - (_GAP_SIZE * 2) - (_BORDER_SIZE * 2), display_size[1] - (_GAP_SIZE * 2) - (_BORDER_SIZE * 2)
21
+ image_size = ImageFrame.calculate_max_inbound_size(image_bounds=image_bounds, full_size=(image.width, image.height))
22
+ resized_image = image.resize(image_size, resample=Image.Resampling.LANCZOS)
23
+ self._image = ImageTk.PhotoImage(image=resized_image)
24
+ resized_image.close()
25
+
26
+ self._borderedFrame = BorderedImageFrame(master=self, image=self._image, padding=_GAP_SIZE, borderwidth=_BORDER_SIZE)
27
+ self._borderedFrame.place(relx=0.5, rely=0.5, anchor="center")
28
+
29
+ self._selected = selected
30
+
31
+ if self._selected:
32
+ self.select()
33
+ else:
34
+ self.unselect()
35
+
36
+ self.pack_propagate(False)
37
+
38
+ @staticmethod
39
+ def calculate_max_inbound_size(image_bounds: Tuple[int, int], full_size: Tuple[int, int]) -> Tuple[int, int]:
40
+ width_factor = image_bounds[0] / full_size[0]
41
+ height_factor = image_bounds[1] / full_size[1]
42
+ factor = min(width_factor, height_factor)
43
+ return (int(math.floor(full_size[0] * factor)), int(math.floor(full_size[1] * factor)))
44
+
45
+ @property
46
+ def selected(self) -> bool:
47
+ return self._selected
48
+
49
+ def select(self) -> None:
50
+ self._selected = True
51
+ self._borderedFrame._border_frame.configure(background="blue")
52
+
53
+ def unselect(self) -> None:
54
+ self._selected = False
55
+ self._borderedFrame._border_frame.configure(background="SystemButtonFace")
56
+
57
+ def toggle(self) -> None:
58
+ if self.selected:
59
+ self.unselect()
60
+ else:
61
+ self.select()
@@ -0,0 +1,24 @@
1
+ import tkinter as tk
2
+ from PIL import ImageTk
3
+
4
+
5
+
6
+ class PaddedImageFrame(tk.Frame):
7
+
8
+ def __init__(self, master: tk.Widget, image: ImageTk.PhotoImage, padding: int):
9
+ full_width = image.width() + (padding * 2)
10
+ full_height =image.height() + (padding * 2)
11
+
12
+ super().__init__(master, width = full_width, height = full_height, borderwidth=0, highlightthickness=0)
13
+
14
+ self._pad_Frame = tk.Frame(master=self, width = full_width, height=full_height, borderwidth=0, highlightthickness=0)
15
+ self._image_label = tk.Label(master=self._pad_Frame, image=image, borderwidth=0, highlightthickness=0, padx=0, pady=0)
16
+
17
+ self._image_label.place(x=padding, y=padding)
18
+ self._pad_Frame.place(x=0, y=0)
19
+
20
+ self.pack_propagate(False)
21
+
22
+
23
+
24
+
@@ -0,0 +1,90 @@
1
+ import tkinter as tk
2
+ from tkinter import ttk
3
+ from typing import Tuple
4
+ from pathlib import Path
5
+
6
+ from ...logic.load.loaderThread import LoaderThread
7
+ from ...logic.load.loader import Loader
8
+ from ...logic.selector import Selector
9
+
10
+ from .displays.selectImageFrame import SelectImageFrame
11
+ from .displays.imageViewFrame import ImageViewFrame
12
+
13
+
14
+
15
+ _PADDING_PIXELS = 70
16
+
17
+
18
+
19
+ class DisplayFrame(ttk.Frame):
20
+
21
+ def __init__(self, master: tk.Widget, loader_controller: LoaderThread, selector: Selector):
22
+ super().__init__(master=master)
23
+
24
+ loader_controller.subscribe_on_busy(callback=self._on_loader_busy)
25
+ loader_controller.subscribe_on_idle(callback=self._on_loader_idle)
26
+
27
+ selector.subscribe_on_select(callback=self._on_select)
28
+
29
+ self._selection_count: tk.StringVar = tk.StringVar(self, f"{0:05}")
30
+ self._selection_count_label: tk.Label = tk.Label(master=self,
31
+ textvariable=self._selection_count,
32
+ font=("Arial", -20))
33
+ self._selection_count_label.place(relx=0, rely=0, anchor="nw")
34
+
35
+ self._loader_busy: tk.StringVar = tk.StringVar(self, "")
36
+ self._busy_indicator_label: tk.Label = tk.Label(master=self,
37
+ textvariable=self._loader_busy,
38
+ font=("Arial", -20))
39
+ self._busy_indicator_label.place(relx=0, rely=1, anchor="sw")
40
+
41
+ self._image_name: tk.StringVar = tk.StringVar(self, "")
42
+ self._image_name_label: tk.Label = tk.Label(master=self,
43
+ textvariable=self._image_name,
44
+ font=("Arial", -20))
45
+ self._image_name_label.place(relx=0.5, rely=1, anchor="s")
46
+
47
+ self._select_image_frame: SelectImageFrame = None
48
+ self._image_view_frame: ImageViewFrame = None
49
+
50
+ @property
51
+ def image_view_frame(self) -> ImageViewFrame:
52
+ return self._image_view_frame
53
+
54
+ def _on_loader_busy(self) -> None:
55
+ self._loader_busy.set("Loading ...")
56
+
57
+ def _on_loader_idle(self) -> None:
58
+ self._loader_busy.set("")
59
+
60
+ def _on_new_image(self, path: Path) -> None:
61
+ self.show_image(path=path)
62
+
63
+ def _on_select(self, path: Path, selected: bool, count: int) -> None:
64
+ self._selection_count.set(f"{count:05}")
65
+
66
+ def setup_displays(self, selector: Selector, loader: Loader) -> None:
67
+ self.master.update_idletasks()
68
+
69
+ self._image_view_frame = ImageViewFrame(master=self, display_size=self.display_size(), selector=selector, loader=loader)
70
+ self._image_view_frame.place(relx=0.5, rely=0.5, anchor="center")
71
+
72
+ self._select_image_frame = SelectImageFrame(master=self, display_size=self.display_size())
73
+ self._select_image_frame.place(relx=0.5, rely=0.5, anchor="center")
74
+
75
+ def display_size(self) -> Tuple[int, int]:
76
+ width, height = self.winfo_width(), self.winfo_height()
77
+ return width - _PADDING_PIXELS, height -_PADDING_PIXELS
78
+
79
+ def show_image(self, path: Path) -> None:
80
+ self._image_name.set(path.name)
81
+ self._image_view_frame.display_image_frame(path=path)
82
+ self._image_view_frame.tkraise()
83
+ self._image_view_frame.focus()
84
+
85
+ def show_select_image(self) -> None:
86
+ self._select_image_frame.tkraise()
87
+ self._select_image_frame.focus()
88
+
89
+ def set_image_name(self, path: Path) -> None:
90
+ self._image_name.set(path.name)
@@ -0,0 +1,108 @@
1
+ import tkinter as tk
2
+ from typing import Tuple, Dict, Self
3
+ from pathlib import Path
4
+ from collections.abc import Mapping
5
+ from PIL import Image
6
+
7
+ from ....logic.selector import Selector
8
+ from ....logic.load.loader import Loader
9
+
10
+ from ..components.imageFrame import ImageFrame
11
+ from ..displays.loadFrame import LoadFrame
12
+
13
+
14
+
15
+ class ImageViewFrame(tk.Frame, Mapping):
16
+
17
+ def __init__(self, master: tk.Widget, display_size: Tuple[int, int], selector: Selector, loader: Loader):
18
+ super().__init__(master=master, width=display_size[0], height=display_size[1])
19
+
20
+ self.pack_propagate(False)
21
+
22
+ loader.subscribe_on_load(callback=self._on_load)
23
+ loader.subscribe_on_unload(callback=self._on_unload)
24
+
25
+ selector.subscribe_on_select(callback=self._on_select)
26
+
27
+ self._selector = selector
28
+
29
+ self._image_frames: Dict[Path, ImageFrame] = {}
30
+
31
+ self._current_image_path: Path = None
32
+
33
+ self._load_frame: LoadFrame = LoadFrame(master=self, display_size=display_size)
34
+ self._load_frame.place(relx=0.5, rely=0.5, anchor="center", )
35
+
36
+ def __getitem__(self, path: Path):
37
+ if path not in self._images:
38
+ raise KeyError(f"{path} is not framed")
39
+ return self._image_frames[path]
40
+
41
+ def __setitem__(self, path: Path, image: Image.Image):
42
+ raise RuntimeError("use ImageViewFrame.create_image_frame()")
43
+
44
+ def __delitem__(self, path: Path):
45
+ raise RuntimeError("use ImageViewFrame.remove_image_frame()")
46
+
47
+ def __contains__(self, path: Path):
48
+ return path in self._image_frames
49
+
50
+ def __iter__(self):
51
+ return iter(self._image_frames)
52
+
53
+ def __len__(self):
54
+ return len(self._image_frames)
55
+
56
+ def _on_load(self, path: Path, image: Image.Image) -> None:
57
+ self._create_image_frame(path=path, image=image)
58
+ if self._current_image_path == path:
59
+ self.display_image_frame(path=path)
60
+
61
+ def _on_unload(self, unloaded: Path) -> None:
62
+ if unloaded is not None:
63
+ self.remove_image_frame(path=unloaded)
64
+
65
+ def _on_select(self, path: Path, selected: bool, count: int) -> None:
66
+ if path not in self._image_frames:
67
+ return None
68
+ if selected:
69
+ self._image_frames[path].select()
70
+ else:
71
+ self._image_frames[path].unselect()
72
+
73
+ def _create_image_frame(self, path: Path, image: Image.Image) -> Self:
74
+ self._image_frames[path] = ImageFrame(master=self,
75
+ display_size=(self.winfo_width(), self.winfo_height()),
76
+ image=image,
77
+ selected=self._selector.is_selected(path=path))
78
+ self._image_frames[path].place(relx=0.5, rely=0.5, anchor="center")
79
+
80
+ if self._current_image_path in self:
81
+ self._image_frames[self._current_image_path].tkraise()
82
+ else:
83
+ self._load_frame.tkraise()
84
+
85
+ @property
86
+ def current_image_path(self) -> Path:
87
+ return self._current_image_path
88
+
89
+ def remove_image_frame(self, path: Path) -> None:
90
+ self._image_frames.pop(path)
91
+
92
+ def display_image_frame(self, path: Path) -> None:
93
+ if path not in self:
94
+ self._load_frame.set_text(name=path.name)
95
+ self._load_frame.tkraise()
96
+ else:
97
+ self._image_frames[path].tkraise()
98
+ self._current_image_path = path
99
+
100
+ def select_all(self, unselect = False):
101
+ for frame in self._image_frames.values():
102
+ if unselect:
103
+ frame.unselect()
104
+ else:
105
+ frame.select()
106
+
107
+ def is_waiting_on_loader(self) -> bool:
108
+ return self._current_image_path not in self
@@ -0,0 +1,20 @@
1
+ import tkinter as tk
2
+ from typing import Tuple
3
+
4
+
5
+
6
+ class LoadFrame(tk.Frame):
7
+
8
+ def __init__(self, master: tk.Widget, display_size: Tuple[int, int]):
9
+ super().__init__(master=master, width=display_size[0], height=display_size[1])
10
+
11
+ self.pack_propagate(False)
12
+
13
+ self._text: tk.StringVar = tk.StringVar(self)
14
+ self._label: tk.Label = tk.Label(master=self,
15
+ textvariable=self._text,
16
+ font=("Arial, 18"))
17
+ self._label.place(relx=0.5, rely=0.5, anchor=tk.CENTER)
18
+
19
+ def set_text(self, name: str) -> None:
20
+ self._text.set(f"loading image '{name}'")
@@ -0,0 +1,16 @@
1
+ import tkinter as tk
2
+ from typing import Tuple
3
+
4
+
5
+
6
+ class SelectImageFrame(tk.Frame):
7
+
8
+ def __init__(self, master: tk.Widget, display_size: Tuple[int, int]):
9
+ super().__init__(master=master, width=display_size[0], height=display_size[1])
10
+
11
+ self.pack_propagate(False)
12
+
13
+ self._label: tk.Label = tk.Label(master=self,
14
+ text="Select a file (Strg + O)",
15
+ font=("Arial, 18"))
16
+ self._label.place(relx=0.5, rely=0.5, anchor=tk.CENTER)
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+ import tkinter as tk
3
+ from tkinter import filedialog
4
+
5
+
6
+
7
+ def ask_file_name(parent: tk.Tk) -> Path:
8
+ path = filedialog.askopenfilename(parent=parent)
9
+ return Path(path)
@@ -0,0 +1,4 @@
1
+ from .logger import Logger
2
+
3
+
4
+ logger = Logger()
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+ from datetime import datetime
3
+
4
+
5
+
6
+ class Logger():
7
+ def __init__(self, log_file: Path = None):
8
+ self._log_file = log_file
9
+
10
+ def log_file_action(self, thread: str, file: Path, action: str) -> None:
11
+ message = f"{thread} - {file.name} - {action}"
12
+ self._log(message=message)
13
+
14
+ def log_user_input(self, input: str) -> None:
15
+ self._log(message=input, new_line=True)
16
+
17
+ def log_benchmark(self, title: str, time_ms: int) -> None:
18
+ message = f"{title} - {time_ms}ms"
19
+ self._log(message=message, new_line=True)
20
+
21
+ def _log(self, message: str, new_line: bool = False) -> None:
22
+ current_time = datetime.now().strftime("%H:%M:%S")
23
+ good_message = f"{current_time}: {message}"
24
+ if self._log_file is None:
25
+ if new_line:
26
+ print()
27
+ print(good_message)
28
+ else:
29
+ raise NotImplementedError()
@@ -0,0 +1,13 @@
1
+ import subprocess
2
+ from typing import List
3
+ from pathlib import Path
4
+
5
+
6
+
7
+ def copy_files_to_clipboard(files: List[Path]) -> None:
8
+ if files is None or len(files) == 0:
9
+ return None
10
+ seperator = "', '"
11
+ cmd = f"Set-Clipboard -Path '{seperator.join([str(p) for p in files])}'"
12
+ subprocess.run(["powershell", cmd], shell=True)
13
+
@@ -0,0 +1,12 @@
1
+ from enum import Enum
2
+
3
+
4
+
5
+ class UserInput(Enum):
6
+ OPEN = "open"
7
+ FORWARD = "forward"
8
+ BACKWARD = "backward"
9
+ SELECT = "select"
10
+ COPY = "copy"
11
+ SELECT_ALL = "selectAll"
12
+ UNSELECT_ALL = "unselectAll"
@@ -0,0 +1,94 @@
1
+ from typing import Literal
2
+ from pathlib import Path
3
+
4
+ from ..clipboard import copy_files_to_clipboard
5
+ from ..load.loaderThread import LoaderThread
6
+ from ..load.pictureDirectory import PictureDirectory
7
+ from ..selector import Selector
8
+ from .UserInput import UserInput
9
+
10
+ from ...gui.frames.displays.imageViewFrame import ImageViewFrame
11
+ from ...gui.frames.displayFrame import DisplayFrame
12
+
13
+
14
+
15
+ class UserInputHandler:
16
+
17
+ def __init__(self, selector: Selector, loader_thread: LoaderThread, picture_directory: PictureDirectory):
18
+
19
+ self._selector = selector
20
+ self._loader_thread = loader_thread
21
+ self._picture_directory = picture_directory
22
+
23
+ self._image_view_frame = None
24
+ self._display_frame = None
25
+
26
+ self._last_arrow_input: Literal[UserInput.FORWARD, UserInput.BACKWARD] = None
27
+
28
+ def set_image_view_frame(self, image_view_frame: ImageViewFrame) -> None:
29
+ self._image_view_frame = image_view_frame
30
+
31
+ def set_display_frame(self, display_frame: DisplayFrame) -> None:
32
+ self._display_frame = display_frame
33
+
34
+ def handle_input(self, input: UserInput, **kwargs):
35
+ if input not in UserInput:
36
+ raise RuntimeError("Invalid input")
37
+
38
+ match input:
39
+ case UserInput.OPEN:
40
+ self._open(file = kwargs["file"])
41
+ case UserInput.FORWARD:
42
+ self._forward()
43
+ case UserInput.BACKWARD:
44
+ self._backward()
45
+ case UserInput.SELECT:
46
+ self._select()
47
+ case UserInput.COPY:
48
+ self._copy()
49
+ case UserInput.SELECT_ALL:
50
+ self._select_all()
51
+ case UserInput.UNSELECT_ALL:
52
+ self._unselect_all()
53
+
54
+ def _open(self, file: Path) -> None:
55
+ self._picture_directory.directory = file.parent
56
+ self._loader_thread.update_to(path=file)
57
+ self._display_frame.show_image(path=file)
58
+
59
+ def _forward(self) -> None:
60
+ if self._image_view_frame.is_waiting_on_loader() and self._last_arrow_input == UserInput.FORWARD:
61
+ return None
62
+
63
+ self._last_arrow_input = UserInput.FORWARD
64
+
65
+ next_file = self._picture_directory.next_file(anchor=self._image_view_frame.current_image_path)
66
+ if next_file is not None:
67
+ self._display_frame.show_image(path=next_file)
68
+ self._loader_thread.update_to(path=next_file)
69
+
70
+ def _backward(self) -> None:
71
+ if self._image_view_frame.is_waiting_on_loader() and self._last_arrow_input == UserInput.BACKWARD:
72
+ return None
73
+
74
+ self._last_arrow_input = UserInput.BACKWARD
75
+
76
+ previous_file = self._picture_directory.previous_file(anchor=self._image_view_frame.current_image_path)
77
+ if previous_file is not None:
78
+ self._display_frame.show_image(path=previous_file)
79
+ self._loader_thread.update_to(path=previous_file)
80
+
81
+ def _select(self) -> None:
82
+ path = self._image_view_frame.current_image_path
83
+ if path in self._image_view_frame:
84
+ self._selector.toggle(path)
85
+
86
+ def _copy(self) -> None:
87
+ selection = self._selector.get_selection()
88
+ copy_files_to_clipboard(files=selection)
89
+
90
+ def _select_all(self) -> None:
91
+ self._selector.select_all()
92
+
93
+ def _unselect_all(self) -> None:
94
+ self._selector.unselect_all()
@@ -0,0 +1,105 @@
1
+ from PIL import Image
2
+ from pathlib import Path
3
+ from typing import Dict, Callable, Set, Tuple
4
+ from collections.abc import Mapping
5
+
6
+ from .pictureDirectory import PictureDirectory
7
+
8
+
9
+
10
+ _EXIF_FORMATS = ["JPEG", "TIFF"]
11
+ _TRANSPOSE_MAP = {
12
+ 2: Image.Transpose.FLIP_LEFT_RIGHT,
13
+ 3: Image.Transpose.ROTATE_180,
14
+ 4: Image.Transpose.FLIP_TOP_BOTTOM,
15
+ 5: Image.Transpose.TRANSPOSE,
16
+ 6: Image.Transpose.ROTATE_270,
17
+ 7: Image.Transpose.TRANSVERSE,
18
+ 8: Image.Transpose.ROTATE_90,
19
+ }
20
+
21
+
22
+
23
+ class Loader(Mapping):
24
+
25
+ def __init__(self, picture_directory: PictureDirectory, max_image_size: Tuple[int, int] = None):
26
+ if picture_directory is None:
27
+ raise RuntimeError("picture_directory can not be None")
28
+ self._picture_directory = picture_directory
29
+ self._max_image_size = max_image_size
30
+
31
+ self._images: Dict[Path, Image.Image] = dict()
32
+
33
+ self._load_callbacks: Set[Callable[[Path, Image.Image], None]] = set()
34
+ self._unload_callbacks: Set[Callable[[Path], None]] = set()
35
+
36
+ def __getitem__(self, path: Path):
37
+ if path not in self._images:
38
+ raise KeyError(f"{path} is not loaded")
39
+ return self._images[path]
40
+
41
+ def __setitem__(self, path: Path, image: Image.Image):
42
+ raise RuntimeError("use Loader.load()")
43
+
44
+ def __delitem__(self, path: Path):
45
+ raise RuntimeError("use Loader.unload()")
46
+
47
+ def __contains__(self, path: Path):
48
+ return path in self._images
49
+
50
+ def __iter__(self):
51
+ return iter(self._images)
52
+
53
+ def __len__(self):
54
+ return len(self._images)
55
+
56
+ def load(self, path: Path) -> Image.Image:
57
+ if path not in self._picture_directory:
58
+ raise KeyError(f"{path} not in {self._picture_directory.directory}")
59
+
60
+ if path in self:
61
+ return self[path]
62
+
63
+ image: Image = Image.open(path)
64
+
65
+ if image.format in _EXIF_FORMATS:
66
+ exif = image.getexif()
67
+ orientation = exif.get(0x0112)
68
+ if orientation is not None and orientation > 1:
69
+ method = _TRANSPOSE_MAP.get(orientation)
70
+ image = image.transpose(method)
71
+
72
+ if self._max_image_size is not None:
73
+ image.thumbnail(self._max_image_size, Image.Resampling.LANCZOS)
74
+
75
+ self._images[path] = image
76
+ self._trigger_load(image=image, path=path)
77
+ return image
78
+
79
+ def unload(self, path: Path) -> None:
80
+ if path not in self._images:
81
+ return None
82
+
83
+ self._images[path].close()
84
+ self._images.pop(path)
85
+ self._trigger_unload(unloaded_path=path)
86
+
87
+ def _trigger_load(self, image: Image.Image, path: Path) -> None:
88
+ for callback in self._load_callbacks:
89
+ callback(path, image)
90
+
91
+ def _trigger_unload(self, unloaded_path: Path) -> None:
92
+ for callback in self._unload_callbacks:
93
+ callback(unloaded_path)
94
+
95
+ def subscribe_on_load(self, callback: Callable[[Path, Image.Image], None]):
96
+ self._load_callbacks.add(callback)
97
+
98
+ def unsubscribe_on_load(self, callback: Callable[[Path, Image.Image], None]):
99
+ self._load_callbacks.remove(callback)
100
+
101
+ def subscribe_on_unload(self, callback: Callable[[Path], None]):
102
+ self._unload_callbacks.add(callback)
103
+
104
+ def unsubscribe_on_unload(self, callback: Callable[[Path], None]):
105
+ self._unload_callbacks.remove(callback)
@@ -0,0 +1,124 @@
1
+ from queue import Queue
2
+ from typing import Callable, Set
3
+ from pathlib import Path
4
+
5
+ from .loader import Loader
6
+ from .pictureDirectory import PictureDirectory
7
+
8
+
9
+
10
+ class LoaderThread:
11
+
12
+ def __init__(self, loader: Loader, picture_directory: PictureDirectory, centre_file: Path = None, preload_length: int = 6, batch_size: int = 3):
13
+ self._preload_length = preload_length
14
+ self._batch_size = batch_size
15
+
16
+ self._loader = loader
17
+ self._picture_directory = picture_directory
18
+
19
+ self._centre_file = centre_file
20
+
21
+ self._queue = Queue()
22
+ self._queue_size = 0
23
+
24
+ self._idle_callbacks: Set[Callable[[], None]] = set()
25
+ self._busy_callbacks: Set[Callable[[], None]] = set()
26
+
27
+ def _run(self) -> None:
28
+ while True:
29
+ request = self._queue.get()
30
+ self._execute_queue(first_request=request)
31
+ if self._queue.qsize() == 0:
32
+ self._trigger_idle()
33
+
34
+ def _execute_queue(self, first_request) -> None:
35
+ requests = [first_request]
36
+
37
+ while not self._queue.qsize() == 0:
38
+ requests.append(self._queue.get_nowait())
39
+
40
+ request = requests[-1]
41
+
42
+ for i in range(len(requests) - 1):
43
+ self._queue.task_done()
44
+ self._queue_size = self._queue.qsize()
45
+
46
+ self._trigger_busy()
47
+ self._update_loader(path = request)
48
+ self._queue.task_done()
49
+
50
+ def _update_loader(self, path: Path) -> None:
51
+ if self._centre_file is None:
52
+ self._centre_file = path
53
+ if self._unload_out_of_scope_images(path=path):
54
+ self._centre_file = path
55
+ return None
56
+ if self._load_in_scope_images(path=path):
57
+ self._centre_file = path
58
+ return None
59
+ self._centre_file = path
60
+
61
+ def _unload_out_of_scope_images(self, path: Path) -> bool:
62
+ out_of_scope_images = []
63
+ for path in self._loader.keys():
64
+ if self._picture_directory.distance(self._centre_file, path) > self._preload_length:
65
+ out_of_scope_images.append(path)
66
+ for path in out_of_scope_images:
67
+ self._loader.unload(path=path)
68
+ if self._queue_size < self._queue.qsize():
69
+ return True
70
+
71
+ def _load_in_scope_images(self, path: Path) -> bool:
72
+
73
+ def load_file(file: Path) -> None:
74
+ if file is None:
75
+ return None
76
+ if file in self._loader.keys():
77
+ return None
78
+ self._loader.load(path=file)
79
+
80
+ load_file(path)
81
+ for relative_position in range(1, self._preload_length + 1):
82
+ load_file(self._picture_directory.get_relative_file(anchor=path, distance=relative_position))
83
+ if self._queue_size < self._queue.qsize():
84
+ return True
85
+ load_file(self._picture_directory.get_relative_file(anchor=path, distance=-relative_position))
86
+ if self._queue_size < self._queue.qsize():
87
+ return True
88
+
89
+ def _trigger_idle(self) -> None:
90
+ for callback in self._idle_callbacks:
91
+ callback()
92
+
93
+ def _trigger_busy(self) -> None:
94
+ for callback in self._busy_callbacks:
95
+ callback()
96
+
97
+ def _clear_queue(self) -> None:
98
+ while not self._queue.qsize() == 0:
99
+ self._queue.get_nowait()
100
+ self._queue.task_done()
101
+
102
+ def mainloop(self) -> None:
103
+ self._run()
104
+
105
+ def _append_reload(self, path: Path) -> None:
106
+ self._queue.put(path)
107
+
108
+ def update_to(self, path: Path) -> None:
109
+ if self._centre_file is None or self._picture_directory.distance(self._centre_file, path) > self._batch_size:
110
+ self._append_reload(path=path)
111
+ else:
112
+ self._append_reload(path=self._centre_file)
113
+
114
+ def subscribe_on_idle(self, callback: Callable[[], None]):
115
+ self._idle_callbacks.add(callback)
116
+
117
+ def unsubscribe_on_idle(self, callback: Callable[[], None]):
118
+ self._idle_callbacks.remove(callback)
119
+
120
+ def subscribe_on_busy(self, callback: Callable[[], None]):
121
+ self._busy_callbacks.add(callback)
122
+
123
+ def unsubscribe_on_busy(self, callback: Callable[[], None]):
124
+ self._busy_callbacks.remove(callback)
@@ -0,0 +1,53 @@
1
+ from pathlib import Path
2
+ from typing import List
3
+ from collections import UserList
4
+
5
+
6
+
7
+ class PictureDirectory(UserList):
8
+
9
+ def __init__(self, directory: Path = None):
10
+ if directory is None:
11
+ self._directory = directory
12
+ self._files = list()
13
+ else:
14
+ self.directory = directory
15
+ self.data = self._files
16
+
17
+ def _lookup_files(self) -> None:
18
+ self._files = [f for f in self._directory.iterdir() if f.is_file()]
19
+ self._files.sort()
20
+
21
+ @property
22
+ def directory(self) -> Path:
23
+ return self._directory
24
+
25
+ @directory.setter
26
+ def directory(self, directory: Path):
27
+ if not directory.is_dir():
28
+ raise ValueError(f"{directory} is no directory")
29
+ else:
30
+ self._directory = directory
31
+ self._lookup_files()
32
+ self.data = self._files
33
+
34
+ def get_relative_file(self, anchor: Path, distance: int) -> Path:
35
+ if anchor not in self:
36
+ raise RuntimeError(f"{anchor} not in {self._directory}")
37
+ index = self.index(anchor) + distance
38
+ if index < 0 or index >= len(self):
39
+ return None
40
+ return self[index]
41
+
42
+ def previous_file(self, anchor: Path) -> Path:
43
+ return self.get_relative_file(anchor=anchor, distance=-1)
44
+
45
+ def next_file(self, anchor: Path) -> Path:
46
+ return self.get_relative_file(anchor=anchor, distance=1)
47
+
48
+ def distance(self, pathA: Path, pathB: Path) -> int:
49
+ if pathA not in self:
50
+ raise RuntimeError(f"{pathA} not in {self._directory}")
51
+ if pathB not in self:
52
+ raise RuntimeError(f"{pathB} not in {self._directory}")
53
+ return abs(self.index(pathA) - self.index(pathB))
@@ -0,0 +1,78 @@
1
+ from pathlib import Path
2
+ from typing import Dict, List, Set, Callable
3
+ from collections.abc import Mapping
4
+
5
+ from .load.pictureDirectory import PictureDirectory
6
+
7
+
8
+
9
+ class Selector(Mapping):
10
+
11
+ def __init__(self, picture_directoy: PictureDirectory):
12
+ self._picture_directory = picture_directoy
13
+ self._files: Dict[Path, bool] = {}
14
+
15
+ self._callbacks: Set[Callable[[Path, bool, int], None]] = set()
16
+
17
+ def __getitem__(self, path: Path):
18
+ if path not in self._files:
19
+ return False
20
+ return self._files[path]
21
+
22
+ def __setitem__(self, path: Path, selected: bool):
23
+ self._files[path] = selected
24
+
25
+ def __delitem__(self, path: Path):
26
+ del self._files[path]
27
+
28
+ def __contains__(self, path: Path):
29
+ return path in self._files
30
+
31
+ def __iter__(self):
32
+ return iter(self._files)
33
+
34
+ def __len__(self):
35
+ return len(self._files)
36
+
37
+ def _trigger_select(self, path: Path) -> None:
38
+ for callback in self._callbacks:
39
+ callback(path, self.is_selected(path=path), self.get_number_selected())
40
+
41
+ def toggle(self, path: Path) -> bool:
42
+ if path in self._files:
43
+ self._files[path] = not self._files[path]
44
+ else:
45
+ self._files[path] = True
46
+ self._trigger_select(path=path)
47
+ return self.is_selected(path=path)
48
+
49
+ def get_selection(self) -> List[Path]:
50
+ return list({k: v for k, v in self._files.items() if v is True}.keys())
51
+
52
+ def get_number_selected(self) -> int:
53
+ return len(self.get_selection())
54
+
55
+ def is_selected(self, path: Path) -> bool:
56
+ if path in self._files:
57
+ return self._files[path]
58
+ else:
59
+ return False
60
+
61
+ def multi_select(self, paths: List[Path]) -> None:
62
+ for path in paths:
63
+ self._files[path] = True
64
+ self._trigger_select(path=path)
65
+
66
+ def select_all(self) -> None:
67
+ self.multi_select(paths=self._picture_directory)
68
+
69
+ def unselect_all(self) -> None:
70
+ for path in self._files.keys():
71
+ self._files[path] = False
72
+ self._trigger_select(path=path)
73
+
74
+ def subscribe_on_select(self, callback: Callable[[Path, bool, int], None]):
75
+ self._callbacks.add(callback)
76
+
77
+ def unsubscribe_on_select(self, callback: Callable[[Path, bool, int], None]):
78
+ self._callbacks.remove(callback)
@@ -0,0 +1,19 @@
1
+ from screeninfo import get_monitors
2
+ from typing import Tuple
3
+
4
+
5
+ class SizeManager():
6
+
7
+ def __init__(self):
8
+ self._max_monitor_extension = self.get_max_monitor_extension()
9
+
10
+ @property
11
+ def max_monitor_extension(self) -> Tuple[int, int]:
12
+ return self._max_monitor_extension
13
+
14
+ @staticmethod
15
+ def get_max_monitor_extension() -> Tuple[int, int]:
16
+ widths, heights = zip(*[(m.width, m.height) for m in get_monitors()])
17
+ return max(widths), max(heights)
18
+
19
+
@@ -0,0 +1,36 @@
1
+ import threading
2
+
3
+ from .logic.load.loader import Loader
4
+ from .logic.load.loaderThread import LoaderThread
5
+ from .logic.load.pictureDirectory import PictureDirectory
6
+ from .logic.selector import Selector
7
+ from .logic.input.userInputHandler import UserInputHandler
8
+ from .logic.sizeManager import SizeManager
9
+
10
+ from .gui.app import App
11
+ from .gui.selectFile import ask_file_name
12
+
13
+
14
+
15
+ def start():
16
+ picture_directory = PictureDirectory()
17
+ size_manager = SizeManager()
18
+ loader = Loader(picture_directory=picture_directory, max_image_size=size_manager.max_monitor_extension)
19
+ loader_thread = LoaderThread(loader=loader, picture_directory=picture_directory)
20
+ selector = Selector(picture_directoy=picture_directory)
21
+ input_handler = UserInputHandler(selector=selector, loader_thread=loader_thread, picture_directory=picture_directory)
22
+
23
+ worker_thread = threading.Thread(target=loader_thread.mainloop, daemon=True)
24
+
25
+ app = App(title="",
26
+ input_handler=input_handler,
27
+ loader=loader,
28
+ loader_thread=loader_thread,
29
+ selector=selector)
30
+
31
+ input_handler.set_display_frame(display_frame=app.display_frame)
32
+ input_handler.set_image_view_frame(image_view_frame=app.image_view_frame)
33
+
34
+ worker_thread.start()
35
+
36
+ app.mainloop()
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: pic_select
3
+ Version: 1.0.1
4
+ Author: Nils Urbach
5
+ Author-email: ndu01u@gmail.com
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Natural Language :: English
12
+ Classifier: Programming Language :: Python
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: author
16
+ Dynamic: author-email
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: license
21
+ Dynamic: license-file
22
+
23
+ # pic_select
24
+
25
+ This is a small module that easily lets you view; select and copy images in a directory.
26
+
27
+ ## Installation and Start
28
+
29
+ - install using `python -m pip install pic_select`
30
+ - run using `python -m pic_select`
31
+
32
+ ## Usage
33
+
34
+ - Ctrl+O: open a image file
35
+ - Left arrow: show next picture in directory (alphabetical order)
36
+ - Right arrow: show next picture in directory (alphabetical order)
37
+ - Space bar: (un)select an image
38
+ - Ctrl+C: copy all selected images to clipboard
39
+ - Ctrl+A: select all images in the directory
40
+ - Esc: unselect all images
41
+ - Ctrl+T: exit the program
42
+ - F11: toggle fullscreen
@@ -0,0 +1,27 @@
1
+ pic_select/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pic_select/__main__.py,sha256=MsBjJvgc-QnEDRJP6_V4zdrk9YYzOetR1IwPudFI3uA,103
3
+ pic_select/pic_select.py,sha256=N7mdvwARsxGCOoG_XTlB8DM0ysJ3Ff-5oOY-8FXEQHQ,1322
4
+ pic_select/gui/app.py,sha256=aTCGude6kGkNJsUO2pFql6dzI89ECzIDczLCNPEsZV4,3750
5
+ pic_select/gui/selectFile.py,sha256=C4_C4kHow0GiiTA0C8vFmlA59uXchWn8KiLn3YAViUo,204
6
+ pic_select/gui/frames/displayFrame.py,sha256=3gvJmkKFOevitzDWqUWrXvnmxCu1ix4hEs1Zvhr4XKg,3668
7
+ pic_select/gui/frames/components/borderedImageFrame.py,sha256=kd1cMIQb4XxL04ufYIKJkciOkJxH5_cStSQCRIe-gFM,1117
8
+ pic_select/gui/frames/components/imageFrame.py,sha256=77t49lqyFLHWT2K8xqQ_u1rDSZwLOLubC6gkR4JTlD8,2149
9
+ pic_select/gui/frames/components/paddedImageFrame.py,sha256=TdmE4heWfylodmu4xKcyPKVKp8VW7JiPGEQp7_rFQmk,795
10
+ pic_select/gui/frames/displays/imageViewFrame.py,sha256=3k5tZLWniSHmI7CC00h_0eX-0qiH1xGUszuyXE2C2XQ,3831
11
+ pic_select/gui/frames/displays/loadFrame.py,sha256=VRgx_ipZDNlJXvS0jIQukGV1FhMrZL_CMR1m3q5PCT0,686
12
+ pic_select/gui/frames/displays/selectImageFrame.py,sha256=ksoBIcs5K9xbjRFY7NWAkqWYe_8V_n96jck1GMAbQTQ,557
13
+ pic_select/log/__init__.py,sha256=8UOcu1oKmPPmUfRtZwak2Ldt5J4HF78b_CNBP19IbQ4,49
14
+ pic_select/log/logger.py,sha256=8LLQAn9a3nS9Q22tpar3Zx8LdDaGuj1sJC2t-h5GCjw,956
15
+ pic_select/logic/clipboard.py,sha256=Qv7dvR8akce3wvPNSMZoblm5lZNvCtRCgbkl98VoRlc,359
16
+ pic_select/logic/selector.py,sha256=TjJrdOs7CJUhztGhc_KjI7wGq9ow-rn60k6vdrypAMg,2456
17
+ pic_select/logic/sizeManager.py,sha256=HXuzJItZmlSKTuCYNqd_b_3IHquiDdjcmXl6y2Cynq0,515
18
+ pic_select/logic/input/UserInput.py,sha256=U1EyU_akDZpk7KdmLfLP8UdK6Oynmn6nSKr4SYLx5RQ,228
19
+ pic_select/logic/input/userInputHandler.py,sha256=uFeA3IUW8qN_8SLs1tx0uwOA-M1RmjT3kbg4BHqbGJ4,3443
20
+ pic_select/logic/load/loader.py,sha256=t05b9PDmCvat1m6H81Ah8SGwp0RVKKYvqBNOjxtrB-o,3561
21
+ pic_select/logic/load/loaderThread.py,sha256=Li6gSmUqbDXoWtWv_2h8aDfoZ7yFQ-vpKXbpykRJYOg,4328
22
+ pic_select/logic/load/pictureDirectory.py,sha256=ch5hSqlSpJnw76X5owYZ44sjco4Btvoet0hXzdBNudo,1766
23
+ pic_select-1.0.1.dist-info/licenses/LICENSE,sha256=9eUWP30YVqvg82ZPXd86acXWRKPhIJPT-QaRcQLVg8s,1077
24
+ pic_select-1.0.1.dist-info/METADATA,sha256=NjYCB_AR0CgJMe0SYLhUUpfwKy-oxYrbSgTmi8Ccgs4,1286
25
+ pic_select-1.0.1.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
26
+ pic_select-1.0.1.dist-info/top_level.txt,sha256=FJbRlZms-SbCfEc_ffS-Y0htl2oltq9K-HpTR1G9MK8,11
27
+ pic_select-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Nils Urbach
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ pic_select