pygpt-net 2.7.7__py3-none-any.whl → 2.7.8__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.
- pygpt_net/CHANGELOG.txt +7 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +5 -1
- pygpt_net/controller/assistant/batch.py +2 -2
- pygpt_net/controller/assistant/files.py +7 -6
- pygpt_net/controller/assistant/threads.py +0 -0
- pygpt_net/controller/chat/command.py +0 -0
- pygpt_net/controller/dialogs/confirm.py +35 -58
- pygpt_net/controller/lang/mapping.py +9 -9
- pygpt_net/controller/remote_store/{google/batch.py → batch.py} +209 -252
- pygpt_net/controller/remote_store/remote_store.py +982 -13
- pygpt_net/core/command/command.py +0 -0
- pygpt_net/core/db/viewer.py +1 -1
- pygpt_net/core/realtime/worker.py +3 -1
- pygpt_net/{controller/remote_store/google → core/remote_store/anthropic}/__init__.py +0 -1
- pygpt_net/core/remote_store/anthropic/files.py +211 -0
- pygpt_net/core/remote_store/anthropic/store.py +208 -0
- pygpt_net/core/remote_store/openai/store.py +5 -4
- pygpt_net/core/remote_store/remote_store.py +5 -1
- pygpt_net/{controller/remote_store/openai → core/remote_store/xai}/__init__.py +0 -1
- pygpt_net/core/remote_store/xai/files.py +225 -0
- pygpt_net/core/remote_store/xai/store.py +219 -0
- pygpt_net/data/config/config.json +9 -6
- pygpt_net/data/config/models.json +5 -4
- pygpt_net/data/config/settings.json +54 -1
- pygpt_net/data/icons/folder_eye.svg +1 -0
- pygpt_net/data/icons/folder_eye_filled.svg +1 -0
- pygpt_net/data/icons/folder_open.svg +1 -0
- pygpt_net/data/icons/folder_open_filled.svg +1 -0
- pygpt_net/data/locale/locale.de.ini +4 -3
- pygpt_net/data/locale/locale.en.ini +14 -4
- pygpt_net/data/locale/locale.es.ini +4 -3
- pygpt_net/data/locale/locale.fr.ini +4 -3
- pygpt_net/data/locale/locale.it.ini +4 -3
- pygpt_net/data/locale/locale.pl.ini +5 -4
- pygpt_net/data/locale/locale.uk.ini +4 -3
- pygpt_net/data/locale/locale.zh.ini +4 -3
- pygpt_net/icons.qrc +4 -0
- pygpt_net/icons_rc.py +282 -138
- pygpt_net/provider/api/anthropic/__init__.py +2 -0
- pygpt_net/provider/api/anthropic/chat.py +84 -1
- pygpt_net/provider/api/anthropic/store.py +307 -0
- pygpt_net/provider/api/anthropic/stream.py +75 -0
- pygpt_net/provider/api/anthropic/worker/__init__.py +0 -0
- pygpt_net/provider/api/anthropic/worker/importer.py +278 -0
- pygpt_net/provider/api/google/chat.py +59 -2
- pygpt_net/provider/api/google/store.py +124 -3
- pygpt_net/provider/api/google/stream.py +91 -24
- pygpt_net/provider/api/google/worker/importer.py +16 -28
- pygpt_net/provider/api/openai/assistants.py +2 -2
- pygpt_net/provider/api/openai/store.py +4 -1
- pygpt_net/provider/api/openai/worker/importer.py +19 -61
- pygpt_net/provider/api/openai/worker/importer_assistants.py +230 -0
- pygpt_net/provider/api/x_ai/__init__.py +30 -6
- pygpt_net/provider/api/x_ai/audio.py +43 -11
- pygpt_net/provider/api/x_ai/chat.py +92 -4
- pygpt_net/provider/api/x_ai/realtime/__init__.py +12 -0
- pygpt_net/provider/api/x_ai/realtime/client.py +1825 -0
- pygpt_net/provider/api/x_ai/realtime/realtime.py +198 -0
- pygpt_net/provider/api/x_ai/remote_tools.py +19 -1
- pygpt_net/provider/api/x_ai/store.py +610 -0
- pygpt_net/provider/api/x_ai/stream.py +30 -9
- pygpt_net/provider/api/x_ai/worker/importer.py +308 -0
- pygpt_net/provider/audio_input/xai_grok_voice.py +390 -0
- pygpt_net/provider/audio_output/xai_tts.py +325 -0
- pygpt_net/provider/core/config/patch.py +18 -3
- pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +2 -2
- pygpt_net/provider/core/model/patch.py +13 -0
- pygpt_net/tools/image_viewer/tool.py +334 -34
- pygpt_net/tools/image_viewer/ui/dialogs.py +317 -21
- pygpt_net/ui/dialog/assistant.py +1 -1
- pygpt_net/ui/dialog/plugins.py +13 -5
- pygpt_net/ui/dialog/remote_store.py +552 -0
- pygpt_net/ui/dialogs.py +3 -5
- pygpt_net/ui/layout/ctx/ctx_list.py +58 -7
- pygpt_net/ui/menu/tools.py +6 -13
- pygpt_net/ui/widget/dialog/{remote_store_google.py → remote_store.py} +10 -10
- pygpt_net/ui/widget/element/button.py +4 -4
- pygpt_net/ui/widget/image/display.py +2 -2
- pygpt_net/ui/widget/lists/context.py +2 -2
- {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.8.dist-info}/METADATA +9 -2
- {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.8.dist-info}/RECORD +82 -70
- pygpt_net/controller/remote_store/google/store.py +0 -615
- pygpt_net/controller/remote_store/openai/batch.py +0 -524
- pygpt_net/controller/remote_store/openai/store.py +0 -699
- pygpt_net/ui/dialog/remote_store_google.py +0 -539
- pygpt_net/ui/dialog/remote_store_openai.py +0 -539
- pygpt_net/ui/widget/dialog/remote_store_openai.py +0 -56
- pygpt_net/ui/widget/lists/remote_store_google.py +0 -248
- pygpt_net/ui/widget/lists/remote_store_openai.py +0 -317
- {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.8.dist-info}/LICENSE +0 -0
- {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.8.dist-info}/WHEEL +0 -0
- {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.8.dist-info}/entry_points.txt +0 -0
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date:
|
|
9
|
+
# Updated Date: 2026.01.06 19:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
import hashlib
|
|
13
13
|
import os
|
|
14
14
|
import shutil
|
|
15
|
-
from typing import Dict, Optional
|
|
15
|
+
from typing import Dict, Optional, List, Tuple
|
|
16
16
|
|
|
17
17
|
from PySide6 import QtGui, QtCore
|
|
18
18
|
from PySide6.QtGui import QAction, QIcon
|
|
@@ -86,67 +86,161 @@ class ImageViewer(BaseTool):
|
|
|
86
86
|
for path in paths:
|
|
87
87
|
self.open_preview(path, current_id, auto_close)
|
|
88
88
|
|
|
89
|
-
|
|
90
89
|
def open_preview(
|
|
91
90
|
self,
|
|
92
91
|
path: str = None,
|
|
93
92
|
current_id: str = None,
|
|
94
|
-
auto_close: bool = True
|
|
93
|
+
auto_close: bool = True,
|
|
94
|
+
reuse: bool = False):
|
|
95
95
|
"""
|
|
96
96
|
Open image preview in dialog
|
|
97
97
|
|
|
98
98
|
:param path: path to image
|
|
99
99
|
:param current_id: current dialog id
|
|
100
100
|
:param auto_close: auto close current dialog
|
|
101
|
+
:param reuse: reuse existing dialog id (in-place reload)
|
|
101
102
|
"""
|
|
103
|
+
# determine dialog id
|
|
102
104
|
if path:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
if reuse and current_id:
|
|
106
|
+
id = current_id
|
|
107
|
+
else:
|
|
108
|
+
id = self.prepare_id(path)
|
|
109
|
+
if current_id and auto_close:
|
|
110
|
+
if id != current_id:
|
|
111
|
+
self.close_preview(current_id)
|
|
107
112
|
else:
|
|
108
113
|
# new instance id
|
|
109
114
|
id = 'image_viewer_' + str(self.instance_id)
|
|
110
115
|
self.instance_id += 1
|
|
111
116
|
|
|
112
|
-
self.window.ui.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
dialog_exists = id in self.window.ui.dialog
|
|
118
|
+
if not dialog_exists:
|
|
119
|
+
self.window.ui.dialogs.open_instance(
|
|
120
|
+
id,
|
|
121
|
+
width=self.width,
|
|
122
|
+
height=self.height,
|
|
123
|
+
type="image_viewer",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# dimensions: keep current size if dialog already exists
|
|
127
|
+
if id in self.window.ui.dialog:
|
|
128
|
+
w = self.window.ui.dialog[id].width()
|
|
129
|
+
h = self.window.ui.dialog[id].height()
|
|
130
|
+
else:
|
|
131
|
+
w = self.width
|
|
132
|
+
h = self.height
|
|
118
133
|
|
|
119
|
-
w = self.width
|
|
120
|
-
h = self.height
|
|
121
134
|
img_suffix = ""
|
|
122
135
|
|
|
136
|
+
# compute directory index/total for status bar if possible
|
|
137
|
+
def _index_and_total(p: Optional[str]) -> Tuple[int, int]:
|
|
138
|
+
if not p:
|
|
139
|
+
return 0, 0
|
|
140
|
+
files = self._list_images_in_dir(p)
|
|
141
|
+
total = len(files)
|
|
142
|
+
if total == 0:
|
|
143
|
+
return 0, 0
|
|
144
|
+
try:
|
|
145
|
+
idx = files.index(p)
|
|
146
|
+
except ValueError:
|
|
147
|
+
low = [x.lower() for x in files]
|
|
148
|
+
try:
|
|
149
|
+
idx = low.index(p.lower())
|
|
150
|
+
except ValueError:
|
|
151
|
+
return 0, total
|
|
152
|
+
# convert to 1-based for display
|
|
153
|
+
return idx + 1, total
|
|
154
|
+
|
|
123
155
|
if path is None:
|
|
124
|
-
|
|
156
|
+
# reuse previous image path if any
|
|
157
|
+
if id in self.window.ui.dialog and hasattr(self.window.ui.dialog[id].source, 'path'):
|
|
158
|
+
path = self.window.ui.dialog[id].source.path
|
|
125
159
|
|
|
126
160
|
if path is None:
|
|
127
|
-
|
|
161
|
+
# blank image
|
|
162
|
+
pixmap = QtGui.QPixmap(0, 0)
|
|
128
163
|
self.window.ui.dialog[id].source.setPixmap(pixmap)
|
|
164
|
+
self.window.ui.dialog[id].source.path = None
|
|
129
165
|
self.window.ui.dialog[id].pixmap.path = None
|
|
130
166
|
self.window.ui.dialog[id].pixmap.resize(w, h)
|
|
167
|
+
self.window.ui.dialog[id].path = None
|
|
168
|
+
self.window.ui.dialog[id].setWindowTitle("Image Viewer")
|
|
169
|
+
|
|
170
|
+
# update status bar to empty state
|
|
171
|
+
try:
|
|
172
|
+
self.window.ui.dialog[id].set_status_meta(index=0, total=0, img_size=pixmap.size(), file_size_str="")
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
131
175
|
else:
|
|
132
|
-
|
|
133
|
-
|
|
176
|
+
# load image
|
|
177
|
+
src_pixmap = QtGui.QPixmap(path)
|
|
178
|
+
img_suffix = " ({}x{}px)".format(src_pixmap.width(), src_pixmap.height())
|
|
134
179
|
file_size = self.window.core.filesystem.sizeof_fmt(os.path.getsize(path))
|
|
135
180
|
img_suffix += " - {}".format(file_size)
|
|
136
|
-
self.window.ui.dialog[id].source.setPixmap(pixmap)
|
|
137
|
-
self.window.ui.dialog[id].pixmap.path = path
|
|
138
|
-
self.window.ui.dialog[id].pixmap.resize(w, h)
|
|
139
|
-
|
|
140
|
-
if path is not None:
|
|
141
|
-
self.window.ui.dialog[id].path = path
|
|
142
|
-
self.window.ui.dialog[id].setWindowTitle(os.path.basename(path) + img_suffix)
|
|
143
|
-
else:
|
|
144
|
-
self.window.ui.dialog[id].path = None
|
|
145
|
-
self.window.ui.dialog[id].setWindowTitle("Image Viewer")
|
|
146
181
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
182
|
+
# in-place reload when reusing the same dialog
|
|
183
|
+
if reuse and (current_id == id) and (id in self.window.ui.dialog):
|
|
184
|
+
dialog = self.window.ui.dialog[id]
|
|
185
|
+
dialog.source.setPixmap(src_pixmap)
|
|
186
|
+
dialog.source.path = path
|
|
187
|
+
dialog.pixmap.path = path
|
|
188
|
+
|
|
189
|
+
# reset zoom/pan to fit and update view without resizing window
|
|
190
|
+
dialog._zoom_mode = 'fit'
|
|
191
|
+
dialog._drag_active = False
|
|
192
|
+
if dialog.scroll_area is not None:
|
|
193
|
+
dialog.scroll_area.setWidgetResizable(True)
|
|
194
|
+
if dialog.pixmap is not None:
|
|
195
|
+
dialog.pixmap.setScaledContents(False)
|
|
196
|
+
target = dialog._viewport_size()
|
|
197
|
+
scaled = src_pixmap.scaled(target, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
|
|
198
|
+
dialog.pixmap.setPixmap(scaled)
|
|
199
|
+
dialog._fit_factor = dialog._compute_fit_factor(src_pixmap.size(), target)
|
|
200
|
+
dialog._last_src_key = src_pixmap.cacheKey()
|
|
201
|
+
dialog._last_target_size = target
|
|
202
|
+
|
|
203
|
+
dialog.path = path
|
|
204
|
+
dialog.setWindowTitle(os.path.basename(path) + img_suffix)
|
|
205
|
+
|
|
206
|
+
# update status bar metadata (index/total, original size, file size)
|
|
207
|
+
try:
|
|
208
|
+
idx, total = _index_and_total(path)
|
|
209
|
+
dialog.set_status_meta(index=idx, total=total, img_size=src_pixmap.size(), file_size_str=file_size)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
# keep dialog visible but do not force reposition
|
|
214
|
+
if not dialog.isVisible():
|
|
215
|
+
dialog.show()
|
|
216
|
+
else:
|
|
217
|
+
# standard open path
|
|
218
|
+
self.window.ui.dialog[id].source.setPixmap(src_pixmap)
|
|
219
|
+
self.window.ui.dialog[id].source.path = path
|
|
220
|
+
self.window.ui.dialog[id].pixmap.path = path
|
|
221
|
+
self.window.ui.dialog[id].pixmap.resize(w, h)
|
|
222
|
+
self.window.ui.dialog[id].path = path
|
|
223
|
+
self.window.ui.dialog[id].setWindowTitle(os.path.basename(path) + img_suffix)
|
|
224
|
+
|
|
225
|
+
# update status bar metadata before first resizeEvent recomputes fit
|
|
226
|
+
try:
|
|
227
|
+
idx, total = _index_and_total(path)
|
|
228
|
+
self.window.ui.dialog[id].set_status_meta(index=idx, total=total, img_size=src_pixmap.size(), file_size_str=file_size)
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
# ensure dialog visible without altering user-set position if already shown
|
|
233
|
+
dlg = self.window.ui.dialog[id]
|
|
234
|
+
if not dlg.isVisible():
|
|
235
|
+
dlg.show()
|
|
236
|
+
|
|
237
|
+
# refresh drawing when needed (avoid forcing default size on reuse)
|
|
238
|
+
if not (reuse and (current_id == id)):
|
|
239
|
+
dlg.resize(w - 1, h - 1) # redraw fix
|
|
240
|
+
dlg.resize(w, h)
|
|
241
|
+
|
|
242
|
+
# update toolbar actions availability
|
|
243
|
+
self._update_toolbar_actions_state(id)
|
|
150
244
|
|
|
151
245
|
def close_preview(self, id: str):
|
|
152
246
|
"""
|
|
@@ -201,7 +295,7 @@ class ImageViewer(BaseTool):
|
|
|
201
295
|
|
|
202
296
|
def open_dir(self, path: str):
|
|
203
297
|
"""
|
|
204
|
-
Open
|
|
298
|
+
Open directory in file manager
|
|
205
299
|
|
|
206
300
|
:param path: path to image
|
|
207
301
|
"""
|
|
@@ -210,6 +304,7 @@ class ImageViewer(BaseTool):
|
|
|
210
304
|
path,
|
|
211
305
|
True,
|
|
212
306
|
)
|
|
307
|
+
|
|
213
308
|
def save_by_id(self, id: str):
|
|
214
309
|
"""
|
|
215
310
|
Save image by dialog id
|
|
@@ -274,6 +369,211 @@ class ImageViewer(BaseTool):
|
|
|
274
369
|
except Exception as e:
|
|
275
370
|
self.window.core.debug.log(e)
|
|
276
371
|
|
|
372
|
+
# =========================
|
|
373
|
+
# Toolbar actions (prev/next/open dir/copy/fullscreen)
|
|
374
|
+
# =========================
|
|
375
|
+
|
|
376
|
+
def prev_by_id(self, id: str):
|
|
377
|
+
"""
|
|
378
|
+
Show previous image in the same directory (with wrap-around).
|
|
379
|
+
"""
|
|
380
|
+
dialog = self.window.ui.dialog.get(id)
|
|
381
|
+
if dialog is None or dialog.path is None:
|
|
382
|
+
self.window.update_status("No image selected")
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
prev_path, _ = self._get_neighbors(dialog.path)
|
|
386
|
+
if prev_path:
|
|
387
|
+
self.open_preview(prev_path, current_id=id, auto_close=False, reuse=True)
|
|
388
|
+
else:
|
|
389
|
+
self.window.update_status("No previous image")
|
|
390
|
+
|
|
391
|
+
def next_by_id(self, id: str):
|
|
392
|
+
"""
|
|
393
|
+
Show next image in the same directory (with wrap-around).
|
|
394
|
+
"""
|
|
395
|
+
dialog = self.window.ui.dialog.get(id)
|
|
396
|
+
if dialog is None or dialog.path is None:
|
|
397
|
+
self.window.update_status("No image selected")
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
_, next_path = self._get_neighbors(dialog.path)
|
|
401
|
+
if next_path:
|
|
402
|
+
self.open_preview(next_path, current_id=id, auto_close=False, reuse=True)
|
|
403
|
+
else:
|
|
404
|
+
self.window.update_status("No next image")
|
|
405
|
+
|
|
406
|
+
def open_dir_by_id(self, id: str):
|
|
407
|
+
"""
|
|
408
|
+
Open current image in file manager (select in directory if supported).
|
|
409
|
+
"""
|
|
410
|
+
dialog = self.window.ui.dialog.get(id)
|
|
411
|
+
if not dialog or not dialog.path:
|
|
412
|
+
self.window.update_status("No image to open in directory")
|
|
413
|
+
return
|
|
414
|
+
self.open_dir(dialog.path)
|
|
415
|
+
|
|
416
|
+
def copy_by_id(self, id: str):
|
|
417
|
+
"""
|
|
418
|
+
Copy current image to clipboard.
|
|
419
|
+
"""
|
|
420
|
+
dialog = self.window.ui.dialog.get(id)
|
|
421
|
+
if not dialog:
|
|
422
|
+
self.window.update_status("No dialog")
|
|
423
|
+
return
|
|
424
|
+
pixmap = None
|
|
425
|
+
try:
|
|
426
|
+
if dialog.source is not None and dialog.source.pixmap() is not None and not dialog.source.pixmap().isNull():
|
|
427
|
+
pixmap = dialog.source.pixmap()
|
|
428
|
+
elif dialog.path and os.path.exists(dialog.path):
|
|
429
|
+
pixmap = QtGui.QPixmap(dialog.path)
|
|
430
|
+
except Exception as e:
|
|
431
|
+
self.window.core.debug.log(e)
|
|
432
|
+
if pixmap is None or pixmap.isNull():
|
|
433
|
+
self.window.update_status("No image to copy")
|
|
434
|
+
return
|
|
435
|
+
QtGui.QGuiApplication.clipboard().setPixmap(pixmap)
|
|
436
|
+
self.window.update_status("Image copied to clipboard")
|
|
437
|
+
|
|
438
|
+
def toggle_fullscreen_by_id(self, id: str):
|
|
439
|
+
"""
|
|
440
|
+
Maximize dialog to screen edges using geometry (toggle).
|
|
441
|
+
This avoids platform issues with WindowFullScreen on dialogs.
|
|
442
|
+
"""
|
|
443
|
+
dialog = self.window.ui.dialog.get(id)
|
|
444
|
+
if not dialog:
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
# restore if already maximized by geometry
|
|
449
|
+
if getattr(dialog, "_edge_max", False):
|
|
450
|
+
dialog.showNormal() # ensure normal state before applying geometry
|
|
451
|
+
normal = getattr(dialog, "_edge_normal_geom", None)
|
|
452
|
+
if normal is not None:
|
|
453
|
+
dialog.setGeometry(normal)
|
|
454
|
+
dialog._edge_max = False
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
# store current geometry to allow restoring later
|
|
458
|
+
dialog._edge_normal_geom = dialog.geometry()
|
|
459
|
+
dialog.showNormal() # leave any native maximize/fullscreen state
|
|
460
|
+
|
|
461
|
+
# pick the screen under the dialog, fallback to primary
|
|
462
|
+
screen = None
|
|
463
|
+
try:
|
|
464
|
+
if dialog.windowHandle() is not None:
|
|
465
|
+
screen = dialog.windowHandle().screen()
|
|
466
|
+
if screen is None:
|
|
467
|
+
screen = QtGui.QGuiApplication.screenAt(dialog.frameGeometry().center())
|
|
468
|
+
except Exception:
|
|
469
|
+
screen = None
|
|
470
|
+
if screen is None:
|
|
471
|
+
screen = QtGui.QGuiApplication.primaryScreen()
|
|
472
|
+
|
|
473
|
+
available = screen.availableGeometry()
|
|
474
|
+
dialog.setGeometry(available)
|
|
475
|
+
dialog._edge_max = True
|
|
476
|
+
except Exception as e:
|
|
477
|
+
# fallback to native maximize if anything goes wrong
|
|
478
|
+
try:
|
|
479
|
+
if dialog.isMaximized():
|
|
480
|
+
dialog.showNormal()
|
|
481
|
+
else:
|
|
482
|
+
dialog.showMaximized()
|
|
483
|
+
except Exception:
|
|
484
|
+
# last resort: ignore
|
|
485
|
+
self.window.core.debug.log(e)
|
|
486
|
+
|
|
487
|
+
# =========================
|
|
488
|
+
# Helpers for directory navigation
|
|
489
|
+
# =========================
|
|
490
|
+
|
|
491
|
+
def _image_exts(self) -> Tuple[str, ...]:
|
|
492
|
+
"""Return supported image extensions."""
|
|
493
|
+
return ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tif', '.tiff', '.webp')
|
|
494
|
+
|
|
495
|
+
def _list_images_in_dir(self, path: str) -> List[str]:
|
|
496
|
+
"""
|
|
497
|
+
Return sorted list of image paths in the directory of given image.
|
|
498
|
+
"""
|
|
499
|
+
try:
|
|
500
|
+
base_dir = os.path.dirname(path)
|
|
501
|
+
if not os.path.isdir(base_dir):
|
|
502
|
+
return []
|
|
503
|
+
exts = self._image_exts()
|
|
504
|
+
files = []
|
|
505
|
+
for name in os.listdir(base_dir):
|
|
506
|
+
full = os.path.join(base_dir, name)
|
|
507
|
+
if os.path.isfile(full) and name.lower().endswith(exts):
|
|
508
|
+
files.append(full)
|
|
509
|
+
files.sort(key=lambda s: s.lower())
|
|
510
|
+
return files
|
|
511
|
+
except Exception as e:
|
|
512
|
+
self.window.core.debug.log(e)
|
|
513
|
+
return []
|
|
514
|
+
|
|
515
|
+
def _get_neighbors(self, path: str) -> Tuple[Optional[str], Optional[str]]:
|
|
516
|
+
"""
|
|
517
|
+
Get previous and next image paths with wrap-around.
|
|
518
|
+
If there is only one image in directory returns (None, None).
|
|
519
|
+
"""
|
|
520
|
+
files = self._list_images_in_dir(path)
|
|
521
|
+
if len(files) <= 1:
|
|
522
|
+
return None, None
|
|
523
|
+
try:
|
|
524
|
+
idx = files.index(path)
|
|
525
|
+
except ValueError:
|
|
526
|
+
low = [p.lower() for p in files]
|
|
527
|
+
try:
|
|
528
|
+
idx = low.index(path.lower())
|
|
529
|
+
except ValueError:
|
|
530
|
+
return None, None
|
|
531
|
+
|
|
532
|
+
n = len(files)
|
|
533
|
+
prev_idx = (idx - 1) % n
|
|
534
|
+
next_idx = (idx + 1) % n
|
|
535
|
+
return files[prev_idx], files[next_idx]
|
|
536
|
+
|
|
537
|
+
def _has_multiple_in_dir(self, path: Optional[str]) -> bool:
|
|
538
|
+
"""Check if directory of path has more than one image."""
|
|
539
|
+
if not path:
|
|
540
|
+
return False
|
|
541
|
+
return len(self._list_images_in_dir(path)) > 1
|
|
542
|
+
|
|
543
|
+
def _update_toolbar_actions_state(self, id: str):
|
|
544
|
+
"""
|
|
545
|
+
Enable/disable toolbar actions based on current state.
|
|
546
|
+
- Prev/Next enabled only if there are at least 2 images in directory.
|
|
547
|
+
- Copy/OpenDir/SaveAs enabled only if image path is set.
|
|
548
|
+
"""
|
|
549
|
+
dialog = self.window.ui.dialog.get(id)
|
|
550
|
+
if not dialog:
|
|
551
|
+
return
|
|
552
|
+
has_img = bool(getattr(dialog, 'path', None))
|
|
553
|
+
has_multi = self._has_multiple_in_dir(dialog.path) if has_img else False
|
|
554
|
+
|
|
555
|
+
actions = getattr(dialog, 'actions', {})
|
|
556
|
+
prev_act = actions.get('tb_prev')
|
|
557
|
+
next_act = actions.get('tb_next')
|
|
558
|
+
open_dir_act = actions.get('tb_open_dir')
|
|
559
|
+
save_as_act = actions.get('tb_save_as')
|
|
560
|
+
copy_act = actions.get('tb_copy')
|
|
561
|
+
fullscreen_act = actions.get('tb_fullscreen')
|
|
562
|
+
|
|
563
|
+
if prev_act:
|
|
564
|
+
prev_act.setEnabled(has_multi)
|
|
565
|
+
if next_act:
|
|
566
|
+
next_act.setEnabled(has_multi)
|
|
567
|
+
|
|
568
|
+
if open_dir_act:
|
|
569
|
+
open_dir_act.setEnabled(has_img)
|
|
570
|
+
if save_as_act:
|
|
571
|
+
save_as_act.setEnabled(has_img)
|
|
572
|
+
if copy_act:
|
|
573
|
+
copy_act.setEnabled(has_img)
|
|
574
|
+
if fullscreen_act:
|
|
575
|
+
fullscreen_act.setEnabled(True)
|
|
576
|
+
|
|
277
577
|
def setup_menu(self) -> Dict[str, QAction]:
|
|
278
578
|
"""
|
|
279
579
|
Setup main menu
|