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