pygpt-net 2.7.5__py3-none-any.whl → 2.7.7__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 (82) hide show
  1. pygpt_net/CHANGELOG.txt +14 -0
  2. pygpt_net/__init__.py +4 -4
  3. pygpt_net/controller/chat/remote_tools.py +3 -9
  4. pygpt_net/controller/chat/stream.py +2 -2
  5. pygpt_net/controller/chat/{handler/worker.py → stream_worker.py} +20 -64
  6. pygpt_net/controller/debug/fixtures.py +3 -2
  7. pygpt_net/controller/files/files.py +65 -4
  8. pygpt_net/core/debug/models.py +2 -2
  9. pygpt_net/core/filesystem/url.py +4 -1
  10. pygpt_net/core/render/web/body.py +3 -2
  11. pygpt_net/core/types/chunk.py +27 -0
  12. pygpt_net/data/config/config.json +14 -4
  13. pygpt_net/data/config/models.json +192 -4
  14. pygpt_net/data/config/settings.json +126 -36
  15. pygpt_net/data/js/app/template.js +1 -1
  16. pygpt_net/data/js/app.min.js +2 -2
  17. pygpt_net/data/locale/locale.de.ini +5 -0
  18. pygpt_net/data/locale/locale.en.ini +35 -8
  19. pygpt_net/data/locale/locale.es.ini +5 -0
  20. pygpt_net/data/locale/locale.fr.ini +5 -0
  21. pygpt_net/data/locale/locale.it.ini +5 -0
  22. pygpt_net/data/locale/locale.pl.ini +5 -0
  23. pygpt_net/data/locale/locale.uk.ini +5 -0
  24. pygpt_net/data/locale/locale.zh.ini +5 -0
  25. pygpt_net/data/locale/plugin.cmd_mouse_control.en.ini +2 -2
  26. pygpt_net/item/ctx.py +3 -5
  27. pygpt_net/js_rc.py +2449 -2447
  28. pygpt_net/plugin/cmd_mouse_control/config.py +8 -7
  29. pygpt_net/plugin/cmd_mouse_control/plugin.py +3 -4
  30. pygpt_net/plugin/cmd_mouse_control/worker.py +2 -1
  31. pygpt_net/plugin/cmd_mouse_control/worker_sandbox.py +2 -1
  32. pygpt_net/provider/api/anthropic/__init__.py +16 -9
  33. pygpt_net/provider/api/anthropic/chat.py +259 -11
  34. pygpt_net/provider/api/anthropic/computer.py +844 -0
  35. pygpt_net/provider/api/anthropic/remote_tools.py +172 -0
  36. pygpt_net/{controller/chat/handler/anthropic_stream.py → provider/api/anthropic/stream.py} +24 -10
  37. pygpt_net/provider/api/anthropic/tools.py +32 -77
  38. pygpt_net/provider/api/anthropic/utils.py +30 -0
  39. pygpt_net/provider/api/google/__init__.py +6 -5
  40. pygpt_net/provider/api/google/chat.py +3 -8
  41. pygpt_net/{controller/chat/handler/google_stream.py → provider/api/google/stream.py} +1 -1
  42. pygpt_net/provider/api/google/utils.py +185 -0
  43. pygpt_net/{controller/chat/handler → provider/api/langchain}/__init__.py +0 -0
  44. pygpt_net/{controller/chat/handler/langchain_stream.py → provider/api/langchain/stream.py} +1 -1
  45. pygpt_net/provider/api/llama_index/__init__.py +0 -0
  46. pygpt_net/{controller/chat/handler/llamaindex_stream.py → provider/api/llama_index/stream.py} +1 -1
  47. pygpt_net/provider/api/openai/__init__.py +7 -3
  48. pygpt_net/provider/api/openai/image.py +2 -2
  49. pygpt_net/provider/api/openai/responses.py +0 -0
  50. pygpt_net/{controller/chat/handler/openai_stream.py → provider/api/openai/stream.py} +1 -1
  51. pygpt_net/provider/api/openai/utils.py +69 -3
  52. pygpt_net/provider/api/x_ai/__init__.py +117 -17
  53. pygpt_net/provider/api/x_ai/chat.py +272 -102
  54. pygpt_net/provider/api/x_ai/image.py +149 -47
  55. pygpt_net/provider/api/x_ai/{remote.py → remote_tools.py} +165 -70
  56. pygpt_net/provider/api/x_ai/responses.py +507 -0
  57. pygpt_net/provider/api/x_ai/stream.py +715 -0
  58. pygpt_net/provider/api/x_ai/tools.py +59 -8
  59. pygpt_net/{controller/chat/handler → provider/api/x_ai}/utils.py +1 -2
  60. pygpt_net/provider/api/x_ai/vision.py +1 -4
  61. pygpt_net/provider/core/config/patch.py +22 -1
  62. pygpt_net/provider/core/model/patch.py +26 -1
  63. pygpt_net/tools/image_viewer/ui/dialogs.py +300 -13
  64. pygpt_net/tools/text_editor/ui/dialogs.py +3 -2
  65. pygpt_net/tools/text_editor/ui/widgets.py +5 -1
  66. pygpt_net/ui/base/context_menu.py +44 -1
  67. pygpt_net/ui/layout/toolbox/indexes.py +22 -19
  68. pygpt_net/ui/layout/toolbox/model.py +28 -5
  69. pygpt_net/ui/widget/dialog/base.py +16 -5
  70. pygpt_net/ui/widget/image/display.py +25 -8
  71. pygpt_net/ui/widget/tabs/output.py +9 -1
  72. pygpt_net/ui/widget/textarea/editor.py +14 -1
  73. pygpt_net/ui/widget/textarea/input.py +20 -7
  74. pygpt_net/ui/widget/textarea/notepad.py +24 -1
  75. pygpt_net/ui/widget/textarea/output.py +23 -1
  76. pygpt_net/ui/widget/textarea/web.py +16 -1
  77. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/METADATA +16 -2
  78. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/RECORD +80 -73
  79. pygpt_net/controller/chat/handler/xai_stream.py +0 -135
  80. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/LICENSE +0 -0
  81. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/WHEEL +0 -0
  82. {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/entry_points.txt +0 -0
@@ -6,20 +6,26 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 01:00:00 #
9
+ # Updated Date: 2026.01.04 19:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
13
13
  from typing import List, Any, Optional
14
14
 
15
+ # xAI SDK client-side tool descriptor
16
+ try:
17
+ from xai_sdk.chat import tool as x_tool
18
+ except Exception:
19
+ x_tool = None
20
+
15
21
 
16
22
  class Tools:
17
23
  def __init__(self, window=None):
18
24
  """
19
- Tools mapper for xAI Chat Completions-compatible schema.
25
+ Tools mapper for xAI.
20
26
 
21
- Input: app 'functions' list with keys: name, desc, params (JSON Schema string).
22
- Output: list of dicts with keys: name, description, parameters.
27
+ - prepare(): legacy OpenAI-compatible dicts (kept for compatibility if needed).
28
+ - prepare_sdk_tools(): xAI SDK client-side tool descriptors for Chat Responses.
23
29
 
24
30
  :param window: Window instance
25
31
  """
@@ -84,12 +90,10 @@ class Tools:
84
90
 
85
91
  def prepare(self, functions: list) -> List[dict]:
86
92
  """
87
- Prepare xAI tools list (OpenAI-compatible schema) from app functions list.
88
-
89
- Returns [] if no functions provided.
93
+ Prepare legacy xAI/OpenAI-compatible tools list from app functions list.
90
94
 
91
95
  :param functions: List of functions with keys: name (str), desc (str), params (JSON Schema str)
92
- :return: List of tools with keys: name (str), description (str), parameters (dict)
96
+ :return: List of tools in dict format
93
97
  """
94
98
  if not functions or not isinstance(functions, list):
95
99
  return []
@@ -117,4 +121,51 @@ class Tools:
117
121
  "description": desc,
118
122
  "parameters": params,
119
123
  })
124
+ return tools
125
+
126
+ def prepare_sdk_tools(self, functions: list) -> List[object]:
127
+ """
128
+ Prepare xAI SDK client-side tool descriptors for Chat Responses.
129
+
130
+ :param functions: List of functions with keys: name (str), desc (str), params (JSON Schema str)
131
+ :return: List of xai_sdk.chat.tool(...) objects
132
+ """
133
+ if x_tool is None:
134
+ return [] # SDK too old; skip silently
135
+ if not functions or not isinstance(functions, list):
136
+ return []
137
+
138
+ tools: List[object] = []
139
+ for fn in functions:
140
+ name = str(fn.get("name") or "").strip()
141
+ if not name:
142
+ continue
143
+ desc = fn.get("desc") or ""
144
+ params: Optional[dict] = {}
145
+ if fn.get("params"):
146
+ try:
147
+ params = json.loads(fn["params"])
148
+ except Exception:
149
+ params = {}
150
+ params = self._sanitize_schema(params or {})
151
+ if not params.get("type"):
152
+ params["type"] = "object"
153
+ else:
154
+ params = {"type": "object"}
155
+ try:
156
+ tools.append(x_tool(
157
+ name=name,
158
+ description=desc,
159
+ parameters=params,
160
+ ))
161
+ except Exception:
162
+ # In case of schema issues, fallback to empty-params tool
163
+ try:
164
+ tools.append(x_tool(
165
+ name=name,
166
+ description=desc,
167
+ parameters={"type": "object"},
168
+ ))
169
+ except Exception:
170
+ continue
120
171
  return tools
@@ -6,10 +6,9 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 00:00:00 #
9
+ # Updated Date: 2026.01.05 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
- import base64
13
12
  from typing import Any, Optional
14
13
 
15
14
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 01:00:00 #
9
+ # Updated Date: 2026.01.04 19:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
@@ -50,9 +50,6 @@ class Vision:
50
50
  try:
51
51
  if att.path and self.window.core.api.xai.vision.is_image(att.path):
52
52
  mime = self.window.core.api.xai.vision.guess_mime(att.path)
53
- # Accept only JPEG/PNG for SDK too (for consistency)
54
- #if mime not in self.allowed_mimes:
55
- # continue
56
53
  with open(att.path, "rb") as f:
57
54
  b64 = base64.b64encode(f.read()).decode("utf-8")
58
55
  images.append(f"data:{mime};base64,{b64}")
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2026.01.02 02:00:00 #
9
+ # Updated Date: 2026.01.05 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import copy
@@ -238,6 +238,27 @@ class Patch:
238
238
  data["remote_store.openai.hide_threads"] = True
239
239
  updated = True
240
240
 
241
+ # < 2.7.7
242
+ if old < parse_version("2.7.7"):
243
+ print("Migrating config from < 2.7.7...")
244
+ to_add = [
245
+ "remote_tools.anthropic.code_execution",
246
+ "remote_tools.anthropic.file_search",
247
+ "remote_tools.anthropic.mcp",
248
+ "remote_tools.anthropic.mcp.tools",
249
+ "remote_tools.anthropic.mcp.mcp_servers",
250
+ "remote_tools.anthropic.web_fetch",
251
+ "remote_tools.xai.code_execution",
252
+ "remote_tools.xai.mcp",
253
+ "remote_tools.xai.mcp.args",
254
+ "remote_tools.xai.web_search",
255
+ "remote_tools.xai.x_search"
256
+ ]
257
+ for key in to_add:
258
+ if key not in data:
259
+ data[key] = cfg_get_base(key)
260
+ updated = True
261
+
241
262
  # update file
242
263
  migrated = False
243
264
  if updated:
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2026.01.03 02:10:00 #
9
+ # Updated Date: 2026.01.04 19:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from packaging.version import parse as parse_version, Version
@@ -116,6 +116,31 @@ class Patch:
116
116
  data[model] = base_model
117
117
  updated = True
118
118
 
119
+ # < 2.7.7 <--- add missing image input
120
+ if old < parse_version("2.7.7"):
121
+ print("Migrating models from < 2.7.7...")
122
+ models_to_add = [
123
+ "grok-4-1-fast-non-reasoning",
124
+ "grok-4-1-fast-reasoning",
125
+ "grok-4-fast-non-reasoning",
126
+ "grok-4-fast-reasoning"
127
+ ]
128
+ for model in models_to_add:
129
+ if model not in data:
130
+ base_model = from_base(model)
131
+ if base_model:
132
+ data[model] = base_model
133
+
134
+ models_to_update = [
135
+ "grok-4"
136
+ ]
137
+ for model in models_to_update:
138
+ if model in data:
139
+ m = data[model]
140
+ if not m.is_image_input():
141
+ m.input.append("image")
142
+ updated = True
143
+
119
144
  # update file
120
145
  if updated:
121
146
  # fix empty/broken data
@@ -6,17 +6,18 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2026.01.05 23:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6.QtCore import Qt
12
+ from PySide6.QtCore import Qt, QPoint, QSize, QEvent
13
13
  from PySide6.QtGui import QAction, QIcon
14
- from PySide6.QtWidgets import QMenuBar, QVBoxLayout, QHBoxLayout, QSizePolicy
14
+ from PySide6.QtWidgets import QMenuBar, QVBoxLayout, QHBoxLayout, QSizePolicy, QScrollArea
15
15
 
16
16
  from pygpt_net.ui.widget.dialog.base import BaseDialog
17
17
  from pygpt_net.ui.widget.image.display import ImageLabel
18
18
  from pygpt_net.utils import trans
19
19
 
20
+
20
21
  class DialogSpawner:
21
22
  def __init__(self, window=None):
22
23
  """
@@ -36,16 +37,23 @@ class DialogSpawner:
36
37
  :return: BaseDialog instance
37
38
  """
38
39
  dialog = ImageViewerDialog(self.window, self.id)
39
- dialog.disable_geometry_store = True # disable geometry store
40
+ dialog.disable_geometry_store = False
40
41
  dialog.id = id
42
+ dialog.shared_id = self.id
41
43
 
42
44
  source = ImageLabel(dialog, self.path)
43
45
  source.setVisible(False)
44
46
  pixmap = ImageLabel(dialog, self.path)
45
47
  pixmap.setSizePolicy(QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored))
46
48
 
49
+ # scrollable container for pixmap
50
+ scroll = QScrollArea(dialog)
51
+ scroll.setWidgetResizable(True)
52
+ scroll.setAlignment(Qt.AlignCenter)
53
+ scroll.setWidget(pixmap)
54
+
47
55
  row = QHBoxLayout()
48
- row.addWidget(pixmap)
56
+ row.addWidget(scroll)
49
57
 
50
58
  layout = QVBoxLayout()
51
59
  layout.addLayout(row)
@@ -53,6 +61,10 @@ class DialogSpawner:
53
61
  dialog.append_layout(layout)
54
62
  dialog.source = source
55
63
  dialog.pixmap = pixmap
64
+ dialog.scroll_area = scroll
65
+
66
+ # install event filter for wheel zoom and panning and cursor changes
67
+ dialog.scroll_area.viewport().installEventFilter(dialog)
56
68
 
57
69
  return dialog
58
70
 
@@ -74,13 +86,32 @@ class ImageViewerDialog(BaseDialog):
74
86
  self.actions = {}
75
87
  self.source = None
76
88
  self.pixmap = None
89
+ self.scroll_area = None
90
+
91
+ # cache for image change / resize handling
77
92
  self._last_src_key = 0
78
93
  self._last_target_size = None
94
+
95
+ # icons
79
96
  self._icon_add = QIcon(":/icons/add.svg")
80
97
  self._icon_folder = QIcon(":/icons/folder.svg")
81
98
  self._icon_save = QIcon(":/icons/save.svg")
82
99
  self._icon_logout = QIcon(":/icons/logout.svg")
83
100
 
101
+ # zoom / pan state
102
+ self._zoom_mode = 'fit' # 'fit' or 'manual'
103
+ self._zoom_factor = 1.0 # current manual factor (image space)
104
+ self._fit_factor = 1.0 # computed on-the-fly for fit mode
105
+ self._min_zoom = 0.05
106
+ self._max_zoom = 16.0
107
+ self._zoom_step = 1.25
108
+ self._drag_active = False
109
+ self._drag_last_pos = QPoint()
110
+
111
+ # performance guards to prevent extremely huge widget sizes
112
+ self._max_widget_dim = 32768 # max single dimension (px) for the view widget
113
+ self._max_total_pixels = 80_000_000 # max total pixels of the view widget (about 80MP)
114
+
84
115
  def append_layout(self, layout):
85
116
  """
86
117
  Update layout
@@ -98,18 +129,30 @@ class ImageViewerDialog(BaseDialog):
98
129
  """
99
130
  src = self.source.pixmap() if self.source is not None else None
100
131
  if src and not src.isNull() and self.pixmap is not None:
101
- target_size = self.pixmap.size()
102
132
  key = src.cacheKey()
103
- if key != self._last_src_key or target_size != self._last_target_size:
104
- self.pixmap.setPixmap(
105
- src.scaled(
133
+ # reset to fit mode on new image
134
+ if key != self._last_src_key:
135
+ self._zoom_mode = 'fit'
136
+ self._zoom_factor = 1.0
137
+ if self.scroll_area is not None:
138
+ self.scroll_area.setWidgetResizable(True)
139
+ # ensure no distortion in fit mode
140
+ if self.pixmap is not None:
141
+ self.pixmap.setScaledContents(False)
142
+
143
+ if self._zoom_mode == 'fit':
144
+ target_size = self._viewport_size()
145
+ if key != self._last_src_key or target_size != self._last_target_size:
146
+ # scale to viewport while keeping aspect ratio, smooth transform
147
+ scaled = src.scaled(
106
148
  target_size,
107
149
  Qt.KeepAspectRatio,
108
150
  Qt.SmoothTransformation
109
151
  )
110
- )
111
- self._last_src_key = key
112
- self._last_target_size = target_size
152
+ self.pixmap.setPixmap(scaled)
153
+ self._fit_factor = self._compute_fit_factor(src.size(), target_size)
154
+ self._last_src_key = key
155
+ self._last_target_size = target_size
113
156
  super(ImageViewerDialog, self).resizeEvent(event)
114
157
 
115
158
  def setup_menu(self) -> QMenuBar:
@@ -152,4 +195,248 @@ class ImageViewerDialog(BaseDialog):
152
195
  self.file_menu.addAction(self.actions["save_as"])
153
196
  self.file_menu.addAction(self.actions["exit"])
154
197
 
155
- return self.menu_bar
198
+ return self.menu_bar
199
+
200
+ # =========================
201
+ # Zoom / pan implementation
202
+ # =========================
203
+
204
+ def eventFilter(self, obj, event):
205
+ """
206
+ Handle wheel zoom, panning and cursor changes on the scroll area viewport.
207
+ """
208
+ if self.scroll_area is None or obj is not self.scroll_area.viewport():
209
+ return super(ImageViewerDialog, self).eventFilter(obj, event)
210
+
211
+ et = event.type()
212
+
213
+ # Always show grab cursor when mouse is over the image area
214
+ if et == QEvent.Enter:
215
+ if self._has_image():
216
+ self.scroll_area.viewport().setCursor(Qt.OpenHandCursor)
217
+ return False
218
+
219
+ if et == QEvent.Leave:
220
+ self.scroll_area.viewport().unsetCursor()
221
+ return False
222
+
223
+ if et == QEvent.MouseButtonPress:
224
+ if event.button() == Qt.LeftButton and self._can_drag():
225
+ self._drag_active = True
226
+ self._drag_last_pos = self._event_pos(event)
227
+ self.scroll_area.viewport().setCursor(Qt.ClosedHandCursor)
228
+ event.accept()
229
+ return True
230
+ return False
231
+
232
+ if et == QEvent.MouseMove:
233
+ if self._drag_active:
234
+ pos = self._event_pos(event)
235
+ delta = pos - self._drag_last_pos
236
+ self._drag_last_pos = pos
237
+ # move scrollbars opposite to mouse movement
238
+ hbar = self.scroll_area.horizontalScrollBar()
239
+ vbar = self.scroll_area.verticalScrollBar()
240
+ hbar.setValue(hbar.value() - delta.x())
241
+ vbar.setValue(vbar.value() - delta.y())
242
+ event.accept()
243
+ return True
244
+ return False
245
+
246
+ if et == QEvent.MouseButtonRelease:
247
+ if event.button() == Qt.LeftButton and self._drag_active:
248
+ self._drag_active = False
249
+ # back to grab cursor
250
+ if self._has_image():
251
+ self.scroll_area.viewport().setCursor(Qt.OpenHandCursor)
252
+ event.accept()
253
+ return True
254
+ return False
255
+
256
+ if et == QEvent.Wheel:
257
+ # zoom in/out with mouse wheel
258
+ if not self._has_image():
259
+ return False
260
+
261
+ # start manual zoom from current fit factor if needed
262
+ if self._zoom_mode == 'fit':
263
+ self._fit_factor = self._compute_fit_factor(
264
+ self.source.pixmap().size(),
265
+ self._viewport_size()
266
+ )
267
+ self._zoom_factor = self._fit_factor
268
+ self._zoom_mode = 'manual'
269
+ # switch to manual rendering path: original pixmap + scaled contents
270
+ self.scroll_area.setWidgetResizable(False)
271
+ self.pixmap.setScaledContents(True)
272
+ # ensure we display original image for better performance (no giant intermediate pixmaps)
273
+ self.pixmap.setPixmap(self.source.pixmap())
274
+
275
+ old_w = max(1, self.pixmap.width())
276
+ old_h = max(1, self.pixmap.height())
277
+
278
+ angle = 0
279
+ try:
280
+ angle = event.angleDelta().y()
281
+ if angle == 0:
282
+ angle = event.angleDelta().x()
283
+ except Exception:
284
+ pass
285
+
286
+ if angle == 0:
287
+ return False
288
+
289
+ step = self._zoom_step if angle > 0 else (1.0 / self._zoom_step)
290
+ # compute tentative new factor and clamp by hard min/max and size guards
291
+ tentative = self._zoom_factor * step
292
+ tentative = max(self._min_zoom, min(self._max_zoom, tentative))
293
+ # apply size-based guards to avoid extremely huge widget sizes
294
+ tentative = self._clamp_factor_by_size(tentative)
295
+
296
+ if abs(tentative - self._zoom_factor) < 1e-9:
297
+ event.accept()
298
+ return True
299
+
300
+ vp_pos = self._event_pos(event)
301
+ hbar = self.scroll_area.horizontalScrollBar()
302
+ vbar = self.scroll_area.verticalScrollBar()
303
+
304
+ # position in content coords before zoom (keep point under cursor stable)
305
+ content_x = hbar.value() + vp_pos.x()
306
+ content_y = vbar.value() + vp_pos.y()
307
+ rx = content_x / float(old_w)
308
+ ry = content_y / float(old_h)
309
+
310
+ self._zoom_factor = tentative
311
+ self._set_scaled_pixmap_by_factor(self._zoom_factor)
312
+
313
+ new_w = max(1, self.pixmap.width())
314
+ new_h = max(1, self.pixmap.height())
315
+
316
+ hbar.setValue(int(rx * new_w - vp_pos.x()))
317
+ vbar.setValue(int(ry * new_h - vp_pos.y()))
318
+
319
+ event.accept()
320
+ return True
321
+
322
+ return super(ImageViewerDialog, self).eventFilter(obj, event)
323
+
324
+ def _has_image(self) -> bool:
325
+ """Check if source image is available."""
326
+ return (
327
+ self.source is not None
328
+ and self.source.pixmap() is not None
329
+ and not self.source.pixmap().isNull()
330
+ and self.pixmap is not None
331
+ )
332
+
333
+ def _can_drag(self) -> bool:
334
+ """Allow dragging only when image does not fit into the viewport."""
335
+ if not self._has_image():
336
+ return False
337
+ if self._zoom_mode != 'manual':
338
+ return False
339
+ vp = self._viewport_size()
340
+ return self.pixmap.width() > vp.width() or self.pixmap.height() > vp.height()
341
+
342
+ def _viewport_size(self) -> QSize:
343
+ """Get current viewport size."""
344
+ if self.scroll_area is not None:
345
+ return self.scroll_area.viewport().size()
346
+ return self.size()
347
+
348
+ def _compute_fit_factor(self, img_size: QSize, target: QSize) -> float:
349
+ """Compute factor for fitting image into target size."""
350
+ iw = max(1, img_size.width())
351
+ ih = max(1, img_size.height())
352
+ tw = max(1, target.width())
353
+ th = max(1, target.height())
354
+ return min(tw / float(iw), th / float(ih))
355
+
356
+ def _clamp_factor_by_size(self, factor: float) -> float:
357
+ """
358
+ Clamp zoom factor to avoid creating extremely large widget sizes.
359
+ This keeps interactivity smooth by limiting max resulting dimensions and total pixels.
360
+ """
361
+ src = self.source.pixmap()
362
+ if not src or src.isNull():
363
+ return factor
364
+
365
+ iw = max(1, src.width())
366
+ ih = max(1, src.height())
367
+
368
+ # only guard when zooming in; zooming out should not be limited by these caps
369
+ if factor <= 1.0:
370
+ return factor
371
+
372
+ # desired size
373
+ dw = iw * factor
374
+ dh = ih * factor
375
+
376
+ scale = 1.0
377
+
378
+ # total pixel cap
379
+ total = dw * dh
380
+ if total > self._max_total_pixels:
381
+ from math import sqrt
382
+ scale = min(scale, sqrt(self._max_total_pixels / float(total)))
383
+
384
+ # dimension caps
385
+ if dw * scale > self._max_widget_dim:
386
+ scale = min(scale, self._max_widget_dim / float(dw))
387
+ if dh * scale > self._max_widget_dim:
388
+ scale = min(scale, self._max_widget_dim / float(dh))
389
+
390
+ if scale < 1.0:
391
+ return max(self._min_zoom, factor * scale)
392
+ return factor
393
+
394
+ def _set_scaled_pixmap_by_factor(self, factor: float):
395
+ """
396
+ Scale and display the image using provided factor relative to the original image.
397
+ In manual mode this avoids allocating giant intermediate QPixmaps by:
398
+ - drawing the original pixmap;
399
+ - enabling scaled contents;
400
+ - resizing the label to the required size.
401
+ """
402
+ if not self._has_image():
403
+ return
404
+
405
+ src = self.source.pixmap()
406
+ iw = max(1, src.width())
407
+ ih = max(1, src.height())
408
+
409
+ # target size based on factor (KeepAspectRatio preserved by proportional math)
410
+ new_w = max(1, int(round(iw * factor)))
411
+ new_h = max(1, int(round(ih * factor)))
412
+
413
+ # enforce guards once more to be safe
414
+ guarded_factor = self._clamp_factor_by_size(factor)
415
+ if abs(guarded_factor - factor) > 1e-9:
416
+ new_w = max(1, int(round(iw * guarded_factor)))
417
+ new_h = max(1, int(round(ih * guarded_factor)))
418
+ self._zoom_factor = guarded_factor # keep internal factor in sync
419
+
420
+ if self._zoom_mode == 'manual':
421
+ # ensure manual path uses original pixmap and scaled contents
422
+ if self.pixmap.pixmap() is None or self.pixmap.pixmap().cacheKey() != src.cacheKey():
423
+ self.pixmap.setPixmap(src)
424
+ self.pixmap.setScaledContents(True)
425
+ self.pixmap.resize(new_w, new_h)
426
+ else:
427
+ # fallback (not expected here): keep classic high-quality scaling
428
+ scaled = src.scaled(new_w, new_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
429
+ self.pixmap.setScaledContents(False)
430
+ self.pixmap.setPixmap(scaled)
431
+ if self.scroll_area is not None:
432
+ self.scroll_area.setWidgetResizable(True)
433
+
434
+ def _event_pos(self, event) -> QPoint:
435
+ """
436
+ Extract integer QPoint from mouse/touchpad event position (supports QPointF in 6.9+).
437
+ """
438
+ if hasattr(event, "position"):
439
+ return event.position().toPoint()
440
+ if hasattr(event, "pos"):
441
+ return event.pos()
442
+ return QPoint(0, 0)
@@ -6,7 +6,7 @@
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.03.26 15:00:00 #
9
+ # Updated Date: 2026.01.05 23:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtWidgets import QPushButton, QHBoxLayout, QVBoxLayout
@@ -63,8 +63,9 @@ class DialogSpawner:
63
63
  layout.addLayout(bottom_layout)
64
64
 
65
65
  dialog = EditorFileDialog(self.window)
66
- dialog.disable_geometry_store = True # disable geometry store
66
+ dialog.disable_geometry_store = False
67
67
  dialog.id = id
68
+ dialog.shared_id = "text-edit"
68
69
  dialog.append_layout(layout)
69
70
  dialog.setWindowTitle("Text editor")
70
71
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2026.01.03 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtGui import QAction, QIcon, QKeySequence
@@ -67,6 +67,10 @@ class TextFileEditor(BaseCodeEditor):
67
67
  )
68
68
  menu.addAction(action)
69
69
 
70
+ # Add zoom submenu
71
+ zoom_menu = self.window.ui.context_menu.get_zoom_menu(self, "editor", self.value, self.on_zoom_changed)
72
+ menu.addMenu(zoom_menu)
73
+
70
74
  action = QAction(self._icon_search, trans('text.context_menu.find'), menu)
71
75
  action.triggered.connect(self.find_open)
72
76
  action.setShortcut(QKeySequence.StandardKey.Find)