pygpt-net 2.7.6__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.
Files changed (120) hide show
  1. pygpt_net/CHANGELOG.txt +13 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +5 -1
  4. pygpt_net/controller/assistant/batch.py +2 -2
  5. pygpt_net/controller/assistant/files.py +7 -6
  6. pygpt_net/controller/assistant/threads.py +0 -0
  7. pygpt_net/controller/chat/command.py +0 -0
  8. pygpt_net/controller/chat/remote_tools.py +3 -9
  9. pygpt_net/controller/chat/stream.py +2 -2
  10. pygpt_net/controller/chat/{handler/worker.py → stream_worker.py} +13 -35
  11. pygpt_net/controller/dialogs/confirm.py +35 -58
  12. pygpt_net/controller/lang/mapping.py +9 -9
  13. pygpt_net/controller/remote_store/{google/batch.py → batch.py} +209 -252
  14. pygpt_net/controller/remote_store/remote_store.py +982 -13
  15. pygpt_net/core/command/command.py +0 -0
  16. pygpt_net/core/db/viewer.py +1 -1
  17. pygpt_net/core/debug/models.py +2 -2
  18. pygpt_net/core/realtime/worker.py +3 -1
  19. pygpt_net/{controller/remote_store/google → core/remote_store/anthropic}/__init__.py +0 -1
  20. pygpt_net/core/remote_store/anthropic/files.py +211 -0
  21. pygpt_net/core/remote_store/anthropic/store.py +208 -0
  22. pygpt_net/core/remote_store/openai/store.py +5 -4
  23. pygpt_net/core/remote_store/remote_store.py +5 -1
  24. pygpt_net/{controller/remote_store/openai → core/remote_store/xai}/__init__.py +0 -1
  25. pygpt_net/core/remote_store/xai/files.py +225 -0
  26. pygpt_net/core/remote_store/xai/store.py +219 -0
  27. pygpt_net/data/config/config.json +18 -5
  28. pygpt_net/data/config/models.json +193 -4
  29. pygpt_net/data/config/settings.json +179 -36
  30. pygpt_net/data/icons/folder_eye.svg +1 -0
  31. pygpt_net/data/icons/folder_eye_filled.svg +1 -0
  32. pygpt_net/data/icons/folder_open.svg +1 -0
  33. pygpt_net/data/icons/folder_open_filled.svg +1 -0
  34. pygpt_net/data/locale/locale.de.ini +6 -3
  35. pygpt_net/data/locale/locale.en.ini +46 -12
  36. pygpt_net/data/locale/locale.es.ini +6 -3
  37. pygpt_net/data/locale/locale.fr.ini +6 -3
  38. pygpt_net/data/locale/locale.it.ini +6 -3
  39. pygpt_net/data/locale/locale.pl.ini +7 -4
  40. pygpt_net/data/locale/locale.uk.ini +6 -3
  41. pygpt_net/data/locale/locale.zh.ini +6 -3
  42. pygpt_net/icons.qrc +4 -0
  43. pygpt_net/icons_rc.py +282 -138
  44. pygpt_net/plugin/cmd_mouse_control/worker.py +2 -1
  45. pygpt_net/plugin/cmd_mouse_control/worker_sandbox.py +2 -1
  46. pygpt_net/provider/api/anthropic/__init__.py +10 -3
  47. pygpt_net/provider/api/anthropic/chat.py +342 -11
  48. pygpt_net/provider/api/anthropic/computer.py +844 -0
  49. pygpt_net/provider/api/anthropic/remote_tools.py +172 -0
  50. pygpt_net/provider/api/anthropic/store.py +307 -0
  51. pygpt_net/{controller/chat/handler/anthropic_stream.py → provider/api/anthropic/stream.py} +99 -10
  52. pygpt_net/provider/api/anthropic/tools.py +32 -77
  53. pygpt_net/provider/api/anthropic/utils.py +30 -0
  54. pygpt_net/{controller/chat/handler → provider/api/anthropic/worker}/__init__.py +0 -0
  55. pygpt_net/provider/api/anthropic/worker/importer.py +278 -0
  56. pygpt_net/provider/api/google/chat.py +62 -9
  57. pygpt_net/provider/api/google/store.py +124 -3
  58. pygpt_net/{controller/chat/handler/google_stream.py → provider/api/google/stream.py} +92 -25
  59. pygpt_net/provider/api/google/utils.py +185 -0
  60. pygpt_net/provider/api/google/worker/importer.py +16 -28
  61. pygpt_net/provider/api/langchain/__init__.py +0 -0
  62. pygpt_net/{controller/chat/handler/langchain_stream.py → provider/api/langchain/stream.py} +1 -1
  63. pygpt_net/provider/api/llama_index/__init__.py +0 -0
  64. pygpt_net/{controller/chat/handler/llamaindex_stream.py → provider/api/llama_index/stream.py} +1 -1
  65. pygpt_net/provider/api/openai/assistants.py +2 -2
  66. pygpt_net/provider/api/openai/image.py +2 -2
  67. pygpt_net/provider/api/openai/store.py +4 -1
  68. pygpt_net/{controller/chat/handler/openai_stream.py → provider/api/openai/stream.py} +1 -1
  69. pygpt_net/provider/api/openai/utils.py +69 -3
  70. pygpt_net/provider/api/openai/worker/importer.py +19 -61
  71. pygpt_net/provider/api/openai/worker/importer_assistants.py +230 -0
  72. pygpt_net/provider/api/x_ai/__init__.py +138 -15
  73. pygpt_net/provider/api/x_ai/audio.py +43 -11
  74. pygpt_net/provider/api/x_ai/chat.py +92 -4
  75. pygpt_net/provider/api/x_ai/image.py +149 -47
  76. pygpt_net/provider/api/x_ai/realtime/__init__.py +12 -0
  77. pygpt_net/provider/api/x_ai/realtime/client.py +1825 -0
  78. pygpt_net/provider/api/x_ai/realtime/realtime.py +198 -0
  79. pygpt_net/provider/api/x_ai/{remote.py → remote_tools.py} +183 -70
  80. pygpt_net/provider/api/x_ai/responses.py +507 -0
  81. pygpt_net/provider/api/x_ai/store.py +610 -0
  82. pygpt_net/{controller/chat/handler/xai_stream.py → provider/api/x_ai/stream.py} +42 -10
  83. pygpt_net/provider/api/x_ai/tools.py +59 -8
  84. pygpt_net/{controller/chat/handler → provider/api/x_ai}/utils.py +1 -2
  85. pygpt_net/provider/api/x_ai/vision.py +1 -4
  86. pygpt_net/provider/api/x_ai/worker/importer.py +308 -0
  87. pygpt_net/provider/audio_input/xai_grok_voice.py +390 -0
  88. pygpt_net/provider/audio_output/xai_tts.py +325 -0
  89. pygpt_net/provider/core/config/patch.py +39 -3
  90. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +2 -2
  91. pygpt_net/provider/core/model/patch.py +39 -1
  92. pygpt_net/tools/image_viewer/tool.py +334 -34
  93. pygpt_net/tools/image_viewer/ui/dialogs.py +319 -22
  94. pygpt_net/tools/text_editor/ui/dialogs.py +3 -2
  95. pygpt_net/tools/text_editor/ui/widgets.py +0 -0
  96. pygpt_net/ui/dialog/assistant.py +1 -1
  97. pygpt_net/ui/dialog/plugins.py +13 -5
  98. pygpt_net/ui/dialog/remote_store.py +552 -0
  99. pygpt_net/ui/dialogs.py +3 -5
  100. pygpt_net/ui/layout/ctx/ctx_list.py +58 -7
  101. pygpt_net/ui/menu/tools.py +6 -13
  102. pygpt_net/ui/widget/dialog/base.py +16 -5
  103. pygpt_net/ui/widget/dialog/{remote_store_google.py → remote_store.py} +10 -10
  104. pygpt_net/ui/widget/element/button.py +4 -4
  105. pygpt_net/ui/widget/image/display.py +2 -2
  106. pygpt_net/ui/widget/lists/context.py +2 -2
  107. pygpt_net/ui/widget/textarea/editor.py +0 -0
  108. {pygpt_net-2.7.6.dist-info → pygpt_net-2.7.8.dist-info}/METADATA +15 -2
  109. {pygpt_net-2.7.6.dist-info → pygpt_net-2.7.8.dist-info}/RECORD +107 -89
  110. pygpt_net/controller/remote_store/google/store.py +0 -615
  111. pygpt_net/controller/remote_store/openai/batch.py +0 -524
  112. pygpt_net/controller/remote_store/openai/store.py +0 -699
  113. pygpt_net/ui/dialog/remote_store_google.py +0 -539
  114. pygpt_net/ui/dialog/remote_store_openai.py +0 -539
  115. pygpt_net/ui/widget/dialog/remote_store_openai.py +0 -56
  116. pygpt_net/ui/widget/lists/remote_store_google.py +0 -248
  117. pygpt_net/ui/widget/lists/remote_store_openai.py +0 -317
  118. {pygpt_net-2.7.6.dist-info → pygpt_net-2.7.8.dist-info}/LICENSE +0 -0
  119. {pygpt_net-2.7.6.dist-info → pygpt_net-2.7.8.dist-info}/WHEEL +0 -0
  120. {pygpt_net-2.7.6.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: 2024.12.14 22:00:00 #
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
- id = self.prepare_id(path)
104
- if current_id and auto_close:
105
- if id != current_id:
106
- self.close_preview(current_id)
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.dialogs.open_instance(
113
- id,
114
- width=self.width,
115
- height=self.height,
116
- type="image_viewer",
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
- path = self.window.ui.dialog[id].source.path # previous img
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
- pixmap = QtGui.QPixmap(0, 0) # blank image
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
- pixmap = QtGui.QPixmap(path)
133
- img_suffix = " ({}x{}px)".format(pixmap.width(), pixmap.height())
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
- self.window.ui.dialog[id].resize(w - 1, h - 1) # redraw fix
148
- self.window.ui.dialog[id].resize(w, h)
149
- self.window.ui.dialog[id].show()
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 image in default image viewer
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