Source code for spacr.qt.widgets.ai_toggle_label

"""
AiToggleLabel — a clickable text label used in place of a QCheckBox
for the "AI" switch that sits at the bottom-right of every AppScreen.

* Reads "AI" in the current theme's foreground colour when off — white
  on the dark themes, near-black on light.
* Reads "AI" in the accent blue when on.
* Emits `toggled(bool)` on click; also exposes a QCheckBox-compatible
  `isChecked()` / `setChecked()` API so the AppScreen doesn't care
  which widget it's talking to.

.. note::

   The OFF colour is resolved through :func:`spacr.qt.theme.active_palette`
   (i.e. ``palette_for(resolve_effective_theme())``) every time the style
   is rebuilt, never imported from ``theme.PALETTE``. That module-level
   name is the *dark* palette and nothing updates it, so importing it
   painted ``#ffffff`` "AI" text onto the light theme's ``#fafafa`` page:
   **1.04:1** measured, white on near-white, invisible. It is 18.50:1
   now. See :mod:`tests.qt.test_theme_blind_console_widgets`.
"""
from __future__ import annotations

from PySide6.QtCore import QEvent, QSize, Qt, Signal
from PySide6.QtWidgets import QLabel

from ..i18n import tr
from ..theme import active_palette, button_accent_text, font_px

#: The widest a toggle may force the row it sits in to be.
#:
#: These toggles live in the action row, and that row's minimum width is
#: the minimum width of the whole screen -- a QHBoxLayout cannot go below
#: the sum of its children. "Live" is 83px and never caused anyone
#: trouble, which is why this went unnoticed for as long as it did.
#: "Hyperparameter search" is 281px, and it held the action row at 1109px
#: on every module with a hyperparameter panel (activation, classify,
#: classify_merged, ml_analyze). At a 1200px window the body splitter was
#: then left with 60px for the entire settings column -- a 290px settings
#: card inside a 50px viewport, with the labels hanging out of the right
#: of it.
#:
#: Only the MINIMUM is capped. `sizeHint` still asks for the full text, so
#: a layout with room to spare shows all of it; a squeezed one elides
#: instead of forcing the window wider. A secondary control must not be
#: able to starve the primary one -- see INVARIANTS 10.
ELIDE_ABOVE_PX = 120


[docs] class AiToggleLabel(QLabel): """Clickable text label that behaves like a QCheckBox toggle. Originally the "AI" switch, now also used for the "Live" preview toggle. Every consumer gets the same on-blue / off-white visual so the row of toggles reads consistently. :param text: label text (default ``"AI"`` for back-compat). :param tooltip: hover tooltip; falls back to a sensible AI-flavoured message when omitted. :param parent: parent widget; ownership only. :ivar toggled: emitted with the new on/off state whenever the user clicks or :meth:`setChecked` flips the state. """ toggled = Signal(bool) def __init__(self, parent=None, text: str = "AI", tooltip: str | None = None): """Build the AI toggle label, off. The English source text and tooltip are kept as properties, so a runtime language switch translates the original rather than translating a translation, and the toggle's state survives it. :param parent: parent widget, or ``None``. :param text: the label; ``"AI"`` by default. :param tooltip: hover text; ``None`` uses the standard explanation of what the toggle does. """ source_text = str(text) source_tooltip = tooltip if tooltip is not None else ( "Click to toggle AI. When ON (blue), pressing Enter in " "the console routes your message through your chat " "subscription via the selected provider." ) super().__init__(tr(source_text), parent) self.setProperty("_spacr_i18n_text", source_text) self.setObjectName("AiToggleLabel") self.setCursor(Qt.PointingHandCursor) self.setProperty("_spacr_i18n_tooltip", source_tooltip) self.setToolTip(tr(source_tooltip)) self._on = False self._restyling = False self._full_text = tr(source_text) self._eliding = False self._refresh_style()
[docs] def changeEvent(self, event): """Re-style when the application sheet or palette is replaced. Saving Preferences calls ``app.setStyleSheet(...)``, which sends every widget a ``StyleChange``. Widgets styled by the application sheet pick the new Zoom up for free; this one is not, so without this hook "Live" and "AI" would keep the size and the colour they were built with until the app was restarted. :param event: the change event; passed to the base class, and a style, palette, application-palette or application-font change re-styles the label. """ try: kind = event.type() except Exception: kind = None super().changeEvent(event) if kind in (QEvent.StyleChange, QEvent.PaletteChange, QEvent.ApplicationPaletteChange, QEvent.ApplicationFontChange): self._refresh_style()
[docs] def minimumSizeHint(self) -> QSize: # noqa: N802 (Qt naming) """Cap how much width this toggle can demand of its row. QLabel reports the full width of its text here, which makes the text a hard floor for every ancestor layout. See :data:`ELIDE_ABOVE_PX` for what that cost. """ hint = super().minimumSizeHint() if hint.width() <= ELIDE_ABOVE_PX: return hint return QSize(ELIDE_ABOVE_PX, hint.height())
[docs] def setText(self, text) -> None: # noqa: N802 (Qt naming) """Remember the full text, then show as much of it as fits. The language switch calls this with a fresh translation, so the stored text has to follow it rather than be captured once. :param text: the full label text; stored as :meth:`text` and elided to the current width when it does not fit. """ if not self._eliding: self._full_text = str(text) super().setText(text) if not self._eliding: self._apply_elision()
[docs] def text(self) -> str: """The full logical text, even when the label is showing less.""" return getattr(self, "_full_text", None) or super().text()
[docs] def displayed_text(self) -> str: """What the label is actually painting, elided or not. Distinct from :meth:`text`, which is the logical label. Tests need both to tell "the toggle says X" from "the toggle currently fits this much of X". """ return QLabel.text(self)
[docs] def resizeEvent(self, event): # noqa: N802 (Qt naming) """Re-elide for the width just granted. :param event: the resize event; passed to the base class, and the new width is read back from the widget itself. """ super().resizeEvent(event) self._apply_elision()
def _apply_elision(self) -> None: """Show the full text when it fits, an elided one when it does not.""" full = getattr(self, "_full_text", "") if not full: return inner = self.contentsRect().width() if inner <= 0: return metrics = self.fontMetrics() shown = (full if metrics.horizontalAdvance(full) <= inner else metrics.elidedText(full, Qt.ElideRight, inner)) if not shown.strip("…. \t"): shown = full if shown == QLabel.text(self): return self._eliding = True try: QLabel.setText(self, shown) finally: self._eliding = False
[docs] def isChecked(self) -> bool: """Return True when the AI toggle is currently ON.""" return self._on
[docs] def setChecked(self, on: bool) -> None: """Set the toggle state; emits ``toggled`` only on a real change. :param on: the new state, coerced to ``bool``; ``True`` is ON. """ on = bool(on) if on == self._on: return self._on = on self._refresh_style() self.toggled.emit(self._on)
[docs] def mousePressEvent(self, event): """Flip the toggle on left-click; forward other buttons to Qt. :param event: the mouse press; only its button is read, and a left-button press is consumed. """ if event.button() == Qt.LeftButton: self._on = not self._on self._refresh_style() self.toggled.emit(self._on) return super().mousePressEvent(event)
def _refresh_style(self) -> None: """Re-ink and re-size the label for the current state, theme and zoom. The off colour is resolved from the palette in force right now rather than imported: the frozen dark palette put white "AI" on the light theme's near-white page. The size comes through ``font_px`` because a per-widget stylesheet outranks the application sheet, so a literal size here pinned the label whatever the zoom preference said, and the padding scales with it or the hit target stops matching the glyphs. Setting a stylesheet posts a style change back to this widget, so both guards matter: the flag stops the immediate recursion and the comparison stops a storm when nothing about the answer changed. The geometry is invalidated afterwards because the new sheet moves both the size and the padding, and eliding against a stale hint would hide the text the zoom just enlarged. """ palette = active_palette() on_color = button_accent_text(palette) color = on_color if self._on else palette["fg"] size = font_px("body") sheet = ( f"QLabel#AiToggleLabel {{" f" color: {color};" f" font-size: {size}px;" f" font-weight: 600;" f" padding: {max(2, round(size * 4 / 13))}px" f" {max(4, round(size * 10 / 13))}px;" f" background: transparent;" f"}}" ) if self._restyling or sheet == self.styleSheet(): return self._restyling = True try: self.setStyleSheet(sheet) finally: self._restyling = False self.updateGeometry() self._apply_elision()