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 +0 -0
- pic_select/__main__.py +9 -0
- pic_select/gui/app.py +115 -0
- pic_select/gui/frames/components/borderedImageFrame.py +35 -0
- pic_select/gui/frames/components/imageFrame.py +61 -0
- pic_select/gui/frames/components/paddedImageFrame.py +24 -0
- pic_select/gui/frames/displayFrame.py +90 -0
- pic_select/gui/frames/displays/imageViewFrame.py +108 -0
- pic_select/gui/frames/displays/loadFrame.py +20 -0
- pic_select/gui/frames/displays/selectImageFrame.py +16 -0
- pic_select/gui/selectFile.py +9 -0
- pic_select/log/__init__.py +4 -0
- pic_select/log/logger.py +29 -0
- pic_select/logic/clipboard.py +13 -0
- pic_select/logic/input/UserInput.py +12 -0
- pic_select/logic/input/userInputHandler.py +94 -0
- pic_select/logic/load/loader.py +105 -0
- pic_select/logic/load/loaderThread.py +124 -0
- pic_select/logic/load/pictureDirectory.py +53 -0
- pic_select/logic/selector.py +78 -0
- pic_select/logic/sizeManager.py +19 -0
- pic_select/pic_select.py +36 -0
- pic_select-1.0.1.dist-info/METADATA +42 -0
- pic_select-1.0.1.dist-info/RECORD +27 -0
- pic_select-1.0.1.dist-info/WHEEL +5 -0
- pic_select-1.0.1.dist-info/licenses/LICENSE +9 -0
- pic_select-1.0.1.dist-info/top_level.txt +1 -0
pic_select/__init__.py
ADDED
|
File without changes
|
pic_select/__main__.py
ADDED
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)
|
pic_select/log/logger.py
ADDED
|
@@ -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,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
|
+
|
pic_select/pic_select.py
ADDED
|
@@ -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,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
|