picview 0.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.
- picview/__init__.py +3 -0
- picview/__main__.py +3 -0
- picview/app.py +264 -0
- picview/assets/picview-icon.png +0 -0
- picview/image_pane.py +215 -0
- picview/qt_app.py +589 -0
- picview/settings.py +46 -0
- picview/thumbnail_pane.py +108 -0
- picview/ui.py +108 -0
- picview/utils.py +32 -0
- picview-0.0.1.dist-info/METADATA +93 -0
- picview-0.0.1.dist-info/RECORD +14 -0
- picview-0.0.1.dist-info/WHEEL +4 -0
- picview-0.0.1.dist-info/entry_points.txt +3 -0
picview/__init__.py
ADDED
picview/__main__.py
ADDED
picview/app.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
import locale
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tkinter import filedialog, messagebox, ttk
|
|
7
|
+
import customtkinter as ctk
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from tkinterdnd2 import DND_FILES, TkinterDnD
|
|
11
|
+
except ImportError: # Allows unit-level imports if optional UI dependency is absent.
|
|
12
|
+
DND_FILES = "DND_Files"
|
|
13
|
+
TkinterDnD = None
|
|
14
|
+
|
|
15
|
+
from .image_pane import ImagePane
|
|
16
|
+
from .settings import load_settings, save_settings
|
|
17
|
+
from .thumbnail_pane import ThumbnailPane
|
|
18
|
+
from .utils import image_files
|
|
19
|
+
from .ui import TEXT, IconButton, configure_flat_style
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PicViewApp:
|
|
23
|
+
def __init__(self, root: tk.Tk) -> None:
|
|
24
|
+
self.root = root
|
|
25
|
+
self.settings = load_settings()
|
|
26
|
+
self.folder: Path | None = None
|
|
27
|
+
self.files: list[Path] = []
|
|
28
|
+
self.orientation = self.settings["orientation"]
|
|
29
|
+
# A maximized pane is a transient viewing mode. Restoring it before a
|
|
30
|
+
# ttk PanedWindow has completed geometry produces an orphaned sash and
|
|
31
|
+
# hides the other pane, so startup always restores the normal layout.
|
|
32
|
+
self.maximized_pane = ""
|
|
33
|
+
self.sash_fraction = float(self.settings["sash_fraction"])
|
|
34
|
+
self.language_mode = self.settings["language"]
|
|
35
|
+
system_language = (locale.getlocale()[0] or "en").lower()
|
|
36
|
+
self.language = "zh" if self.language_mode == "system" and system_language.startswith("zh") else (self.language_mode if self.language_mode in TEXT else "en")
|
|
37
|
+
self.labels = TEXT[self.language]
|
|
38
|
+
self._build_window()
|
|
39
|
+
self._restore_state()
|
|
40
|
+
|
|
41
|
+
def _build_window(self) -> None:
|
|
42
|
+
self.root.title("PicView")
|
|
43
|
+
self.root.minsize(720, 500)
|
|
44
|
+
self.root.geometry("1100x780")
|
|
45
|
+
configure_flat_style(self.root)
|
|
46
|
+
icon_path = Path(__file__).parent / "assets" / "picview-icon.png"
|
|
47
|
+
if icon_path.exists():
|
|
48
|
+
self.icon_image = tk.PhotoImage(file=icon_path)
|
|
49
|
+
self.root.iconphoto(True, self.icon_image)
|
|
50
|
+
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
|
51
|
+
self.root.drop_target_register(DND_FILES)
|
|
52
|
+
self.root.dnd_bind("<<Drop>>", self._on_drop)
|
|
53
|
+
|
|
54
|
+
ctk.set_appearance_mode("system")
|
|
55
|
+
ctk.set_default_color_theme("blue")
|
|
56
|
+
toolbar = ctk.CTkFrame(self.root, height=48, corner_radius=0, fg_color=("#eaf1f4", "#16232c"))
|
|
57
|
+
toolbar.pack(fill="x")
|
|
58
|
+
toolbar.pack_propagate(False)
|
|
59
|
+
self._icon_button(toolbar, "folder", "open", self.choose_folder).pack(side="left")
|
|
60
|
+
ctk.CTkFrame(toolbar, width=1, fg_color=("#c9d8dd", "#36505d")).pack(side="left", fill="y", pady=9, padx=8)
|
|
61
|
+
self._icon_button(toolbar, "split_h", "vertical", lambda: self.set_orientation("vertical")).pack(side="left")
|
|
62
|
+
self._icon_button(toolbar, "split_v", "horizontal", lambda: self.set_orientation("horizontal")).pack(side="left", padx=2)
|
|
63
|
+
ctk.CTkFrame(toolbar, width=1, fg_color=("#c9d8dd", "#36505d")).pack(side="left", fill="y", pady=9, padx=8)
|
|
64
|
+
self._icon_button(toolbar, "En" if self.language == "zh" else "文", "language", self.toggle_language).pack(side="left")
|
|
65
|
+
|
|
66
|
+
self.pane_host = ctk.CTkFrame(self.root, corner_radius=0, fg_color=("#dce7eb", "#101820"))
|
|
67
|
+
self.pane_host.pack(fill="both", expand=True, padx=8, pady=8)
|
|
68
|
+
self._create_panes()
|
|
69
|
+
|
|
70
|
+
def _create_panes(self) -> None:
|
|
71
|
+
old_thumbnail_size = int(self.settings["thumbnail_size"])
|
|
72
|
+
old_image_zoom = float(self.settings["image_zoom"])
|
|
73
|
+
selected = None
|
|
74
|
+
if hasattr(self, "thumbnails"):
|
|
75
|
+
old_thumbnail_size = self.thumbnails.thumbnail_size
|
|
76
|
+
old_image_zoom = self.image.zoom_var.get()
|
|
77
|
+
selected = self.thumbnails.selected
|
|
78
|
+
self.paned.destroy()
|
|
79
|
+
orient = tk.VERTICAL if self.orientation == "vertical" else tk.HORIZONTAL
|
|
80
|
+
self.paned = ttk.Panedwindow(self.pane_host, orient=orient)
|
|
81
|
+
self.paned.pack(fill="both", expand=True)
|
|
82
|
+
self.thumbnails = ThumbnailPane(self.paned, self.select_image, self.labels, self.toggle_maximize, old_thumbnail_size)
|
|
83
|
+
self.image = ImagePane(self.paned, self.labels, self.toggle_maximize, old_image_zoom)
|
|
84
|
+
self.paned.add(self.thumbnails, weight=1)
|
|
85
|
+
self.paned.add(self.image, weight=2)
|
|
86
|
+
self.thumbnails.set_files(self.files, selected)
|
|
87
|
+
if self.maximized_pane:
|
|
88
|
+
self.root.after_idle(lambda: self._apply_maximized(self.maximized_pane))
|
|
89
|
+
else:
|
|
90
|
+
self.root.after_idle(self._restore_sash)
|
|
91
|
+
|
|
92
|
+
def choose_folder(self) -> None:
|
|
93
|
+
chosen = filedialog.askdirectory(parent=self.root, title="Open image folder", initialdir=str(self.folder) if self.folder else None)
|
|
94
|
+
if chosen:
|
|
95
|
+
self.open_folder(Path(chosen))
|
|
96
|
+
|
|
97
|
+
def open_folder(self, folder: Path, selected: Path | None = None) -> None:
|
|
98
|
+
try:
|
|
99
|
+
folder = folder.resolve()
|
|
100
|
+
if not folder.is_dir():
|
|
101
|
+
raise OSError
|
|
102
|
+
except OSError:
|
|
103
|
+
messagebox.showerror("PicView", "That folder cannot be opened.", parent=self.root)
|
|
104
|
+
return
|
|
105
|
+
self.folder = folder
|
|
106
|
+
self.files = image_files(folder)
|
|
107
|
+
self._create_panes()
|
|
108
|
+
target = selected if selected in self.files else (self.files[0] if self.files else None)
|
|
109
|
+
if target:
|
|
110
|
+
self.thumbnails.select(target)
|
|
111
|
+
else:
|
|
112
|
+
self.image.load_empty()
|
|
113
|
+
self._update_title()
|
|
114
|
+
|
|
115
|
+
def select_image(self, path: Path) -> None:
|
|
116
|
+
self.image.load(path)
|
|
117
|
+
|
|
118
|
+
def current_selection(self) -> Path | None:
|
|
119
|
+
return getattr(self, "thumbnails", None).selected if hasattr(self, "thumbnails") else None
|
|
120
|
+
|
|
121
|
+
def set_orientation(self, orientation: str) -> None:
|
|
122
|
+
if orientation == self.orientation:
|
|
123
|
+
return
|
|
124
|
+
# Layout switches are always two-pane layouts; do not route through
|
|
125
|
+
# the maximize restore path, which can leave a stale sash in ttk.
|
|
126
|
+
self.sash_fraction = self._current_sash_fraction()
|
|
127
|
+
self.maximized_pane = ""
|
|
128
|
+
self.orientation = orientation
|
|
129
|
+
self._create_panes()
|
|
130
|
+
self.root.after(40, self._ensure_two_panes)
|
|
131
|
+
|
|
132
|
+
def _ensure_two_panes(self) -> None:
|
|
133
|
+
"""Repair a deferred ttk PanedWindow layout after orientation changes."""
|
|
134
|
+
if self.maximized_pane:
|
|
135
|
+
return
|
|
136
|
+
current = set(self.paned.panes())
|
|
137
|
+
wanted = {str(self.thumbnails), str(self.image)}
|
|
138
|
+
if current != wanted:
|
|
139
|
+
for pane in current:
|
|
140
|
+
self.paned.forget(pane)
|
|
141
|
+
self.paned.add(self.thumbnails, weight=1)
|
|
142
|
+
self.paned.add(self.image, weight=2)
|
|
143
|
+
self.thumbnails.set_maximized(False)
|
|
144
|
+
self.image.set_maximized(False)
|
|
145
|
+
self._restore_sash()
|
|
146
|
+
|
|
147
|
+
def toggle_maximize(self, pane: str) -> None:
|
|
148
|
+
if pane == self.maximized_pane or not pane:
|
|
149
|
+
self.maximized_pane = ""
|
|
150
|
+
self._apply_maximized("")
|
|
151
|
+
else:
|
|
152
|
+
if self.maximized_pane:
|
|
153
|
+
self.maximized_pane = ""
|
|
154
|
+
self._apply_maximized("")
|
|
155
|
+
self.sash_fraction = self._current_sash_fraction()
|
|
156
|
+
self.maximized_pane = pane
|
|
157
|
+
self._apply_maximized(pane)
|
|
158
|
+
|
|
159
|
+
def _apply_maximized(self, pane: str) -> None:
|
|
160
|
+
panes = list(self.paned.panes())
|
|
161
|
+
target = str(self.thumbnails if pane == "thumbnails" else self.image)
|
|
162
|
+
if pane:
|
|
163
|
+
for item in panes:
|
|
164
|
+
if item != target:
|
|
165
|
+
self.paned.forget(item)
|
|
166
|
+
self.thumbnails.set_maximized(pane == "thumbnails")
|
|
167
|
+
self.image.set_maximized(pane == "image")
|
|
168
|
+
else:
|
|
169
|
+
for item in panes:
|
|
170
|
+
self.paned.forget(item)
|
|
171
|
+
self.paned.add(self.thumbnails, weight=1)
|
|
172
|
+
self.paned.add(self.image, weight=2)
|
|
173
|
+
self.thumbnails.set_maximized(False)
|
|
174
|
+
self.image.set_maximized(False)
|
|
175
|
+
self.root.after(20, self._restore_sash)
|
|
176
|
+
|
|
177
|
+
def _restore_sash(self, attempts: int = 0) -> None:
|
|
178
|
+
if not self.paned.panes() or self.maximized_pane:
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
dimension = self.paned.winfo_height() if self.orientation == "vertical" else self.paned.winfo_width()
|
|
182
|
+
# `after_idle` can run before the PanedWindow receives its final
|
|
183
|
+
# geometry (dimension == 1). Setting sashpos then collapses the
|
|
184
|
+
# first pane to zero, which looks like a false maximized image.
|
|
185
|
+
if dimension <= 1 and attempts < 10:
|
|
186
|
+
self.root.after(50, lambda: self._restore_sash(attempts + 1))
|
|
187
|
+
return
|
|
188
|
+
if dimension <= 1:
|
|
189
|
+
return
|
|
190
|
+
self.paned.sashpos(0, int(dimension * self.sash_fraction))
|
|
191
|
+
except tk.TclError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
def _on_drop(self, event: tk.Event) -> str:
|
|
195
|
+
items = self.root.tk.splitlist(event.data)
|
|
196
|
+
if not items:
|
|
197
|
+
return "break"
|
|
198
|
+
path = Path(items[0])
|
|
199
|
+
if path.is_dir():
|
|
200
|
+
self.open_folder(path)
|
|
201
|
+
elif path.is_file():
|
|
202
|
+
self.open_folder(path.parent, path)
|
|
203
|
+
return "break"
|
|
204
|
+
|
|
205
|
+
def _restore_state(self) -> None:
|
|
206
|
+
folder = Path(self.settings["folder"]) if self.settings["folder"] else None
|
|
207
|
+
if folder and folder.is_dir():
|
|
208
|
+
saved = Path(self.settings["selected_file"])
|
|
209
|
+
self.open_folder(folder, saved if saved.is_file() else None)
|
|
210
|
+
|
|
211
|
+
def _update_title(self) -> None:
|
|
212
|
+
self.root.title(f"PicView — {self.folder}" if self.folder else "PicView")
|
|
213
|
+
|
|
214
|
+
def close(self) -> None:
|
|
215
|
+
sash_fraction = self._current_sash_fraction()
|
|
216
|
+
selected = self.current_selection()
|
|
217
|
+
save_settings({
|
|
218
|
+
"folder": str(self.folder) if self.folder else "",
|
|
219
|
+
"selected_file": str(selected) if selected else "",
|
|
220
|
+
"orientation": self.orientation,
|
|
221
|
+
"sash_fraction": sash_fraction,
|
|
222
|
+
"maximized_pane": "",
|
|
223
|
+
"thumbnail_size": self.thumbnails.thumbnail_size,
|
|
224
|
+
"image_zoom": self.image.zoom_var.get(),
|
|
225
|
+
"language": self.language_mode,
|
|
226
|
+
})
|
|
227
|
+
self.image.close_fullscreen()
|
|
228
|
+
self.root.destroy()
|
|
229
|
+
|
|
230
|
+
def _current_sash_fraction(self) -> float:
|
|
231
|
+
if self.maximized_pane or not self.paned.panes():
|
|
232
|
+
return self.sash_fraction
|
|
233
|
+
try:
|
|
234
|
+
dimension = self.paned.winfo_height() if self.orientation == "vertical" else self.paned.winfo_width()
|
|
235
|
+
return self.paned.sashpos(0) / dimension if dimension else self.sash_fraction
|
|
236
|
+
except tk.TclError:
|
|
237
|
+
return self.sash_fraction
|
|
238
|
+
|
|
239
|
+
def toggle_language(self) -> None:
|
|
240
|
+
selected = self.current_selection()
|
|
241
|
+
thumbnail_size = self.thumbnails.thumbnail_size
|
|
242
|
+
image_zoom = self.image.zoom_var.get()
|
|
243
|
+
self.language_mode = "en" if self.language == "zh" else "zh"
|
|
244
|
+
self.language = self.language_mode
|
|
245
|
+
self.labels = TEXT[self.language]
|
|
246
|
+
self.settings["thumbnail_size"] = thumbnail_size
|
|
247
|
+
self.settings["image_zoom"] = image_zoom
|
|
248
|
+
for child in self.root.winfo_children():
|
|
249
|
+
child.destroy()
|
|
250
|
+
del self.thumbnails, self.image, self.paned, self.pane_host
|
|
251
|
+
self._build_window()
|
|
252
|
+
if selected:
|
|
253
|
+
self.thumbnails.select(selected)
|
|
254
|
+
|
|
255
|
+
def _icon_button(self, parent: tk.Misc, icon: str, label_key: str, command) -> ttk.Button:
|
|
256
|
+
return IconButton(parent, icon, self.labels[label_key], command)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main() -> None:
|
|
260
|
+
if TkinterDnD is None:
|
|
261
|
+
raise RuntimeError("tkinterdnd2 is required. Run `uv sync` before launching PicView.")
|
|
262
|
+
root = TkinterDnD.Tk()
|
|
263
|
+
PicViewApp(root)
|
|
264
|
+
root.mainloop()
|
|
Binary file
|
picview/image_pane.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import customtkinter as ctk
|
|
6
|
+
|
|
7
|
+
from PIL import Image, ImageOps, ImageTk
|
|
8
|
+
|
|
9
|
+
from .utils import fit_size
|
|
10
|
+
from .ui import IconButton
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImagePane(ctk.CTkFrame):
|
|
14
|
+
"""Image display with non-destructive view transforms and GIF playback."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, parent: tk.Misc, labels: dict[str, str], on_toggle_maximize, zoom: float = 1.0) -> None:
|
|
17
|
+
super().__init__(parent, corner_radius=12, fg_color=("#f8fafc", "#1b2630"))
|
|
18
|
+
self.path: Path | None = None
|
|
19
|
+
self.source: Image.Image | None = None
|
|
20
|
+
self.frames: list[Image.Image] = []
|
|
21
|
+
self.frame_delays: list[int] = []
|
|
22
|
+
self.frame_index = 0
|
|
23
|
+
self.animation_id: str | None = None
|
|
24
|
+
self.rotation = 0
|
|
25
|
+
self.flip_horizontal = False
|
|
26
|
+
self.flip_vertical = False
|
|
27
|
+
self.photo: ImageTk.PhotoImage | None = None
|
|
28
|
+
self.zoom_var = tk.DoubleVar(value=zoom)
|
|
29
|
+
self.fullscreen: tk.Toplevel | None = None
|
|
30
|
+
self.fullscreen_label: ttk.Label | None = None
|
|
31
|
+
self.labels, self.on_toggle_maximize = labels, on_toggle_maximize
|
|
32
|
+
|
|
33
|
+
controls = ctk.CTkFrame(self, fg_color="transparent")
|
|
34
|
+
controls.pack(fill="x")
|
|
35
|
+
self._icon_button(controls, "rotate", labels["rotate"], self.rotate).pack(side="left")
|
|
36
|
+
self._icon_button(controls, "mirror_h", labels["flip_h"], self.mirror_horizontal).pack(side="left", padx=2)
|
|
37
|
+
self._icon_button(controls, "mirror_v", labels["flip_v"], self.mirror_vertical).pack(side="left")
|
|
38
|
+
self.max_button = self._icon_button(controls, "expand", labels["maximize"], lambda: on_toggle_maximize("image"))
|
|
39
|
+
self.max_button.pack(side="right", padx=(2, 0))
|
|
40
|
+
self._icon_button(controls, "fullscreen", labels["fullscreen"], self.open_fullscreen).pack(side="right")
|
|
41
|
+
ctk.CTkLabel(controls, text=labels["zoom"]).pack(side="right", padx=(0, 4))
|
|
42
|
+
self.zoom_scale = ctk.CTkSlider(controls, from_=0.1, to=4.0, variable=self.zoom_var, command=lambda _v: self.render(), width=160)
|
|
43
|
+
self.zoom_scale.pack(side="right", fill="x", expand=True, padx=(8, 8))
|
|
44
|
+
|
|
45
|
+
view = ctk.CTkFrame(self, fg_color="transparent")
|
|
46
|
+
view.pack(fill="both", expand=True)
|
|
47
|
+
self.canvas = tk.Canvas(view, background="#202020", highlightthickness=0)
|
|
48
|
+
self.xscroll = ctk.CTkScrollbar(view, orientation="horizontal", command=self.canvas.xview, height=12)
|
|
49
|
+
self.yscroll = ctk.CTkScrollbar(view, orientation="vertical", command=self.canvas.yview, width=12)
|
|
50
|
+
self.canvas.configure(xscrollcommand=self.xscroll.set, yscrollcommand=self.yscroll.set)
|
|
51
|
+
self.canvas.grid(row=0, column=0, sticky="nsew")
|
|
52
|
+
self.yscroll.grid(row=0, column=1, sticky="ns")
|
|
53
|
+
self.xscroll.grid(row=1, column=0, sticky="ew")
|
|
54
|
+
view.rowconfigure(0, weight=1); view.columnconfigure(0, weight=1)
|
|
55
|
+
self.image_item = self.canvas.create_image(0, 0, anchor="center")
|
|
56
|
+
self.empty_item = self.canvas.create_text(0, 0, fill="#d0d0d0", text=labels["empty"])
|
|
57
|
+
self.canvas.bind("<Configure>", lambda _event: self.render())
|
|
58
|
+
self.canvas.bind("<Enter>", self._bind_zoom_shortcuts)
|
|
59
|
+
self.canvas.bind("<Leave>", self._unbind_zoom_shortcuts)
|
|
60
|
+
self.canvas.bind("<MouseWheel>", self._wheel_scroll)
|
|
61
|
+
|
|
62
|
+
def load(self, path: Path) -> None:
|
|
63
|
+
self._stop_animation()
|
|
64
|
+
self.path = path
|
|
65
|
+
self.rotation = 0
|
|
66
|
+
self.flip_horizontal = False
|
|
67
|
+
self.flip_vertical = False
|
|
68
|
+
self.frame_index = 0
|
|
69
|
+
self.frames = []
|
|
70
|
+
self.frame_delays = []
|
|
71
|
+
try:
|
|
72
|
+
with Image.open(path) as opened:
|
|
73
|
+
frame_count = getattr(opened, "n_frames", 1)
|
|
74
|
+
for index in range(frame_count):
|
|
75
|
+
opened.seek(index)
|
|
76
|
+
self.frames.append(ImageOps.exif_transpose(opened).convert("RGBA").copy())
|
|
77
|
+
self.frame_delays.append(max(20, int(opened.info.get("duration", 100))))
|
|
78
|
+
self.source = self.frames[0]
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
self.source = None
|
|
81
|
+
self.render()
|
|
82
|
+
if len(self.frames) > 1:
|
|
83
|
+
self._animate()
|
|
84
|
+
|
|
85
|
+
def load_empty(self) -> None:
|
|
86
|
+
self._stop_animation()
|
|
87
|
+
self.path = None
|
|
88
|
+
self.source = None
|
|
89
|
+
self.frames = []
|
|
90
|
+
self.render()
|
|
91
|
+
|
|
92
|
+
def rotate(self) -> None:
|
|
93
|
+
self.rotation = (self.rotation + 90) % 360
|
|
94
|
+
self.render()
|
|
95
|
+
|
|
96
|
+
def mirror_horizontal(self) -> None:
|
|
97
|
+
self.flip_horizontal = not self.flip_horizontal
|
|
98
|
+
self.render()
|
|
99
|
+
|
|
100
|
+
def mirror_vertical(self) -> None:
|
|
101
|
+
self.flip_vertical = not self.flip_vertical
|
|
102
|
+
self.render()
|
|
103
|
+
|
|
104
|
+
def reset_fit(self, _event: tk.Event | None = None) -> str | None:
|
|
105
|
+
self.zoom_var.set(1.0)
|
|
106
|
+
self.render()
|
|
107
|
+
return "break" if _event else None
|
|
108
|
+
|
|
109
|
+
def adjust_zoom(self, amount: float) -> None:
|
|
110
|
+
self.zoom_var.set(min(4.0, max(0.1, self.zoom_var.get() + amount)))
|
|
111
|
+
self.render()
|
|
112
|
+
|
|
113
|
+
def render(self) -> None:
|
|
114
|
+
if not self.source:
|
|
115
|
+
self.canvas.itemconfigure(self.empty_item, state="normal")
|
|
116
|
+
self.canvas.coords(self.empty_item, self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2)
|
|
117
|
+
return
|
|
118
|
+
self.canvas.itemconfigure(self.empty_item, state="hidden")
|
|
119
|
+
frame = self._transformed_frame()
|
|
120
|
+
size = fit_size(frame.size, (self.canvas.winfo_width() - 8, self.canvas.winfo_height() - 8), self.zoom_var.get())
|
|
121
|
+
image = frame.resize(size, Image.Resampling.LANCZOS)
|
|
122
|
+
self.photo = ImageTk.PhotoImage(image)
|
|
123
|
+
self.canvas.itemconfigure(self.image_item, image=self.photo)
|
|
124
|
+
width, height = size
|
|
125
|
+
viewport_w, viewport_h = self.canvas.winfo_width(), self.canvas.winfo_height()
|
|
126
|
+
scroll_w, scroll_h = max(viewport_w, width), max(viewport_h, height)
|
|
127
|
+
self.canvas.configure(scrollregion=(0, 0, scroll_w, scroll_h))
|
|
128
|
+
self.canvas.coords(self.image_item, scroll_w / 2, scroll_h / 2)
|
|
129
|
+
self._render_fullscreen(frame)
|
|
130
|
+
|
|
131
|
+
def open_fullscreen(self) -> None:
|
|
132
|
+
if self.fullscreen is not None or self.source is None:
|
|
133
|
+
return
|
|
134
|
+
window = tk.Toplevel(self)
|
|
135
|
+
window.attributes("-fullscreen", True)
|
|
136
|
+
window.configure(background="#101010")
|
|
137
|
+
window.bind("<Escape>", lambda _event: self.close_fullscreen())
|
|
138
|
+
window.protocol("WM_DELETE_WINDOW", self.close_fullscreen)
|
|
139
|
+
window.bind("<Configure>", lambda _event: self.render())
|
|
140
|
+
label = ctk.CTkLabel(window, text="", anchor="center")
|
|
141
|
+
label.pack(fill="both", expand=True)
|
|
142
|
+
ctk.CTkButton(window, text=self.labels["exit_full"], command=self.close_fullscreen, corner_radius=10).place(relx=0.5, rely=0.97, anchor="s")
|
|
143
|
+
self.fullscreen = window
|
|
144
|
+
self.fullscreen_label = label
|
|
145
|
+
self.render()
|
|
146
|
+
|
|
147
|
+
def close_fullscreen(self) -> None:
|
|
148
|
+
if self.fullscreen:
|
|
149
|
+
self.fullscreen.destroy()
|
|
150
|
+
self.fullscreen = None
|
|
151
|
+
self.fullscreen_label = None
|
|
152
|
+
|
|
153
|
+
def _render_fullscreen(self, frame: Image.Image) -> None:
|
|
154
|
+
if not self.fullscreen or not self.fullscreen_label or not self.fullscreen.winfo_exists():
|
|
155
|
+
return
|
|
156
|
+
size = fit_size(frame.size, (self.fullscreen.winfo_width() - 20, self.fullscreen.winfo_height() - 50), self.zoom_var.get())
|
|
157
|
+
photo = ImageTk.PhotoImage(frame.resize(size, Image.Resampling.LANCZOS))
|
|
158
|
+
self.fullscreen_label.configure(image=photo)
|
|
159
|
+
self.fullscreen_label.image = photo
|
|
160
|
+
|
|
161
|
+
def _transformed_frame(self) -> Image.Image:
|
|
162
|
+
frame = self.frames[self.frame_index] if self.frames else self.source
|
|
163
|
+
assert frame is not None
|
|
164
|
+
if self.rotation:
|
|
165
|
+
frame = frame.rotate(-self.rotation, expand=True)
|
|
166
|
+
if self.flip_horizontal:
|
|
167
|
+
frame = ImageOps.mirror(frame)
|
|
168
|
+
if self.flip_vertical:
|
|
169
|
+
frame = ImageOps.flip(frame)
|
|
170
|
+
return frame
|
|
171
|
+
|
|
172
|
+
def _animate(self) -> None:
|
|
173
|
+
if not self.winfo_exists() or len(self.frames) < 2:
|
|
174
|
+
return
|
|
175
|
+
self.frame_index = (self.frame_index + 1) % len(self.frames)
|
|
176
|
+
self.render()
|
|
177
|
+
self.animation_id = self.after(self.frame_delays[self.frame_index], self._animate)
|
|
178
|
+
|
|
179
|
+
def _stop_animation(self) -> None:
|
|
180
|
+
if self.animation_id:
|
|
181
|
+
self.after_cancel(self.animation_id)
|
|
182
|
+
self.animation_id = None
|
|
183
|
+
|
|
184
|
+
def _bind_zoom_shortcuts(self, _event: tk.Event) -> None:
|
|
185
|
+
self.canvas.bind_all("<Control-MouseWheel>", self._wheel_zoom)
|
|
186
|
+
self.canvas.bind_all("<plus>", lambda _e: self._key_zoom(0.1))
|
|
187
|
+
self.canvas.bind_all("<minus>", lambda _e: self._key_zoom(-0.1))
|
|
188
|
+
self.canvas.bind_all("<Key-0>", self.reset_fit)
|
|
189
|
+
|
|
190
|
+
def _unbind_zoom_shortcuts(self, _event: tk.Event) -> None:
|
|
191
|
+
self.canvas.unbind_all("<Control-MouseWheel>")
|
|
192
|
+
self.canvas.unbind_all("<plus>")
|
|
193
|
+
self.canvas.unbind_all("<minus>")
|
|
194
|
+
self.canvas.unbind_all("<Key-0>")
|
|
195
|
+
|
|
196
|
+
def _wheel_zoom(self, event: tk.Event) -> str:
|
|
197
|
+
self.adjust_zoom(0.1 if event.delta > 0 else -0.1)
|
|
198
|
+
return "break"
|
|
199
|
+
|
|
200
|
+
def _wheel_scroll(self, event: tk.Event) -> str:
|
|
201
|
+
"""Plain wheel pans an enlarged image; Ctrl+wheel remains zoom."""
|
|
202
|
+
if event.state & 0x0004:
|
|
203
|
+
return self._wheel_zoom(event)
|
|
204
|
+
self.canvas.yview_scroll(-int(event.delta / 120), "units")
|
|
205
|
+
return "break"
|
|
206
|
+
|
|
207
|
+
def _key_zoom(self, amount: float) -> str:
|
|
208
|
+
self.adjust_zoom(amount)
|
|
209
|
+
return "break"
|
|
210
|
+
|
|
211
|
+
def _icon_button(self, parent: tk.Misc, icon: str, tooltip: str, command) -> IconButton:
|
|
212
|
+
return IconButton(parent, icon, tooltip, command)
|
|
213
|
+
|
|
214
|
+
def set_maximized(self, maximized: bool) -> None:
|
|
215
|
+
self.max_button.set_icon("shrink" if maximized else "expand", self.labels["restore"] if maximized else self.labels["maximize"])
|