pygpt-net 2.6.26__py3-none-any.whl → 2.6.28__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 +10 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +5 -1
- pygpt_net/controller/access/voice.py +3 -5
- pygpt_net/controller/audio/audio.py +9 -6
- pygpt_net/controller/audio/ui.py +263 -0
- pygpt_net/controller/chat/common.py +17 -1
- pygpt_net/controller/kernel/kernel.py +2 -0
- pygpt_net/controller/notepad/notepad.py +10 -1
- pygpt_net/controller/theme/markdown.py +2 -0
- pygpt_net/controller/theme/theme.py +4 -1
- pygpt_net/controller/ui/tabs.py +5 -0
- pygpt_net/core/audio/backend/native.py +114 -82
- pygpt_net/core/audio/backend/pyaudio.py +16 -19
- pygpt_net/core/audio/backend/pygame.py +12 -15
- pygpt_net/core/audio/capture.py +10 -9
- pygpt_net/core/audio/context.py +3 -6
- pygpt_net/core/command/command.py +2 -0
- pygpt_net/core/render/web/helpers.py +13 -3
- pygpt_net/core/render/web/renderer.py +3 -3
- pygpt_net/data/config/config.json +7 -5
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +24 -10
- pygpt_net/data/css/web-blocks.darkest.css +91 -0
- pygpt_net/data/css/web-chatgpt.css +7 -5
- pygpt_net/data/css/web-chatgpt.dark.css +5 -2
- pygpt_net/data/css/web-chatgpt.darkest.css +91 -0
- pygpt_net/data/css/web-chatgpt.light.css +8 -2
- pygpt_net/data/css/web-chatgpt_wide.css +7 -4
- pygpt_net/data/css/web-chatgpt_wide.dark.css +5 -2
- pygpt_net/data/css/web-chatgpt_wide.darkest.css +91 -0
- pygpt_net/data/css/web-chatgpt_wide.light.css +9 -6
- pygpt_net/data/locale/locale.de.ini +2 -0
- pygpt_net/data/locale/locale.en.ini +2 -0
- pygpt_net/data/locale/locale.es.ini +2 -0
- pygpt_net/data/locale/locale.fr.ini +2 -0
- pygpt_net/data/locale/locale.it.ini +2 -0
- pygpt_net/data/locale/locale.pl.ini +3 -1
- pygpt_net/data/locale/locale.uk.ini +2 -0
- pygpt_net/data/locale/locale.zh.ini +2 -0
- pygpt_net/data/themes/dark_darkest.css +31 -0
- pygpt_net/data/themes/dark_darkest.xml +10 -0
- pygpt_net/plugin/audio_input/simple.py +5 -10
- pygpt_net/plugin/audio_output/plugin.py +4 -17
- pygpt_net/plugin/tuya/__init__.py +12 -0
- pygpt_net/plugin/tuya/config.py +256 -0
- pygpt_net/plugin/tuya/plugin.py +117 -0
- pygpt_net/plugin/tuya/worker.py +588 -0
- pygpt_net/plugin/wikipedia/__init__.py +12 -0
- pygpt_net/plugin/wikipedia/config.py +228 -0
- pygpt_net/plugin/wikipedia/plugin.py +114 -0
- pygpt_net/plugin/wikipedia/worker.py +430 -0
- pygpt_net/provider/core/config/patch.py +11 -0
- pygpt_net/ui/layout/chat/input.py +5 -2
- pygpt_net/ui/main.py +1 -2
- pygpt_net/ui/widget/audio/bar.py +5 -1
- pygpt_net/ui/widget/tabs/output.py +2 -0
- pygpt_net/ui/widget/textarea/input.py +483 -55
- {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/METADATA +78 -35
- {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/RECORD +63 -49
- {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/entry_points.txt +0 -0
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.08.
|
|
9
|
+
# Updated Date: 2025.08.27 07:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
12
14
|
from PySide6.QtCore import Qt, QSize
|
|
13
15
|
from PySide6.QtGui import QAction, QIcon, QImage
|
|
14
|
-
from PySide6.QtWidgets import
|
|
15
|
-
|
|
16
|
+
from PySide6.QtWidgets import (
|
|
17
|
+
QTextEdit,
|
|
18
|
+
QApplication,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QWidget,
|
|
21
|
+
QHBoxLayout,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from pygpt_net.core.events import Event
|
|
16
25
|
from pygpt_net.utils import trans
|
|
17
26
|
|
|
18
27
|
class ChatInput(QTextEdit):
|
|
@@ -21,6 +30,9 @@ class ChatInput(QTextEdit):
|
|
|
21
30
|
ICON_VOLUME = QIcon(":/icons/volume.svg")
|
|
22
31
|
ICON_SAVE = QIcon(":/icons/save.svg")
|
|
23
32
|
ICON_ATTACHMENT = QIcon(":/icons/add.svg")
|
|
33
|
+
#ICON_ATTACHMENT = QIcon(":/icons/attachment.svg")
|
|
34
|
+
ICON_MIC_ON = QIcon(":/icons/mic.svg")
|
|
35
|
+
ICON_MIC_OFF = QIcon(":/icons/mic_off.svg")
|
|
24
36
|
|
|
25
37
|
def __init__(self, window=None):
|
|
26
38
|
"""
|
|
@@ -35,23 +47,62 @@ class ChatInput(QTextEdit):
|
|
|
35
47
|
self.value = self.window.core.config.data['font_size.input']
|
|
36
48
|
self.max_font_size = 42
|
|
37
49
|
self.min_font_size = 8
|
|
38
|
-
self._text_top_padding =
|
|
50
|
+
self._text_top_padding = 10
|
|
39
51
|
self.textChanged.connect(self.window.controller.ui.update_tokens)
|
|
40
52
|
self.setProperty('class', 'layout-input')
|
|
41
53
|
|
|
54
|
+
if self.window.core.platforms.is_windows():
|
|
55
|
+
self._text_top_padding = 8
|
|
56
|
+
|
|
57
|
+
# --- Icon bar (left) settings ---
|
|
58
|
+
# Settings controlling the left icon bar (spacing, sizes, margins)
|
|
59
|
+
self._icons_margin = 6 # inner left/right padding around the bar
|
|
60
|
+
self._icons_spacing = 4 # spacing between buttons
|
|
61
|
+
self._icons_offset_y = -4 # small upward shift (visual alignment)
|
|
62
|
+
self._icon_size = QSize(18, 18) # icon size (matches your original)
|
|
63
|
+
self._btn_size = QSize(24, 24) # button size (w x h), matches previous QPushButton
|
|
64
|
+
|
|
65
|
+
# Storage for icon buttons and metadata
|
|
66
|
+
self._icons = {} # key -> QPushButton
|
|
67
|
+
self._icon_meta = {} # key -> {"icon": QIcon, "alt_icon": Optional[QIcon], "tooltip": str, "alt_tooltip": Optional[str], "active": bool}
|
|
68
|
+
self._icon_order = [] # rendering order
|
|
69
|
+
|
|
70
|
+
self._init_icon_bar()
|
|
42
71
|
# Add a "+" button in the top-left corner to add attachments
|
|
43
|
-
self.
|
|
44
|
-
|
|
72
|
+
self.add_icon(
|
|
73
|
+
key="attach",
|
|
74
|
+
icon=self.ICON_ATTACHMENT,
|
|
75
|
+
tooltip=trans("attachments.btn.input.add"),
|
|
76
|
+
callback=self.action_add_attachment,
|
|
77
|
+
visible=True,
|
|
78
|
+
)
|
|
79
|
+
# Add a microphone button (hidden by default; shown when audio input is enabled)
|
|
80
|
+
self.add_icon(
|
|
81
|
+
key="mic",
|
|
82
|
+
icon=self.ICON_MIC_ON,
|
|
83
|
+
alt_icon=self.ICON_MIC_OFF,
|
|
84
|
+
tooltip=trans('audio.speak.btn'),
|
|
85
|
+
alt_tooltip=trans('audio.speak.btn.stop.tooltip'),
|
|
86
|
+
callback=self.action_toggle_mic,
|
|
87
|
+
visible=False,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Apply initial margins (top padding + left space for icons)
|
|
91
|
+
self._apply_margins()
|
|
45
92
|
|
|
46
93
|
def _apply_text_top_padding(self):
|
|
47
94
|
"""Apply extra top padding inside the text area by using viewport margins."""
|
|
48
|
-
|
|
49
|
-
self.
|
|
95
|
+
# Left margin is computed in _apply_margins()
|
|
96
|
+
self._apply_margins()
|
|
50
97
|
|
|
51
98
|
def set_text_top_padding(self, px: int):
|
|
52
|
-
"""
|
|
99
|
+
"""
|
|
100
|
+
Public helper to adjust top padding at runtime.
|
|
101
|
+
|
|
102
|
+
:param px: padding in pixels
|
|
103
|
+
"""
|
|
53
104
|
self._text_top_padding = max(0, int(px))
|
|
54
|
-
self.
|
|
105
|
+
self._apply_margins()
|
|
55
106
|
|
|
56
107
|
def insertFromMimeData(self, source):
|
|
57
108
|
"""
|
|
@@ -132,9 +183,7 @@ class ChatInput(QTextEdit):
|
|
|
132
183
|
menu.deleteLater()
|
|
133
184
|
|
|
134
185
|
def action_from_clipboard(self):
|
|
135
|
-
"""
|
|
136
|
-
Get from clipboard
|
|
137
|
-
"""
|
|
186
|
+
"""Paste from clipboard"""
|
|
138
187
|
clipboard = QApplication.clipboard()
|
|
139
188
|
source = clipboard.mimeData()
|
|
140
189
|
self.handle_clipboard(source)
|
|
@@ -195,53 +244,432 @@ class ChatInput(QTextEdit):
|
|
|
195
244
|
return
|
|
196
245
|
super().wheelEvent(event)
|
|
197
246
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
self.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
self.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
247
|
+
def action_add_attachment(self):
|
|
248
|
+
"""Add attachment (button click)."""
|
|
249
|
+
self.window.controller.attachment.open_add()
|
|
250
|
+
|
|
251
|
+
def action_toggle_mic(self):
|
|
252
|
+
"""Toggle microphone (button click)."""
|
|
253
|
+
self.window.dispatch(Event(Event.AUDIO_INPUT_RECORD_TOGGLE))
|
|
254
|
+
|
|
255
|
+
# -------------------- Left icon bar --------------------
|
|
256
|
+
# - Add icons: add_icon(...) or add_icons([...])
|
|
257
|
+
# - Show/hide: set_icon_visible(key, bool)
|
|
258
|
+
# - Swap icon at runtime: set_icon_state(key, active) with optional alt_icon
|
|
259
|
+
|
|
260
|
+
def _init_icon_bar(self):
|
|
261
|
+
"""Create the left-side icon bar pinned in the top-left corner."""
|
|
262
|
+
self._icon_bar = QWidget(self)
|
|
263
|
+
self._icon_bar.setObjectName("chatInputIconBar")
|
|
264
|
+
|
|
265
|
+
# Keep styled background enabled so the style engine (Qt Material) can still
|
|
266
|
+
# paint hover/pressed states on child buttons.
|
|
267
|
+
self._icon_bar.setAttribute(Qt.WA_StyledBackground, True)
|
|
268
|
+
self._icon_bar.setAutoFillBackground(False)
|
|
269
|
+
|
|
270
|
+
# Scope the rule to this object by its ID to avoid cascading 'background: transparent'
|
|
271
|
+
# to child QPushButtons.
|
|
272
|
+
self._icon_bar.setStyleSheet("""
|
|
273
|
+
#chatInputIconBar { background-color: transparent; }
|
|
274
|
+
""")
|
|
275
|
+
|
|
276
|
+
layout = QHBoxLayout(self._icon_bar)
|
|
277
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
278
|
+
layout.setSpacing(self._icons_spacing)
|
|
279
|
+
self._icon_bar.setLayout(layout)
|
|
280
|
+
|
|
281
|
+
self._icon_bar.setFixedHeight(self._btn_size.height())
|
|
282
|
+
self._icon_bar.show() # make sure it's visible so children render
|
|
283
|
+
|
|
284
|
+
self._reposition_icon_bar()
|
|
285
|
+
self._update_icon_bar_geometry()
|
|
286
|
+
self._apply_margins()
|
|
287
|
+
|
|
288
|
+
# ---- Public API for icons ----
|
|
289
|
+
|
|
290
|
+
def add_icon(
|
|
291
|
+
self,
|
|
292
|
+
key: str,
|
|
293
|
+
icon: QIcon,
|
|
294
|
+
tooltip: str = "",
|
|
295
|
+
callback=None,
|
|
296
|
+
visible: bool = True,
|
|
297
|
+
alt_icon: Optional[QIcon] = None,
|
|
298
|
+
alt_tooltip: Optional[str] = None,
|
|
299
|
+
) -> QPushButton:
|
|
300
|
+
"""
|
|
301
|
+
Add a new icon button to the left bar.
|
|
302
|
+
|
|
303
|
+
:param key: unique identifier for the icon
|
|
304
|
+
:param icon: default QIcon (e.g., mic off)
|
|
305
|
+
:param tooltip: default tooltip text
|
|
306
|
+
:param callback: callable executed on click
|
|
307
|
+
:param visible: initial visibility (True=shown, False=hidden)
|
|
308
|
+
:param alt_icon: optional alternate icon (e.g., mic on / recording)
|
|
309
|
+
:param alt_tooltip: optional alternate tooltip text
|
|
310
|
+
:return: the created QPushButton (or existing one if key already present)
|
|
311
|
+
"""
|
|
312
|
+
if key in self._icons:
|
|
313
|
+
btn = self._icons[key]
|
|
314
|
+
meta = self._icon_meta.get(key, {})
|
|
315
|
+
meta.update({
|
|
316
|
+
"icon": icon or meta.get("icon"),
|
|
317
|
+
"alt_icon": alt_icon if alt_icon is not None else meta.get("alt_icon"),
|
|
318
|
+
"tooltip": tooltip or meta.get("tooltip", key),
|
|
319
|
+
"alt_tooltip": alt_tooltip if alt_tooltip is not None else meta.get("alt_tooltip"),
|
|
320
|
+
})
|
|
321
|
+
self._icon_meta[key] = meta
|
|
322
|
+
btn.setIcon(meta["icon"])
|
|
323
|
+
btn.setToolTip(meta["tooltip"])
|
|
324
|
+
if callback is not None:
|
|
325
|
+
try:
|
|
326
|
+
btn.clicked.disconnect()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
btn.clicked.connect(callback)
|
|
330
|
+
btn.setHidden(not visible)
|
|
331
|
+
self._rebuild_icon_layout()
|
|
332
|
+
self._update_icon_bar_geometry()
|
|
333
|
+
self._apply_margins()
|
|
334
|
+
return btn
|
|
335
|
+
|
|
336
|
+
btn = QPushButton(self._icon_bar)
|
|
337
|
+
btn.setObjectName(f"chatInputIconBtn_{key}")
|
|
338
|
+
btn.setIcon(icon)
|
|
339
|
+
btn.setIconSize(self._icon_size)
|
|
340
|
+
btn.setFixedSize(self._btn_size)
|
|
341
|
+
btn.setCursor(Qt.PointingHandCursor)
|
|
342
|
+
btn.setToolTip(tooltip or key)
|
|
343
|
+
btn.setFocusPolicy(Qt.NoFocus)
|
|
344
|
+
btn.setFlat(True) # flat button style like your original
|
|
345
|
+
# optional: no text
|
|
346
|
+
btn.setText("")
|
|
347
|
+
|
|
348
|
+
if callback is not None:
|
|
349
|
+
btn.clicked.connect(callback)
|
|
350
|
+
|
|
351
|
+
self._icons[key] = btn
|
|
352
|
+
self._icon_order.append(key)
|
|
353
|
+
self._icon_meta[key] = {
|
|
354
|
+
"icon": icon,
|
|
355
|
+
"alt_icon": alt_icon,
|
|
356
|
+
"tooltip": tooltip or key,
|
|
357
|
+
"alt_tooltip": alt_tooltip,
|
|
358
|
+
"active": False,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
self._apply_icon_visual(key)
|
|
362
|
+
btn.setHidden(not visible)
|
|
363
|
+
|
|
364
|
+
self._rebuild_icon_layout()
|
|
365
|
+
self._update_icon_bar_geometry()
|
|
366
|
+
self._apply_margins()
|
|
367
|
+
return btn
|
|
368
|
+
|
|
369
|
+
def add_icons(self, items):
|
|
370
|
+
"""
|
|
371
|
+
Add multiple icons at once.
|
|
372
|
+
|
|
373
|
+
- items: iterable of tuples/dicts:
|
|
374
|
+
tuple: (key, icon, tooltip, callback, visible=True, alt_icon=None, alt_tooltip=None)
|
|
375
|
+
dict : {"key":..., "icon":..., "tooltip":..., "callback":..., "visible":True, "alt_icon":..., "alt_tooltip":...}
|
|
376
|
+
|
|
377
|
+
:param items: iterable of tuples/dicts defining icons
|
|
378
|
+
"""
|
|
379
|
+
for it in items:
|
|
380
|
+
if isinstance(it, dict):
|
|
381
|
+
self.add_icon(
|
|
382
|
+
key=it["key"],
|
|
383
|
+
icon=it["icon"],
|
|
384
|
+
tooltip=it.get("tooltip", ""),
|
|
385
|
+
callback=it.get("callback"),
|
|
386
|
+
visible=it.get("visible", True),
|
|
387
|
+
alt_icon=it.get("alt_icon"),
|
|
388
|
+
alt_tooltip=it.get("alt_tooltip"),
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
key, icon = it[0], it[1]
|
|
392
|
+
tooltip = it[2] if len(it) > 2 else ""
|
|
393
|
+
callback = it[3] if len(it) > 3 else None
|
|
394
|
+
visible = it[4] if len(it) > 4 else True
|
|
395
|
+
alt_icon = it[5] if len(it) > 5 else None
|
|
396
|
+
alt_tooltip = it[6] if len(it) > 6 else None
|
|
397
|
+
self.add_icon(key, icon, tooltip, callback, visible, alt_icon, alt_tooltip)
|
|
398
|
+
|
|
399
|
+
def remove_icon(self, key: str):
|
|
400
|
+
"""
|
|
401
|
+
Remove an icon from the bar.
|
|
402
|
+
|
|
403
|
+
:param key: icon key
|
|
404
|
+
"""
|
|
405
|
+
btn = self._icons.pop(key, None)
|
|
406
|
+
self._icon_meta.pop(key, None)
|
|
407
|
+
if btn is not None:
|
|
408
|
+
try:
|
|
409
|
+
self._icon_order.remove(key)
|
|
410
|
+
except ValueError:
|
|
411
|
+
pass
|
|
412
|
+
btn.setParent(None)
|
|
413
|
+
btn.deleteLater()
|
|
414
|
+
self._rebuild_icon_layout()
|
|
415
|
+
self._update_icon_bar_geometry()
|
|
416
|
+
self._apply_margins()
|
|
417
|
+
|
|
418
|
+
def set_icon_visible(self, key: str, visible: bool):
|
|
419
|
+
"""
|
|
420
|
+
Show or hide an icon by key; margins are recalculated.
|
|
421
|
+
|
|
422
|
+
:param key: icon key
|
|
423
|
+
:param visible: True to show, False to hide
|
|
424
|
+
"""
|
|
425
|
+
btn = self._icons.get(key)
|
|
426
|
+
if not btn:
|
|
427
|
+
return
|
|
428
|
+
btn.setHidden(not visible)
|
|
429
|
+
self._update_icon_bar_geometry()
|
|
430
|
+
self._apply_margins()
|
|
431
|
+
|
|
432
|
+
def toggle_icon(self, key: str):
|
|
433
|
+
"""
|
|
434
|
+
Toggle icon visibility and recalc margins.
|
|
435
|
+
|
|
436
|
+
:param key: icon key
|
|
437
|
+
"""
|
|
438
|
+
btn = self._icons.get(key)
|
|
439
|
+
if not btn:
|
|
440
|
+
return
|
|
441
|
+
btn.setHidden(not btn.isHidden())
|
|
442
|
+
self._update_icon_bar_geometry()
|
|
443
|
+
self._apply_margins()
|
|
444
|
+
|
|
445
|
+
def is_icon_visible(self, key: str) -> bool:
|
|
446
|
+
"""
|
|
447
|
+
Return True if icon is visible (not hidden).
|
|
448
|
+
|
|
449
|
+
:param key: icon key
|
|
450
|
+
"""
|
|
451
|
+
btn = self._icons.get(key)
|
|
452
|
+
return bool(btn and not btn.isHidden())
|
|
453
|
+
|
|
454
|
+
def set_icon_order(self, keys):
|
|
455
|
+
"""
|
|
456
|
+
Set rendering order for icons by a list of keys.
|
|
457
|
+
Icons not listed keep their relative order at the end.
|
|
458
|
+
|
|
459
|
+
:param keys: list of icon keys in desired order
|
|
460
|
+
"""
|
|
461
|
+
new_order = []
|
|
462
|
+
seen = set()
|
|
463
|
+
for k in keys:
|
|
464
|
+
if k in self._icons and k not in seen:
|
|
465
|
+
new_order.append(k)
|
|
466
|
+
seen.add(k)
|
|
467
|
+
for k in self._icon_order:
|
|
468
|
+
if k not in seen and k in self._icons:
|
|
469
|
+
new_order.append(k)
|
|
470
|
+
self._icon_order = new_order
|
|
471
|
+
self._rebuild_icon_layout()
|
|
472
|
+
self._update_icon_bar_geometry()
|
|
473
|
+
self._apply_margins()
|
|
474
|
+
|
|
475
|
+
# ---- Runtime icon swap / state API ----
|
|
476
|
+
|
|
477
|
+
def set_icon_state(self, key: str, active: bool):
|
|
478
|
+
"""
|
|
479
|
+
Switch between base icon and alt icon at runtime.
|
|
480
|
+
- active=False -> show base icon/tooltip
|
|
481
|
+
- active=True -> show alt icon/tooltip (if provided; falls back to base icon if not)
|
|
482
|
+
|
|
483
|
+
:param key: icon key
|
|
484
|
+
:param active: True to show alt icon, False for base icon
|
|
485
|
+
"""
|
|
486
|
+
if key not in self._icons:
|
|
487
|
+
return
|
|
488
|
+
meta = self._icon_meta.get(key, {})
|
|
489
|
+
meta["active"] = bool(active)
|
|
490
|
+
self._icon_meta[key] = meta
|
|
491
|
+
self._apply_icon_visual(key)
|
|
492
|
+
|
|
493
|
+
def toggle_icon_state(self, key: str) -> bool:
|
|
494
|
+
"""
|
|
495
|
+
Toggle active state and return new state.
|
|
496
|
+
|
|
497
|
+
:param key: icon key
|
|
498
|
+
:return: new active state (True if alt icon is now shown)
|
|
499
|
+
"""
|
|
500
|
+
if key not in self._icons:
|
|
501
|
+
return False
|
|
502
|
+
current = bool(self._icon_meta.get(key, {}).get("active", False))
|
|
503
|
+
self.set_icon_state(key, not current)
|
|
504
|
+
return not current
|
|
505
|
+
|
|
506
|
+
def set_icon_pixmap(self, key: str, icon: QIcon):
|
|
507
|
+
"""
|
|
508
|
+
Replace base icon at runtime (does not touch alt icon).
|
|
509
|
+
|
|
510
|
+
:param key: icon key
|
|
511
|
+
:param icon: new QIcon
|
|
512
|
+
"""
|
|
513
|
+
if key not in self._icons:
|
|
514
|
+
return
|
|
515
|
+
meta = self._icon_meta.get(key, {})
|
|
516
|
+
meta["icon"] = icon
|
|
517
|
+
self._icon_meta[key] = meta
|
|
518
|
+
self._apply_icon_visual(key)
|
|
519
|
+
|
|
520
|
+
def set_icon_alt(self, key: str, alt_icon: Optional[QIcon], alt_tooltip: Optional[str] = None):
|
|
521
|
+
"""
|
|
522
|
+
Set/replace alternate icon and optional tooltip
|
|
523
|
+
|
|
524
|
+
:param key: icon key
|
|
525
|
+
:param alt_icon: new alternate QIcon (or None to clear)
|
|
526
|
+
:param alt_tooltip: new alternate tooltip (or None to keep existing)
|
|
527
|
+
"""
|
|
528
|
+
if key not in self._icons:
|
|
529
|
+
return
|
|
530
|
+
meta = self._icon_meta.get(key, {})
|
|
531
|
+
meta["alt_icon"] = alt_icon
|
|
532
|
+
if alt_tooltip is not None:
|
|
533
|
+
meta["alt_tooltip"] = alt_tooltip
|
|
534
|
+
self._icon_meta[key] = meta
|
|
535
|
+
self._apply_icon_visual(key)
|
|
536
|
+
|
|
537
|
+
def set_icon_tooltip(self, key: str, tooltip: str, for_alt: bool = False):
|
|
538
|
+
"""
|
|
539
|
+
Update tooltip; for_alt=True updates alternate tooltip
|
|
540
|
+
|
|
541
|
+
:param key: icon key
|
|
542
|
+
:param tooltip: new tooltip text
|
|
543
|
+
:param for_alt: if True, update alt tooltip instead of base tooltip
|
|
544
|
+
"""
|
|
545
|
+
if key not in self._icons:
|
|
546
|
+
return
|
|
547
|
+
meta = self._icon_meta.get(key, {})
|
|
548
|
+
if for_alt:
|
|
549
|
+
meta["alt_tooltip"] = tooltip
|
|
550
|
+
else:
|
|
551
|
+
meta["tooltip"] = tooltip
|
|
552
|
+
self._icon_meta[key] = meta
|
|
553
|
+
self._apply_icon_visual(key)
|
|
554
|
+
|
|
555
|
+
def set_icon_callback(self, key: str, callback):
|
|
556
|
+
"""
|
|
557
|
+
Update click callback at runtime.
|
|
558
|
+
|
|
559
|
+
:param key: icon key
|
|
560
|
+
:param callback: new callable (or None to disconnect)
|
|
561
|
+
"""
|
|
562
|
+
btn = self._icons.get(key)
|
|
563
|
+
if not btn:
|
|
564
|
+
return
|
|
565
|
+
try:
|
|
566
|
+
btn.clicked.disconnect()
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
if callback is not None:
|
|
570
|
+
btn.clicked.connect(callback)
|
|
571
|
+
|
|
572
|
+
def get_icon_state(self, key: str) -> bool:
|
|
573
|
+
"""
|
|
574
|
+
Return active state for icon (True if alt icon is displayed).
|
|
575
|
+
|
|
576
|
+
:param key: icon key
|
|
577
|
+
"""
|
|
578
|
+
return bool(self._icon_meta.get(key, {}).get("active", False))
|
|
579
|
+
|
|
580
|
+
def get_icon_button(self, key: str) -> Optional[QPushButton]:
|
|
581
|
+
"""
|
|
582
|
+
Return the underlying QPushButton for advanced customization.
|
|
583
|
+
|
|
584
|
+
:param key: icon key
|
|
585
|
+
:return: QPushButton or None if key not found
|
|
586
|
+
"""
|
|
587
|
+
return self._icons.get(key)
|
|
588
|
+
|
|
589
|
+
# ---- Internal layout helpers ----
|
|
590
|
+
|
|
591
|
+
def _apply_icon_visual(self, key: str):
|
|
592
|
+
"""
|
|
593
|
+
Apply correct icon and tooltip based on meta state.
|
|
594
|
+
|
|
595
|
+
:param key: icon key
|
|
596
|
+
"""
|
|
597
|
+
btn = self._icons.get(key)
|
|
598
|
+
meta = self._icon_meta.get(key, {})
|
|
599
|
+
if not btn or not meta:
|
|
600
|
+
return
|
|
601
|
+
active = meta.get("active", False)
|
|
602
|
+
base_icon = meta.get("icon")
|
|
603
|
+
alt_icon = meta.get("alt_icon")
|
|
604
|
+
base_tt = meta.get("tooltip") or key
|
|
605
|
+
alt_tt = meta.get("alt_tooltip") or base_tt
|
|
606
|
+
|
|
607
|
+
use_alt = active and isinstance(alt_icon, QIcon)
|
|
608
|
+
btn.setIcon(alt_icon if use_alt else base_icon)
|
|
609
|
+
btn.setToolTip(alt_tt if use_alt else base_tt)
|
|
610
|
+
|
|
611
|
+
def _rebuild_icon_layout(self):
|
|
612
|
+
"""Rebuild the layout according to current _icon_order."""
|
|
613
|
+
if not hasattr(self, "_icon_bar"):
|
|
614
|
+
return
|
|
615
|
+
layout = self._icon_bar.layout()
|
|
616
|
+
while layout.count():
|
|
617
|
+
item = layout.takeAt(0)
|
|
618
|
+
w = item.widget()
|
|
619
|
+
if w:
|
|
620
|
+
layout.removeWidget(w)
|
|
621
|
+
for k in self._icon_order:
|
|
622
|
+
btn = self._icons.get(k)
|
|
623
|
+
if btn:
|
|
624
|
+
layout.addWidget(btn)
|
|
625
|
+
|
|
626
|
+
def _visible_buttons(self):
|
|
627
|
+
"""Helper to list icon buttons that are not hidden."""
|
|
628
|
+
return [self._icons[k] for k in self._icon_order if k in self._icons and not self._icons[k].isHidden()]
|
|
629
|
+
|
|
630
|
+
def _compute_icon_bar_width(self) -> int:
|
|
631
|
+
"""
|
|
632
|
+
Compute width from button count to ensure padding before layout measures.
|
|
633
|
+
|
|
634
|
+
:return: total width in pixels
|
|
635
|
+
"""
|
|
636
|
+
vis = self._visible_buttons()
|
|
637
|
+
if not vis:
|
|
638
|
+
return 0
|
|
639
|
+
count = len(vis)
|
|
640
|
+
w = count * self._btn_size.width() + (count - 1) * self._icons_spacing
|
|
641
|
+
return w
|
|
642
|
+
|
|
643
|
+
def _update_icon_bar_geometry(self):
|
|
644
|
+
"""Update the bar width and keep it raised above the text viewport."""
|
|
645
|
+
if not hasattr(self, "_icon_bar"):
|
|
646
|
+
return
|
|
647
|
+
width = self._compute_icon_bar_width()
|
|
648
|
+
self._icon_bar.setFixedWidth(max(0, width))
|
|
649
|
+
self._icon_bar.raise_()
|
|
650
|
+
self._reposition_icon_bar()
|
|
651
|
+
|
|
652
|
+
def _reposition_icon_bar(self):
|
|
653
|
+
"""Keep the icon bar pinned to the top-left corner."""
|
|
654
|
+
if hasattr(self, "_icon_bar"):
|
|
228
655
|
fw = self.frameWidth()
|
|
229
|
-
x = fw + self.
|
|
230
|
-
y = fw + self.
|
|
656
|
+
x = fw + self._icons_margin
|
|
657
|
+
y = fw + self._icons_margin + self._icons_offset_y
|
|
231
658
|
if y < 0:
|
|
232
659
|
y = 0
|
|
233
|
-
self.
|
|
234
|
-
|
|
660
|
+
self._icon_bar.move(x, y)
|
|
661
|
+
|
|
662
|
+
def _apply_margins(self):
|
|
663
|
+
"""Reserve left space for visible icons and apply top text padding."""
|
|
664
|
+
left_space = self._compute_icon_bar_width()
|
|
665
|
+
if left_space > 0:
|
|
666
|
+
left_space += self._icons_margin * 2
|
|
667
|
+
self.setViewportMargins(left_space, self._text_top_padding, 0, 0)
|
|
235
668
|
|
|
236
669
|
def resizeEvent(self, event):
|
|
237
|
-
"""Resize event keeps the
|
|
670
|
+
"""Resize event keeps the icon bar in place."""
|
|
238
671
|
super().resizeEvent(event)
|
|
239
|
-
# Keep the attachment button pinned when resizing
|
|
240
672
|
try:
|
|
241
|
-
self.
|
|
673
|
+
self._reposition_icon_bar()
|
|
242
674
|
except Exception:
|
|
243
|
-
pass
|
|
244
|
-
|
|
245
|
-
def action_add_attachment(self):
|
|
246
|
-
"""Add attachment (button click)."""
|
|
247
|
-
self.window.controller.attachment.open_add()
|
|
675
|
+
pass
|