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.
Files changed (63) hide show
  1. pygpt_net/CHANGELOG.txt +10 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +5 -1
  4. pygpt_net/controller/access/voice.py +3 -5
  5. pygpt_net/controller/audio/audio.py +9 -6
  6. pygpt_net/controller/audio/ui.py +263 -0
  7. pygpt_net/controller/chat/common.py +17 -1
  8. pygpt_net/controller/kernel/kernel.py +2 -0
  9. pygpt_net/controller/notepad/notepad.py +10 -1
  10. pygpt_net/controller/theme/markdown.py +2 -0
  11. pygpt_net/controller/theme/theme.py +4 -1
  12. pygpt_net/controller/ui/tabs.py +5 -0
  13. pygpt_net/core/audio/backend/native.py +114 -82
  14. pygpt_net/core/audio/backend/pyaudio.py +16 -19
  15. pygpt_net/core/audio/backend/pygame.py +12 -15
  16. pygpt_net/core/audio/capture.py +10 -9
  17. pygpt_net/core/audio/context.py +3 -6
  18. pygpt_net/core/command/command.py +2 -0
  19. pygpt_net/core/render/web/helpers.py +13 -3
  20. pygpt_net/core/render/web/renderer.py +3 -3
  21. pygpt_net/data/config/config.json +7 -5
  22. pygpt_net/data/config/models.json +3 -3
  23. pygpt_net/data/config/settings.json +24 -10
  24. pygpt_net/data/css/web-blocks.darkest.css +91 -0
  25. pygpt_net/data/css/web-chatgpt.css +7 -5
  26. pygpt_net/data/css/web-chatgpt.dark.css +5 -2
  27. pygpt_net/data/css/web-chatgpt.darkest.css +91 -0
  28. pygpt_net/data/css/web-chatgpt.light.css +8 -2
  29. pygpt_net/data/css/web-chatgpt_wide.css +7 -4
  30. pygpt_net/data/css/web-chatgpt_wide.dark.css +5 -2
  31. pygpt_net/data/css/web-chatgpt_wide.darkest.css +91 -0
  32. pygpt_net/data/css/web-chatgpt_wide.light.css +9 -6
  33. pygpt_net/data/locale/locale.de.ini +2 -0
  34. pygpt_net/data/locale/locale.en.ini +2 -0
  35. pygpt_net/data/locale/locale.es.ini +2 -0
  36. pygpt_net/data/locale/locale.fr.ini +2 -0
  37. pygpt_net/data/locale/locale.it.ini +2 -0
  38. pygpt_net/data/locale/locale.pl.ini +3 -1
  39. pygpt_net/data/locale/locale.uk.ini +2 -0
  40. pygpt_net/data/locale/locale.zh.ini +2 -0
  41. pygpt_net/data/themes/dark_darkest.css +31 -0
  42. pygpt_net/data/themes/dark_darkest.xml +10 -0
  43. pygpt_net/plugin/audio_input/simple.py +5 -10
  44. pygpt_net/plugin/audio_output/plugin.py +4 -17
  45. pygpt_net/plugin/tuya/__init__.py +12 -0
  46. pygpt_net/plugin/tuya/config.py +256 -0
  47. pygpt_net/plugin/tuya/plugin.py +117 -0
  48. pygpt_net/plugin/tuya/worker.py +588 -0
  49. pygpt_net/plugin/wikipedia/__init__.py +12 -0
  50. pygpt_net/plugin/wikipedia/config.py +228 -0
  51. pygpt_net/plugin/wikipedia/plugin.py +114 -0
  52. pygpt_net/plugin/wikipedia/worker.py +430 -0
  53. pygpt_net/provider/core/config/patch.py +11 -0
  54. pygpt_net/ui/layout/chat/input.py +5 -2
  55. pygpt_net/ui/main.py +1 -2
  56. pygpt_net/ui/widget/audio/bar.py +5 -1
  57. pygpt_net/ui/widget/tabs/output.py +2 -0
  58. pygpt_net/ui/widget/textarea/input.py +483 -55
  59. {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/METADATA +78 -35
  60. {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/RECORD +63 -49
  61. {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/LICENSE +0 -0
  62. {pygpt_net-2.6.26.dist-info → pygpt_net-2.6.28.dist-info}/WHEEL +0 -0
  63. {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.25 20:00:00 #
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 QTextEdit, QApplication, QPushButton
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 = 12
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._init_attachment_button()
44
- self._apply_text_top_padding()
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
- m = self.viewportMargins()
49
- self.setViewportMargins(m.left(), self._text_top_padding, m.right(), m.bottom())
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
- """Public helper to adjust top padding at runtime."""
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._apply_text_top_padding()
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
- # -------------------- Attachment button (top-left) --------------------
199
-
200
- def _init_attachment_button(self):
201
- """Create and place the '+' attachment button pinned in the top-left corner."""
202
- self._attach_margin = 6 # inner padding around the button
203
- self._attach_offset_y = -4 # shift the button 2px up
204
-
205
- self._attach_btn = QPushButton(self)
206
- self._attach_btn.setObjectName("chatInputAttachBtn")
207
- self._attach_btn.setIconSize(QSize(18, 18)) # icon size (slightly larger for visibility)
208
- self._attach_btn.setFixedSize(24, 24) # full button size
209
- self._attach_btn.setCursor(Qt.PointingHandCursor)
210
- self._attach_btn.setToolTip(trans("attachments.btn.input.add"))
211
- self._attach_btn.setFocusPolicy(Qt.NoFocus)
212
- self._attach_btn.setFlat(True) # flat button style
213
-
214
- self._attach_btn.setIcon(self.ICON_ATTACHMENT)
215
- self._attach_btn.clicked.connect(self.action_add_attachment)
216
- self._update_viewport_margins_for_attachment()
217
- self._reposition_attachment_button()
218
-
219
- def _update_viewport_margins_for_attachment(self):
220
- """Reserve space for the attachment button on the left and apply top text padding."""
221
- top = self._text_top_padding
222
- left = self._attach_btn.width() + self._attach_margin * 2 if hasattr(self, "_attach_btn") else self.viewportMargins().left()
223
- self.setViewportMargins(left, top, 0, 0)
224
-
225
- def _reposition_attachment_button(self):
226
- """Keep the attachment button pinned to the top-left corner."""
227
- if hasattr(self, "_attach_btn"):
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._attach_margin
230
- y = fw + self._attach_margin + self._attach_offset_y # shift up by ~2px
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._attach_btn.move(x, y)
234
- self._attach_btn.raise_()
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 attachment button in place."""
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._reposition_attachment_button()
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