# Runtime Dashboard in Python: Progress Bars and Tables

> Drive a runtime dashboard from a Python macro with mas.ui in MAS: labels, progress bars, charts, logs, tables with buttons, input fields and event handlers.

Source: https://automationmacro.com/docs/sdk/ui (Python SDK, updated 2026-09-04)

`mas.ui` is the namespace a Macro Automation Studio (MAS) macro written in Python uses to drive the runtime dashboard shown next to a run. You design the dashboard in the UI Builder as a `.uibrt` file, give each widget a name, and call `mas.ui.set_text("status", "Working")` from the script. The app receives the update over JSON-RPC and re-renders that widget. This page explains every function, how names map to widgets, the batching rule and the interactive widgets. Designing the dashboard itself is covered on [UI Builder](/docs/ui-builder); full signatures are on the [API reference](/docs/api-reference). If you describe the task to [MAS Agent](/agent), it writes this code for you.

## Names bind the runtime dashboard to your Python script

Every `mas.ui` function takes the widget name as its first argument. That name is the `name` property you set in the UI Builder Inspector, saved in the `.uibrt` file. The dashboard appears in the Code Editor's bottom panel and in the **Dashboard** view of a device card on **Device Groups**.

A name that does not exist in the file updates nothing, silently. Keep names identifier-safe and unique, and check the file first when a widget stays blank.

| Widget | Kind | Functions |
|---|---|---|
| Label | `runtime-label` | `set_text` |
| Progress bar | `runtime-progress` | `set_progress`, `set_text` for its label |
| Chart | `runtime-chart` | `add_data_point`, `set_chart_data`, `clear_chart` |
| Text area | `runtime-textarea` | `append_text`, `set_textarea`, `clear_text` |
| Table | `runtime-table` | `set_table_data`, `append_table_row`, `clear_table`, `cell_button`, `set_cell_button_enabled` |
| Button | `runtime-button` | `wait_for_event`, `on_click`, `set_text` |
| Input | `runtime-input` | `get_input_value`, `on_change`, `wait_for_event` |

## Text and the macro progress bar

[`set_text(name, text)`](/docs/api-reference#set_text) sets the text of a label, a text area, a button or a progress bar's label. The value is converted with `str()`, so numbers are fine.

[`set_progress(name, value)`](/docs/api-reference#set_progress) sets a progress bar. The app clamps the value to the widget's `min` and `max`, so a bar defined as 0 to 100 takes a percentage and a bar defined as 0 to 500 takes a count.

```python
import mas

mas.ui.set_text("status", "Collecting")
mas.ui.set_progress("main_bar", 40)
```

## Charts

[`add_data_point(name, value, label, series=None)`](/docs/api-reference#add_data_point) appends one point to a streaming chart. `label` is the text on the x-axis and `series` groups points for multi-series charts. Points beyond the widget's `maxDataPoints` are dropped by the app, oldest first. [`set_chart_data(name, data)`](/docs/api-reference#set_chart_data) replaces the whole dataset with a list of `{"label": ..., "value": ...}` dictionaries, each with an optional `"series"`. [`clear_chart(name)`](/docs/api-reference#clear_chart) empties it.

```python
import mas

mas.ui.add_data_point("gold_chart", value=1250, label="run 7", series="gold")
mas.ui.set_chart_data("totals", [
    {"label": "Gold", "value": 1250},
    {"label": "XP", "value": 45000},
])
```

## Text areas

[`append_text(name, line)`](/docs/api-reference#append_text) adds a line to a text area, which auto-scrolls and drops lines beyond its `maxLines`. [`set_textarea(name, text)`](/docs/api-reference#set_textarea) replaces the content and [`clear_text(name)`](/docs/api-reference#clear_text) empties it.

The app keeps no memory of a text area's previous content; it expects the full text on every update. The SDK keeps a local mirror so `append_text` can build on the last value without a round trip. The mirror starts empty with each script process, so a restarted macro starts its log from a blank line even if the panel still shows old ones. Call `clear_text` at the top of the run to make that explicit.

## Tables

[`set_table_data(name, rows)`](/docs/api-reference#set_table_data) replaces all rows. Each row is a dictionary whose keys must match the table's `columns` as defined in the UI Builder. [`append_table_row(name, row)`](/docs/api-reference#append_table_row) adds one row and [`clear_table(name)`](/docs/api-reference#clear_table) removes them all. Tables use the same local mirror as text areas.

A cell can be a button. [`cell_button(*, id, label, variant="default", disabled=False)`](/docs/api-reference#cell_button) builds the cell value. `variant` is `"default"`, `"outline"` or `"destructive"`; anything else raises `ValueError`. A click on the cell emits a UI event whose `widget_id` equals the `id` you gave, so you handle it like a standalone button. [`set_cell_button_enabled(table_name, button_id, enabled)`](/docs/api-reference#set_cell_button_enabled) flips a button's state in the mirror and re-pushes the table; it does nothing when no cell has that id.

```python
import mas

mas.ui.set_table_data("results", [
    {"Name": "Gold", "Value": "1,250", "Status": "OK"},
])
mas.ui.append_table_row("orders", {
    "Name": "Gold",
    "Action": mas.ui.cell_button(id="refund_gold", label="Refund", variant="destructive"),
})
```

## Interactive widgets

There are two ways to receive input: block for it, or register a callback.

[`wait_for_event(timeout=30.0)`](/docs/api-reference#wait_for_event) blocks until the user clicks a button or changes an input. It returns a `UIEvent` with `widget_id`, `event_type` (`"click"` or `"change"`) and `value`, or `None` when the timeout passes. Use it when the script has nothing else to do, such as a start gate at the top of a run.

```python
import mas

mas.ui.set_text("status", "Press Start when the game is on the lobby screen")
event = mas.ui.wait_for_event(timeout=120)
if event is None or event.widget_id != "start_btn":
    mas.log("no start within two minutes, exiting")
    raise SystemExit(0)
```

[`on_click(name, handler)`](/docs/api-reference#on_click) and [`on_change(name, handler)`](/docs/api-reference#on_change) register callbacks. A click handler takes no arguments; a change handler receives the new value. They fire only while the listener runs, so call [`start_listener()`](/docs/api-reference#start_listener) once, after registering every handler. The listener is a daemon thread with its own connection to the app, so it never blocks the main thread's `mas.*` calls and it does not keep the process alive after your script returns. Calling it twice is a no-op. An exception inside a handler is printed and does not stop the listener. [`stop_listener()`](/docs/api-reference#stop_listener) shuts it down, which is mostly useful in tests.

[`get_input_value(name)`](/docs/api-reference#get_input_value) returns the most recent text in an input widget, or `None`. Use it to read a setting at the moment you need it instead of tracking every change.

> [!WARNING]
> Do not mix `wait_for_event` on the main thread with a running listener. Both call the same wait on the app, so a click can be consumed by either one. Pick one style per script.

## batch

[`batch()`](/docs/api-reference#batch) is a context manager that collects every update inside the `with` block and sends them as one message. Use it whenever a loop iteration touches more than one widget; it is the difference between a smooth dashboard and one that flickers. Batches cannot be nested: entering a second one raises `RuntimeError`. The buffer is process-wide, not per thread, so updates made by a handler while a batch is open are pulled into that batch. Finish the batch before you let handlers fire.

## Example: a progress bar plus a log

The dashboard has a label `status`, a progress bar `main_bar` (0 to 100) and a text area `log`.

```python
import mas

images = mas.images({"chest": 412})
steps = ["open lobby", "collect chests", "claim daily", "close pop-ups"]

mas.ui.set_text("status", "Starting")
mas.ui.clear_text("log")

for i, step in enumerate(steps, start=1):
    chest = mas.find_object_retry(images.chest, total_tries=3, time_sleep=2.0)
    found = chest is not None
    if found:
        mas.click(chest.x, chest.y)
    with mas.ui.batch():
        mas.ui.set_text("status", f"Step {i} of {len(steps)}: {step}")
        mas.ui.set_progress("main_bar", i * 100 // len(steps))
        mas.ui.append_text("log", f"{step}: {'ok' if found else 'skipped'}")

mas.ui.set_text("status", "Done")
```

## Example: a table with a button per row

The dashboard has a table `accounts` with the columns `Account`, `Gold` and `Action`, and a label `status`. Each row gets a button whose id encodes the account. One handler per row reads that id, and the button is disabled once used. The main thread waits until every row is handled or a deadline passes, so the macro still ends if nobody clicks.

```python
import time
import mas

accounts = {"alt-1": 1250, "alt-2": 980, "alt-3": 2210}
pending = set(accounts)

def build_rows():
    return [
        {
            "Account": name,
            "Gold": str(gold),
            "Action": mas.ui.cell_button(id=f"collect_{name}", label="Collect"),
        }
        for name, gold in accounts.items()
    ]

def make_handler(name):
    def collect():
        mas.ui.set_text("status", f"collecting for {name}")
        # ... switch to the account and collect ...
        mas.ui.set_cell_button_enabled("accounts", f"collect_{name}", False)
        pending.discard(name)
    return collect

mas.ui.set_table_data("accounts", build_rows())
for name in accounts:
    mas.ui.on_click(f"collect_{name}", make_handler(name))
mas.ui.start_listener()
mas.ui.set_text("status", "Click Collect on a row")

deadline = time.time() + 300
while pending and time.time() < deadline:
    time.sleep(0.5)
mas.ui.set_text("status", "Done" if not pending else "Timed out")
```

## Gotchas

- A wrong name is the most common bug. The call succeeds and nothing changes.
- Row keys must match the table's columns exactly, including case.
- `set_progress` is clamped to the widget's range, so a value of 150 on a 0 to 100 bar shows as full.
- The local mirrors for text areas and tables reset when the script process restarts.
- `batch()` cannot be nested, and it collects updates from every thread while it is open.
- Register handlers before `start_listener()`; a handler registered afterwards still works, but clicks before registration are lost.
- Runtime UI calls need no device, but they need the app to be showing the dashboard for the run. On a cloud device the updates travel with the run's log stream.
