pygpt-net 2.7.2__py3-none-any.whl → 2.7.4__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 +12 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +382 -350
- pygpt_net/controller/chat/attachment.py +5 -1
- pygpt_net/controller/chat/image.py +40 -5
- pygpt_net/controller/files/files.py +3 -1
- pygpt_net/controller/layout/layout.py +2 -2
- pygpt_net/controller/media/media.py +70 -1
- pygpt_net/controller/theme/nodes.py +2 -1
- pygpt_net/controller/ui/mode.py +5 -1
- pygpt_net/controller/ui/ui.py +17 -2
- pygpt_net/core/filesystem/url.py +4 -1
- pygpt_net/core/render/web/helpers.py +5 -0
- pygpt_net/data/config/config.json +5 -4
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +0 -14
- pygpt_net/data/css/web-blocks.css +3 -0
- pygpt_net/data/css/web-chatgpt.css +3 -0
- pygpt_net/data/locale/locale.de.ini +6 -0
- pygpt_net/data/locale/locale.en.ini +7 -1
- pygpt_net/data/locale/locale.es.ini +6 -0
- pygpt_net/data/locale/locale.fr.ini +6 -0
- pygpt_net/data/locale/locale.it.ini +6 -0
- pygpt_net/data/locale/locale.pl.ini +7 -1
- pygpt_net/data/locale/locale.uk.ini +6 -0
- pygpt_net/data/locale/locale.zh.ini +6 -0
- pygpt_net/launcher.py +115 -55
- pygpt_net/preload.py +243 -0
- pygpt_net/provider/api/google/image.py +317 -10
- pygpt_net/provider/api/google/video.py +160 -4
- pygpt_net/provider/api/openai/image.py +201 -93
- pygpt_net/provider/api/openai/video.py +99 -24
- pygpt_net/provider/api/x_ai/image.py +25 -2
- pygpt_net/provider/core/config/patch.py +17 -1
- pygpt_net/ui/layout/chat/input.py +20 -2
- pygpt_net/ui/layout/chat/painter.py +6 -4
- pygpt_net/ui/layout/toolbox/image.py +21 -11
- pygpt_net/ui/layout/toolbox/raw.py +2 -2
- pygpt_net/ui/layout/toolbox/video.py +22 -9
- pygpt_net/ui/main.py +84 -3
- pygpt_net/ui/widget/dialog/base.py +3 -10
- pygpt_net/ui/widget/option/combo.py +119 -1
- pygpt_net/ui/widget/textarea/input_extra.py +664 -0
- {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/METADATA +27 -20
- {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/RECORD +48 -46
- {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/LICENSE +0 -0
- {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/WHEEL +0 -0
- {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# ================================================== #
|
|
4
|
+
# This file is a part of PYGPT package #
|
|
5
|
+
# Website: https://pygpt.net #
|
|
6
|
+
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
+
# MIT License #
|
|
8
|
+
# Created By : Marcin Szczygliński #
|
|
9
|
+
# Updated Date: 2025.12.31 16:00:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import Qt, QTimer, QEvent
|
|
16
|
+
from PySide6.QtGui import QAction, QIcon, QImage
|
|
17
|
+
from PySide6.QtWidgets import (
|
|
18
|
+
QTextEdit,
|
|
19
|
+
QApplication,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from pygpt_net.utils import trans
|
|
23
|
+
from pygpt_net.core.attachments.clipboard import AttachmentDropHandler
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExtraInput(QTextEdit):
|
|
27
|
+
|
|
28
|
+
ICON_PASTE = QIcon(":/icons/paste.svg")
|
|
29
|
+
ICON_VOLUME = QIcon(":/icons/volume.svg")
|
|
30
|
+
ICON_SAVE = QIcon(":/icons/save.svg")
|
|
31
|
+
|
|
32
|
+
def __init__(self, window=None):
|
|
33
|
+
"""
|
|
34
|
+
Chat input
|
|
35
|
+
|
|
36
|
+
:param window: main window
|
|
37
|
+
"""
|
|
38
|
+
super().__init__(window)
|
|
39
|
+
self.window = window
|
|
40
|
+
self.setAcceptRichText(False)
|
|
41
|
+
self.setFocus()
|
|
42
|
+
self.value = self.window.core.config.data['font_size.input']
|
|
43
|
+
self.max_font_size = 42
|
|
44
|
+
self.min_font_size = 8
|
|
45
|
+
self._text_top_padding = 10
|
|
46
|
+
self.setProperty('class', 'layout-input')
|
|
47
|
+
|
|
48
|
+
if self.window.core.platforms.is_windows():
|
|
49
|
+
self._text_top_padding = 8
|
|
50
|
+
|
|
51
|
+
# ---- Auto-resize config (input in splitter) ----
|
|
52
|
+
self._auto_max_lines = 10 # max lines for auto-expansion
|
|
53
|
+
self._auto_max_ratio = 0.25 # max fraction of main window height
|
|
54
|
+
self._auto_debounce_ms = 0 # coalesce updates in next event loop turn
|
|
55
|
+
self._auto_updating = False # reentrancy guard
|
|
56
|
+
self._splitter_resize_in_progress = False
|
|
57
|
+
self._splitter_connected = False
|
|
58
|
+
self._user_adjusting_splitter = False
|
|
59
|
+
self._auto_pause_ms_after_user_drag = 350
|
|
60
|
+
self._last_target_container_h = None
|
|
61
|
+
|
|
62
|
+
self._auto_timer = QTimer(self)
|
|
63
|
+
self._auto_timer.setSingleShot(True)
|
|
64
|
+
self._auto_timer.timeout.connect(self._auto_resize_tick)
|
|
65
|
+
|
|
66
|
+
# Paste/input safety limits
|
|
67
|
+
self._paste_max_chars = 1000000000 # hard cap to prevent pathological pastes from freezing/crashing
|
|
68
|
+
|
|
69
|
+
# One-shot guard to avoid duplicate attachment processing on drops that also insert text.
|
|
70
|
+
self._skip_clipboard_on_next_insert = False
|
|
71
|
+
|
|
72
|
+
# Drag & Drop: add as attachments; do not insert file paths into text
|
|
73
|
+
self._dnd_handler = AttachmentDropHandler(self.window, self, policy=AttachmentDropHandler.INPUT_MIX)
|
|
74
|
+
|
|
75
|
+
def _on_text_changed_tokens(self):
|
|
76
|
+
"""Schedule token count update with debounce."""
|
|
77
|
+
self._tokens_timer.start()
|
|
78
|
+
|
|
79
|
+
def _apply_text_top_padding(self):
|
|
80
|
+
"""Apply extra top padding inside the text area by using viewport margins."""
|
|
81
|
+
# Left margin is computed in _apply_margins()
|
|
82
|
+
self._apply_margins()
|
|
83
|
+
|
|
84
|
+
def set_text_top_padding(self, px: int):
|
|
85
|
+
"""
|
|
86
|
+
Public helper to adjust top padding at runtime.
|
|
87
|
+
|
|
88
|
+
:param px: padding in pixels
|
|
89
|
+
"""
|
|
90
|
+
self._text_top_padding = max(0, int(px))
|
|
91
|
+
self._apply_margins()
|
|
92
|
+
|
|
93
|
+
def canInsertFromMimeData(self, source) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Restrict accepted MIME types to safe, explicitly handled ones.
|
|
96
|
+
This prevents Qt from trying to parse unknown/broken formats.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
if source is None:
|
|
100
|
+
return False
|
|
101
|
+
return source.hasText() or source.hasUrls() or source.hasImage()
|
|
102
|
+
except Exception:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def _mime_has_local_file_urls(self, source) -> bool:
|
|
106
|
+
"""
|
|
107
|
+
Detects whether mime data contains any local file/directory URLs.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
if source and source.hasUrls():
|
|
111
|
+
for url in source.urls():
|
|
112
|
+
if url.isLocalFile():
|
|
113
|
+
return True
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def insertFromMimeData(self, source):
|
|
119
|
+
"""
|
|
120
|
+
Insert from mime data
|
|
121
|
+
|
|
122
|
+
:param source: source
|
|
123
|
+
"""
|
|
124
|
+
has_local_files = self._mime_has_local_file_urls(source)
|
|
125
|
+
|
|
126
|
+
# Avoid double-processing when drop is allowed to fall through to default insertion.
|
|
127
|
+
should_skip = bool(getattr(self, "_skip_clipboard_on_next_insert", False))
|
|
128
|
+
if should_skip:
|
|
129
|
+
self._skip_clipboard_on_next_insert = False
|
|
130
|
+
else:
|
|
131
|
+
# Always process attachments first; never break input pipeline on errors.
|
|
132
|
+
try:
|
|
133
|
+
self.handle_clipboard(source)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
try:
|
|
136
|
+
self.window.core.debug.log(e)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# Do not insert textual representation for images nor local file URLs (including directories).
|
|
141
|
+
try:
|
|
142
|
+
if source and (source.hasImage() or has_local_files):
|
|
143
|
+
return
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
# Insert only sanitized plain text (no HTML, no custom formats).
|
|
148
|
+
try:
|
|
149
|
+
text = self._safe_text_from_mime(source)
|
|
150
|
+
if text:
|
|
151
|
+
self.insertPlainText(text)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
try:
|
|
154
|
+
self.window.core.debug.log(e)
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def _safe_text_from_mime(self, source) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Extracts plain text from QMimeData safely, normalizes and sanitizes it.
|
|
161
|
+
Falls back to URLs joined by space only for non-local URLs.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
if source is None:
|
|
165
|
+
return ""
|
|
166
|
+
# Prefer real text if present
|
|
167
|
+
if source.hasText():
|
|
168
|
+
return self._sanitize_text(source.text())
|
|
169
|
+
# Fallback: for non-local URLs we allow insertion as text (e.g., http/https)
|
|
170
|
+
if source.hasUrls():
|
|
171
|
+
parts = []
|
|
172
|
+
for url in source.urls():
|
|
173
|
+
try:
|
|
174
|
+
if url.isLocalFile():
|
|
175
|
+
# Skip local files/dirs textual fallback; they are handled as attachments
|
|
176
|
+
continue
|
|
177
|
+
parts.append(url.toString())
|
|
178
|
+
except Exception:
|
|
179
|
+
continue
|
|
180
|
+
if parts:
|
|
181
|
+
return self._sanitize_text(" ".join([p for p in parts if p]))
|
|
182
|
+
except Exception as e:
|
|
183
|
+
try:
|
|
184
|
+
self.window.core.debug.log(e)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
return ""
|
|
188
|
+
|
|
189
|
+
def _sanitize_text(self, text: str) -> str:
|
|
190
|
+
"""
|
|
191
|
+
Sanitize pasted text:
|
|
192
|
+
- normalize newlines
|
|
193
|
+
- remove NUL and most control chars except tab/newline
|
|
194
|
+
- strip zero-width and bidi control characters
|
|
195
|
+
- hard-cap maximum length to avoid UI freeze
|
|
196
|
+
"""
|
|
197
|
+
if not text:
|
|
198
|
+
return ""
|
|
199
|
+
if not isinstance(text, str):
|
|
200
|
+
try:
|
|
201
|
+
text = str(text)
|
|
202
|
+
except Exception:
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
# Normalize line breaks
|
|
206
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
207
|
+
|
|
208
|
+
# Remove disallowed control chars, keep tab/newline
|
|
209
|
+
out = []
|
|
210
|
+
for ch in text:
|
|
211
|
+
code = ord(ch)
|
|
212
|
+
if code == 0:
|
|
213
|
+
continue # NUL
|
|
214
|
+
if code < 32:
|
|
215
|
+
if ch in ("\n", "\t"):
|
|
216
|
+
out.append(ch)
|
|
217
|
+
else:
|
|
218
|
+
out.append(" ")
|
|
219
|
+
continue
|
|
220
|
+
if code == 0x7F:
|
|
221
|
+
continue # DEL
|
|
222
|
+
# Remove zero-width and bidi controls
|
|
223
|
+
if (0x200B <= code <= 0x200F) or (0x202A <= code <= 0x202E) or (0x2066 <= code <= 0x2069):
|
|
224
|
+
continue
|
|
225
|
+
out.append(ch)
|
|
226
|
+
|
|
227
|
+
s = "".join(out)
|
|
228
|
+
|
|
229
|
+
# Cap very large pastes
|
|
230
|
+
try:
|
|
231
|
+
limit = int(self._paste_max_chars)
|
|
232
|
+
except Exception:
|
|
233
|
+
limit = 250000
|
|
234
|
+
if limit > 0 and len(s) > limit:
|
|
235
|
+
s = s[:limit]
|
|
236
|
+
try:
|
|
237
|
+
self.window.core.debug.log(f"Input paste truncated to {limit} chars")
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
return s
|
|
242
|
+
|
|
243
|
+
def handle_clipboard(self, source):
|
|
244
|
+
"""
|
|
245
|
+
Handle clipboard
|
|
246
|
+
|
|
247
|
+
:param source: source
|
|
248
|
+
"""
|
|
249
|
+
if source is None:
|
|
250
|
+
return
|
|
251
|
+
try:
|
|
252
|
+
if source.hasImage():
|
|
253
|
+
image = source.imageData()
|
|
254
|
+
if isinstance(image, QImage):
|
|
255
|
+
self.window.controller.attachment.from_clipboard_image(image)
|
|
256
|
+
else:
|
|
257
|
+
# Some platforms provide QPixmap; convert to QImage if possible
|
|
258
|
+
try:
|
|
259
|
+
img = image.toImage()
|
|
260
|
+
if isinstance(img, QImage):
|
|
261
|
+
self.window.controller.attachment.from_clipboard_image(img)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
elif source.hasUrls():
|
|
265
|
+
urls = source.urls()
|
|
266
|
+
for url in urls:
|
|
267
|
+
try:
|
|
268
|
+
if url.isLocalFile():
|
|
269
|
+
local_path = url.toLocalFile()
|
|
270
|
+
if not local_path:
|
|
271
|
+
continue
|
|
272
|
+
if os.path.isdir(local_path):
|
|
273
|
+
# Recursively add all files from the dropped directory
|
|
274
|
+
for root, _, files in os.walk(local_path):
|
|
275
|
+
for name in files:
|
|
276
|
+
fpath = os.path.join(root, name)
|
|
277
|
+
try:
|
|
278
|
+
self.window.controller.attachment.from_clipboard_url(fpath, all=True)
|
|
279
|
+
except Exception:
|
|
280
|
+
continue
|
|
281
|
+
else:
|
|
282
|
+
self.window.controller.attachment.from_clipboard_url(local_path, all=True)
|
|
283
|
+
else:
|
|
284
|
+
# Non-local URLs are handled as text (if any) by _safe_text_from_mime
|
|
285
|
+
pass
|
|
286
|
+
except Exception:
|
|
287
|
+
# Ignore broken URL entries
|
|
288
|
+
continue
|
|
289
|
+
elif source.hasText():
|
|
290
|
+
text = self._sanitize_text(source.text())
|
|
291
|
+
if text:
|
|
292
|
+
self.window.controller.attachment.from_clipboard_text(text)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
# Never propagate clipboard errors to UI thread
|
|
295
|
+
try:
|
|
296
|
+
self.window.core.debug.log(e)
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
def contextMenuEvent(self, event):
|
|
301
|
+
"""
|
|
302
|
+
Context menu event
|
|
303
|
+
|
|
304
|
+
:param event: event
|
|
305
|
+
"""
|
|
306
|
+
menu = self.createStandardContextMenu()
|
|
307
|
+
try:
|
|
308
|
+
if self.window.controller.attachment.clipboard_has_attachment():
|
|
309
|
+
action = QAction(self.ICON_PASTE, trans("action.use.attachment"), menu)
|
|
310
|
+
action.triggered.connect(self.action_from_clipboard)
|
|
311
|
+
menu.addAction(action)
|
|
312
|
+
|
|
313
|
+
cursor = self.textCursor()
|
|
314
|
+
selected_text = cursor.selectedText()
|
|
315
|
+
if selected_text:
|
|
316
|
+
plain_text = cursor.selection().toPlainText()
|
|
317
|
+
|
|
318
|
+
action = QAction(self.ICON_VOLUME, trans('text.context_menu.audio.read'), menu)
|
|
319
|
+
action.triggered.connect(self.audio_read_selection)
|
|
320
|
+
menu.addAction(action)
|
|
321
|
+
|
|
322
|
+
copy_to_menu = self.window.ui.context_menu.get_copy_to_menu(menu, selected_text, excluded=["input"])
|
|
323
|
+
menu.addMenu(copy_to_menu)
|
|
324
|
+
|
|
325
|
+
action = QAction(self.ICON_SAVE, trans('action.save_selection_as'), menu)
|
|
326
|
+
action.triggered.connect(lambda: self.window.controller.chat.common.save_text(plain_text))
|
|
327
|
+
menu.addAction(action)
|
|
328
|
+
else:
|
|
329
|
+
action = QAction(self.ICON_SAVE, trans('action.save_as'), menu)
|
|
330
|
+
action.triggered.connect(lambda: self.window.controller.chat.common.save_text(self.toPlainText()))
|
|
331
|
+
menu.addAction(action)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
self.window.core.prompt.template.to_menu_options(menu, "input")
|
|
335
|
+
self.window.core.prompt.custom.to_menu_options(menu, "input")
|
|
336
|
+
except Exception as e:
|
|
337
|
+
self.window.core.debug.log(e)
|
|
338
|
+
|
|
339
|
+
action = QAction(self.ICON_SAVE, trans('preset.prompt.save_custom'), menu)
|
|
340
|
+
action.triggered.connect(self.window.controller.presets.save_prompt)
|
|
341
|
+
menu.addAction(action)
|
|
342
|
+
|
|
343
|
+
menu.exec(event.globalPos())
|
|
344
|
+
finally:
|
|
345
|
+
menu.deleteLater()
|
|
346
|
+
|
|
347
|
+
def action_from_clipboard(self):
|
|
348
|
+
"""Paste from clipboard"""
|
|
349
|
+
clipboard = QApplication.clipboard()
|
|
350
|
+
source = clipboard.mimeData()
|
|
351
|
+
self.handle_clipboard(source)
|
|
352
|
+
|
|
353
|
+
def audio_read_selection(self):
|
|
354
|
+
"""Read selected text (audio)"""
|
|
355
|
+
self.window.controller.audio.read_text(self.textCursor().selectedText())
|
|
356
|
+
|
|
357
|
+
def keyPressEvent(self, event):
|
|
358
|
+
"""
|
|
359
|
+
Key press event
|
|
360
|
+
"""
|
|
361
|
+
handled = False
|
|
362
|
+
key = event.key()
|
|
363
|
+
|
|
364
|
+
if key in (Qt.Key_Return, Qt.Key_Enter):
|
|
365
|
+
mode = self.window.core.config.get('send_mode')
|
|
366
|
+
if mode > 0:
|
|
367
|
+
mods = event.modifiers()
|
|
368
|
+
has_shift_or_ctrl = bool(mods & (Qt.ShiftModifier | Qt.ControlModifier))
|
|
369
|
+
|
|
370
|
+
if mode == 2:
|
|
371
|
+
if has_shift_or_ctrl:
|
|
372
|
+
self.window.controller.chat.input.send_input()
|
|
373
|
+
handled = True
|
|
374
|
+
else:
|
|
375
|
+
if not has_shift_or_ctrl:
|
|
376
|
+
self.window.controller.chat.input.send_input()
|
|
377
|
+
handled = True
|
|
378
|
+
|
|
379
|
+
self.setFocus()
|
|
380
|
+
if handled:
|
|
381
|
+
QTimer.singleShot(0, self.collapse_to_min)
|
|
382
|
+
|
|
383
|
+
elif key == Qt.Key_Escape and self.window.controller.ctx.extra.is_editing():
|
|
384
|
+
self.window.controller.ctx.extra.edit_cancel()
|
|
385
|
+
handled = True
|
|
386
|
+
|
|
387
|
+
if not handled:
|
|
388
|
+
super().keyPressEvent(event)
|
|
389
|
+
|
|
390
|
+
def wheelEvent(self, event):
|
|
391
|
+
"""
|
|
392
|
+
Wheel event: set font size
|
|
393
|
+
|
|
394
|
+
:param event: Event
|
|
395
|
+
"""
|
|
396
|
+
if event.modifiers() & Qt.ControlModifier:
|
|
397
|
+
prev = self.value
|
|
398
|
+
dy = event.angleDelta().y()
|
|
399
|
+
if dy > 0:
|
|
400
|
+
if self.value < self.max_font_size:
|
|
401
|
+
self.value += 1
|
|
402
|
+
else:
|
|
403
|
+
if self.value > self.min_font_size:
|
|
404
|
+
self.value -= 1
|
|
405
|
+
|
|
406
|
+
if self.value != prev:
|
|
407
|
+
self.window.core.config.data['font_size.input'] = self.value
|
|
408
|
+
self.window.core.config.save()
|
|
409
|
+
self.window.controller.ui.update_font_size()
|
|
410
|
+
# Reflow may change number of lines; adjust auto-height next tick
|
|
411
|
+
QTimer.singleShot(0, self._schedule_auto_resize)
|
|
412
|
+
event.accept()
|
|
413
|
+
return
|
|
414
|
+
super().wheelEvent(event)
|
|
415
|
+
|
|
416
|
+
def changeEvent(self, event):
|
|
417
|
+
super().changeEvent(event)
|
|
418
|
+
if event.type() == QEvent.FontChange:
|
|
419
|
+
self._schedule_auto_resize()
|
|
420
|
+
|
|
421
|
+
def resizeEvent(self, event):
|
|
422
|
+
"""Resize event keeps the icon bar in place."""
|
|
423
|
+
super().resizeEvent(event)
|
|
424
|
+
# Recompute on width changes (word wrap may change line count)
|
|
425
|
+
if not self._splitter_resize_in_progress:
|
|
426
|
+
if self.hasFocus():
|
|
427
|
+
self._schedule_auto_resize()
|
|
428
|
+
else:
|
|
429
|
+
# Allow shrinking to minimum when content is single line
|
|
430
|
+
self._schedule_auto_resize(enforce_minimize_if_single=True)
|
|
431
|
+
|
|
432
|
+
# ================== Auto-resize inside QSplitter ==================
|
|
433
|
+
|
|
434
|
+
def _ensure_splitter_hook(self):
|
|
435
|
+
"""Lazy-connect to main splitter to detect manual drags."""
|
|
436
|
+
if self._splitter_connected:
|
|
437
|
+
return
|
|
438
|
+
splitter = self._get_main_splitter()
|
|
439
|
+
if splitter is not None:
|
|
440
|
+
try:
|
|
441
|
+
splitter.splitterMoved.connect(self._on_splitter_moved_by_user)
|
|
442
|
+
self._splitter_connected = True
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
def _on_splitter_moved_by_user(self, pos, index):
|
|
447
|
+
"""Pause auto-resize briefly while the user drags the splitter."""
|
|
448
|
+
self._user_adjusting_splitter = True
|
|
449
|
+
QTimer.singleShot(self._auto_pause_ms_after_user_drag, self._reset_user_adjusting_flag)
|
|
450
|
+
|
|
451
|
+
def _reset_user_adjusting_flag(self):
|
|
452
|
+
self._user_adjusting_splitter = False
|
|
453
|
+
|
|
454
|
+
def _get_main_splitter(self):
|
|
455
|
+
"""Get main vertical splitter from window registry."""
|
|
456
|
+
try:
|
|
457
|
+
return self.window.ui.splitters.get('main.output')
|
|
458
|
+
except Exception:
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def _find_container_in_splitter(self, splitter):
|
|
462
|
+
"""Find the direct child of splitter that contains this ChatInput."""
|
|
463
|
+
if splitter is None:
|
|
464
|
+
return None, -1
|
|
465
|
+
for i in range(splitter.count()):
|
|
466
|
+
w = splitter.widget(i)
|
|
467
|
+
if w and w.isAncestorOf(self):
|
|
468
|
+
return w, i
|
|
469
|
+
return None, -1
|
|
470
|
+
|
|
471
|
+
def _schedule_auto_resize(self, force: bool = False, enforce_minimize_if_single: bool = False):
|
|
472
|
+
"""Schedule auto-resize; multiple calls are coalesced."""
|
|
473
|
+
# Store flags for the next tick
|
|
474
|
+
self._pending_force = getattr(self, "_pending_force", False) or bool(force)
|
|
475
|
+
self._pending_minimize_if_single = getattr(self, "_pending_minimize_if_single", False) or bool(
|
|
476
|
+
enforce_minimize_if_single)
|
|
477
|
+
# Avoid scheduling when splitter drag in progress
|
|
478
|
+
if self._user_adjusting_splitter or self._splitter_resize_in_progress:
|
|
479
|
+
return
|
|
480
|
+
self._ensure_splitter_hook()
|
|
481
|
+
# Debounce to next event loop to ensure document layout is up to date
|
|
482
|
+
if not self._auto_timer.isActive():
|
|
483
|
+
self._auto_timer.start(self._auto_debounce_ms)
|
|
484
|
+
|
|
485
|
+
def _auto_resize_tick(self):
|
|
486
|
+
"""Execute auto-resize once after debounce."""
|
|
487
|
+
force = getattr(self, "_pending_force", False)
|
|
488
|
+
minimize_if_single = getattr(self, "_pending_minimize_if_single", False)
|
|
489
|
+
self._pending_force = False
|
|
490
|
+
self._pending_minimize_if_single = False
|
|
491
|
+
try:
|
|
492
|
+
self._update_auto_height(force=force, minimize_if_single=minimize_if_single)
|
|
493
|
+
except Exception:
|
|
494
|
+
# Never break input pipeline on errors
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
def _document_content_height(self) -> int:
|
|
498
|
+
"""Return QTextDocument layout height in pixels."""
|
|
499
|
+
doc = self.document()
|
|
500
|
+
layout = doc.documentLayout()
|
|
501
|
+
if layout is not None:
|
|
502
|
+
h = layout.documentSize().height()
|
|
503
|
+
else:
|
|
504
|
+
h = doc.size().height()
|
|
505
|
+
return int(math.ceil(h))
|
|
506
|
+
|
|
507
|
+
def _line_spacing(self) -> int:
|
|
508
|
+
"""Return current line spacing for font."""
|
|
509
|
+
return int(math.ceil(self.fontMetrics().lineSpacing()))
|
|
510
|
+
|
|
511
|
+
def _effective_lines(self, doc_h: int, line_h: int, doc_margin: float) -> float:
|
|
512
|
+
"""Rough estimate of visible line count from document height."""
|
|
513
|
+
base = max(0.0, doc_h - 2.0 * float(doc_margin))
|
|
514
|
+
if line_h <= 0:
|
|
515
|
+
return 1.0
|
|
516
|
+
return max(1.0, base / float(line_h))
|
|
517
|
+
|
|
518
|
+
def _min_input_widget_height(self, non_viewport_h: int) -> int:
|
|
519
|
+
"""Height of QTextEdit widget required to fit a single line without scrollbars."""
|
|
520
|
+
line_h = self._line_spacing()
|
|
521
|
+
doc_margin = float(self.document().documentMargin())
|
|
522
|
+
min_viewport_h = int(math.ceil(2.0 * doc_margin + line_h))
|
|
523
|
+
# Respect current minimum size hint to avoid jitter on some styles
|
|
524
|
+
min_hint = max(self.minimumSizeHint().height(), 0)
|
|
525
|
+
return max(min_hint, min_viewport_h + non_viewport_h)
|
|
526
|
+
|
|
527
|
+
def _max_input_widget_height_by_lines(self, non_viewport_h: int) -> int:
|
|
528
|
+
"""Max widget height allowed by line count cap."""
|
|
529
|
+
line_h = self._line_spacing()
|
|
530
|
+
doc_margin = float(self.document().documentMargin())
|
|
531
|
+
max_viewport_h = int(math.ceil(2.0 * doc_margin + self._auto_max_lines * line_h))
|
|
532
|
+
return max_viewport_h + non_viewport_h
|
|
533
|
+
|
|
534
|
+
def _should_shrink_to_min(self, doc_h: int) -> bool:
|
|
535
|
+
"""Decide if we should collapse to minimum (single line or empty)."""
|
|
536
|
+
line_h = self._line_spacing()
|
|
537
|
+
doc_margin = float(self.document().documentMargin())
|
|
538
|
+
threshold = 2.0 * doc_margin + 1.25 * line_h # small slack for layout rounding
|
|
539
|
+
return doc_h <= threshold
|
|
540
|
+
|
|
541
|
+
def _update_auto_height(self, force: bool = False, minimize_if_single: bool = False):
|
|
542
|
+
"""
|
|
543
|
+
Core auto-resize routine:
|
|
544
|
+
- expand only when the input has focus (unless force=True),
|
|
545
|
+
- cap by max lines and 1/4 of main window height,
|
|
546
|
+
- shrink back to minimal only after send or when text is effectively one line.
|
|
547
|
+
"""
|
|
548
|
+
if self._auto_updating or self._splitter_resize_in_progress:
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
splitter = self._get_main_splitter()
|
|
552
|
+
container, idx = self._find_container_in_splitter(splitter)
|
|
553
|
+
if splitter is None or container is None or idx < 0:
|
|
554
|
+
return # Not yet attached to the splitter
|
|
555
|
+
|
|
556
|
+
# Expansion only with focus unless forced
|
|
557
|
+
has_focus = self.hasFocus()
|
|
558
|
+
can_expand = force or has_focus
|
|
559
|
+
if self._user_adjusting_splitter and not force:
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
# Measure current layout and targets
|
|
563
|
+
doc_h = self._document_content_height()
|
|
564
|
+
non_viewport_h = self.height() - self.viewport().height()
|
|
565
|
+
needed_input_h = int(math.ceil(doc_h + non_viewport_h))
|
|
566
|
+
min_input_h = self._min_input_widget_height(non_viewport_h)
|
|
567
|
+
max_input_by_lines = self._max_input_widget_height_by_lines(non_viewport_h)
|
|
568
|
+
|
|
569
|
+
# Container overhead above the inner QTextEdit
|
|
570
|
+
container_overhead = max(0, container.height() - self.height())
|
|
571
|
+
needed_container_h = needed_input_h + container_overhead
|
|
572
|
+
min_container_h = min_input_h + container_overhead
|
|
573
|
+
|
|
574
|
+
# Max cap by window fraction
|
|
575
|
+
try:
|
|
576
|
+
max_container_by_ratio = int(self.window.height() * self._auto_max_ratio)
|
|
577
|
+
except Exception:
|
|
578
|
+
max_container_by_ratio = 0 # fallback disables ratio cap if window unavailable
|
|
579
|
+
|
|
580
|
+
max_container_by_lines = max_input_by_lines + container_overhead
|
|
581
|
+
cap_container_max = max_container_by_lines
|
|
582
|
+
if max_container_by_ratio > 0:
|
|
583
|
+
cap_container_max = min(cap_container_max, max_container_by_ratio)
|
|
584
|
+
|
|
585
|
+
current_sizes = splitter.sizes()
|
|
586
|
+
if idx >= len(current_sizes):
|
|
587
|
+
return
|
|
588
|
+
current_container_h = current_sizes[idx]
|
|
589
|
+
|
|
590
|
+
# Decide on action
|
|
591
|
+
target_container_h = None
|
|
592
|
+
|
|
593
|
+
# Shrink only when requested or effectively single line
|
|
594
|
+
if minimize_if_single or self._should_shrink_to_min(doc_h):
|
|
595
|
+
if current_container_h > min_container_h + 1:
|
|
596
|
+
target_container_h = min_container_h
|
|
597
|
+
|
|
598
|
+
# Expand if focused (or forced), but only up to caps
|
|
599
|
+
elif can_expand:
|
|
600
|
+
desired = min(needed_container_h, cap_container_max)
|
|
601
|
+
if desired > current_container_h + 1:
|
|
602
|
+
target_container_h = desired
|
|
603
|
+
|
|
604
|
+
# Apply if needed
|
|
605
|
+
if target_container_h is None:
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
total = sum(current_sizes)
|
|
609
|
+
# Clamp to splitter total height
|
|
610
|
+
target_container_h = max(0, min(target_container_h, total))
|
|
611
|
+
|
|
612
|
+
if abs(target_container_h - current_container_h) <= 1:
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
# Prepare new sizes (2 widgets expected: output at 0, input at 1)
|
|
616
|
+
new_sizes = list(current_sizes)
|
|
617
|
+
# Distribute delta to other panes; here we have exactly 2
|
|
618
|
+
other_total = total - current_container_h
|
|
619
|
+
new_other_total = total - target_container_h
|
|
620
|
+
if other_total <= 0:
|
|
621
|
+
# degenerate case; just set directly
|
|
622
|
+
pass
|
|
623
|
+
else:
|
|
624
|
+
# Scale other widgets proportionally
|
|
625
|
+
scale = new_other_total / float(other_total) if other_total > 0 else 1.0
|
|
626
|
+
for i in range(len(new_sizes)):
|
|
627
|
+
if i != idx:
|
|
628
|
+
new_sizes[i] = int(round(new_sizes[i] * scale))
|
|
629
|
+
new_sizes[idx] = int(target_container_h)
|
|
630
|
+
|
|
631
|
+
# Final clamp to preserve sum
|
|
632
|
+
diff = total - sum(new_sizes)
|
|
633
|
+
if diff != 0 and len(new_sizes) > 0:
|
|
634
|
+
# Adjust the first non-target pane to fix rounding
|
|
635
|
+
for i in range(len(new_sizes)):
|
|
636
|
+
if i != idx:
|
|
637
|
+
new_sizes[i] += diff
|
|
638
|
+
break
|
|
639
|
+
|
|
640
|
+
self._splitter_resize_in_progress = True
|
|
641
|
+
try:
|
|
642
|
+
old_block = splitter.blockSignals(True)
|
|
643
|
+
splitter.setSizes(new_sizes)
|
|
644
|
+
splitter.blockSignals(old_block)
|
|
645
|
+
finally:
|
|
646
|
+
self._splitter_resize_in_progress = False
|
|
647
|
+
|
|
648
|
+
# Keep stored sizes in sync with app expectations (mirrors ChatMain.on_splitter_moved)
|
|
649
|
+
try:
|
|
650
|
+
tabs = self.window.ui.tabs
|
|
651
|
+
if "input" in tabs:
|
|
652
|
+
t_idx = tabs['input'].currentIndex()
|
|
653
|
+
if t_idx != 0:
|
|
654
|
+
self.window.controller.ui.splitter_output_size_files = new_sizes
|
|
655
|
+
else:
|
|
656
|
+
self.window.controller.ui.splitter_output_size_input = new_sizes
|
|
657
|
+
except Exception:
|
|
658
|
+
pass
|
|
659
|
+
|
|
660
|
+
self._last_target_container_h = target_container_h
|
|
661
|
+
|
|
662
|
+
def collapse_to_min(self):
|
|
663
|
+
"""Public helper to collapse input area to minimal height."""
|
|
664
|
+
self._schedule_auto_resize(force=True, enforce_minimize_if_single=True)
|