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.
- pygpt_net/CHANGELOG.txt +14 -0
- pygpt_net/__init__.py +4 -4
- pygpt_net/controller/chat/remote_tools.py +3 -9
- pygpt_net/controller/chat/stream.py +2 -2
- pygpt_net/controller/chat/{handler/worker.py → stream_worker.py} +20 -64
- pygpt_net/controller/debug/fixtures.py +3 -2
- pygpt_net/controller/files/files.py +65 -4
- pygpt_net/core/debug/models.py +2 -2
- pygpt_net/core/filesystem/url.py +4 -1
- pygpt_net/core/render/web/body.py +3 -2
- pygpt_net/core/types/chunk.py +27 -0
- pygpt_net/data/config/config.json +14 -4
- pygpt_net/data/config/models.json +192 -4
- pygpt_net/data/config/settings.json +126 -36
- pygpt_net/data/js/app/template.js +1 -1
- pygpt_net/data/js/app.min.js +2 -2
- pygpt_net/data/locale/locale.de.ini +5 -0
- pygpt_net/data/locale/locale.en.ini +35 -8
- pygpt_net/data/locale/locale.es.ini +5 -0
- pygpt_net/data/locale/locale.fr.ini +5 -0
- pygpt_net/data/locale/locale.it.ini +5 -0
- pygpt_net/data/locale/locale.pl.ini +5 -0
- pygpt_net/data/locale/locale.uk.ini +5 -0
- pygpt_net/data/locale/locale.zh.ini +5 -0
- pygpt_net/data/locale/plugin.cmd_mouse_control.en.ini +2 -2
- pygpt_net/item/ctx.py +3 -5
- pygpt_net/js_rc.py +2449 -2447
- pygpt_net/plugin/cmd_mouse_control/config.py +8 -7
- pygpt_net/plugin/cmd_mouse_control/plugin.py +3 -4
- pygpt_net/plugin/cmd_mouse_control/worker.py +2 -1
- pygpt_net/plugin/cmd_mouse_control/worker_sandbox.py +2 -1
- pygpt_net/provider/api/anthropic/__init__.py +16 -9
- pygpt_net/provider/api/anthropic/chat.py +259 -11
- pygpt_net/provider/api/anthropic/computer.py +844 -0
- pygpt_net/provider/api/anthropic/remote_tools.py +172 -0
- pygpt_net/{controller/chat/handler/anthropic_stream.py → provider/api/anthropic/stream.py} +24 -10
- pygpt_net/provider/api/anthropic/tools.py +32 -77
- pygpt_net/provider/api/anthropic/utils.py +30 -0
- pygpt_net/provider/api/google/__init__.py +6 -5
- pygpt_net/provider/api/google/chat.py +3 -8
- pygpt_net/{controller/chat/handler/google_stream.py → provider/api/google/stream.py} +1 -1
- pygpt_net/provider/api/google/utils.py +185 -0
- pygpt_net/{controller/chat/handler → provider/api/langchain}/__init__.py +0 -0
- pygpt_net/{controller/chat/handler/langchain_stream.py → provider/api/langchain/stream.py} +1 -1
- pygpt_net/provider/api/llama_index/__init__.py +0 -0
- pygpt_net/{controller/chat/handler/llamaindex_stream.py → provider/api/llama_index/stream.py} +1 -1
- pygpt_net/provider/api/openai/__init__.py +7 -3
- pygpt_net/provider/api/openai/image.py +2 -2
- pygpt_net/provider/api/openai/responses.py +0 -0
- pygpt_net/{controller/chat/handler/openai_stream.py → provider/api/openai/stream.py} +1 -1
- pygpt_net/provider/api/openai/utils.py +69 -3
- pygpt_net/provider/api/x_ai/__init__.py +117 -17
- pygpt_net/provider/api/x_ai/chat.py +272 -102
- pygpt_net/provider/api/x_ai/image.py +149 -47
- pygpt_net/provider/api/x_ai/{remote.py → remote_tools.py} +165 -70
- pygpt_net/provider/api/x_ai/responses.py +507 -0
- pygpt_net/provider/api/x_ai/stream.py +715 -0
- pygpt_net/provider/api/x_ai/tools.py +59 -8
- pygpt_net/{controller/chat/handler → provider/api/x_ai}/utils.py +1 -2
- pygpt_net/provider/api/x_ai/vision.py +1 -4
- pygpt_net/provider/core/config/patch.py +22 -1
- pygpt_net/provider/core/model/patch.py +26 -1
- pygpt_net/tools/image_viewer/ui/dialogs.py +300 -13
- pygpt_net/tools/text_editor/ui/dialogs.py +3 -2
- pygpt_net/tools/text_editor/ui/widgets.py +5 -1
- pygpt_net/ui/base/context_menu.py +44 -1
- pygpt_net/ui/layout/toolbox/indexes.py +22 -19
- pygpt_net/ui/layout/toolbox/model.py +28 -5
- pygpt_net/ui/widget/dialog/base.py +16 -5
- pygpt_net/ui/widget/image/display.py +25 -8
- pygpt_net/ui/widget/tabs/output.py +9 -1
- pygpt_net/ui/widget/textarea/editor.py +14 -1
- pygpt_net/ui/widget/textarea/input.py +20 -7
- pygpt_net/ui/widget/textarea/notepad.py +24 -1
- pygpt_net/ui/widget/textarea/output.py +23 -1
- pygpt_net/ui/widget/textarea/web.py +16 -1
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/METADATA +16 -2
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/RECORD +80 -73
- pygpt_net/controller/chat/handler/xai_stream.py +0 -135
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/LICENSE +0 -0
- {pygpt_net-2.7.5.dist-info → pygpt_net-2.7.7.dist-info}/WHEEL +0 -0
- {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:
|
|
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
|
|
25
|
+
Tools mapper for xAI.
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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)
|