pygpt-net 2.7.3__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.
Files changed (46) hide show
  1. pygpt_net/CHANGELOG.txt +8 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +382 -350
  4. pygpt_net/controller/chat/attachment.py +5 -1
  5. pygpt_net/controller/chat/image.py +15 -3
  6. pygpt_net/controller/files/files.py +3 -1
  7. pygpt_net/controller/layout/layout.py +2 -2
  8. pygpt_net/controller/theme/nodes.py +2 -1
  9. pygpt_net/controller/ui/mode.py +5 -1
  10. pygpt_net/controller/ui/ui.py +17 -2
  11. pygpt_net/core/filesystem/url.py +4 -1
  12. pygpt_net/core/render/web/helpers.py +5 -0
  13. pygpt_net/data/config/config.json +3 -4
  14. pygpt_net/data/config/models.json +3 -3
  15. pygpt_net/data/config/settings.json +0 -14
  16. pygpt_net/data/css/web-blocks.css +3 -0
  17. pygpt_net/data/css/web-chatgpt.css +3 -0
  18. pygpt_net/data/locale/locale.de.ini +2 -0
  19. pygpt_net/data/locale/locale.en.ini +3 -1
  20. pygpt_net/data/locale/locale.es.ini +2 -0
  21. pygpt_net/data/locale/locale.fr.ini +2 -0
  22. pygpt_net/data/locale/locale.it.ini +2 -0
  23. pygpt_net/data/locale/locale.pl.ini +2 -0
  24. pygpt_net/data/locale/locale.uk.ini +2 -0
  25. pygpt_net/data/locale/locale.zh.ini +2 -0
  26. pygpt_net/launcher.py +115 -55
  27. pygpt_net/preload.py +243 -0
  28. pygpt_net/provider/api/google/image.py +74 -6
  29. pygpt_net/provider/api/google/video.py +9 -4
  30. pygpt_net/provider/api/openai/image.py +42 -19
  31. pygpt_net/provider/api/openai/video.py +27 -2
  32. pygpt_net/provider/api/x_ai/image.py +25 -2
  33. pygpt_net/provider/core/config/patch.py +7 -0
  34. pygpt_net/ui/layout/chat/input.py +20 -2
  35. pygpt_net/ui/layout/chat/painter.py +6 -4
  36. pygpt_net/ui/layout/toolbox/image.py +5 -5
  37. pygpt_net/ui/layout/toolbox/video.py +5 -4
  38. pygpt_net/ui/main.py +84 -3
  39. pygpt_net/ui/widget/dialog/base.py +3 -10
  40. pygpt_net/ui/widget/option/combo.py +119 -1
  41. pygpt_net/ui/widget/textarea/input_extra.py +664 -0
  42. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/METADATA +17 -9
  43. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/RECORD +46 -44
  44. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/LICENSE +0 -0
  45. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/WHEEL +0 -0
  46. {pygpt_net-2.7.3.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)