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