picview 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
picview-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.3
2
+ Name: picview
3
+ Version: 0.0.1
4
+ Summary: A lightweight Windows image folder viewer
5
+ Requires-Dist: pillow>=10.0
6
+ Requires-Dist: tkinterdnd2>=0.4.3
7
+ Requires-Dist: customtkinter>=5.2.2
8
+ Requires-Dist: pyside6>=6.8
9
+ Requires-Python: >=3.11
10
+ Project-URL: Homepage, https://github.com/codemee/picview
11
+ Project-URL: Repository, https://github.com/codemee/picview
12
+ Project-URL: Issues, https://github.com/codemee/picview/issues
13
+ Description-Content-Type: text/markdown
14
+
15
+ # PicView
16
+
17
+ Image folder viewer built with Qt.
18
+
19
+ [繁體中文](https://github.com/codemee/picview/blob/main/README.zh-TW.md)
20
+
21
+ ## Install and run
22
+
23
+ - Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) and a graphical desktop. PicView is developed and tested on Windows.
24
+ - Run without a permanent tool installation:
25
+
26
+ ```powershell
27
+ uvx picview
28
+ ```
29
+
30
+ - Install as a command-line tool, then launch:
31
+
32
+ ```powershell
33
+ uv tool install picview
34
+ picview
35
+ ```
36
+
37
+ - Upgrade an installed copy:
38
+
39
+ ```powershell
40
+ uv tool upgrade picview
41
+ ```
42
+
43
+ - Check the version:
44
+
45
+ ```powershell
46
+ picview --version
47
+ # Or without installing:
48
+ uvx picview --version
49
+ ```
50
+
51
+ ## Run from source
52
+
53
+ From the project directory, run:
54
+
55
+ ```powershell
56
+ uv run picview
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ### Open images
62
+
63
+ - The window title shows the current version (for example, **PicView v0.0.1**).
64
+ - Click **Open folder**, or drop a folder or image file onto the window.
65
+ - Rotation and mirror controls only affect the current view; image files are never modified.
66
+
67
+ ### Zoom and thumbnail size
68
+
69
+ - Images open at their original pixel size (100%) when they fit; larger images are scaled to fit the pane.
70
+ - Click **Fit to pane** to toggle between fitted and original size.
71
+ - Use the zoom slider or `+` / `-` to resize the view; press `0` to return to 100%.
72
+ - Click the value to the left of either slider to enter an exact zoom percentage or thumbnail size.
73
+ - In the value popup:
74
+ - Press `Enter` to apply.
75
+ - Press `Esc` or click outside to cancel.
76
+ - Use the reset button to restore 100% zoom or the 140 px thumbnail size.
77
+
78
+ ### Keyboard shortcuts
79
+
80
+ | Action | Shortcut |
81
+ | --- | --- |
82
+ | Previous image | `Left`, `Page Up`, or `Backspace` |
83
+ | Next image | `Right`, `Page Down`, or `Space` |
84
+ | First / last image | `Home` / `End` |
85
+ | Open full screen | `F11` |
86
+ | Close full screen | `Esc` |
87
+ | Open a folder | System Open shortcut |
88
+ | Copy the selected image file to the clipboard | `Ctrl+C` (Windows / Linux), `Command+C` (macOS) |
89
+
90
+ ### Theme
91
+
92
+ - Click the theme button to cycle through **Follow system**, **Light**, and **Dark**.
93
+ - **Follow system** is the default.
@@ -0,0 +1,79 @@
1
+ # PicView
2
+
3
+ Image folder viewer built with Qt.
4
+
5
+ [繁體中文](https://github.com/codemee/picview/blob/main/README.zh-TW.md)
6
+
7
+ ## Install and run
8
+
9
+ - Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) and a graphical desktop. PicView is developed and tested on Windows.
10
+ - Run without a permanent tool installation:
11
+
12
+ ```powershell
13
+ uvx picview
14
+ ```
15
+
16
+ - Install as a command-line tool, then launch:
17
+
18
+ ```powershell
19
+ uv tool install picview
20
+ picview
21
+ ```
22
+
23
+ - Upgrade an installed copy:
24
+
25
+ ```powershell
26
+ uv tool upgrade picview
27
+ ```
28
+
29
+ - Check the version:
30
+
31
+ ```powershell
32
+ picview --version
33
+ # Or without installing:
34
+ uvx picview --version
35
+ ```
36
+
37
+ ## Run from source
38
+
39
+ From the project directory, run:
40
+
41
+ ```powershell
42
+ uv run picview
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ### Open images
48
+
49
+ - The window title shows the current version (for example, **PicView v0.0.1**).
50
+ - Click **Open folder**, or drop a folder or image file onto the window.
51
+ - Rotation and mirror controls only affect the current view; image files are never modified.
52
+
53
+ ### Zoom and thumbnail size
54
+
55
+ - Images open at their original pixel size (100%) when they fit; larger images are scaled to fit the pane.
56
+ - Click **Fit to pane** to toggle between fitted and original size.
57
+ - Use the zoom slider or `+` / `-` to resize the view; press `0` to return to 100%.
58
+ - Click the value to the left of either slider to enter an exact zoom percentage or thumbnail size.
59
+ - In the value popup:
60
+ - Press `Enter` to apply.
61
+ - Press `Esc` or click outside to cancel.
62
+ - Use the reset button to restore 100% zoom or the 140 px thumbnail size.
63
+
64
+ ### Keyboard shortcuts
65
+
66
+ | Action | Shortcut |
67
+ | --- | --- |
68
+ | Previous image | `Left`, `Page Up`, or `Backspace` |
69
+ | Next image | `Right`, `Page Down`, or `Space` |
70
+ | First / last image | `Home` / `End` |
71
+ | Open full screen | `F11` |
72
+ | Close full screen | `Esc` |
73
+ | Open a folder | System Open shortcut |
74
+ | Copy the selected image file to the clipboard | `Ctrl+C` (Windows / Linux), `Command+C` (macOS) |
75
+
76
+ ### Theme
77
+
78
+ - Click the theme button to cycle through **Follow system**, **Light**, and **Dark**.
79
+ - **Follow system** is the default.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "picview"
3
+ version = "0.0.1"
4
+ description = "A lightweight Windows image folder viewer"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "pillow>=10.0",
9
+ "tkinterdnd2>=0.4.3",
10
+ "customtkinter>=5.2.2",
11
+ "pyside6>=6.8",
12
+ ]
13
+
14
+ [project.scripts]
15
+ picview = "picview.qt_app:main"
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/codemee/picview"
19
+ Repository = "https://github.com/codemee/picview"
20
+ Issues = "https://github.com/codemee/picview/issues"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.0,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = ["pytest>=8.0"]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["src"]
31
+ testpaths = ["tests"]
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "picview"
3
+ version = "0.0.1"
4
+ description = "A lightweight Windows image folder viewer"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "pillow>=10.0",
9
+ "tkinterdnd2>=0.4.3",
10
+ "customtkinter>=5.2.2",
11
+ "pyside6>=6.8",
12
+ ]
13
+
14
+ [project.scripts]
15
+ picview = "picview.qt_app:main"
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/codemee/picview"
19
+ Repository = "https://github.com/codemee/picview"
20
+ Issues = "https://github.com/codemee/picview/issues"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.0,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = ["pytest>=8.0"]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["src"]
31
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """PicView image viewer."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,3 @@
1
+ from .qt_app import main
2
+
3
+ main()
@@ -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()