diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index ca67a0aa..8bb9b905 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -1,3 +1,5 @@ +import math +import time from dataclasses import dataclass from aarecommon.models.models import SessionsStateEnum @@ -42,6 +44,36 @@ class BusyOverlayStyle: # AxisVideoPanel strips it because only the sample-camera badge is a # click target and the hint invites a click. subtext: str = "" + # Wave-animate the title letters. Only the busy/robot warnings: a static + # "In viewing mode" badge must not look like an in-progress operation. + animate: bool = False + + +def draw_wave_text( + painter: QPainter, + x: int, + baseline: int, + text: str, + fm: QFontMetrics, + fg: QColor, + shadow: QColor | None = None, + amplitude: int = 5, +) -> None: + """Per-letter hop wave for the busy warnings, so the text reads as an + in-progress signal instead of a frozen label. The phase comes from the + wall clock and repaints ride the ~20 fps camera frames — no timer here; + if the stream stalls the wave freezes, which is acceptable (the text + stays legible and a stalled feed has its own error surface).""" + now = time.monotonic() + for index, char in enumerate(text): + # Clipped sine: letters rest on the baseline and hop up in sequence. + lift = -round(amplitude * max(0.0, math.sin(now * 5.5 - index * 0.55))) + if shadow is not None: + painter.setPen(QPen(shadow)) + painter.drawText(QPoint(x + 1, baseline + lift + 1), char) + painter.setPen(QPen(fg)) + painter.drawText(QPoint(x, baseline + lift), char) + x += fm.horizontalAdvance(char) def draw_busy_badge( @@ -89,7 +121,12 @@ def draw_busy_badge( painter.setFont(font) title_x = position_x + (bg_width - title_width) // 2 title_y = position_y + padding_y + font_metrics.ascent() - painter.drawText(QPoint(title_x, title_y), style.text) + if style.animate: + draw_wave_text( + painter, title_x, title_y, style.text, font_metrics, QColor(style.overlay_text) + ) + else: + painter.drawText(QPoint(title_x, title_y), style.text) if style.subtext: painter.setFont(sub_font) @@ -115,6 +152,17 @@ def draw_busy_status_text( x = (viewport_width - font_metrics.horizontalAdvance(style.text)) // 2 baseline = int(viewport_height * 0.68) + font_metrics.ascent() // 2 + if style.animate: + draw_wave_text( + painter, + x, + baseline, + style.text, + font_metrics, + qcolor(style.badge_bg), + shadow=qcolor(SHADOW, 200), + ) + return painter.setPen(QPen(qcolor(SHADOW, 200))) painter.drawText(QPoint(x + 1, baseline + 1), style.text) painter.setPen(QPen(qcolor(style.badge_bg))) @@ -156,6 +204,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_RED_BORDER, 230), overlay_text=qcolor(WHITE), accent_dot=BUSY_RED_DOT, + animate=True, ) if activity_value == "unmounting": @@ -167,6 +216,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_ORANGE_BORDER, 230), overlay_text=qcolor(WHITE), accent_dot=BUSY_ORANGE_DOT, + animate=True, ) if activity_value == "drying": @@ -178,6 +228,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_YELLOW_BORDER, 235), overlay_text=qcolor(BUSY_YELLOW_TEXT_DARK), accent_dot=BUSY_YELLOW_DOT, + animate=True, ) if activity_value == "cooling": @@ -189,6 +240,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_BLUE_BORDER, 235), overlay_text=qcolor(WHITE), accent_dot=BUSY_BLUE_DOT, + animate=True, ) return BusyOverlayStyle( @@ -199,4 +251,5 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_PSI_RED_BORDER, 235), overlay_text=qcolor(WHITE), accent_dot=BUSY_PSI_RED_DOT, + animate=True, ) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 763c5cdf..88c1b5d8 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -67,6 +67,7 @@ from aare.gui.widgets.busy_overlay import ( BusyOverlayStyle, build_busy_overlay_style, draw_busy_badge, + draw_wave_text, ) logger = setup_logger(LOGGER_NAME) @@ -337,14 +338,26 @@ class SampleCameraImageLabel(QGraphicsView): self._session_badge_rect = None painter.setFont(font) baseline = int(self.viewport().height() * 0.68) + font_metrics.ascent() // 2 - self._draw_status_text( - painter, - style.text, - style.badge_bg, - self.viewport().width() // 2, - baseline, - font_metrics, - ) + if style.animate: + x = self.viewport().width() // 2 - font_metrics.horizontalAdvance(style.text) // 2 + draw_wave_text( + painter, + x, + baseline, + style.text, + font_metrics, + qcolor(style.badge_bg), + shadow=qcolor(SHADOW, 200), + ) + else: + self._draw_status_text( + painter, + style.text, + style.badge_bg, + self.viewport().width() // 2, + baseline, + font_metrics, + ) painter.restore() return