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.QtGui import (
    QAccessible, QAccessibleActionInterface, QAccessibleStateChangeEvent,
    QColor, QPainter, QPalette, QPen,
)
from PySide6.QtWidgets import QAccessibleWidget, 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


def _show_focus_ring(label) -> None:
    """Show ``label``'s keyboard-focus outline in its paint layer, or hide it.

    The outline used to be drawn by a Python ``paintEvent`` that every frame
    of the window's field re-entered, focused or not.
    """
    from ..gil_priority import _blank_layer_pixmap, _show_paint_layer

    if not label.hasFocus():
        _show_paint_layer(label, "_focus_layer", None)
        return
    pixmap = _blank_layer_pixmap(label)
    painter = QPainter(pixmap)
    try:
        width, colour = label._strong_focus_ring or (
            2, label.palette().color(QPalette.Highlight))
        painter.setPen(QPen(QColor(colour), width))
        painter.setBrush(Qt.NoBrush)
        painter.drawRect(label.rect().adjusted(1, 1, -1, -1))
    finally:
        painter.end()
    _show_paint_layer(label, "_focus_layer", pixmap)


[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) from .figure_grid_view import _strong_focus_style self._strong_focus_ring = _strong_focus_style() self.setFocusPolicy(Qt.StrongFocus) 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): from .figure_grid_view import _strong_focus_style self._strong_focus_ring = _strong_focus_style() self._refresh_style() _show_focus_ring(self)
[docs] def focusInEvent(self, event): """Repaint the outline when keyboard focus enters the toggle. :param event: focus-entry event, forwarded to QLabel. """ super().focusInEvent(event) _show_focus_ring(self) self.update()
[docs] def focusOutEvent(self, event): """Remove the outline when keyboard focus leaves the toggle. :param event: focus-exit event, forwarded to QLabel. """ super().focusOutEvent(event) _show_focus_ring(self) self.update()
[docs] def keyPressEvent(self, event): """Toggle once on unmodified Space, Return or numeric Enter while focused. Automatic key repeats are consumed without changing state. Other key events are forwarded to QLabel. Activation uses :meth:`setChecked`, preserving the existing state-change signal and routing connections. :param event: key-press event delivered to the toggle. """ key = event.key() modifiers = event.modifiers() if (self.hasFocus() and key in (Qt.Key_Space, Qt.Key_Return, Qt.Key_Enter) and (modifiers == Qt.NoModifier or (key == Qt.Key_Enter and modifiers == Qt.KeypadModifier))): if not event.isAutoRepeat(): self.setChecked(not self.isChecked()) event.accept() return super().keyPressEvent(event)
[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() if self.__dict__.get("_focus_layer") is not None: _show_focus_ring(self)
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() changed = QAccessible.State() changed.checked = True QAccessible.updateAccessibility(QAccessibleStateChangeEvent(self, changed)) 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.setChecked(not self.isChecked()) 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 {{" 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()
class _AccessibleAiToggle(QAccessibleWidget): """Expose the existing label's toggle semantics to assistive clients.""" def __init__(self, widget): """Wrap the label with checkbox role and normal widget navigation.""" super().__init__(widget, QAccessible.CheckBox) def state(self): """Combine normal widget state with the label's current checked value.""" state = super().state() state.checkable = True state.checked = bool(getattr(self.widget(), "_on", False)) state.readOnly = False return state def text(self, kind): """Use an explicit accessible name or the full translated label.""" if kind == QAccessible.Name: widget = self.widget() return widget.accessibleName() or widget.text() return super().text(kind) def actionNames(self): """Offer toggle and normal widget actions while the control is enabled.""" if not self.widget().isEnabled(): return [] return [QAccessibleActionInterface.toggleAction(), *super().actionNames()] def doAction(self, action): """Dispatch a client toggle through the label's existing state setter.""" widget = self.widget() if not widget.isEnabled(): return if action == QAccessibleActionInterface.toggleAction(): widget.setChecked(not widget.isChecked()) return super().doAction(action) def _ai_toggle_accessible_factory(_key, widget): """Provide this interface only for AI/Live labels and retain its wrapper.""" if isinstance(widget, AiToggleLabel): interface = _AccessibleAiToggle(widget) widget._accessible_toggle = interface return interface return None QAccessible.installFactory(_ai_toggle_accessible_factory)