pygpt-net 2.7.9__py3-none-any.whl → 2.7.10__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 +9 -0
- pygpt_net/LICENSE +1 -1
- pygpt_net/__init__.py +3 -3
- pygpt_net/config.py +15 -1
- pygpt_net/controller/chat/common.py +5 -4
- pygpt_net/controller/chat/image.py +3 -3
- pygpt_net/controller/chat/stream.py +76 -41
- pygpt_net/controller/chat/stream_worker.py +3 -3
- pygpt_net/controller/ctx/extra.py +3 -1
- pygpt_net/controller/dialogs/debug.py +37 -8
- pygpt_net/controller/kernel/kernel.py +3 -7
- pygpt_net/controller/lang/custom.py +25 -12
- pygpt_net/controller/lang/lang.py +45 -3
- pygpt_net/controller/lang/mapping.py +15 -2
- pygpt_net/controller/notepad/notepad.py +68 -25
- pygpt_net/controller/presets/editor.py +5 -1
- pygpt_net/controller/presets/presets.py +17 -5
- pygpt_net/controller/theme/theme.py +11 -2
- pygpt_net/controller/ui/tabs.py +1 -1
- pygpt_net/core/ctx/output.py +38 -12
- pygpt_net/core/db/database.py +4 -2
- pygpt_net/core/debug/console/console.py +30 -2
- pygpt_net/core/debug/context.py +2 -1
- pygpt_net/core/debug/ui.py +26 -4
- pygpt_net/core/filesystem/filesystem.py +6 -2
- pygpt_net/core/notepad/notepad.py +2 -2
- pygpt_net/core/tabs/tabs.py +79 -19
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +12 -0
- pygpt_net/data/locale/locale.ar.ini +1833 -0
- pygpt_net/data/locale/locale.bg.ini +1833 -0
- pygpt_net/data/locale/locale.cs.ini +1833 -0
- pygpt_net/data/locale/locale.da.ini +1833 -0
- pygpt_net/data/locale/locale.de.ini +4 -1
- pygpt_net/data/locale/locale.en.ini +70 -67
- pygpt_net/data/locale/locale.es.ini +4 -1
- pygpt_net/data/locale/locale.fi.ini +1833 -0
- pygpt_net/data/locale/locale.fr.ini +4 -1
- pygpt_net/data/locale/locale.he.ini +1833 -0
- pygpt_net/data/locale/locale.hi.ini +1833 -0
- pygpt_net/data/locale/locale.hu.ini +1833 -0
- pygpt_net/data/locale/locale.it.ini +4 -1
- pygpt_net/data/locale/locale.ja.ini +1833 -0
- pygpt_net/data/locale/locale.ko.ini +1833 -0
- pygpt_net/data/locale/locale.nl.ini +1833 -0
- pygpt_net/data/locale/locale.no.ini +1833 -0
- pygpt_net/data/locale/locale.pl.ini +5 -2
- pygpt_net/data/locale/locale.pt.ini +1833 -0
- pygpt_net/data/locale/locale.ro.ini +1833 -0
- pygpt_net/data/locale/locale.ru.ini +1833 -0
- pygpt_net/data/locale/locale.sk.ini +1833 -0
- pygpt_net/data/locale/locale.sv.ini +1833 -0
- pygpt_net/data/locale/locale.tr.ini +1833 -0
- pygpt_net/data/locale/locale.uk.ini +4 -1
- pygpt_net/data/locale/locale.zh.ini +4 -1
- pygpt_net/item/notepad.py +8 -2
- pygpt_net/migrations/Version20260121190000.py +25 -0
- pygpt_net/migrations/Version20260122140000.py +25 -0
- pygpt_net/migrations/__init__.py +5 -1
- pygpt_net/preload.py +246 -3
- pygpt_net/provider/api/__init__.py +16 -2
- pygpt_net/provider/api/anthropic/__init__.py +21 -7
- pygpt_net/provider/api/google/__init__.py +21 -7
- pygpt_net/provider/api/google/image.py +89 -2
- pygpt_net/provider/api/google/video.py +2 -2
- pygpt_net/provider/api/openai/__init__.py +26 -11
- pygpt_net/provider/api/openai/image.py +79 -3
- pygpt_net/provider/api/openai/responses.py +11 -31
- pygpt_net/provider/api/openai/video.py +2 -2
- pygpt_net/provider/api/x_ai/__init__.py +21 -7
- pygpt_net/provider/core/notepad/db_sqlite/storage.py +53 -10
- pygpt_net/tools/agent_builder/ui/dialogs.py +2 -1
- pygpt_net/tools/audio_transcriber/ui/dialogs.py +2 -1
- pygpt_net/tools/code_interpreter/ui/dialogs.py +2 -1
- pygpt_net/tools/html_canvas/ui/dialogs.py +2 -1
- pygpt_net/tools/image_viewer/ui/dialogs.py +3 -5
- pygpt_net/tools/indexer/ui/dialogs.py +2 -1
- pygpt_net/tools/media_player/ui/dialogs.py +2 -1
- pygpt_net/tools/translator/ui/dialogs.py +2 -1
- pygpt_net/tools/translator/ui/widgets.py +6 -2
- pygpt_net/ui/dialog/about.py +2 -2
- pygpt_net/ui/dialog/db.py +2 -1
- pygpt_net/ui/dialog/debug.py +169 -6
- pygpt_net/ui/dialog/logger.py +6 -2
- pygpt_net/ui/dialog/models.py +36 -3
- pygpt_net/ui/dialog/preset.py +5 -1
- pygpt_net/ui/dialog/remote_store.py +2 -1
- pygpt_net/ui/main.py +3 -2
- pygpt_net/ui/widget/dialog/editor_file.py +2 -1
- pygpt_net/ui/widget/lists/debug.py +12 -7
- pygpt_net/ui/widget/option/checkbox.py +2 -8
- pygpt_net/ui/widget/option/combo.py +10 -2
- pygpt_net/ui/widget/textarea/console.py +156 -7
- pygpt_net/ui/widget/textarea/highlight.py +66 -0
- pygpt_net/ui/widget/textarea/input.py +624 -57
- pygpt_net/ui/widget/textarea/notepad.py +294 -27
- {pygpt_net-2.7.9.dist-info → pygpt_net-2.7.10.dist-info}/LICENSE +1 -1
- {pygpt_net-2.7.9.dist-info → pygpt_net-2.7.10.dist-info}/METADATA +11 -64
- {pygpt_net-2.7.9.dist-info → pygpt_net-2.7.10.dist-info}/RECORD +102 -81
- {pygpt_net-2.7.9.dist-info → pygpt_net-2.7.10.dist-info}/WHEEL +0 -0
- {pygpt_net-2.7.9.dist-info → pygpt_net-2.7.10.dist-info}/entry_points.txt +0 -0
|
@@ -6,15 +6,15 @@
|
|
|
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.22 14:40:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
|
-
from typing import Optional
|
|
12
|
+
from typing import Optional, Union, Tuple
|
|
13
13
|
import math
|
|
14
14
|
import os
|
|
15
15
|
|
|
16
16
|
from PySide6.QtCore import Qt, QSize, QTimer, QEvent
|
|
17
|
-
from PySide6.QtGui import QAction, QIcon, QImage
|
|
17
|
+
from PySide6.QtGui import QAction, QIcon, QImage, QTextCursor
|
|
18
18
|
from PySide6.QtWidgets import (
|
|
19
19
|
QTextEdit,
|
|
20
20
|
QApplication,
|
|
@@ -49,6 +49,7 @@ class ChatInput(QTextEdit):
|
|
|
49
49
|
super().__init__(window)
|
|
50
50
|
self.window = window
|
|
51
51
|
self.setAcceptRichText(False)
|
|
52
|
+
self.setPlaceholderText(trans("input.placeholder"))
|
|
52
53
|
self.setFocus()
|
|
53
54
|
self.value = self.window.core.config.data['font_size.input']
|
|
54
55
|
self.max_font_size = 42
|
|
@@ -68,12 +69,30 @@ class ChatInput(QTextEdit):
|
|
|
68
69
|
self._icon_size = QSize(18, 18) # icon size (matches original)
|
|
69
70
|
self._btn_size = QSize(24, 24) # button size (w x h), matches QPushButton
|
|
70
71
|
|
|
72
|
+
# Independent sizes for the right-bottom icon bar
|
|
73
|
+
self._icon_size_right = QSize(20, 20) # slightly larger by default
|
|
74
|
+
self._btn_size_right = QSize(26, 26) # slightly larger by default
|
|
75
|
+
|
|
76
|
+
# Independent margins/spacing/offset for the right-bottom bar
|
|
77
|
+
self._icons_margin_right = 6
|
|
78
|
+
self._icons_spacing_right = 4
|
|
79
|
+
self._icons_offset_x_right = 0
|
|
80
|
+
self._icons_offset_y_right = 4 # position the right bar 4px lower by default
|
|
81
|
+
|
|
71
82
|
# Storage for icon buttons and metadata
|
|
72
83
|
self._icons = {} # key -> QPushButton
|
|
73
84
|
self._icon_meta = {} # key -> {"icon": QIcon, "alt_icon": Optional[QIcon], "tooltip": str, "alt_tooltip": Optional[str], "active": bool}
|
|
74
85
|
self._icon_order = [] # rendering order
|
|
75
86
|
|
|
87
|
+
# Storage for right-bottom icon buttons and metadata
|
|
88
|
+
self._icons_right = {} # key -> QPushButton
|
|
89
|
+
self._icon_meta_right = {} # key -> meta as above
|
|
90
|
+
self._icon_order_right = [] # rendering order for right bar
|
|
91
|
+
|
|
76
92
|
self._init_icon_bar()
|
|
93
|
+
# Initialize the bottom-right icon bar (independent from the left one)
|
|
94
|
+
self._init_icon_bar_right()
|
|
95
|
+
|
|
77
96
|
# Add a "+" button in the top-left corner to add attachments
|
|
78
97
|
self.add_icon(
|
|
79
98
|
key="attach",
|
|
@@ -83,7 +102,8 @@ class ChatInput(QTextEdit):
|
|
|
83
102
|
visible=True,
|
|
84
103
|
)
|
|
85
104
|
# Add a microphone button (hidden by default; shown when audio input is enabled)
|
|
86
|
-
|
|
105
|
+
# Placed on the bottom-right icon bar
|
|
106
|
+
self.add_right_icon(
|
|
87
107
|
key="mic",
|
|
88
108
|
icon=self.ICON_MIC_ON,
|
|
89
109
|
alt_icon=self.ICON_MIC_OFF,
|
|
@@ -104,6 +124,7 @@ class ChatInput(QTextEdit):
|
|
|
104
124
|
)
|
|
105
125
|
|
|
106
126
|
# Apply initial margins (top padding + left space for icons)
|
|
127
|
+
# Also reserve right space for bottom-right icons; bottom margin stays 0
|
|
107
128
|
self._apply_margins()
|
|
108
129
|
|
|
109
130
|
# ---- Auto-resize config (input in splitter) ----
|
|
@@ -136,9 +157,19 @@ class ChatInput(QTextEdit):
|
|
|
136
157
|
# Drag & Drop: add as attachments; do not insert file paths into text
|
|
137
158
|
self._dnd_handler = AttachmentDropHandler(self.window, self, policy=AttachmentDropHandler.INPUT_MIX)
|
|
138
159
|
|
|
160
|
+
# --- History navigation (input prompts) ---
|
|
161
|
+
# Stores sent prompts and allows keyboard navigation through the history.
|
|
162
|
+
self._history = [] # list[str]
|
|
163
|
+
self._history_limit = 30
|
|
164
|
+
self._history_index = -1 # -1 when not navigating; otherwise index of current history item
|
|
165
|
+
self._history_active = False
|
|
166
|
+
self._history_saved_current = "" # snapshot of the current typed text before entering history nav
|
|
167
|
+
|
|
139
168
|
def _on_text_changed_tokens(self):
|
|
140
169
|
"""Schedule token count update with debounce."""
|
|
141
170
|
self._tokens_timer.start()
|
|
171
|
+
# Keep auto-height in sync with content growth/shrink on every edit
|
|
172
|
+
self._schedule_auto_resize()
|
|
142
173
|
|
|
143
174
|
def _apply_text_top_padding(self):
|
|
144
175
|
"""Apply extra top padding inside the text area by using viewport margins."""
|
|
@@ -428,6 +459,29 @@ class ChatInput(QTextEdit):
|
|
|
428
459
|
"""
|
|
429
460
|
handled = False
|
|
430
461
|
key = event.key()
|
|
462
|
+
mods = event.modifiers()
|
|
463
|
+
|
|
464
|
+
# --- History navigation and recall ---
|
|
465
|
+
# Ctrl/Command + Up/Down navigates history regardless of current text.
|
|
466
|
+
if key in (Qt.Key_Up, Qt.Key_Down) and (mods & (Qt.ControlModifier | Qt.MetaModifier)):
|
|
467
|
+
if key == Qt.Key_Up:
|
|
468
|
+
self._history_navigate(-1)
|
|
469
|
+
else:
|
|
470
|
+
self._history_navigate(+1)
|
|
471
|
+
handled = True
|
|
472
|
+
|
|
473
|
+
# Up with empty input and no modifiers recalls the last sent prompt.
|
|
474
|
+
elif key == Qt.Key_Up and mods == Qt.NoModifier:
|
|
475
|
+
if self._is_effectively_empty() and self._history:
|
|
476
|
+
if not self._history_active:
|
|
477
|
+
self._history_begin()
|
|
478
|
+
# Start from sentinel and move one step up to the latest entry
|
|
479
|
+
self._history_index = len(self._history)
|
|
480
|
+
self._history_navigate(-1)
|
|
481
|
+
handled = True
|
|
482
|
+
|
|
483
|
+
if handled:
|
|
484
|
+
return
|
|
431
485
|
|
|
432
486
|
if key in (Qt.Key_Return, Qt.Key_Enter):
|
|
433
487
|
mode = self.window.core.config.get('send_mode')
|
|
@@ -437,11 +491,15 @@ class ChatInput(QTextEdit):
|
|
|
437
491
|
|
|
438
492
|
if mode == 2:
|
|
439
493
|
if has_shift_or_ctrl:
|
|
494
|
+
text_before_send = self.toPlainText()
|
|
440
495
|
self.window.controller.chat.input.send_input()
|
|
496
|
+
self._on_prompt_sent(text_before_send)
|
|
441
497
|
handled = True
|
|
442
498
|
else:
|
|
443
499
|
if not has_shift_or_ctrl:
|
|
500
|
+
text_before_send = self.toPlainText()
|
|
444
501
|
self.window.controller.chat.input.send_input()
|
|
502
|
+
self._on_prompt_sent(text_before_send)
|
|
445
503
|
handled = True
|
|
446
504
|
|
|
447
505
|
self.setFocus()
|
|
@@ -540,6 +598,31 @@ class ChatInput(QTextEdit):
|
|
|
540
598
|
self._update_icon_bar_geometry()
|
|
541
599
|
self._apply_margins()
|
|
542
600
|
|
|
601
|
+
# -------------------- Right-bottom icon bar --------------------
|
|
602
|
+
# Independent bar anchored at the bottom-right corner of the input widget.
|
|
603
|
+
|
|
604
|
+
def _init_icon_bar_right(self):
|
|
605
|
+
"""Create the right-side icon bar pinned in the bottom-right corner."""
|
|
606
|
+
self._icon_bar_right = QWidget(self)
|
|
607
|
+
self._icon_bar_right.setObjectName("chatInputIconBarRight")
|
|
608
|
+
self._icon_bar_right.setAttribute(Qt.WA_StyledBackground, True)
|
|
609
|
+
self._icon_bar_right.setAutoFillBackground(False)
|
|
610
|
+
self._icon_bar_right.setStyleSheet("""
|
|
611
|
+
#chatInputIconBarRight { background-color: transparent; }
|
|
612
|
+
""")
|
|
613
|
+
|
|
614
|
+
layout = QHBoxLayout(self._icon_bar_right)
|
|
615
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
616
|
+
layout.setSpacing(self._icons_spacing_right)
|
|
617
|
+
self._icon_bar_right.setLayout(layout)
|
|
618
|
+
|
|
619
|
+
self._icon_bar_right.setFixedHeight(self._btn_size_right.height())
|
|
620
|
+
self._icon_bar_right.show()
|
|
621
|
+
|
|
622
|
+
self._reposition_icon_bar_right()
|
|
623
|
+
self._update_icon_bar_geometry_right()
|
|
624
|
+
self._apply_margins()
|
|
625
|
+
|
|
543
626
|
# ---- Public API for icons ----
|
|
544
627
|
|
|
545
628
|
def add_icon(
|
|
@@ -651,15 +734,120 @@ class ChatInput(QTextEdit):
|
|
|
651
734
|
alt_tooltip = it[6] if len(it) > 6 else None
|
|
652
735
|
self.add_icon(key, icon, tooltip, callback, visible, alt_icon, alt_tooltip)
|
|
653
736
|
|
|
737
|
+
# ---- Public API for icons (RIGHT-BOTTOM) ----
|
|
738
|
+
|
|
739
|
+
def add_right_icon(
|
|
740
|
+
self,
|
|
741
|
+
key: str,
|
|
742
|
+
icon: QIcon,
|
|
743
|
+
tooltip: str = "",
|
|
744
|
+
callback=None,
|
|
745
|
+
visible: bool = True,
|
|
746
|
+
alt_icon: Optional[QIcon] = None,
|
|
747
|
+
alt_tooltip: Optional[str] = None,
|
|
748
|
+
) -> QPushButton:
|
|
749
|
+
"""
|
|
750
|
+
Add a new icon button to the right-bottom bar.
|
|
751
|
+
|
|
752
|
+
:param key: unique identifier for the icon
|
|
753
|
+
:param icon: default QIcon
|
|
754
|
+
:param tooltip: default tooltip text
|
|
755
|
+
:param callback: callable executed on click
|
|
756
|
+
:param visible: initial visibility (True=shown, False=hidden)
|
|
757
|
+
:param alt_icon: optional alternate icon
|
|
758
|
+
:param alt_tooltip: optional alternate tooltip text
|
|
759
|
+
:return: the created QPushButton (or existing one if key already present)
|
|
760
|
+
"""
|
|
761
|
+
if key in self._icons_right:
|
|
762
|
+
btn = self._icons_right[key]
|
|
763
|
+
meta = self._icon_meta_right.get(key, {})
|
|
764
|
+
meta.update({
|
|
765
|
+
"icon": icon or meta.get("icon"),
|
|
766
|
+
"alt_icon": alt_icon if alt_icon is not None else meta.get("alt_icon"),
|
|
767
|
+
"tooltip": tooltip or meta.get("tooltip", key),
|
|
768
|
+
"alt_tooltip": alt_tooltip if alt_tooltip is not None else meta.get("alt_tooltip"),
|
|
769
|
+
})
|
|
770
|
+
self._icon_meta_right[key] = meta
|
|
771
|
+
btn.setIcon(meta["icon"])
|
|
772
|
+
btn.setToolTip(meta["tooltip"])
|
|
773
|
+
if callback is not None:
|
|
774
|
+
try:
|
|
775
|
+
btn.clicked.disconnect()
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
btn.clicked.connect(callback)
|
|
779
|
+
btn.setHidden(not visible)
|
|
780
|
+
self._rebuild_icon_layout_right()
|
|
781
|
+
self._update_icon_bar_geometry_right()
|
|
782
|
+
self._apply_margins()
|
|
783
|
+
return btn
|
|
784
|
+
|
|
785
|
+
btn = QPushButton(self._icon_bar_right)
|
|
786
|
+
btn.setObjectName(f"chatInputIconBtnRight_{key}")
|
|
787
|
+
btn.setIcon(icon)
|
|
788
|
+
btn.setIconSize(self._icon_size_right)
|
|
789
|
+
btn.setFixedSize(self._btn_size_right)
|
|
790
|
+
btn.setCursor(Qt.PointingHandCursor)
|
|
791
|
+
btn.setToolTip(tooltip or key)
|
|
792
|
+
btn.setFocusPolicy(Qt.NoFocus)
|
|
793
|
+
btn.setFlat(True)
|
|
794
|
+
btn.setText("")
|
|
795
|
+
|
|
796
|
+
if callback is not None:
|
|
797
|
+
btn.clicked.connect(callback)
|
|
798
|
+
|
|
799
|
+
self._icons_right[key] = btn
|
|
800
|
+
self._icon_order_right.append(key)
|
|
801
|
+
self._icon_meta_right[key] = {
|
|
802
|
+
"icon": icon,
|
|
803
|
+
"alt_icon": alt_icon,
|
|
804
|
+
"tooltip": tooltip or key,
|
|
805
|
+
"alt_tooltip": alt_tooltip,
|
|
806
|
+
"active": False,
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
self._apply_icon_visual(key)
|
|
810
|
+
btn.setHidden(not visible)
|
|
811
|
+
|
|
812
|
+
self._rebuild_icon_layout_right()
|
|
813
|
+
self._update_icon_bar_geometry_right()
|
|
814
|
+
self._apply_margins()
|
|
815
|
+
return btn
|
|
816
|
+
|
|
817
|
+
def add_right_icons(self, items):
|
|
818
|
+
"""Add multiple right-bottom icons at once."""
|
|
819
|
+
for it in items:
|
|
820
|
+
if isinstance(it, dict):
|
|
821
|
+
self.add_right_icon(
|
|
822
|
+
key=it["key"],
|
|
823
|
+
icon=it["icon"],
|
|
824
|
+
tooltip=it.get("tooltip", ""),
|
|
825
|
+
callback=it.get("callback"),
|
|
826
|
+
visible=it.get("visible", True),
|
|
827
|
+
alt_icon=it.get("alt_icon"),
|
|
828
|
+
alt_tooltip=it.get("alt_tooltip"),
|
|
829
|
+
)
|
|
830
|
+
else:
|
|
831
|
+
key, icon = it[0], it[1]
|
|
832
|
+
tooltip = it[2] if len(it) > 2 else ""
|
|
833
|
+
callback = it[3] if len(it) > 3 else None
|
|
834
|
+
visible = it[4] if len(it) > 4 else True
|
|
835
|
+
alt_icon = it[5] if len(it) > 5 else None
|
|
836
|
+
alt_tooltip = it[6] if len(it) > 6 else None
|
|
837
|
+
self.add_right_icon(key, icon, tooltip, callback, visible, alt_icon, alt_tooltip)
|
|
838
|
+
|
|
839
|
+
# ---- Cross-bar helpers (operate on both bars where applicable) ----
|
|
840
|
+
|
|
654
841
|
def remove_icon(self, key: str):
|
|
655
842
|
"""
|
|
656
843
|
Remove an icon from the bar.
|
|
657
844
|
|
|
658
845
|
:param key: icon key
|
|
659
846
|
"""
|
|
847
|
+
# Left bar
|
|
660
848
|
btn = self._icons.pop(key, None)
|
|
661
|
-
self._icon_meta.pop(key, None)
|
|
662
849
|
if btn is not None:
|
|
850
|
+
self._icon_meta.pop(key, None)
|
|
663
851
|
try:
|
|
664
852
|
self._icon_order.remove(key)
|
|
665
853
|
except ValueError:
|
|
@@ -669,6 +857,21 @@ class ChatInput(QTextEdit):
|
|
|
669
857
|
self._rebuild_icon_layout()
|
|
670
858
|
self._update_icon_bar_geometry()
|
|
671
859
|
self._apply_margins()
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
# Right-bottom bar
|
|
863
|
+
btn = self._icons_right.pop(key, None)
|
|
864
|
+
if btn is not None:
|
|
865
|
+
self._icon_meta_right.pop(key, None)
|
|
866
|
+
try:
|
|
867
|
+
self._icon_order_right.remove(key)
|
|
868
|
+
except ValueError:
|
|
869
|
+
pass
|
|
870
|
+
btn.setParent(None)
|
|
871
|
+
btn.deleteLater()
|
|
872
|
+
self._rebuild_icon_layout_right()
|
|
873
|
+
self._update_icon_bar_geometry_right()
|
|
874
|
+
self._apply_margins()
|
|
672
875
|
|
|
673
876
|
def set_icon_visible(self, key: str, visible: bool):
|
|
674
877
|
"""
|
|
@@ -678,11 +881,16 @@ class ChatInput(QTextEdit):
|
|
|
678
881
|
:param visible: True to show, False to hide
|
|
679
882
|
"""
|
|
680
883
|
btn = self._icons.get(key)
|
|
681
|
-
if
|
|
884
|
+
if btn:
|
|
885
|
+
btn.setHidden(not visible)
|
|
886
|
+
self._update_icon_bar_geometry()
|
|
887
|
+
self._apply_margins()
|
|
682
888
|
return
|
|
683
|
-
btn.
|
|
684
|
-
|
|
685
|
-
|
|
889
|
+
btn = self._icons_right.get(key)
|
|
890
|
+
if btn:
|
|
891
|
+
btn.setHidden(not visible)
|
|
892
|
+
self._update_icon_bar_geometry_right()
|
|
893
|
+
self._apply_margins()
|
|
686
894
|
|
|
687
895
|
def toggle_icon(self, key: str):
|
|
688
896
|
"""
|
|
@@ -691,11 +899,16 @@ class ChatInput(QTextEdit):
|
|
|
691
899
|
:param key: icon key
|
|
692
900
|
"""
|
|
693
901
|
btn = self._icons.get(key)
|
|
694
|
-
if
|
|
902
|
+
if btn:
|
|
903
|
+
btn.setHidden(not btn.isHidden())
|
|
904
|
+
self._update_icon_bar_geometry()
|
|
905
|
+
self._apply_margins()
|
|
695
906
|
return
|
|
696
|
-
btn
|
|
697
|
-
|
|
698
|
-
|
|
907
|
+
btn = self._icons_right.get(key)
|
|
908
|
+
if btn:
|
|
909
|
+
btn.setHidden(not btn.isHidden())
|
|
910
|
+
self._update_icon_bar_geometry_right()
|
|
911
|
+
self._apply_margins()
|
|
699
912
|
|
|
700
913
|
def is_icon_visible(self, key: str) -> bool:
|
|
701
914
|
"""
|
|
@@ -703,7 +916,7 @@ class ChatInput(QTextEdit):
|
|
|
703
916
|
|
|
704
917
|
:param key: icon key
|
|
705
918
|
"""
|
|
706
|
-
btn = self._icons.get(key)
|
|
919
|
+
btn = self._icons.get(key) or self._icons_right.get(key)
|
|
707
920
|
return bool(btn and not btn.isHidden())
|
|
708
921
|
|
|
709
922
|
def set_icon_order(self, keys):
|
|
@@ -727,6 +940,27 @@ class ChatInput(QTextEdit):
|
|
|
727
940
|
self._update_icon_bar_geometry()
|
|
728
941
|
self._apply_margins()
|
|
729
942
|
|
|
943
|
+
def set_right_icon_order(self, keys):
|
|
944
|
+
"""
|
|
945
|
+
Set rendering order for RIGHT-BOTTOM icons by a list of keys.
|
|
946
|
+
Icons not listed keep their relative order at the end.
|
|
947
|
+
|
|
948
|
+
:param keys: list of icon keys in desired order
|
|
949
|
+
"""
|
|
950
|
+
new_order = []
|
|
951
|
+
seen = set()
|
|
952
|
+
for k in keys:
|
|
953
|
+
if k in self._icons_right and k not in seen:
|
|
954
|
+
new_order.append(k)
|
|
955
|
+
seen.add(k)
|
|
956
|
+
for k in self._icon_order_right:
|
|
957
|
+
if k not in seen and k in self._icons_right:
|
|
958
|
+
new_order.append(k)
|
|
959
|
+
self._icon_order_right = new_order
|
|
960
|
+
self._rebuild_icon_layout_right()
|
|
961
|
+
self._update_icon_bar_geometry_right()
|
|
962
|
+
self._apply_margins()
|
|
963
|
+
|
|
730
964
|
# ---- Runtime icon swap / state API ----
|
|
731
965
|
|
|
732
966
|
def set_icon_state(self, key: str, active: bool):
|
|
@@ -738,12 +972,17 @@ class ChatInput(QTextEdit):
|
|
|
738
972
|
:param key: icon key
|
|
739
973
|
:param active: True to show alt icon, False for base icon
|
|
740
974
|
"""
|
|
741
|
-
if key
|
|
975
|
+
if key in self._icons:
|
|
976
|
+
meta = self._icon_meta.get(key, {})
|
|
977
|
+
meta["active"] = bool(active)
|
|
978
|
+
self._icon_meta[key] = meta
|
|
979
|
+
self._apply_icon_visual(key)
|
|
742
980
|
return
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
981
|
+
if key in self._icons_right:
|
|
982
|
+
meta = self._icon_meta_right.get(key, {})
|
|
983
|
+
meta["active"] = bool(active)
|
|
984
|
+
self._icon_meta_right[key] = meta
|
|
985
|
+
self._apply_icon_visual(key)
|
|
747
986
|
|
|
748
987
|
def toggle_icon_state(self, key: str) -> bool:
|
|
749
988
|
"""
|
|
@@ -752,11 +991,15 @@ class ChatInput(QTextEdit):
|
|
|
752
991
|
:param key: icon key
|
|
753
992
|
:return: new active state (True if alt icon is now shown)
|
|
754
993
|
"""
|
|
755
|
-
if key
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
994
|
+
if key in self._icons:
|
|
995
|
+
current = bool(self._icon_meta.get(key, {}).get("active", False))
|
|
996
|
+
self.set_icon_state(key, not current)
|
|
997
|
+
return not current
|
|
998
|
+
if key in self._icons_right:
|
|
999
|
+
current = bool(self._icon_meta_right.get(key, {}).get("active", False))
|
|
1000
|
+
self.set_icon_state(key, not current)
|
|
1001
|
+
return not current
|
|
1002
|
+
return False
|
|
760
1003
|
|
|
761
1004
|
def set_icon_pixmap(self, key: str, icon: QIcon):
|
|
762
1005
|
"""
|
|
@@ -765,12 +1008,17 @@ class ChatInput(QTextEdit):
|
|
|
765
1008
|
:param key: icon key
|
|
766
1009
|
:param icon: new QIcon
|
|
767
1010
|
"""
|
|
768
|
-
if key
|
|
1011
|
+
if key in self._icons:
|
|
1012
|
+
meta = self._icon_meta.get(key, {})
|
|
1013
|
+
meta["icon"] = icon
|
|
1014
|
+
self._icon_meta[key] = meta
|
|
1015
|
+
self._apply_icon_visual(key)
|
|
769
1016
|
return
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1017
|
+
if key in self._icons_right:
|
|
1018
|
+
meta = self._icon_meta_right.get(key, {})
|
|
1019
|
+
meta["icon"] = icon
|
|
1020
|
+
self._icon_meta_right[key] = meta
|
|
1021
|
+
self._apply_icon_visual(key)
|
|
774
1022
|
|
|
775
1023
|
def set_icon_alt(self, key: str, alt_icon: Optional[QIcon], alt_tooltip: Optional[str] = None):
|
|
776
1024
|
"""
|
|
@@ -780,14 +1028,21 @@ class ChatInput(QTextEdit):
|
|
|
780
1028
|
:param alt_icon: new alternate QIcon (or None to clear)
|
|
781
1029
|
:param alt_tooltip: new alternate tooltip (or None to keep existing)
|
|
782
1030
|
"""
|
|
783
|
-
if key
|
|
1031
|
+
if key in self._icons:
|
|
1032
|
+
meta = self._icon_meta.get(key, {})
|
|
1033
|
+
meta["alt_icon"] = alt_icon
|
|
1034
|
+
if alt_tooltip is not None:
|
|
1035
|
+
meta["alt_tooltip"] = alt_tooltip
|
|
1036
|
+
self._icon_meta[key] = meta
|
|
1037
|
+
self._apply_icon_visual(key)
|
|
784
1038
|
return
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1039
|
+
if key in self._icons_right:
|
|
1040
|
+
meta = self._icon_meta_right.get(key, {})
|
|
1041
|
+
meta["alt_icon"] = alt_icon
|
|
1042
|
+
if alt_tooltip is not None:
|
|
1043
|
+
meta["alt_tooltip"] = alt_tooltip
|
|
1044
|
+
self._icon_meta_right[key] = meta
|
|
1045
|
+
self._apply_icon_visual(key)
|
|
791
1046
|
|
|
792
1047
|
def set_icon_tooltip(self, key: str, tooltip: str, for_alt: bool = False):
|
|
793
1048
|
"""
|
|
@@ -797,15 +1052,23 @@ class ChatInput(QTextEdit):
|
|
|
797
1052
|
:param tooltip: new tooltip text
|
|
798
1053
|
:param for_alt: if True, update alt tooltip instead of base tooltip
|
|
799
1054
|
"""
|
|
800
|
-
if key
|
|
1055
|
+
if key in self._icons:
|
|
1056
|
+
meta = self._icon_meta.get(key, {})
|
|
1057
|
+
if for_alt:
|
|
1058
|
+
meta["alt_tooltip"] = tooltip
|
|
1059
|
+
else:
|
|
1060
|
+
meta["tooltip"] = tooltip
|
|
1061
|
+
self._icon_meta[key] = meta
|
|
1062
|
+
self._apply_icon_visual(key)
|
|
801
1063
|
return
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1064
|
+
if key in self._icons_right:
|
|
1065
|
+
meta = self._icon_meta_right.get(key, {})
|
|
1066
|
+
if for_alt:
|
|
1067
|
+
meta["alt_tooltip"] = tooltip
|
|
1068
|
+
else:
|
|
1069
|
+
meta["tooltip"] = tooltip
|
|
1070
|
+
self._icon_meta_right[key] = meta
|
|
1071
|
+
self._apply_icon_visual(key)
|
|
809
1072
|
|
|
810
1073
|
def set_icon_callback(self, key: str, callback):
|
|
811
1074
|
"""
|
|
@@ -815,14 +1078,22 @@ class ChatInput(QTextEdit):
|
|
|
815
1078
|
:param callback: new callable (or None to disconnect)
|
|
816
1079
|
"""
|
|
817
1080
|
btn = self._icons.get(key)
|
|
818
|
-
if
|
|
1081
|
+
if btn:
|
|
1082
|
+
try:
|
|
1083
|
+
btn.clicked.disconnect()
|
|
1084
|
+
except Exception:
|
|
1085
|
+
pass
|
|
1086
|
+
if callback is not None:
|
|
1087
|
+
btn.clicked.connect(callback)
|
|
819
1088
|
return
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1089
|
+
btn = self._icons_right.get(key)
|
|
1090
|
+
if btn:
|
|
1091
|
+
try:
|
|
1092
|
+
btn.clicked.disconnect()
|
|
1093
|
+
except Exception:
|
|
1094
|
+
pass
|
|
1095
|
+
if callback is not None:
|
|
1096
|
+
btn.clicked.connect(callback)
|
|
826
1097
|
|
|
827
1098
|
def get_icon_state(self, key: str) -> bool:
|
|
828
1099
|
"""
|
|
@@ -830,7 +1101,11 @@ class ChatInput(QTextEdit):
|
|
|
830
1101
|
|
|
831
1102
|
:param key: icon key
|
|
832
1103
|
"""
|
|
833
|
-
|
|
1104
|
+
if key in self._icon_meta:
|
|
1105
|
+
return bool(self._icon_meta.get(key, {}).get("active", False))
|
|
1106
|
+
if key in self._icon_meta_right:
|
|
1107
|
+
return bool(self._icon_meta_right.get(key, {}).get("active", False))
|
|
1108
|
+
return False
|
|
834
1109
|
|
|
835
1110
|
def get_icon_button(self, key: str) -> Optional[QPushButton]:
|
|
836
1111
|
"""
|
|
@@ -839,7 +1114,100 @@ class ChatInput(QTextEdit):
|
|
|
839
1114
|
:param key: icon key
|
|
840
1115
|
:return: QPushButton or None if key not found
|
|
841
1116
|
"""
|
|
842
|
-
return self._icons.get(key)
|
|
1117
|
+
return self._icons.get(key) or self._icons_right.get(key)
|
|
1118
|
+
|
|
1119
|
+
# ---- Right icons sizing API ----
|
|
1120
|
+
|
|
1121
|
+
def set_right_icon_sizes(
|
|
1122
|
+
self,
|
|
1123
|
+
icon_size: Optional[Union[QSize, Tuple[int, int], int]] = None,
|
|
1124
|
+
btn_size: Optional[Union[QSize, Tuple[int, int], int]] = None,
|
|
1125
|
+
):
|
|
1126
|
+
"""
|
|
1127
|
+
Public API: change sizes for right-bottom icons.
|
|
1128
|
+
- icon_size: QSize | (w, h) | int (square)
|
|
1129
|
+
- btn_size : QSize | (w, h) | int (square)
|
|
1130
|
+
Applies to existing right icons immediately.
|
|
1131
|
+
"""
|
|
1132
|
+
def _to_qsize(v, fallback: QSize) -> QSize:
|
|
1133
|
+
if v is None:
|
|
1134
|
+
return fallback
|
|
1135
|
+
if isinstance(v, QSize):
|
|
1136
|
+
return v
|
|
1137
|
+
if isinstance(v, int):
|
|
1138
|
+
return QSize(v, v)
|
|
1139
|
+
if isinstance(v, (tuple, list)) and len(v) >= 2:
|
|
1140
|
+
return QSize(int(v[0]), int(v[1]))
|
|
1141
|
+
return fallback
|
|
1142
|
+
|
|
1143
|
+
new_icon_sz = _to_qsize(icon_size, self._icon_size_right)
|
|
1144
|
+
new_btn_sz = _to_qsize(btn_size, self._btn_size_right)
|
|
1145
|
+
|
|
1146
|
+
self._icon_size_right = new_icon_sz
|
|
1147
|
+
self._btn_size_right = new_btn_sz
|
|
1148
|
+
|
|
1149
|
+
for btn in self._icons_right.values():
|
|
1150
|
+
btn.setIconSize(self._icon_size_right)
|
|
1151
|
+
btn.setFixedSize(self._btn_size_right)
|
|
1152
|
+
|
|
1153
|
+
if hasattr(self, "_icon_bar_right"):
|
|
1154
|
+
self._icon_bar_right.setFixedHeight(self._btn_size_right.height())
|
|
1155
|
+
|
|
1156
|
+
self._update_icon_bar_geometry_right()
|
|
1157
|
+
self._reposition_icon_bar_right()
|
|
1158
|
+
self._apply_margins()
|
|
1159
|
+
self.update()
|
|
1160
|
+
|
|
1161
|
+
def set_right_icon_px(self, icon_px: int, btn_px: Optional[int] = None):
|
|
1162
|
+
"""
|
|
1163
|
+
Convenience helper to set square sizes for right-bottom icons.
|
|
1164
|
+
"""
|
|
1165
|
+
btn = btn_px if btn_px is not None else self._btn_size_right.height()
|
|
1166
|
+
self.set_right_icon_sizes(icon_px, btn)
|
|
1167
|
+
|
|
1168
|
+
# ---- Right bar margins/spacing/offset API ----
|
|
1169
|
+
|
|
1170
|
+
def set_right_bar_margins(
|
|
1171
|
+
self,
|
|
1172
|
+
margin: Optional[int] = None,
|
|
1173
|
+
spacing: Optional[int] = None,
|
|
1174
|
+
offset_x: Optional[int] = None,
|
|
1175
|
+
offset_y: Optional[int] = None,
|
|
1176
|
+
):
|
|
1177
|
+
"""
|
|
1178
|
+
Public API: change layout params for the right-bottom icon bar.
|
|
1179
|
+
- margin: inner padding from edges (px)
|
|
1180
|
+
- spacing: spacing between right-bar buttons (px)
|
|
1181
|
+
- offset_x: horizontal offset (+ rightwards, - leftwards)
|
|
1182
|
+
- offset_y: vertical offset (+ downwards, - upwards)
|
|
1183
|
+
"""
|
|
1184
|
+
if margin is not None:
|
|
1185
|
+
try:
|
|
1186
|
+
self._icons_margin_right = int(margin)
|
|
1187
|
+
except Exception:
|
|
1188
|
+
pass
|
|
1189
|
+
if spacing is not None:
|
|
1190
|
+
try:
|
|
1191
|
+
self._icons_spacing_right = int(spacing)
|
|
1192
|
+
if hasattr(self, "_icon_bar_right") and self._icon_bar_right.layout():
|
|
1193
|
+
self._icon_bar_right.layout().setSpacing(self._icons_spacing_right)
|
|
1194
|
+
except Exception:
|
|
1195
|
+
pass
|
|
1196
|
+
if offset_x is not None:
|
|
1197
|
+
try:
|
|
1198
|
+
self._icons_offset_x_right = int(offset_x)
|
|
1199
|
+
except Exception:
|
|
1200
|
+
pass
|
|
1201
|
+
if offset_y is not None:
|
|
1202
|
+
try:
|
|
1203
|
+
self._icons_offset_y_right = int(offset_y)
|
|
1204
|
+
except Exception:
|
|
1205
|
+
pass
|
|
1206
|
+
|
|
1207
|
+
self._update_icon_bar_geometry_right()
|
|
1208
|
+
self._reposition_icon_bar_right()
|
|
1209
|
+
self._apply_margins()
|
|
1210
|
+
self.update()
|
|
843
1211
|
|
|
844
1212
|
# ---- Internal layout helpers ----
|
|
845
1213
|
|
|
@@ -849,8 +1217,8 @@ class ChatInput(QTextEdit):
|
|
|
849
1217
|
|
|
850
1218
|
:param key: icon key
|
|
851
1219
|
"""
|
|
852
|
-
btn = self._icons.get(key)
|
|
853
|
-
meta = self._icon_meta.get(key, {})
|
|
1220
|
+
btn = self._icons.get(key) or self._icons_right.get(key)
|
|
1221
|
+
meta = self._icon_meta.get(key) if key in self._icon_meta else self._icon_meta_right.get(key, {})
|
|
854
1222
|
if not btn or not meta:
|
|
855
1223
|
return
|
|
856
1224
|
active = meta.get("active", False)
|
|
@@ -878,10 +1246,29 @@ class ChatInput(QTextEdit):
|
|
|
878
1246
|
if btn:
|
|
879
1247
|
layout.addWidget(btn)
|
|
880
1248
|
|
|
1249
|
+
def _rebuild_icon_layout_right(self):
|
|
1250
|
+
"""Rebuild the RIGHT-BOTTOM layout according to current _icon_order_right."""
|
|
1251
|
+
if not hasattr(self, "_icon_bar_right"):
|
|
1252
|
+
return
|
|
1253
|
+
layout = self._icon_bar_right.layout()
|
|
1254
|
+
while layout.count():
|
|
1255
|
+
item = layout.takeAt(0)
|
|
1256
|
+
w = item.widget()
|
|
1257
|
+
if w:
|
|
1258
|
+
layout.removeWidget(w)
|
|
1259
|
+
for k in self._icon_order_right:
|
|
1260
|
+
btn = self._icons_right.get(k)
|
|
1261
|
+
if btn:
|
|
1262
|
+
layout.addWidget(btn)
|
|
1263
|
+
|
|
881
1264
|
def _visible_buttons(self):
|
|
882
1265
|
"""Helper to list icon buttons that are not hidden."""
|
|
883
1266
|
return [self._icons[k] for k in self._icon_order if k in self._icons and not self._icons[k].isHidden()]
|
|
884
1267
|
|
|
1268
|
+
def _visible_buttons_right(self):
|
|
1269
|
+
"""Helper to list icon buttons that are not hidden on right bar."""
|
|
1270
|
+
return [self._icons_right[k] for k in self._icon_order_right if k in self._icons_right and not self._icons_right[k].isHidden()]
|
|
1271
|
+
|
|
885
1272
|
def _compute_icon_bar_width(self) -> int:
|
|
886
1273
|
"""
|
|
887
1274
|
Compute width from button count to ensure padding before layout measures.
|
|
@@ -895,6 +1282,17 @@ class ChatInput(QTextEdit):
|
|
|
895
1282
|
w = count * self._btn_size.width() + (count - 1) * self._icons_spacing
|
|
896
1283
|
return w
|
|
897
1284
|
|
|
1285
|
+
def _compute_icon_bar_right_width(self) -> int:
|
|
1286
|
+
"""
|
|
1287
|
+
Compute width for right-bottom bar from button count.
|
|
1288
|
+
"""
|
|
1289
|
+
vis = self._visible_buttons_right()
|
|
1290
|
+
if not vis:
|
|
1291
|
+
return 0
|
|
1292
|
+
count = len(vis)
|
|
1293
|
+
w = count * self._btn_size_right.width() + (count - 1) * self._icons_spacing_right
|
|
1294
|
+
return w
|
|
1295
|
+
|
|
898
1296
|
def _update_icon_bar_geometry(self):
|
|
899
1297
|
"""Update the bar width and keep it raised above the text viewport."""
|
|
900
1298
|
if not hasattr(self, "_icon_bar"):
|
|
@@ -904,6 +1302,15 @@ class ChatInput(QTextEdit):
|
|
|
904
1302
|
self._icon_bar.raise_()
|
|
905
1303
|
self._reposition_icon_bar()
|
|
906
1304
|
|
|
1305
|
+
def _update_icon_bar_geometry_right(self):
|
|
1306
|
+
"""Update the right-bottom bar width and keep it raised above the text viewport."""
|
|
1307
|
+
if not hasattr(self, "_icon_bar_right"):
|
|
1308
|
+
return
|
|
1309
|
+
width = self._compute_icon_bar_right_width()
|
|
1310
|
+
self._icon_bar_right.setFixedWidth(max(0, width))
|
|
1311
|
+
self._icon_bar_right.raise_()
|
|
1312
|
+
self._reposition_icon_bar_right()
|
|
1313
|
+
|
|
907
1314
|
def _reposition_icon_bar(self):
|
|
908
1315
|
"""Keep the icon bar pinned to the top-left corner."""
|
|
909
1316
|
if hasattr(self, "_icon_bar"):
|
|
@@ -914,12 +1321,37 @@ class ChatInput(QTextEdit):
|
|
|
914
1321
|
y = 0
|
|
915
1322
|
self._icon_bar.move(x, y)
|
|
916
1323
|
|
|
1324
|
+
def _reposition_icon_bar_right(self):
|
|
1325
|
+
"""Keep the right-bottom icon bar pinned to the bottom-right corner."""
|
|
1326
|
+
if hasattr(self, "_icon_bar_right"):
|
|
1327
|
+
fw = self.frameWidth()
|
|
1328
|
+
bar_w = self._compute_icon_bar_right_width()
|
|
1329
|
+
bar_h = self._btn_size_right.height()
|
|
1330
|
+
x = self.width() - fw - self._icons_margin_right - bar_w + self._icons_offset_x_right
|
|
1331
|
+
y = self.height() - fw - self._icons_margin_right - bar_h + self._icons_offset_y_right
|
|
1332
|
+
# Clamp inside widget bounds
|
|
1333
|
+
x = max(0, min(self.width() - bar_w, x))
|
|
1334
|
+
y = max(0, min(self.height() - bar_h, y))
|
|
1335
|
+
self._icon_bar_right.move(x, y)
|
|
1336
|
+
|
|
917
1337
|
def _apply_margins(self):
|
|
918
1338
|
"""Reserve left space for visible icons and apply top text padding."""
|
|
1339
|
+
# Also reserve right space for the bottom-right icon bar; keep bottom margin at 0 to avoid vertical shrink
|
|
919
1340
|
left_space = self._compute_icon_bar_width()
|
|
920
1341
|
if left_space > 0:
|
|
921
1342
|
left_space += self._icons_margin * 2
|
|
922
|
-
|
|
1343
|
+
|
|
1344
|
+
right_space = self._compute_icon_bar_right_width()
|
|
1345
|
+
if right_space > 0:
|
|
1346
|
+
right_space += self._icons_margin_right * 2
|
|
1347
|
+
|
|
1348
|
+
self.setViewportMargins(left_space, self._text_top_padding, right_space, 0)
|
|
1349
|
+
|
|
1350
|
+
# Reflow may change number of lines; adjust auto-height on next tick
|
|
1351
|
+
try:
|
|
1352
|
+
QTimer.singleShot(0, self._schedule_auto_resize)
|
|
1353
|
+
except Exception:
|
|
1354
|
+
pass
|
|
923
1355
|
|
|
924
1356
|
def resizeEvent(self, event):
|
|
925
1357
|
"""Resize event keeps the icon bar in place."""
|
|
@@ -928,6 +1360,10 @@ class ChatInput(QTextEdit):
|
|
|
928
1360
|
self._reposition_icon_bar()
|
|
929
1361
|
except Exception:
|
|
930
1362
|
pass
|
|
1363
|
+
try:
|
|
1364
|
+
self._reposition_icon_bar_right()
|
|
1365
|
+
except Exception:
|
|
1366
|
+
pass
|
|
931
1367
|
# Recompute on width changes (word wrap may change line count)
|
|
932
1368
|
if not self._splitter_resize_in_progress:
|
|
933
1369
|
if self.hasFocus():
|
|
@@ -1168,4 +1604,135 @@ class ChatInput(QTextEdit):
|
|
|
1168
1604
|
|
|
1169
1605
|
def collapse_to_min(self):
|
|
1170
1606
|
"""Public helper to collapse input area to minimal height."""
|
|
1171
|
-
self._schedule_auto_resize(force=True, enforce_minimize_if_single=True)
|
|
1607
|
+
self._schedule_auto_resize(force=True, enforce_minimize_if_single=True)
|
|
1608
|
+
|
|
1609
|
+
# ================== Prompt history helpers ==================
|
|
1610
|
+
|
|
1611
|
+
def _is_effectively_empty(self) -> bool:
|
|
1612
|
+
"""Returns True if input contains only whitespace or is empty."""
|
|
1613
|
+
try:
|
|
1614
|
+
return len(self.toPlainText().strip()) == 0
|
|
1615
|
+
except Exception:
|
|
1616
|
+
return True
|
|
1617
|
+
|
|
1618
|
+
def _set_text_and_move_end(self, text: str):
|
|
1619
|
+
"""Set text and move cursor to the end."""
|
|
1620
|
+
self.setPlainText(text or "")
|
|
1621
|
+
cursor = self.textCursor()
|
|
1622
|
+
cursor.movePosition(QTextCursor.End)
|
|
1623
|
+
self.setTextCursor(cursor)
|
|
1624
|
+
|
|
1625
|
+
def _history_begin(self):
|
|
1626
|
+
"""Enter history navigation mode and snapshot current typed text."""
|
|
1627
|
+
if self._history_active:
|
|
1628
|
+
return
|
|
1629
|
+
try:
|
|
1630
|
+
self._history_saved_current = self.toPlainText()
|
|
1631
|
+
except Exception:
|
|
1632
|
+
self._history_saved_current = ""
|
|
1633
|
+
self._history_active = True
|
|
1634
|
+
self._history_index = len(self._history)
|
|
1635
|
+
|
|
1636
|
+
def _history_end(self, restore_saved: bool = True):
|
|
1637
|
+
"""Exit history navigation mode; optionally restore the saved typed text."""
|
|
1638
|
+
if restore_saved:
|
|
1639
|
+
self._set_text_and_move_end(self._history_saved_current)
|
|
1640
|
+
self._history_active = False
|
|
1641
|
+
self._history_index = -1
|
|
1642
|
+
self._history_saved_current = ""
|
|
1643
|
+
|
|
1644
|
+
def _history_navigate(self, direction: int):
|
|
1645
|
+
"""
|
|
1646
|
+
Navigate through history.
|
|
1647
|
+
direction: -1 for older (Up), +1 for newer (Down).
|
|
1648
|
+
"""
|
|
1649
|
+
if not self._history:
|
|
1650
|
+
return
|
|
1651
|
+
if not self._history_active:
|
|
1652
|
+
self._history_begin()
|
|
1653
|
+
|
|
1654
|
+
# Move within [0, len(history)-1]; moving past newest restores and exits navigation.
|
|
1655
|
+
if direction < 0:
|
|
1656
|
+
# Older
|
|
1657
|
+
if self._history_index > 0:
|
|
1658
|
+
self._history_index -= 1
|
|
1659
|
+
self._set_text_and_move_end(self._history[self._history_index])
|
|
1660
|
+
elif self._history_index == 0:
|
|
1661
|
+
# Stay at the oldest entry
|
|
1662
|
+
self._set_text_and_move_end(self._history[0])
|
|
1663
|
+
else:
|
|
1664
|
+
# Sentinel -> jump to last
|
|
1665
|
+
self._history_index = max(0, len(self._history) - 1)
|
|
1666
|
+
self._set_text_and_move_end(self._history[self._history_index])
|
|
1667
|
+
else:
|
|
1668
|
+
# Newer
|
|
1669
|
+
if self._history_index < len(self._history) - 1:
|
|
1670
|
+
self._history_index += 1
|
|
1671
|
+
self._set_text_and_move_end(self._history[self._history_index])
|
|
1672
|
+
elif self._history_index == len(self._history) - 1:
|
|
1673
|
+
# Past newest -> restore typed and exit
|
|
1674
|
+
self._history_end(restore_saved=True)
|
|
1675
|
+
else:
|
|
1676
|
+
# Already at sentinel -> ensure restore
|
|
1677
|
+
self._history_end(restore_saved=True)
|
|
1678
|
+
|
|
1679
|
+
def _normalize_history_text(self, text: str) -> str:
|
|
1680
|
+
"""Normalize text before storing in history."""
|
|
1681
|
+
if not isinstance(text, str):
|
|
1682
|
+
try:
|
|
1683
|
+
text = str(text)
|
|
1684
|
+
except Exception:
|
|
1685
|
+
return ""
|
|
1686
|
+
return text.strip()
|
|
1687
|
+
|
|
1688
|
+
def history_push(self, text: str):
|
|
1689
|
+
"""
|
|
1690
|
+
Public API: push a sent prompt to history.
|
|
1691
|
+
Controllers may call this when sending via buttons to keep history in sync.
|
|
1692
|
+
"""
|
|
1693
|
+
s = self._normalize_history_text(text)
|
|
1694
|
+
if not s:
|
|
1695
|
+
return
|
|
1696
|
+
if self._history and self._history[-1] == s:
|
|
1697
|
+
return
|
|
1698
|
+
self._history.append(s)
|
|
1699
|
+
if len(self._history) > self._history_limit:
|
|
1700
|
+
# Keep most recent N entries
|
|
1701
|
+
overflow = len(self._history) - self._history_limit
|
|
1702
|
+
if overflow > 0:
|
|
1703
|
+
del self._history[:overflow]
|
|
1704
|
+
|
|
1705
|
+
def history_clear(self):
|
|
1706
|
+
"""Public API: clear stored prompt history."""
|
|
1707
|
+
self._history.clear()
|
|
1708
|
+
self._history_index = -1
|
|
1709
|
+
self._history_active = False
|
|
1710
|
+
self._history_saved_current = ""
|
|
1711
|
+
|
|
1712
|
+
def _on_prompt_sent(self, text_before_send: str):
|
|
1713
|
+
"""
|
|
1714
|
+
Internal hook executed after sending the input via Enter.
|
|
1715
|
+
Stores the prompt into history and resets navigation snapshot.
|
|
1716
|
+
"""
|
|
1717
|
+
try:
|
|
1718
|
+
# Avoid recording while editing existing messages (best-effort).
|
|
1719
|
+
is_editing = bool(self.window.controller.ctx.extra.is_editing())
|
|
1720
|
+
except Exception:
|
|
1721
|
+
is_editing = False
|
|
1722
|
+
|
|
1723
|
+
if not is_editing:
|
|
1724
|
+
self.history_push(text_before_send)
|
|
1725
|
+
|
|
1726
|
+
# Leave history navigation after send
|
|
1727
|
+
self._history_active = False
|
|
1728
|
+
self._history_index = -1
|
|
1729
|
+
self._history_saved_current = ""
|
|
1730
|
+
|
|
1731
|
+
def on_prompt_sent(self, text: Optional[str] = None):
|
|
1732
|
+
"""
|
|
1733
|
+
Public API: controllers can call this when a prompt is sent by other means (e.g., send button).
|
|
1734
|
+
If text is None the current input text is used.
|
|
1735
|
+
"""
|
|
1736
|
+
if text is None:
|
|
1737
|
+
text = self.toPlainText()
|
|
1738
|
+
self._on_prompt_sent(text)
|