"""
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()