UI Steps & Screens

The Wombat controller has a touchscreen. LibSTP lets you display custom screens on it — progress indicators, sensor readings, confirmation dialogs, input forms, and live visualizations. This is how calibration wizards, wait-for-button prompts, and debug dashboards work under the hood.

You can use the built-in screens for common tasks, or build your own custom screens with a full widget toolkit.

Architecture

graph LR
    subgraph "Your Step (Python)"
        US["UIStep"]
        SC["UIScreen"]
        US -->|"show() / display()"| SC
    end

    subgraph "Raccoon Transport"
        LCM["LCM Messages"]
    end

    subgraph "BotUI (Flutter)"
        FL["Touchscreen Renderer"]
    end

    SC -->|"Widget tree (JSON)"| LCM
    LCM -->|"screen_render"| FL
    FL -->|"screen_render/answer"| LCM
    LCM -->|"clicks, values"| SC

    style US fill:#4CAF50,color:#fff
    style SC fill:#66BB6A,color:#fff
    style FL fill:#42A5F5,color:#fff

Your Python code sends a widget tree to BotUI via Raccoon Transport. BotUI renders it on the touchscreen. When the user taps a button or changes an input, BotUI sends the event back. Your screen’s event handlers react to it.

Quick Helpers (One-Liners)

The fastest way to show UI — no custom screens needed. These are methods on UIStep:

from libstp import UIStep

class MyMissionStep(UIStep):
    async def _execute_step(self, robot):
        # Simple message with OK button
        await self.message("Calibration complete!")

        # Yes/No confirmation — returns True or False
        confirmed = await self.confirm("Ready to start?")

        # Number input — returns the entered value
        distance = await self.input_number("Enter distance", unit="cm",
                                           min_value=10, max_value=200)

        # Text input
        name = await self.input_text("Enter robot name")

        # Slider input
        speed = await self.input_slider("Set speed", min=0, max=100, default=50)

        # Multiple choice — returns the selected option
        choice = await self.choose("Pick a strategy",
                                   options=["Aggressive", "Safe", "Custom"])

        # Wait for physical button press
        await self.wait_for_button("Press button to continue...")

Each helper displays a pre-built screen, waits for the user to interact, and returns the result. Use these when you need a quick interaction without building a custom screen.

Built-In Screens

LibSTP ships with ready-to-use screens for common patterns. Import them from libstp.ui.screens:

Message & Confirmation

from libstp.ui.screens import MessageScreen, ConfirmScreen

class MyStep(UIStep):
    async def _execute_step(self, robot):
        # Message with custom icon
        await self.show(MessageScreen(
            title="Done!",
            message="All POMs collected successfully.",
            button_label="Continue",
            icon_name="check_circle",
        ))

        # Confirmation dialog
        result = await self.show(ConfirmScreen(
            title="Dangerous Action",
            message="This will reset calibration. Continue?",
            confirm_label="Yes, reset",
            cancel_label="Cancel",
        ))
        if result.confirmed:
            # ... reset calibration
            pass

Progress & Status

from libstp.ui.screens import ProgressScreen, StatusScreen

class MyStep(UIStep):
    async def _execute_step(self, robot):
        # Show progress while doing work
        async with self.showing(ProgressScreen(
            message="Calibrating motors...",
            show_spinner=True,
            show_progress=True,
        )) as ctx:
            for i in range(100):
                ctx.screen.progress = i          # 0-100 integer, shown as percentage
                ctx.screen.message = f"Motor {i // 25 + 1} of 4..."
                await ctx.screen.refresh()
                await asyncio.sleep(0.05)

        # Show a status indicator (non-blocking — has no close button)
        await self.display(StatusScreen(
            message="Robot ready",
            icon_name="rocket_launch",
            icon_color="green",
            status="All systems go",
        ))
        await asyncio.sleep(2)
        await self.close_ui()

Input Screens

from libstp.ui.screens import NumberInputScreen, SliderInputScreen, ChoiceScreen

class MyStep(UIStep):
    async def _execute_step(self, robot):
        # Number with keypad
        result = await self.show(NumberInputScreen(
            title="Distance Calibration",
            prompt="Measure how far the robot actually drove:",
            unit="cm",
            initial_value=50.0,
            min_value=1.0,
            max_value=500.0,
        ))
        measured = result.value   # NumberInputResult

        # Slider
        result = await self.show(SliderInputScreen(
            title="Sensitivity",
            prompt="Adjust IR threshold:",
            min=0, max=4095, default=2000,
        ))
        threshold = result.value  # SliderInputResult

        # Multiple choice — choices are (id, label) or (id, label, description) tuples
        result = await self.show(ChoiceScreen(
            title="Calibration Set",
            message="Which surface are you calibrating for?",
            choices=[
                ("ground", "Ground level", "Default table surface"),
                ("upper", "Upper platform", "Elevated surface"),
                ("ramp", "Ramp", "Angled surface"),
            ],
            cancel_label="Cancel",
        ))
        selected = result.choice  # Returns the chosen id string

Wait-for-Button Screen

from libstp.ui.screens import WaitForButtonScreen

class MyStep(UIStep):
    async def _execute_step(self, robot):
        await self.show(WaitForButtonScreen(
            message="Place robot on the starting line",
            icon_name="sports_score",
            icon_color="blue",
        ))

Building Custom Screens

For anything beyond the built-in screens, create your own by extending UIScreen.

Minimal Example

from libstp import UIStep, UIScreen
from libstp.ui.widgets import *
from libstp.ui.events import on_click


class GreetingScreen(UIScreen):
    title = "Hello!"

    def build(self):
        return Center(children=[
            Text("Welcome to my robot", size="large", bold=True),
            Spacer(height=16),
            Text("Press the button to start the mission"),
            Spacer(height=24),
            Button("start", "Start Mission", style="success"),
        ])

    @on_click("start")
    async def on_start(self):
        self.close("started")


class MyStep(UIStep):
    async def _execute_step(self, robot):
        result = await self.show(GreetingScreen())
        # result == "started"

Screen with Typed Results

Screens can return typed results using generics:

from dataclasses import dataclass

@dataclass
class CalibrationResult:
    white_value: float
    black_value: float
    confirmed: bool


class CalibrationScreen(UIScreen[CalibrationResult]):
    title = "Sensor Calibration"
    _primary_button_id = "confirm"   # Physical button triggers this

    def __init__(self, port: int):
        super().__init__()
        self.port = port
        self.white_value = 0.0
        self.black_value = 0.0

    def build(self):
        return Column(children=[
            SensorValue(port=self.port, sensor_type="analog"),
            SensorGraph(port=self.port, sensor_type="analog", max_points=100),
            Divider(),
            Row(children=[
                Button("read_white", "Read White", style="secondary"),
                Button("read_black", "Read Black", style="secondary"),
            ]),
            Spacer(height=16),
            ResultsTable(rows=[
                ("White", f"{self.white_value:.0f}"),
                ("Black", f"{self.black_value:.0f}"),
            ]),
            Spacer(height=16),
            Row(children=[
                Button("cancel", "Cancel", style="secondary"),
                Button("confirm", "Confirm", style="success"),
            ]),
        ])

    @on_click("read_white")
    async def on_read_white(self):
        self.white_value = await self.read_sensor(self.port)
        await self.refresh()   # Re-render with new value

    @on_click("read_black")
    async def on_read_black(self):
        self.black_value = await self.read_sensor(self.port)
        await self.refresh()

    @on_click("confirm")
    async def on_confirm(self):
        self.close(CalibrationResult(
            white_value=self.white_value,
            black_value=self.black_value,
            confirmed=True,
        ))

    @on_click("cancel")
    async def on_cancel(self):
        self.close(CalibrationResult(
            white_value=0, black_value=0, confirmed=False,
        ))

Screen with Live Updates

Screens can update in real time while background work runs:

import asyncio

class LiveSensorScreen(UIScreen):
    title = "Sensor Monitor"

    def __init__(self, ports):
        super().__init__()
        self.ports = ports
        self.values = {p: 0 for p in ports}

    def build(self):
        rows = []
        for port in self.ports:
            rows.append(Row(children=[
                Text(f"Port {port}", bold=True),
                SensorValue(port=port, sensor_type="analog"),
            ]))
        rows.append(Spacer(height=24))
        rows.append(Button("done", "Done", style="primary"))
        return Column(children=rows)

    @on_click("done")
    async def on_done(self):
        self.close(self.values)


class MonitorStep(UIStep):
    async def _execute_step(self, robot):
        # Display screen without blocking — mission continues
        screen = LiveSensorScreen(ports=[0, 1, 2])
        result = await self.show(screen)

Widget Reference

All widgets are imported from libstp.ui.widgets.

Display Widgets

WidgetPurposeKey Parameters
Text(text)Text displaysize (“small”, “medium”, “large”, “title”), color, bold, muted, align
Icon(name)Material iconsize, color — see Material Icons for names
Spacer(height)Vertical spaceheight in pixels
Divider()Horizontal linethickness, color
StatusBadge(text)Colored pillcolor, glow (boolean)
StatusIcon(icon)Icon in circlecolor, animated (boolean)
HintBox(text)Highlighted boxicon, style (“normal”, “prominent”)
DistanceBadge(value)Distance displayunit, color
ResultsTable(rows)Key-value tablerows — list of (label, value) tuples

Input Widgets

WidgetPurposeKey Parameters
Button(id, label)Clickable buttonstyle (“primary”, “secondary”, “success”, “danger”, “warning”), icon, disabled
Slider(id)Slider controlmin, max, value, label, show_value
Checkbox(id, label)Togglevalue (boolean)
Dropdown(id, options)Select menuvalue, label
NumericKeypad()Touch keypad0–9, decimal, backspace
NumericInput(id)Number displayvalue, unit, min_value, max_value
TextInput(id)Text fieldvalue, label, placeholder

Visualization Widgets

WidgetPurposeKey Parameters
SensorValue(port)Large sensor readingsensor_type (“analog”, “digital”) — live updates automatically
SensorGraph(port)Real-time line graphsensor_type, max_points
LightBulb(is_on)Animated light bulbBoolean state
AnimatedRobot(moving)Robot animationsize — shows spinning wheels when moving=True
CircularSlider(id)Circular controlmin, max, value, label
ProgressSpinner()Loading spinnersize, color
PulsingArrow(direction)Directional arrowdirection (“up”, “down”, “left”, “right”)
RobotDrivingAnimation(target_distance)Driving visualizationShows robot moving with distance counter
MeasuringTape(distance)Tape animationdistance in cm
CalibrationChart(samples)Scatter/line chartthresholds, height

Layout Widgets

WidgetPurposeKey Parameters
Row(children)Horizontal layoutalign, spacing
Column(children)Vertical layoutalign, spacing
Center(children)Center contentCenters both horizontally and vertically
Card(children)Card containertitle, padding
Split(left, right)Side-by-side layoutratio — tuple of ints, e.g. (1, 1) or (5, 3)
Expanded(child)Fill available spaceflex weight

Event Decorators

Bind screen methods to user interactions:

from libstp.ui.events import (
    on_click,         # Button tapped
    on_change,        # Input value changed
    on_slider,        # Slider moved
    on_submit,        # Form submitted
    on_keypad,        # Keypad key pressed
    on_button_press,  # Physical Wombat button pressed
    on_screen_tap,    # Screen tapped anywhere
)


class MyScreen(UIScreen):
    title = "Events Demo"

    def build(self):
        return Column(children=[
            Button("go", "Go!", style="success"),
            Slider("speed", min=0, max=100, value=50, label="Speed"),
            NumericKeypad(),
            NumericInput("distance", value=0, unit="cm"),
        ])

    @on_click("go")
    async def handle_go(self):
        speed = self.get_value("speed")
        self.close(speed)

    @on_slider("speed")
    async def handle_speed(self, value):
        # Called every time slider moves
        pass

    @on_keypad()
    async def handle_key(self, key):
        # key is "0"-"9", ".", or "back"
        current = self.get_value("distance") or ""
        if key == "back":
            current = current[:-1]
        else:
            current += key
        self.set_value("distance", current)
        await self.refresh()

    @on_button_press()
    async def handle_physical_button(self):
        # Physical button on the Wombat
        self.close("button")

Display Modes

Blocking (show)

The most common pattern. Shows a screen and waits for close() to be called:

result = await self.show(MyScreen())
# Execution pauses here until the screen calls self.close(result)

Non-Blocking (display + pump_events)

Show a screen while doing background work. You must pump events manually:

screen = StatusScreen(message="Working...")
await self.display(screen)

for i in range(100):
    await self.pump_events()  # Handle any UI events
    screen.message = f"Step {i}/100"
    await screen.refresh()
    await asyncio.sleep(0.1)

await self.close_ui()

Context Manager (showing)

Cleaner syntax for non-blocking display:

async with self.showing(ProgressScreen(message="Calibrating...")) as ctx:
    for i in range(100):
        ctx.screen.progress = i        # 0-100 integer
        await ctx.screen.refresh()
        await asyncio.sleep(0.05)
# Screen automatically closed when exiting the context

Background Task (run_with_ui)

Show a screen while running a coroutine. Screen stays visible until the task completes:

result = await self.run_with_ui(
    ProgressScreen("Running diagnostics..."),
    self.run_diagnostics,   # async method
)

The _primary_button_id Pattern

Set _primary_button_id on your screen to link the physical Wombat button to a specific on-screen button. When the user presses the physical button, it triggers the click handler for that button:

class MyScreen(UIScreen):
    _primary_button_id = "confirm"   # Physical button = clicking "confirm"

    def build(self):
        return Column(children=[
            Text("Ready?"),
            Button("confirm", "OK", style="success"),
        ])

    @on_click("confirm")
    async def on_confirm(self):
        self.close(True)

This is important for screens that need to work without touching the screen — the physical button acts as a universal “OK”.

Accessing the Robot from Screens

Screens have access to the robot instance via self.robot:

class DiagnosticsScreen(UIScreen):
    def build(self):
        return Column(children=[
            Text("Motor Diagnostics", size="large"),
            Button("test", "Run Test"),
        ])

    @on_click("test")
    async def on_test(self):
        # Read sensor directly
        value = await self.read_sensor(0, sensor_type="analog")

        # Access robot hardware
        motor = self.robot.defs.front_left_motor
        # ... use motor ...

read_sensor(port, sensor_type="analog") is a convenience method that reads a sensor value from within a screen. The sensor_type parameter defaults to "analog", so await self.read_sensor(0) is enough for most cases.

Widget State: get_value / set_value

Screens track input widget values automatically. Access them with:

# Read the current value of an input widget
speed = self.get_value("speed_slider")

# Set a widget's value programmatically
self.set_value("distance_input", "42.5")
await self.refresh()  # Re-render to show the new value

Values are automatically updated when users interact with input widgets (sliders, text inputs, keypads, etc.).

Patterns from Real Competition Code

Calibration Loop with Retry

From the actual calibration system — measure, confirm, retry if needed:

class CalibrateStep(UIStep):
    async def _execute_step(self, robot):
        while True:
            # Step 1: Measure
            white = await self.show(MeasureScreen(port=0, surface="white"))
            black = await self.show(MeasureScreen(port=0, surface="black"))

            # Step 2: Confirm
            result = await self.show(ConfirmScreen(
                white_value=white.value,
                black_value=black.value,
            ))

            if result.confirmed:
                # Save and exit
                store_calibration(result)
                break
            # Otherwise loop and re-measure

Wait-for-Light with State Machine

The competition start light detection uses a multi-state UI:

stateDiagram-v2
    [*] --> WarmingUp: Display screen
    WarmingUp --> TestMode: Warmup complete
    TestMode --> TestMode: Light triggers (test count++)
    TestMode --> Armed: Button press
    Armed --> Triggered: Light detected
    Triggered --> [*]: GO!

The screen updates in real time showing the current state, raw sensor value, baseline, and threshold — all while running a Kalman filter for robust detection.

Driving with Visual Feedback

Show an animation while the robot drives:

class DriveWithFeedback(UIStep):
    async def _execute_step(self, robot):
        screen = ProgressScreen(message="Driving to target...")
        async with self.showing(screen) as ctx:
            # Run a drive step while showing progress
            await drive_forward(50).run_step(robot)
        await self.show(MessageScreen(
            title="Arrived!",
            message="Robot reached the target.",
            icon_name="check_circle",
        ))

When to Use UI Steps

SituationUse
Quick “press button to continue”wait_for_button() or self.wait_for_button()
Simple yes/noself.confirm("Ready?")
Show a messageself.message("Done!")
Get a number from the userself.input_number("Enter distance", unit="cm")
Sensor dashboard during testingCustom screen with SensorValue + SensorGraph
Calibration workflowCustom screen with read_sensor() + ResultsTable
Visual feedback during autonomousself.showing(ProgressScreen(...)) context manager
Multi-step wizardChain multiple self.show() calls with different screens

Tip: Start with the quick helpers (message, confirm, input_number). Only build custom screens when you need live sensor data, complex layouts, or multi-step flows.