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 ADDED
@@ -0,0 +1,3 @@
1
+ """PicView image viewer."""
2
+
3
+ __version__ = "0.0.1"
picview/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .qt_app import main
2
+
3
+ main()
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"])