diff --git a/docs/assets/img/waldo-happy.svg b/docs/assets/img/waldo-happy.svg
new file mode 100644
index 00000000..5b33a42c
--- /dev/null
+++ b/docs/assets/img/waldo-happy.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/img/waldo-neutral.svg b/docs/assets/img/waldo-neutral.svg
new file mode 100644
index 00000000..676f8205
--- /dev/null
+++ b/docs/assets/img/waldo-neutral.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/img/waldo-sad.svg b/docs/assets/img/waldo-sad.svg
new file mode 100644
index 00000000..0e652ab9
--- /dev/null
+++ b/docs/assets/img/waldo-sad.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/index.md b/docs/index.md
index c9566256..19d828f2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -39,11 +39,11 @@ Keyboard shortcuts: **WASD** + **Q/E** for Cartesian movement, **[/]** to adjust
### Connecting Your Robot
-In the control panel, switch to the **Settings** tab and select your hardware connection. On Linux you'll need access to the serial device — add yourself to the `dialout` group or set up a udev rule. Connection status is shown in the top right corner.
+In the control panel, switch to the **Settings** tab and select your hardware connection. On Linux you'll need access to the serial device — add yourself to the `dialout` group or set up a udev rule. Connection status is shown by Waldo, the little robot in the top right corner:
-- ■ Connected to robot hardware
-- ■ Robot mode but disconnected
-- ■ Simulator mode
+- Connected to robot hardware
+- Robot mode but disconnected
+- Simulator mode
diff --git a/tests/test_robot_buddy.py b/tests/test_robot_buddy.py
new file mode 100644
index 00000000..801096ec
--- /dev/null
+++ b/tests/test_robot_buddy.py
@@ -0,0 +1,248 @@
+"""The status chip's robot buddy wears the robot's state.
+
+Driven through the real paths on the fake-serial controller — the E-STOP
+and Reset buttons, jog and home buttons, the editor's play and record
+buttons, the settings switches, an MCP session taking control — and read
+back off the buddy element the page renders. How the browser draws each mood
+and reaction is covered in ``test_robot_buddy_screen.py``.
+"""
+
+import asyncio
+import time
+from collections.abc import Callable
+
+import pytest
+import waldoctl
+from fastmcp import Client
+from nicegui import app as ng_app, ui
+from nicegui.testing import User
+
+from tests.helpers.wait import (
+ enable_sim,
+ ensure_robot_ready_for_motion,
+ simulate_click,
+ teleport_to_jog_pose,
+ wait_for_app_ready,
+)
+from waldo_commander.components.robot_buddy import (
+ CALM_STORAGE_KEY,
+ Light,
+ Mood,
+ Reaction,
+ RobotBuddy,
+)
+from waldo_commander.components.script_execution import script_exec
+from waldo_commander.constants import HOME_LONG_PRESS_S
+from waldo_commander.mcp.server import get_mcp
+from waldo_commander.services.control_lease import control_lease
+from waldo_commander.state import robot_events, robot_state, ui_state
+
+
+def _buddy(user: User, marker: str) -> RobotBuddy:
+ element = next(iter(user.find(marker=marker).elements))
+ assert isinstance(element, RobotBuddy)
+ return element
+
+
+async def _wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> bool:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if condition():
+ return True
+ await asyncio.sleep(0.02)
+ return False
+
+
+@pytest.mark.integration
+async def test_chip_buddy_follows_estop_and_watches_the_arm_move(user: User) -> None:
+ """Grey in the simulator, alarmed for as long as an E-STOP is latched
+ (with its own alarmed buddy in the dialog), and focused on the arm
+ while it moves."""
+ await user.open("/")
+ await wait_for_app_ready()
+ await enable_sim(user)
+ await ensure_robot_ready_for_motion()
+
+ chip = _buddy(user, "readout-robot-buddy")
+ assert await _wait_for(lambda: chip.mood == Mood.NEUTRAL)
+ assert await _wait_for(lambda: not chip.busy), "idle arm, idle buddy"
+
+ waldoctl.commander.settings.jog.joint_step_deg = 10.0
+ await simulate_click(user, "btn-j1-plus")
+ assert await _wait_for(lambda: chip.busy, timeout=5.0), (
+ "the buddy should focus on the arm while the jog moves it"
+ )
+ assert await _wait_for(lambda: not chip.busy, timeout=10.0), (
+ "the buddy should relax once the arm has settled"
+ )
+
+ user.find(marker="btn-estop").click()
+ assert await _wait_for(lambda: chip.mood == Mood.ALARMED)
+ assert _buddy(user, "estop-buddy").mood == Mood.ALARMED
+
+ user.find(marker="btn-estop-resume").click()
+ assert await _wait_for(lambda: chip.mood == Mood.NEUTRAL), (
+ "Reset clears the E-STOP, so the buddy calms back down"
+ )
+
+
+@pytest.mark.integration
+async def test_chip_buddy_reacts_to_how_programs_end_and_to_new_warnings(
+ user: User,
+) -> None:
+ """A clean exit is celebrated, a crash is winced at, the buddy works
+ along while the program runs, and a new warning startles it."""
+ await user.open("/")
+ await wait_for_app_ready()
+
+ chip = _buddy(user, "readout-robot-buddy")
+ user.find(marker="tab-program").click()
+ await asyncio.sleep(0)
+ program = waldoctl.commander.programs.active
+ assert program is not None
+
+ async def run(source: str) -> int:
+ ui_state.active_textarea.value = source
+ program.source = source
+ script_exec.last_exit_code = None
+ user.find(marker="editor-play-btn").click()
+ assert await _wait_for(lambda: script_exec.last_exit_code is not None, 15.0)
+ assert script_exec.last_exit_code is not None
+ return script_exec.last_exit_code
+
+ busy_seen = False
+
+ async def watch_busy() -> None:
+ nonlocal busy_seen
+ while not busy_seen:
+ busy_seen = chip.busy
+ await asyncio.sleep(0.02)
+
+ watcher = asyncio.create_task(watch_busy())
+ assert await run("import time\ntime.sleep(1.0)\n") == 0
+ watcher.cancel()
+ assert busy_seen, "the buddy should be busy while a program runs"
+ assert chip.last_reaction == Reaction.CELEBRATE
+ assert await _wait_for(lambda: not chip.busy)
+
+ # Guarded so only the real run crashes: the editor's path preview
+ # executes the same source under ``__name__ == "__simulation__"``.
+ crash = "if __name__ == '__main__':\n raise RuntimeError('boom')\n"
+ assert await run(crash) != 0
+ assert chip.last_reaction == Reaction.OOPS
+
+ # The fake-serial backend reports no warnings of its own; add one the
+ # way the status consumer does when a new condition arrives.
+ robot_events.add("warning", "Control loop degraded", "p99 over band")
+ assert await _wait_for(lambda: chip.last_reaction == Reaction.STARTLE)
+
+
+@pytest.mark.integration
+async def test_chip_buddy_shrugs_at_a_joint_limit_and_nods_when_homed(
+ user: User,
+) -> None:
+ """Jogging a joint into its limit gets a shrug; a calibration homing that
+ completes gets a nod."""
+ await user.open("/")
+ await wait_for_app_ready()
+ await enable_sim(user)
+ await ensure_robot_ready_for_motion()
+ chip = _buddy(user, "readout-robot-buddy")
+ client = ui_state.control_panel.client
+
+ try:
+ # Hold J1+ until the base runs out of travel.
+ await simulate_click(user, "btn-j1-plus", hold_ms=4000)
+ assert await _wait_for(
+ lambda: not waldoctl.commander.status.joints.can_jog_pos[0]
+ ), "the jog should have reached J1's limit"
+ assert await _wait_for(lambda: chip.last_reaction == Reaction.SHRUG)
+ finally:
+ await teleport_to_jog_pose(client)
+
+ assert await _wait_for(lambda: robot_state.homed, timeout=15.0)
+ btn = user.find(marker="btn-home")
+ btn.trigger("pointerdown")
+ deadline = time.monotonic() + HOME_LONG_PRESS_S + 3.0
+ while robot_state.homed and time.monotonic() < deadline:
+ await asyncio.sleep(0.01)
+ assert not robot_state.homed, "calibration never dropped the homed flag"
+ btn.trigger("pointerup")
+ btn.trigger("click")
+ assert await _wait_for(lambda: robot_state.homed, timeout=30.0)
+ assert await _wait_for(lambda: chip.last_reaction == Reaction.NOD)
+
+
+@pytest.mark.integration
+async def test_chip_buddy_lights_up_for_recording_and_for_an_agent_in_control(
+ user: User,
+) -> None:
+ """A REC light while the motion recorder runs, and a steady glow while an
+ MCP session holds control of the arm."""
+ await user.open("/")
+ await wait_for_app_ready()
+ chip = _buddy(user, "readout-robot-buddy")
+ assert chip.light is None
+
+ user.find(marker="tab-program").click()
+ await asyncio.sleep(0)
+ user.find(marker="editor-record-btn").click()
+ assert await _wait_for(lambda: chip.light == Light.RECORDING)
+ user.find(marker="editor-record-btn").click()
+ assert await _wait_for(lambda: chip.light is None)
+
+ try:
+ async with Client(get_mcp()) as client:
+ await client.call_tool("control.take_control")
+ assert await _wait_for(lambda: chip.light == Light.AGENT)
+ await client.call_tool("control.release_control")
+ assert await _wait_for(lambda: chip.light is None)
+ finally:
+ control_lease.reset()
+
+
+@pytest.mark.integration
+async def test_chip_buddy_dozes_only_in_the_simulator_and_calms_on_request(
+ user: User, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Only the simulator buddy may fall asleep — on a hardware connection a
+ sleeping robot would read as "offline". The Calm robot setting stills it
+ and every buddy built afterwards."""
+ await user.open("/")
+ await wait_for_app_ready()
+ chip = _buddy(user, "readout-robot-buddy")
+ assert chip.mood == Mood.NEUTRAL
+ assert chip.sleep_after_s > 0
+
+ # Leaving the simulator makes the controller open a serial port this box
+ # does not have, so the backend flip is stubbed; the GUI side is real.
+ async def _fake_simulator(enabled: bool) -> int:
+ return 1
+
+ monkeypatch.setattr(ui_state.control_panel.client, "simulator", _fake_simulator)
+ try:
+ user.find(marker="btn-robot-toggle").click()
+ assert await _wait_for(lambda: chip.mood == Mood.SAD)
+ assert chip.sleep_after_s == 0
+ user.find(marker="btn-robot-toggle").click()
+ assert await _wait_for(lambda: chip.mood == Mood.NEUTRAL)
+ assert chip.sleep_after_s > 0
+ finally:
+ waldoctl.commander.status.simulator_active = True
+ ng_app.storage.general.pop("startup_mode", None)
+
+ try:
+ assert not chip.calm
+ user.find(kind=ui.tab, content="Settings").click()
+ await asyncio.sleep(0)
+ user.find(marker="switch-calm-buddy").click()
+ assert await _wait_for(lambda: chip.calm)
+
+ # A buddy built after the switch flips (the E-STOP dialog's) is calm too.
+ user.find(marker="btn-estop").click()
+ await user.should_see(marker="estop-buddy")
+ assert _buddy(user, "estop-buddy").calm
+ user.find(marker="btn-estop-resume").click()
+ assert await _wait_for(lambda: chip.mood == Mood.NEUTRAL)
+ finally:
+ ng_app.storage.general.pop(CALM_STORAGE_KEY, None)
diff --git a/tests/test_robot_buddy_screen.py b/tests/test_robot_buddy_screen.py
new file mode 100644
index 00000000..69c95531
--- /dev/null
+++ b/tests/test_robot_buddy_screen.py
@@ -0,0 +1,132 @@
+"""Browser-level check that the robot buddy actually animates.
+
+Everything the buddy does between the server's mood/reaction updates —
+greeting, following the pointer, answering pokes, raising the alarm —
+happens in its Vue component, so only a real browser can see it. Each
+expression is a layer whose opacity the component drives; the checks
+read those layers off the live DOM.
+"""
+
+import re
+import time
+
+import pytest
+from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.by import By
+from selenium.webdriver.common.keys import Keys
+
+from tests.helpers.browser_helpers import dismiss_dialogs, js
+
+CHIP = ".q-chip .robot-buddy"
+DIALOG = ".q-dialog .robot-buddy"
+
+
+def _layer_shown(screen, root: str, layer: str) -> bool:
+ return bool(
+ js(
+ screen,
+ "const el = document.querySelector(arguments[0] + ' ' + arguments[1]);"
+ "return !!el && getComputedStyle(el).opacity === '1';",
+ root,
+ layer,
+ )
+ )
+
+
+def _wait(condition, timeout: float, what: str) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if condition():
+ return
+ time.sleep(0.05)
+ raise AssertionError(f"timed out waiting for {what}")
+
+
+def _pupil_offset(screen) -> tuple[float, float]:
+ transform = js(
+ screen,
+ "return document.querySelector(arguments[0] + ' .bb-pupil').style.transform;",
+ CHIP,
+ )
+ x, y = (float(v) for v in re.findall(r"-?\d+(?:\.\d+)?", transform)[:2])
+ return x, y
+
+
+@pytest.mark.browser
+def test_chip_buddy_greets_watches_the_pointer_and_answers_pokes(screen) -> None:
+ screen.open("/")
+
+ # The page waves hello once it has loaded: a wink from the chip.
+ _wait(
+ lambda: _layer_shown(screen, CHIP, ".bb-eyes-wink"),
+ timeout=30.0,
+ what="the chip buddy's greeting wink",
+ )
+ dismiss_dialogs(screen)
+ _wait(
+ lambda: (
+ _layer_shown(screen, CHIP, ".bb-eyes-open")
+ and not _layer_shown(screen, CHIP, ".bb-eyes-wink")
+ ),
+ timeout=5.0,
+ what="the greeting to finish",
+ )
+ assert js(
+ screen,
+ "return !!document.querySelector(arguments[0] + '.bb-mood-neutral');",
+ CHIP,
+ ), "the simulator's buddy is the grey one"
+
+ # Pointer down and to the left of the chip: the pupils follow it.
+ chip = screen.selenium.find_element(By.CSS_SELECTOR, CHIP)
+ ActionChains(screen.selenium).move_to_element_with_offset(chip, -200, 150).perform()
+ _wait(
+ lambda: _pupil_offset(screen)[0] < -0.3 and _pupil_offset(screen)[1] > 0.2,
+ timeout=3.0,
+ what="the pupils to look down-left at the pointer",
+ )
+
+ # One poke giggles; a flurry makes it dizzy.
+ ActionChains(screen.selenium).move_to_element(chip).click().perform()
+ _wait(
+ lambda: _layer_shown(screen, CHIP, ".bb-eyes-happy"),
+ timeout=2.0,
+ what="a giggle after one poke",
+ )
+ # Four quick pokes land dizzy whether or not the first poke is still
+ # inside the poke window: pokes are ignored while it is dizzy.
+ ActionChains(screen.selenium).click(chip).click().click().click().perform()
+ _wait(
+ lambda: _layer_shown(screen, CHIP, ".bb-eyes-x"),
+ timeout=2.0,
+ what="X eyes after a flurry of pokes",
+ )
+
+ # E-STOP: the chip sounds the alarm and the dialog brings a big one.
+ ActionChains(screen.selenium).move_by_offset(-400, 300).perform()
+ ActionChains(screen.selenium).send_keys(Keys.ESCAPE).perform()
+ _wait(
+ lambda: js(
+ screen,
+ "return !!document.querySelector(arguments[0] + '.bb-mood-alarmed.bb-leds-alarm')"
+ " && !!document.querySelector(arguments[1] + '.bb-leds-alarm');",
+ CHIP,
+ DIALOG,
+ ),
+ timeout=5.0,
+ what="alarmed buddies in the chip and the E-STOP dialog",
+ )
+ js(
+ screen,
+ "for (const b of document.querySelectorAll('.q-dialog button'))"
+ " if (/reset/i.test(b.textContent)) { b.click(); break; }",
+ )
+ _wait(
+ lambda: js(
+ screen,
+ "return !!document.querySelector(arguments[0] + '.bb-mood-neutral');",
+ CHIP,
+ ),
+ timeout=5.0,
+ what="the chip buddy to calm down after Reset",
+ )
diff --git a/tests/test_simulation_services.py b/tests/test_simulation_services.py
index d4506b0a..19f97801 100644
--- a/tests/test_simulation_services.py
+++ b/tests/test_simulation_services.py
@@ -921,6 +921,29 @@ async def main():
f"Expected at least 1 segment, got {len(self._active_dry_run().path_segments)}"
)
+ @pytest.mark.asyncio
+ async def test_program_that_exits_previews_up_to_the_exit(self):
+ """A program ending itself with sys.exit() previews the motion it
+ planned before exiting, like the real run, instead of the SystemExit
+ escaping the preview and taking its caller down."""
+ visualizer = PathVisualizer()
+
+ program = """
+import sys
+import parol6
+
+async def main():
+ async with parol6.AsyncRobotClient() as rbt:
+ await rbt.move_j([85, -85, 175, 5, 5, 175], speed=1.0)
+ sys.exit(3)
+ await rbt.move_j([100, -100, 190, -10, -10, 190], speed=1.0)
+"""
+
+ error = await visualizer.update_path_visualization(program)
+
+ assert error is None
+ assert len(self._active_dry_run().path_segments) == 1
+
@pytest.mark.asyncio
async def test_visualizer_updates_total_steps(self):
"""PathVisualizer should update total_steps after simulation."""
diff --git a/waldo_commander/common/theme.py b/waldo_commander/common/theme.py
index fbb967f8..3d3db527 100644
--- a/waldo_commander/common/theme.py
+++ b/waldo_commander/common/theme.py
@@ -1387,56 +1387,22 @@ def inject_layout_css() -> None:
.file-tree-scroll .q-scrollarea__content { padding: 0 !important; }
+/* Settings rows are w-full with truncating descriptions; pinning the scroll
+ content to the panel width makes a long description truncate instead of
+ widening every row and pushing the controls out of view. */
+.settings-scroll .q-scrollarea__content { width: 100%; }
+
/* ========== File Tree ========== */
.file-tree .q-tree__node-header-content { color: white !important; }
.file-tree .q-tree__node--selected > .q-tree__node-header .q-tree__node-header-content { color: white !important; font-weight: bold !important; }
.file-tree .q-tree__node--parent > .q-tree__node-header .q-tree__node-header-content { font-weight: bold !important; }
-/* ========== Robot Face Indicator ========== */
-
-/* Robot face SVG transitions */
-.robot-face .pupil { transition: transform 0.45s ease; }
-.robot-face .eye-white { transition: opacity 0.25s ease; }
-
-/* Breathing animations */
-@keyframes breathe-happy {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-2px); }
-}
-@keyframes breathe-neutral {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-1.5px); }
-}
-@keyframes breathe-sad {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-2.5px); }
-}
-.robot-face-happy svg { animation: breathe-happy 6s ease-in-out infinite; }
-.robot-face-neutral svg { animation: breathe-neutral 7s ease-in-out infinite; animation-delay: -2s; }
-.robot-face-sad svg { animation: breathe-sad 8s ease-in-out infinite; animation-delay: -4s; }
-
/* Help tab has no panel — hide its indicator to prevent stale marker at startup */
.side-tab-bar.absolute.bottom-0 .q-tab:last-child .q-tab__indicator {
display: none !important;
}
-/* ========== Takeover Overlay ========== */
-
-/* Wandering sad robot — DVD-screensaver-style bounce around the viewport.
- Dimensions and fixed positioning are load-bearing: robot-faces.js uses
- FACE_SIZE = 96 for collision math, and the JS sets `transform` directly
- to compose translate + rotate without browser animation interference. */
-.takeover-face {
- position: fixed;
- top: 0;
- left: 0;
- width: 96px;
- height: 96px;
- pointer-events: none;
-}
-
-
/* ========== Action Log ========== */
.action-log {
diff --git a/waldo_commander/components/control.py b/waldo_commander/components/control.py
index a659d5b5..5ba03a4b 100644
--- a/waldo_commander/components/control.py
+++ b/waldo_commander/components/control.py
@@ -28,6 +28,7 @@
global_phase_timer,
)
from waldo_commander.components.playback import playback
+from waldo_commander.components.robot_buddy import Mood, RobotBuddy
from waldo_commander.components.script_execution import script_exec
from waldo_commander.components.settings import SettingsContent, _setting_row
from waldo_commander.services.control_lease import (
@@ -52,6 +53,9 @@
logger = logging.getLogger(__name__)
+_ESTOP_BUDDY_PX = 160
+_DIGITAL_ESTOP_COLOR = "var(--q-warning)"
+
# Module-level constants and precompiled regexes: avoid recreating them every frame.
_AXIS_ORDER = (
"X+",
@@ -115,8 +119,13 @@ def __init__(self, client: "RobotClient", ui_client_fn: Callable[[], Any]) -> No
self._last_io_state: int = 1
self._digital_active: bool = False
+ @property
+ def active(self) -> bool:
+ """An E-STOP dialog is up: a physical or digital stop is latched."""
+ return self._dialog is not None
+
def show(self, is_physical: bool) -> None:
- """Show E-STOP dialog with Lottie animation."""
+ """Show the E-STOP dialog with an alarmed robot buddy."""
ui_client = self._ui_client_fn()
if not ui_client:
return
@@ -139,10 +148,13 @@ def show(self, is_physical: bool) -> None:
.classes("overlay-card gap-4 items-center")
.mark("estop-dialog"),
):
- ui.html(
- """ """,
- sanitize=False,
- ).classes("w-96")
+ # Red for the hardware button, amber for the software stop,
+ # matching the headline below.
+ RobotBuddy(
+ Mood.ALARMED,
+ size=_ESTOP_BUDDY_PX,
+ color=None if is_physical else _DIGITAL_ESTOP_COLOR,
+ ).classes("my-4").mark("estop-buddy")
if is_physical:
ui.label("Physical E-STOP Active").classes(
@@ -2420,7 +2432,7 @@ def _add_slot(
# Settings panel
with ui.tab_panel(settings_tab).classes("gap-0 p-0"):
- with ui.scroll_area().classes("w-full h-full p-0"):
+ with ui.scroll_area().classes("settings-scroll w-full h-full p-0"):
self._settings_content = SettingsContent(self.client)
self._settings_content.build_embedded(
ai_control_section=self._build_control_mode_selector
diff --git a/waldo_commander/components/help_menu.py b/waldo_commander/components/help_menu.py
index 85704289..0051c90d 100644
--- a/waldo_commander/components/help_menu.py
+++ b/waldo_commander/components/help_menu.py
@@ -2,6 +2,7 @@
from nicegui import app as ng_app, ui
+from waldo_commander.components.robot_buddy import Mood, Reaction, RobotBuddy
from waldo_commander.services.keybindings import keybindings_manager
@@ -201,13 +202,10 @@ def _build_quickstart_stepper(self, include_safety_step: bool = False) -> None:
{
"title": "Connecting Your Robot",
"description": """
- In the control panel, switch to the **Settings** tab and select your hardware connection. On Linux you'll need access to the serial device — add yourself to the `dialout` group or set up a udev rule. Connection status is shown in the top right corner.
-
- - ■ Connected to robot hardware
- - ■ Robot mode but disconnected
- - ■ Simulator mode
+ In the control panel, switch to the **Settings** tab and select your hardware connection. On Linux you'll need access to the serial device — add yourself to the `dialout` group or set up a udev rule. Connection status is shown by Waldo, the little robot in the top right corner:
""",
"video": "connecting_to_robot.mp4",
+ "status_legend": True,
},
{
"title": "Programming, Recording, and Path Visualization",
@@ -281,11 +279,11 @@ def on_accept(e):
"w-full rounded-lg"
).props('preload="metadata"').style("max-height: 360px;")
- # sanitize=False: content is a hardcoded literal whose inline status-marker
- # color spans DOMPurify would otherwise strip.
- ui.markdown(step["description"], sanitize=False).classes(
+ ui.markdown(step["description"]).classes(
"text-md text-gray-300"
)
+ if step.get("status_legend"):
+ self._build_status_legend()
with ui.stepper_navigation():
if i < len(steps) - 1:
@@ -301,6 +299,18 @@ def on_accept(e):
"Back", on_click=self._stepper.previous
).props("flat")
+ def _build_status_legend(self) -> None:
+ """The chip's three moods, drawn by the same buddy that wears them."""
+ with ui.column().classes("gap-1 ml-2").mark("status-legend"):
+ for mood, text in (
+ (Mood.HAPPY, "Connected to robot hardware"),
+ (Mood.SAD, "Robot mode but disconnected"),
+ (Mood.NEUTRAL, "Simulator mode"),
+ ):
+ with ui.row().classes("items-center gap-3 no-wrap"):
+ RobotBuddy(mood, size=28)
+ ui.label(text).classes("text-md text-gray-300")
+
def _on_finish(self) -> None:
"""Handle finish button click - mark tutorial complete and close dialog."""
ng_app.storage.general[self.FIRST_VISIT_KEY] = True
@@ -328,7 +338,12 @@ def create_first_time_dialog(self) -> ui.dialog:
with self._dialog:
with ui.card().classes("overlay-card tutorial-dialog-card"):
with ui.column().classes("w-full h-full gap-0"):
- ui.label("Welcome to PAROL Commander!").classes("text-xl font-bold")
+ with ui.row().classes("items-center gap-3 no-wrap"):
+ buddy = RobotBuddy(Mood.HAPPY, size=44, interactive=True)
+ buddy.react(Reaction.GREET)
+ ui.label("Welcome to PAROL Commander!").classes(
+ "text-xl font-bold"
+ )
ui.label(
"Let's get you started with a quick tour of the interface."
diff --git a/waldo_commander/components/readout.py b/waldo_commander/components/readout.py
index cb731846..af17f3f7 100644
--- a/waldo_commander/components/readout.py
+++ b/waldo_commander/components/readout.py
@@ -3,8 +3,7 @@
import html as html_mod
import logging
import random
-from enum import Enum
-from pathlib import Path
+import time
from nicegui import ui
@@ -12,37 +11,84 @@
from waldoctl import ActionStatus
from waldo_commander.common.theme import IO_COLOR_OFF, IO_COLOR_ON
-from waldo_commander.state import robot_events, ui_state
+from waldo_commander.components.robot_buddy import Light, Mood, Reaction, RobotBuddy
+from waldo_commander.services.control_lease import MCP, control_lease
+from waldo_commander.services.programs import (
+ is_any_program_recording,
+ is_any_program_running,
+)
+from waldo_commander.state import robot_events, robot_state, ui_state
logger = logging.getLogger(__name__)
-class RobotFace(Enum):
- """Robot face states for the connection status indicator."""
-
- HAPPY = "happy"
- NEUTRAL = "neutral"
- SAD = "sad"
-
-
-# Load robot face SVGs at module level for inline rendering (CSS hover needs DOM access)
-_ICONS_DIR = Path(__file__).parent.parent / "static" / "icons"
-FACE_SVGS = {
- RobotFace.HAPPY: (_ICONS_DIR / "robot_happy.svg").read_text(),
- RobotFace.NEUTRAL: (_ICONS_DIR / "robot_neutral.svg").read_text(),
- RobotFace.SAD: (_ICONS_DIR / "robot_sad.svg").read_text(),
+_MOOD_TOOLTIPS = {
+ Mood.HAPPY: "Connected",
+ Mood.NEUTRAL: "Simulator",
+ Mood.SAD: "Disconnected",
+ Mood.ALARMED: "E-STOP active",
}
-_FACE_TOOLTIPS = {
- RobotFace.HAPPY: "Connected",
- RobotFace.NEUTRAL: "Simulator",
- RobotFace.SAD: "Disconnected",
+_LIGHT_TOOLTIPS = {
+ Light.RECORDING: "Recording",
+ Light.AGENT: "AI agent in control",
}
-# Chip background — darker hue of the face icon color
+# Chip background — darker hue of the buddy's colour
_CHIP_COLORS = {
- RobotFace.HAPPY: "var(--color-emerald-400)",
- RobotFace.NEUTRAL: "var(--color-gray-400)",
- RobotFace.SAD: "var(--color-red-400)",
+ Mood.HAPPY: "var(--color-emerald-400)",
+ Mood.NEUTRAL: "var(--color-gray-400)",
+ Mood.SAD: "var(--color-red-400)",
+ Mood.ALARMED: "var(--color-red-400)",
}
+# The buddy watches the arm while it moves; hold that a beat past each stop
+# so a train of step jogs reads as one stretch of work, not a flicker.
+_MOVING_DEG_S = 0.5
+_MOVING_HOLD_S = 1.0
+# Only the simulator buddy dozes: on a live or lost hardware connection a
+# sleeping robot would read as "robot idle / offline" at a glance.
+_BUDDY_SLEEP_AFTER_S = 180.0
+
+
+def _sleep_after(mood: Mood) -> float:
+ return _BUDDY_SLEEP_AFTER_S if mood == Mood.NEUTRAL else 0.0
+
+
+def _chip_style(mood: Mood) -> str:
+ return (
+ f"background-color: {_CHIP_COLORS[mood]} !important;"
+ " margin: 0;"
+ " padding: 20px 12px !important;"
+ " box-shadow: none;"
+ " border-radius: 10px;"
+ )
+
+
+def _status_mood() -> Mood:
+ """The chip buddy's mood from connection and E-STOP state."""
+ status = waldoctl.commander.status
+ control_panel = ui_state._control_panel
+ estop = control_panel.estop if control_panel is not None else None
+ if status.io.estop == 0 or (estop is not None and estop.active):
+ return Mood.ALARMED
+ if status.simulator_active:
+ return Mood.NEUTRAL
+ if status.connected:
+ return Mood.HAPPY
+ return Mood.SAD
+
+
+def _status_light() -> Light | None:
+ """The standing condition the chip buddy's antennae show, if any."""
+ holder = control_lease.holder()
+ if holder is not None and holder.channel == MCP:
+ return Light.AGENT
+ if is_any_program_recording():
+ return Light.RECORDING
+ return None
+
+
+def _tooltip(mood: Mood, light: Light | None) -> str:
+ text = _MOOD_TOOLTIPS[mood]
+ return f"{text} · {_LIGHT_TOOLTIPS[light]}" if light else text
def _fmt_1f(v: float) -> str:
@@ -147,10 +193,9 @@ class ReadoutPanel:
def __init__(self) -> None:
"""Initialize readout panel with UI element references."""
- # Robot face + IO elements
- self._robot_face_html: ui.html | None = None
- self._robot_face_container: ui.element | None = None
- self._robot_face_tooltip: ui.tooltip | None = None
+ # Robot buddy + IO elements
+ self._buddy: RobotBuddy | None = None
+ self._buddy_tooltip: ui.tooltip | None = None
self._robot_chip: ui.chip | None = None
self._backend_label: ui.label | None = None
self._tool_chip: ui.chip | None = None
@@ -171,50 +216,20 @@ def __init__(self) -> None:
self._action_log_expanded: bool = False
# Dirty checking state
- self._last_face_state: RobotFace | None = None
+ self._moving_until: float = 0.0
+ self._last_collision: bool = False
+ self._last_homed: bool | None = None
+ self._seen_jog_pos: list[bool] | None = None
+ self._seen_jog_neg: list[bool] | None = None
+ self._blocked_jogs: int = 0
self._last_tool_key: str | None = None
self._last_io_inputs: list[int] | None = None
self._last_io_outputs: list[int] | None = None
def update_conn_io(self) -> None:
- """Update connection face and IO status. Called from status consumer."""
- if self._robot_face_html and self._robot_face_container:
- sim_active = waldoctl.commander.status.simulator_active
- connected = waldoctl.commander.status.connected
- if sim_active:
- face = RobotFace.NEUTRAL
- elif connected:
- face = RobotFace.HAPPY
- else:
- face = RobotFace.SAD
- if face != self._last_face_state:
- self._last_face_state = face
- self._robot_face_html.set_content(FACE_SVGS[face])
- # Swap CSS class for breathing animation
- remove = " ".join(
- f"robot-face-{s.value}" for s in RobotFace if s != face
- )
- self._robot_face_container.classes(
- add=f"robot-face-{face.value}", remove=remove
- )
- self._robot_face_container.update()
- # Restart JS animations so the new face state animates
- ui.run_javascript(
- "window.stopRobotFace();"
- " window.initRobotFace('" + face.value + "');"
- )
- if self._robot_face_tooltip:
- self._robot_face_tooltip.text = _FACE_TOOLTIPS[face]
- self._robot_face_tooltip.update()
- if self._robot_chip:
- self._robot_chip.style(
- f"background-color: {_CHIP_COLORS[face]} !important;"
- " margin: 0;"
- " padding: 20px 12px !important;"
- " box-shadow: none;"
- " border-radius: 10px;"
- )
- self._robot_chip.update()
+ """Update the buddy and IO status. Called from status consumer."""
+ if self._buddy is not None:
+ self._update_buddy()
tool_key = waldoctl.commander.status.tool.key
if tool_key != self._last_tool_key:
@@ -243,6 +258,67 @@ def update_conn_io(self) -> None:
color = IO_COLOR_ON if all_vals[i] else IO_COLOR_OFF
chip.props(f"color={color}")
+ def _update_buddy(self) -> None:
+ assert self._buddy is not None
+ mood = _status_mood()
+ light = _status_light()
+ if mood != self._buddy.mood:
+ self._buddy.set_mood(mood)
+ self._buddy.set_sleep_after(_sleep_after(mood))
+ if self._robot_chip is not None:
+ self._robot_chip.style(_chip_style(mood))
+ self._robot_chip.update()
+ self._buddy.set_light(light)
+ if self._buddy_tooltip is not None:
+ text = _tooltip(mood, light)
+ if self._buddy_tooltip.text != text:
+ self._buddy_tooltip.text = text
+ self._buddy_tooltip.update()
+
+ now = time.monotonic()
+ speeds = robot_state.speeds
+ if speeds.size and (
+ speeds.max() > _MOVING_DEG_S or speeds.min() < -_MOVING_DEG_S
+ ):
+ self._moving_until = now + _MOVING_HOLD_S
+ moving = now < self._moving_until
+ self._buddy.set_busy(is_any_program_running() or moving)
+
+ collision = waldoctl.commander.status.collision.active
+ if collision and not self._last_collision:
+ self._buddy.react(Reaction.STARTLE)
+ self._last_collision = collision
+
+ # The status loop replaces these lists only when a limit changes, so
+ # an identity check keeps the per-tick cost at two comparisons.
+ joints = waldoctl.commander.status.joints
+ pos, neg = joints.can_jog_pos, joints.can_jog_neg
+ if pos is not self._seen_jog_pos or neg is not self._seen_jog_neg:
+ blocked = pos.count(False) + neg.count(False)
+ if blocked > self._blocked_jogs and moving:
+ self._buddy.react(Reaction.SHRUG)
+ self._blocked_jogs = blocked
+ self._seen_jog_pos, self._seen_jog_neg = pos, neg
+
+ homed = robot_state.homed
+ if homed and self._last_homed is False:
+ self._buddy.react(Reaction.NOD)
+ self._last_homed = homed
+
+ def set_buddy_calm(self, calm: bool) -> None:
+ if self._buddy is not None:
+ self._buddy.set_calm(calm)
+
+ def greet(self) -> None:
+ """Wave hello once the page has finished loading."""
+ if self._buddy is not None:
+ self._buddy.react(Reaction.GREET)
+
+ def on_script_finished(self, exit_code: int) -> None:
+ """Cheer a program that ran to completion; wince at one that crashed."""
+ if self._buddy is not None:
+ self._buddy.react(Reaction.CELEBRATE if exit_code == 0 else Reaction.OOPS)
+
def update_action_log(self) -> None:
"""Rebuild the action log scroll area from ``commander.status.action``.
@@ -300,6 +376,9 @@ def update_event_log(self) -> None:
return
if robot_events.version == self._event_log_version:
return
+ # Only news startles the buddy, not the history a fresh page renders.
+ if self._event_log_version >= 0 and self._buddy is not None:
+ self._buddy.react(Reaction.STARTLE)
self._event_log_version = robot_events.version
self._event_log_row.set_visibility(bool(robot_events.entries))
self._event_log_html.set_content(_build_event_log_html())
@@ -307,6 +386,7 @@ def update_event_log(self) -> None:
def build(self, anchor: str = "tl") -> None:
"""Render the top-left readout panel as an overlay card."""
+ self._event_log_version = -1
with ui.card().classes(f"overlay-panel overlay-card overlay-{anchor}"):
with ui.column().classes("gap-1"):
with (
@@ -314,39 +394,23 @@ def build(self, anchor: str = "tl") -> None:
.classes("items-center w-full no-wrap gap-2")
.style("margin: -10px 0 0 -10px; width: calc(100% + 12px);")
):
- _init_face = (
- RobotFace.NEUTRAL
- if waldoctl.commander.status.simulator_active
- else RobotFace.HAPPY
- if waldoctl.commander.status.connected
- else RobotFace.SAD
- )
- self._last_face_state = _init_face
- self._robot_chip = ui.chip().style(
- f"background-color: {_CHIP_COLORS[_init_face]} !important;"
- " margin: 0;"
- " padding: 20px 12px !important;"
- " box-shadow: none;"
- " border-radius: 10px;"
- )
+ mood = _status_mood()
+ self._robot_chip = ui.chip().style(_chip_style(mood))
with self._robot_chip:
- self._robot_face_container = (
- ui.element("div")
- .classes(f"robot-face robot-face-{_init_face.value}")
+ self._buddy = (
+ RobotBuddy(
+ mood,
+ interactive=True,
+ sleep_after_s=_sleep_after(mood),
+ )
.style(
- "width: 36px; height: 36px;"
- " margin-top: 4px;"
+ "margin-top: 4px;"
" filter: drop-shadow(0 1px 1px rgba(0,0,0,0.4));"
)
- .mark("readout-robot-face")
+ .mark("readout-robot-buddy")
)
- with self._robot_face_container:
- self._robot_face_html = ui.html(
- FACE_SVGS[_init_face], sanitize=False
- ).style("width: 36px; height: 36px")
- self._robot_face_tooltip = ui.tooltip(
- _FACE_TOOLTIPS[_init_face]
- )
+ with self._buddy:
+ self._buddy_tooltip = ui.tooltip(_tooltip(mood, None))
self._backend_label = (
ui.label(ui_state.active_robot.name)
.classes("text-lg font-medium ml-2")
diff --git a/waldo_commander/components/robot_buddy.py b/waldo_commander/components/robot_buddy.py
new file mode 100644
index 00000000..97801dde
--- /dev/null
+++ b/waldo_commander/components/robot_buddy.py
@@ -0,0 +1,146 @@
+"""Waldo, the little robot that fronts Waldo Commander's state.
+
+One element per appearance (status chip, e-stop dialog, loading screen, ...).
+Each instance animates on its own in the browser — blinking, glancing
+around, dozing off, following the pointer — while the server only sets its
+``mood``, whether it is ``busy``, which antenna ``light`` is on, and asks for
+one-shot reactions.
+"""
+
+from enum import StrEnum
+
+from nicegui import app, ui
+
+CALM_STORAGE_KEY = "ui/calm_buddy"
+
+
+def calm_preferred() -> bool:
+ """The user's "Calm robot" setting: no idle fidgets anywhere Waldo appears."""
+ return bool(app.storage.general.get(CALM_STORAGE_KEY, False))
+
+
+class Mood(StrEnum):
+ """Resting expression and colour."""
+
+ HAPPY = "happy"
+ """Green: connected to the robot."""
+ NEUTRAL = "neutral"
+ """Grey: driving the simulator."""
+ SAD = "sad"
+ """Red: robot mode with no robot answering."""
+ ALARMED = "alarmed"
+ """Red, wide-eyed, flashing antennae: an E-STOP is active."""
+ BOOTING = "booting"
+ """Grey with a scanning visor: waiting for the controller."""
+
+
+class Light(StrEnum):
+ """A steady antenna light for a standing condition."""
+
+ RECORDING = "rec"
+ """Red dot on one bulb: the motion recorder is capturing."""
+ AGENT = "agent"
+ """Both bulbs glow warm: an AI agent holds control of the arm."""
+
+
+class Reaction(StrEnum):
+ """One-shot animations, after which the buddy settles back into its mood."""
+
+ GREET = "greet"
+ CELEBRATE = "celebrate"
+ OOPS = "oops"
+ STARTLE = "startle"
+ SHRUG = "shrug"
+ NOD = "nod"
+
+
+class RobotBuddy(ui.element, component="robot_buddy.vue"):
+ def __init__(
+ self,
+ mood: Mood = Mood.HAPPY,
+ *,
+ size: int = 36,
+ color: str | None = None,
+ interactive: bool = False,
+ sleep_after_s: float = 0.0,
+ roam_avoid: str | None = None,
+ ) -> None:
+ """Animated robot character.
+
+ :param mood: resting expression and colour
+ :param size: edge length of the square element in pixels
+ :param color: CSS colour overriding the mood's colour
+ :param interactive: follow the pointer when it comes near, and giggle
+ (or get dizzy) when clicked
+ :param sleep_after_s: doze off after this long without pointer or
+ keyboard activity; 0 never sleeps
+ :param roam_avoid: wander the viewport DVD-screensaver style, bouncing
+ off its edges and off the element this CSS selector matches
+ """
+ super().__init__()
+ self._props["mood"] = Mood(mood).value
+ self._props["color"] = color
+ self._props["busy"] = False
+ self._props["light"] = ""
+ self._props["calm"] = calm_preferred()
+ self._props["interactive"] = interactive
+ self._props["sleepAfter"] = sleep_after_s
+ self._props["roam"] = roam_avoid is not None
+ self._props["roamAvoid"] = roam_avoid or ""
+ self._props["reaction"] = None
+ self._reaction_seq = 0
+ self.style(f"width: {size}px; height: {size}px")
+
+ @property
+ def mood(self) -> Mood:
+ return Mood(self._props["mood"])
+
+ @property
+ def busy(self) -> bool:
+ return self._props["busy"]
+
+ @property
+ def light(self) -> Light | None:
+ return Light(self._props["light"]) if self._props["light"] else None
+
+ @property
+ def calm(self) -> bool:
+ return self._props["calm"]
+
+ @property
+ def sleep_after_s(self) -> float:
+ return self._props["sleepAfter"]
+
+ @property
+ def last_reaction(self) -> Reaction | None:
+ reaction = self._props["reaction"]
+ return Reaction(reaction["name"]) if reaction else None
+
+ def _set(self, prop: str, value: object) -> None:
+ if self._props[prop] != value:
+ self._props[prop] = value
+ self.update()
+
+ def set_mood(self, mood: Mood) -> None:
+ self._set("mood", mood.value)
+
+ def set_busy(self, busy: bool) -> None:
+ """Busy buddies focus: lids lower, eyes on the arm, antenna LEDs chase."""
+ self._set("busy", busy)
+
+ def set_light(self, light: Light | None) -> None:
+ self._set("light", light.value if light else "")
+
+ def set_calm(self, calm: bool) -> None:
+ """Calm buddies skip idle fidgets, breathing, pointer-following and
+ sleep; reactions to what the robot does still play."""
+ self._set("calm", calm)
+
+ def set_sleep_after(self, seconds: float) -> None:
+ """Doze off after this long idle; 0 keeps it awake (and wakes it)."""
+ self._set("sleepAfter", seconds)
+
+ def react(self, reaction: Reaction) -> None:
+ self._reaction_seq += 1
+ self._props["reaction"] = {"name": reaction.value, "seq": self._reaction_seq}
+ self.update()
diff --git a/waldo_commander/components/robot_buddy.vue b/waldo_commander/components/robot_buddy.vue
new file mode 100644
index 00000000..689aa6ac
--- /dev/null
+++ b/waldo_commander/components/robot_buddy.vue
@@ -0,0 +1,966 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/waldo_commander/components/script_execution.py b/waldo_commander/components/script_execution.py
index 8c02cdc7..cdf21a95 100644
--- a/waldo_commander/components/script_execution.py
+++ b/waldo_commander/components/script_execution.py
@@ -359,6 +359,8 @@ async def _monitor_script_completion(
with ui_client:
self._reset_state()
logger.info("Script %s finished with code %s", filename, rc)
+ if ui_state._readout_panel is not None:
+ ui_state.readout_panel.on_script_finished(rc)
except Exception as e:
logger.error("Error monitoring script process: %s", e)
with ui_client:
diff --git a/waldo_commander/components/settings.py b/waldo_commander/components/settings.py
index d7634a6f..a2afe558 100644
--- a/waldo_commander/components/settings.py
+++ b/waldo_commander/components/settings.py
@@ -10,6 +10,7 @@
import waldoctl
from waldoctl import EnvelopeMode, Panel, RobotClient, iter_plugin_panels
+from waldo_commander.components.robot_buddy import CALM_STORAGE_KEY, calm_preferred
from waldo_commander.components.simulation_engine import simulation
from waldo_commander.constants import RESERVED_TAB_IDS
from waldo_commander.services.camera_service import (
@@ -530,6 +531,20 @@ def _build_theme(self, prefs: dict) -> None:
value="dark",
).classes("w-24").props("dense disable")
+ def _on_calm_change(e) -> None:
+ calm = bool(e.value)
+ ng_app.storage.general[CALM_STORAGE_KEY] = calm
+ if ui_state._readout_panel is not None:
+ ui_state.readout_panel.set_buddy_calm(calm)
+
+ with _setting_row(
+ "Calm robot",
+ "No idle fidgets; Waldo still reacts to the robot",
+ ):
+ ui.switch(value=calm_preferred(), on_change=_on_calm_change).props(
+ "dense"
+ ).mark("switch-calm-buddy")
+
def _build_backend_selector(self) -> None:
"""Backend (robot driver) selection dropdown.
diff --git a/waldo_commander/main.py b/waldo_commander/main.py
index a2d2c1ec..f8907954 100644
--- a/waldo_commander/main.py
+++ b/waldo_commander/main.py
@@ -53,6 +53,7 @@
from waldo_commander.components.playback import playback
from waldo_commander.components.script_execution import script_exec
from waldo_commander.components.readout import ReadoutPanel
+from waldo_commander.components.robot_buddy import Mood, RobotBuddy
from waldo_commander.constants import config, DEFAULT_CAMERA, RESERVED_TAB_IDS
from waldo_commander.numba_pipelines import (
pose_extraction_pipeline,
@@ -1030,12 +1031,7 @@ async def restore_active_tabs():
def build_page_content() -> None:
"""Build the Move page UI."""
- # Lottie player for E-STOP dialog animations; load early in HEAD.
- ui.add_head_html(
- ''
- )
ui.add_head_html('')
- ui.add_head_html('')
with ui.column().classes("relative w-screen h-screen overflow-hidden gap-0"):
with ui.column().classes("absolute inset-0 z-0"):
@@ -1049,7 +1045,7 @@ async def _init():
# No STATUS frame (com_port configured but no robot wired).
# The scene only needs local URDF assets and guarded client
# calls, so render it anyway instead of blocking the page.
- loading_spinner.set_visibility(False)
+ loading_buddy.set_mood(Mood.SAD)
loading_status.text = (
"Robot disconnected — proceeding without live data"
)
@@ -1087,6 +1083,7 @@ async def _init():
scene_loading_overlay.classes("opacity-0 pointer-events-none")
await asyncio.sleep(0.4)
scene_loading_overlay.delete()
+ readout_panel.greet()
ui.timer(0.05, _init, once=True)
@@ -1104,7 +1101,7 @@ async def _init():
f"background: {bg}; transition: opacity 0.4s ease;"
) as scene_loading_overlay
):
- loading_spinner = ui.spinner("dots", size="xl", color="grey")
+ loading_buddy = RobotBuddy(Mood.BOOTING, size=96).mark("loading-buddy")
loading_status = ui.label("Connecting to controller...").style(
"color: grey; font-size: 0.9rem;"
)
@@ -1468,36 +1465,26 @@ def _build_takeover_overlay(message: str) -> None:
Used as the entire page body for fresh shadow tabs, and as a top-layer
overlay for previously-active tabs that have been evicted by another tab.
- All visual styling lives in theme.py under the `Takeover Overlay` section;
- this function only assigns class names.
Idempotent per-client: sets a flag on the current Client instance so
repeat callers (e.g. check_ping firing on a shadow tab that was already
built with an overlay by index_page) skip a duplicate build.
"""
- from waldo_commander.components.readout import FACE_SVGS, RobotFace
-
c = ui.context.client
if getattr(c, "_waldo_overlay_shown", False):
return
c._waldo_overlay_shown = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
- # robot-faces.js is normally loaded by build_page_content, which the
- # shadow branch skips. Load it here so initRobotFace / startRobotMope
- # are defined when the run_javascript bootstrap fires below.
- ui.add_head_html('')
-
with ui.column().classes(
"fixed inset-0 z-[9999] items-center justify-center bg-black/60"
):
- # Wandering sad robot — sibling of the card. JS sets transform to
- # mope around the viewport, avoiding the centered card's footprint.
- with ui.element("div").classes("robot-face robot-face-sad takeover-face"):
- ui.html(FACE_SVGS[RobotFace.SAD], sanitize=False).style(
- "width: 96px; height: 96px;"
- )
+ # Moping buddy — a sibling of the card, wandering the viewport and
+ # bouncing off its edges and the card.
+ RobotBuddy(Mood.SAD, size=96, roam_avoid=".takeover-card")
- with ui.column().classes("overlay-card items-center max-w-sm p-8"):
+ with ui.column().classes(
+ "overlay-card takeover-card items-center max-w-sm p-8"
+ ):
ui.label("Waldo Commander").classes("text-xl font-semibold")
ui.label(message).classes("text-sm text-center opacity-90")
@@ -1509,26 +1496,6 @@ def _take_over() -> None:
"color=primary unelevated rounded"
).classes("mt-2")
- # Bootstrap face animations + wandering. The robot-faces.js script tag
- # uses `defer`, so the functions may not be defined yet when this JS
- # arrives over the websocket. Poll briefly for them.
- ui.run_javascript(
- """
- (function bootstrap(retries) {
- if (typeof window.startRobotMope === 'function') {
- if (typeof window.initRobotFace === 'function') {
- window.initRobotFace('sad');
- }
- window.startRobotMope();
- } else if (retries > 0) {
- setTimeout(() => bootstrap(retries - 1), 50);
- } else {
- console.warn('takeover overlay: robot-faces.js never loaded');
- }
- })(60);
- """
- )
-
@ui.page("/")
async def index_page():
diff --git a/waldo_commander/services/path_visualizer.py b/waldo_commander/services/path_visualizer.py
index 424e0b0a..216da708 100644
--- a/waldo_commander/services/path_visualizer.py
+++ b/waldo_commander/services/path_visualizer.py
@@ -427,6 +427,10 @@ def run_async_in_thread():
elif callable(main_func):
cast(Callable[[], None], main_func)()
+ except SystemExit:
+ # The program ended itself (sys.exit / raise SystemExit): the
+ # preview stops where the real run would, keeping what it planned.
+ pass
except Exception as e:
error_message = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
diff --git a/waldo_commander/static/icons/robot_happy.svg b/waldo_commander/static/icons/robot_happy.svg
deleted file mode 100644
index 1578ae0a..00000000
--- a/waldo_commander/static/icons/robot_happy.svg
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/waldo_commander/static/icons/robot_neutral.svg b/waldo_commander/static/icons/robot_neutral.svg
deleted file mode 100644
index f6e5f527..00000000
--- a/waldo_commander/static/icons/robot_neutral.svg
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/waldo_commander/static/icons/robot_sad.svg b/waldo_commander/static/icons/robot_sad.svg
deleted file mode 100644
index 12449975..00000000
--- a/waldo_commander/static/icons/robot_sad.svg
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/waldo_commander/static/js/robot-faces.js b/waldo_commander/static/js/robot-faces.js
deleted file mode 100644
index a21e067e..00000000
--- a/waldo_commander/static/js/robot-faces.js
+++ /dev/null
@@ -1,404 +0,0 @@
-/* Animated robot face system for the connection status indicator. */
-
-function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
-function rand(a, b) { return a + Math.random() * (b - a); }
-
-class Robot {
- constructor(prefix) {
- this.prefix = prefix;
- this.eyeL = document.getElementById(`${prefix}-eyeL`);
- this.eyeR = document.getElementById(`${prefix}-eyeR`);
- this.pupilL = document.getElementById(`${prefix}-pupilL`);
- this.pupilR = document.getElementById(`${prefix}-pupilR`);
- this.blinkEl = document.getElementById(`${prefix}-blink`);
- this.squintEl = document.getElementById(`${prefix}-squint`) || null;
- this.droopEl = document.getElementById(`${prefix}-droop`) || null;
- this.mouthContainer = document.getElementById(`${prefix}-mouth`);
- this.antenna = document.getElementById(`${prefix}-antenna`);
- this.busy = false;
- if (this.mouthContainer) {
- for (const c of this.mouthContainer.children) {
- c.style.transition = 'opacity 0.35s ease';
- }
- }
- }
-
- movePupils(dx, dy, speed = 0.45) {
- const t = `translate(${dx}px, ${dy}px)`;
- if (this.pupilL) {
- this.pupilL.style.transition = `transform ${speed}s ease`;
- this.pupilL.style.transform = t;
- }
- if (this.pupilR) {
- this.pupilR.style.transition = `transform ${speed}s ease`;
- this.pupilR.style.transform = t;
- }
- }
-
- resetPupils(speed = 0.4) { this.movePupils(0, 0, speed); }
-
- setEyeSize(r, speed = 0.3) {
- if (this.eyeL) this.eyeL.setAttribute('r', r);
- if (this.eyeR) this.eyeR.setAttribute('r', r);
- }
-
- resetEyeSize(speed = 0.3) { this.setEyeSize(1.8, speed); }
-
- hideEyes() {
- [this.eyeL, this.eyeR, this.pupilL, this.pupilR].forEach(el => {
- if (el) el.style.opacity = '0';
- });
- }
-
- showEyes(speed = 0.25) {
- [this.eyeL, this.eyeR, this.pupilL, this.pupilR].forEach(el => {
- if (el) {
- el.style.transition = `opacity ${speed}s ease`;
- el.style.opacity = '1';
- }
- });
- }
-
- async blink(duration = 120) {
- this.hideEyes();
- if (this.blinkEl) this.blinkEl.setAttribute('opacity', '1');
- await sleep(duration);
- if (this.blinkEl) this.blinkEl.setAttribute('opacity', '0');
- this.showEyes(0.08);
- }
-
- async showOverlay(el, holdMs, fadeIn = 400) {
- if (!el) return;
- this.hideEyes();
- el.style.transition = `opacity ${fadeIn}ms ease`;
- el.setAttribute('opacity', '1');
- await sleep(holdMs);
- el.style.transition = 'opacity 0.3s ease';
- el.setAttribute('opacity', '0');
- this.showEyes(0.2);
- }
-
- setMouth(state, fadeMs = 300) {
- if (!this.mouthContainer) return;
- for (const c of this.mouthContainer.children) {
- c.style.transition = `opacity ${fadeMs}ms ease`;
- c.setAttribute('opacity', c.dataset.state === state ? '1' : '0');
- }
- }
-
- gentleWiggle(ms = 600) {
- const el = this.antenna;
- if (!el) return;
- el.style.transition = `transform ${ms * 0.3}ms ease`;
- el.style.transform = 'rotate(3deg)';
- setTimeout(() => {
- el.style.transition = `transform ${ms * 0.35}ms ease`;
- el.style.transform = 'rotate(-2deg)';
- }, ms * 0.3);
- setTimeout(() => {
- el.style.transition = `transform ${ms * 0.35}ms ease`;
- el.style.transform = 'rotate(0deg)';
- }, ms * 0.65);
- }
-
- gentleDroop(ms = 2500) {
- const el = this.antenna;
- if (!el) return;
- el.style.transition = `transform ${ms * 0.4}ms ease`;
- el.style.transform = 'rotate(-2.5deg) translateY(0.5px)';
- setTimeout(() => {
- el.style.transition = `transform ${ms * 0.6}ms ease`;
- el.style.transform = 'rotate(0deg) translateY(0)';
- }, ms * 0.5);
- }
-
- async perform(fn) {
- if (this.busy) return;
- this.busy = true;
- await fn();
- await sleep(500);
- this.busy = false;
- }
-}
-
-/* Active timer IDs so we can cancel on face switch. */
-let _activeTimers = [];
-
-function schedule(fn, minMs, maxMs) {
- const idx = _activeTimers.length;
- function tick() {
- fn();
- _activeTimers[idx] = setTimeout(tick, rand(minMs, maxMs));
- }
- _activeTimers[idx] = setTimeout(tick, rand(minMs, maxMs));
-}
-
-/**
- * Cancel all scheduled animations.
- */
-window.stopRobotFace = function() {
- for (const id of _activeTimers) {
- clearTimeout(id);
- }
- _activeTimers = [];
-};
-
-/**
- * Initialize animations for a robot face.
- * @param {string} prefix - "happy", "neutral", or "sad"
- */
-window.initRobotFace = function(prefix) {
- const r = new Robot(prefix);
-
- // Bail out if SVG elements aren't in the DOM yet
- if (!r.eyeL) return;
-
- // Set default mouth state
- if (prefix === 'happy') {
- r.setMouth('smile');
- scheduleHappy(r);
- } else if (prefix === 'neutral') {
- r.setMouth('flat');
- scheduleNeutral(r);
- } else if (prefix === 'sad') {
- r.setMouth('frown');
- scheduleSad(r);
- }
-};
-
-/* ===== HAPPY animations ===== */
-function scheduleHappy(r) {
- // Regular blink
- schedule(() => r.perform(async () => {
- await r.blink(100);
- }), 2500, 5000);
-
- // Double blink
- schedule(() => r.perform(async () => {
- await r.blink(80);
- await sleep(120);
- await r.blink(80);
- }), 8000, 14000);
-
- // Squint + grin
- schedule(() => r.perform(async () => {
- r.setMouth('grin');
- await r.showOverlay(r.squintEl, 1800, 350);
- r.setMouth('smile');
- }), 6000, 12000);
-
- // Excited: big eyes + open-smile
- schedule(() => r.perform(async () => {
- r.setEyeSize(2.2);
- r.setMouth('open-smile');
- await sleep(1200);
- r.resetEyeSize();
- r.setMouth('smile');
- }), 10000, 18000);
-
- // Antenna wiggle
- schedule(() => r.gentleWiggle(), 4000, 9000);
-}
-
-/* ===== NEUTRAL animations ===== */
-function scheduleNeutral(r) {
- // Regular blink
- schedule(() => r.perform(async () => {
- await r.blink(130);
- }), 3000, 6000);
-
- // Look left then right
- schedule(() => r.perform(async () => {
- r.movePupils(-0.5, 0, 0.5);
- await sleep(800);
- r.movePupils(0.5, 0, 0.6);
- await sleep(800);
- r.resetPupils();
- }), 5000, 10000);
-
- // Look up thinking
- schedule(() => r.perform(async () => {
- r.movePupils(0.3, -0.4, 0.5);
- r.setMouth('slant');
- await sleep(1500);
- r.resetPupils();
- r.setMouth('flat');
- }), 8000, 15000);
-
- // Suspicious squint + zigzag mouth
- schedule(() => r.perform(async () => {
- r.setEyeSize(1.3);
- r.setMouth('zigzag');
- await sleep(1600);
- r.resetEyeSize();
- r.setMouth('flat');
- }), 10000, 18000);
-
- // Mouth twitch
- schedule(() => r.perform(async () => {
- r.setMouth('slant');
- await sleep(600);
- r.setMouth('flat');
- }), 6000, 11000);
-
- // Antenna twitch
- schedule(() => r.gentleWiggle(400), 7000, 14000);
-}
-
-/* ===== SAD animations ===== */
-function scheduleSad(r) {
- // Slow blink
- schedule(() => r.perform(async () => {
- await r.blink(200);
- }), 4000, 8000);
-
- // Look down
- schedule(() => r.perform(async () => {
- r.movePupils(0, 0.5, 0.6);
- await sleep(1800);
- r.resetPupils(0.5);
- }), 5000, 10000);
-
- // Look away
- schedule(() => r.perform(async () => {
- r.movePupils(-0.5, 0.2, 0.7);
- await sleep(1400);
- r.resetPupils(0.5);
- }), 7000, 13000);
-
- // Droopy eyes + deep frown
- schedule(() => r.perform(async () => {
- r.setMouth('deep-frown');
- await r.showOverlay(r.droopEl, 2200, 500);
- r.setMouth('frown');
- }), 8000, 15000);
-
- // Watery big eyes + tremble mouth
- schedule(() => r.perform(async () => {
- r.setEyeSize(2.1);
- r.setMouth('tremble');
- await sleep(1800);
- r.resetEyeSize();
- r.setMouth('frown');
- }), 12000, 20000);
-
- // Antenna droop
- schedule(() => r.gentleDroop(), 6000, 12000);
-}
-
-/* ===== Bouncing sad robot for the takeover overlay =====
- * DVD-screensaver-style: the face moves at a constant velocity and reflects
- * off the viewport edges and the centered card. raf-driven so the motion
- * stays smooth at any frame rate. The slow spin lives in CSS (.takeover-face
- * animation: takeover-spin), independent of position, so it composes with
- * the SVG's own breathing animation.
- */
-window.startRobotMope = function() {
- // The face element may not be in the DOM yet when this runs (NiceGUI
- // dispatches run_javascript over the websocket; the DOM mount can lag
- // by a tick or two). Poll briefly before giving up.
- let tries = 0;
- function start() {
- const face = document.querySelector('.takeover-face');
- if (!face) {
- if (tries++ < 30) {
- setTimeout(start, 50);
- } else {
- console.warn('startRobotMope: .takeover-face not found');
- }
- return;
- }
- runMope(face);
- }
- start();
-};
-
-function runMope(face) {
- const FACE_SIZE = 96;
- const SPEED = 90; // pixels per second
- const SPIN_DEG_PER_S = 30; // 360° every 12s, slow and steady
- const CARD_W = 460; // approximate card footprint with breathing room
- const CARD_H = 320;
-
- function randomVelocity() {
- // Avoid axes — pick from a quadrant rotated by a random multiple of 90°.
- const angle = (0.15 + Math.random() * 0.7) * Math.PI / 2 +
- Math.floor(Math.random() * 4) * Math.PI / 2;
- return { vx: Math.cos(angle) * SPEED, vy: Math.sin(angle) * SPEED };
- }
-
- function randomStart() {
- const cx = window.innerWidth / 2;
- const cy = window.innerHeight / 2;
- const cardL = cx - CARD_W / 2, cardR = cx + CARD_W / 2;
- const cardT = cy - CARD_H / 2, cardB = cy + CARD_H / 2;
- for (let i = 0; i < 32; i++) {
- const px = Math.random() * (window.innerWidth - FACE_SIZE);
- const py = Math.random() * (window.innerHeight - FACE_SIZE);
- const intersects = px < cardR && px + FACE_SIZE > cardL &&
- py < cardB && py + FACE_SIZE > cardT;
- if (!intersects) return { x: px, y: py };
- }
- return { x: 40, y: 40 };
- }
-
- let { x, y } = randomStart();
- let { vx, vy } = randomVelocity();
- let angle = 0; // degrees
- let lastTime = null;
-
- // Initial paint so we don't flash at (0,0).
- face.style.transform = `translate(${x}px, ${y}px) rotate(${angle}deg)`;
-
- function step(now) {
- if (lastTime === null) {
- lastTime = now;
- requestAnimationFrame(step);
- return;
- }
- // Cap dt so a backgrounded tab doesn't teleport the face on resume.
- const dt = Math.min(0.05, (now - lastTime) / 1000);
- lastTime = now;
-
- let nx = x + vx * dt;
- let ny = y + vy * dt;
- angle = (angle + SPIN_DEG_PER_S * dt) % 360;
-
- // Viewport edges.
- const maxX = window.innerWidth - FACE_SIZE;
- const maxY = window.innerHeight - FACE_SIZE;
- if (nx < 0) { nx = 0; vx = Math.abs(vx); }
- if (nx > maxX) { nx = maxX; vx = -Math.abs(vx); }
- if (ny < 0) { ny = 0; vy = Math.abs(vy); }
- if (ny > maxY) { ny = maxY; vy = -Math.abs(vy); }
-
- // Card collision: push out along the axis of shallowest penetration.
- const cx = window.innerWidth / 2;
- const cy = window.innerHeight / 2;
- const cardL = cx - CARD_W / 2, cardR = cx + CARD_W / 2;
- const cardT = cy - CARD_H / 2, cardB = cy + CARD_H / 2;
- const overlapsCard = nx < cardR && nx + FACE_SIZE > cardL &&
- ny < cardB && ny + FACE_SIZE > cardT;
- if (overlapsCard) {
- const penL = (nx + FACE_SIZE) - cardL;
- const penR = cardR - nx;
- const penT = (ny + FACE_SIZE) - cardT;
- const penB = cardB - ny;
- const minPen = Math.min(penL, penR, penT, penB);
- if (minPen === penL) { nx = cardL - FACE_SIZE; vx = -Math.abs(vx); }
- else if (minPen === penR) { nx = cardR; vx = Math.abs(vx); }
- else if (minPen === penT) { ny = cardT - FACE_SIZE; vy = -Math.abs(vy); }
- else { ny = cardB; vy = Math.abs(vy); }
- }
-
- x = nx;
- y = ny;
- // Single transform string drives both translate and rotate so nothing
- // can race or get overridden by the browser's animation engine.
- face.style.transform = `translate(${x}px, ${y}px) rotate(${angle}deg)`;
-
- requestAnimationFrame(step);
- }
-
- requestAnimationFrame(step);
-}