from __future__ import annotations
import asyncio
import builtins
import colorsys
import dataclasses
import functools
import threading
import time
import uuid
import warnings
from asyncio import AbstractEventLoop
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Protocol,
Sequence,
Tuple,
TypeVar,
cast,
overload,
)
import numpy as np
from typing_extensions import (
Literal,
LiteralString,
TypeAlias,
TypedDict,
assert_never,
get_type_hints,
)
from viser import theme
from viser._backwards_compat_shims import deprecated_positional_shim
from . import _messages, uplot
from ._gui_handles import (
CONTROL_PANEL_ID,
CommandEvent,
CommandHandle,
GuiButtonGroupHandle,
GuiButtonHandle,
GuiCheckboxHandle,
GuiContainerProtocol,
GuiDividerHandle,
GuiDropdownHandle,
GuiEvent,
GuiFolderHandle,
GuiFormHandle,
GuiHtmlHandle,
GuiImageHandle,
GuiMarkdownHandle,
GuiModalHandle,
GuiMultiSliderHandle,
GuiNumberHandle,
GuiPlotlyHandle,
GuiProgressBarHandle,
GuiRgbaHandle,
GuiRgbHandle,
GuiSliderHandle,
GuiTabGroupHandle,
GuiTabHandle,
GuiTextHandle,
GuiUploadButtonHandle,
GuiUplotHandle,
GuiVector2Handle,
GuiVector3Handle,
MainPanelHandle,
PanelHandle,
SupportsRemoveProtocol,
UploadedFile,
_colors_to_int_tuple,
_CommandHandleState,
_GuiButtonHandleState,
_GuiHandleState,
_GuiInputHandle,
_make_uuid,
)
from ._icons import svg_from_icon
from ._icons_enum import IconName
from ._messages import FileTransferPartAck, GuiBaseProps, GuiSliderMark
from ._scene_api import cast_vector
from ._threadpool_exceptions import print_threadpool_errors
if TYPE_CHECKING:
import plotly.graph_objects as go
from ._viser import ClientHandle, ViserServer
from .infra import ClientId
GuiInputPropsType = TypeVar("GuiInputPropsType", bound=GuiBaseProps)
IntOrFloat = TypeVar("IntOrFloat", int, float)
TString = TypeVar("TString", bound=str)
TLiteralString = TypeVar("TLiteralString", bound=LiteralString)
T = TypeVar("T")
LengthTenStrTuple: TypeAlias = Tuple[str, str, str, str, str, str, str, str, str, str]
LiteralColor: TypeAlias = Literal[
"dark",
"gray",
"red",
"pink",
"grape",
"violet",
"indigo",
"blue",
"cyan",
"green",
"lime",
"yellow",
"orange",
"teal",
]
def _hex_from_hls(h: float, l: float, s: float) -> str:
"""Converts HLS values in [0.0, 1.0] to a hex-formatted string, eg 0xffffff."""
return "#" + "".join(
[
int(min(255, max(0, channel * 255.0)) + 0.5).to_bytes(1, "little").hex()
for channel in colorsys.hls_to_rgb(h, l, s)
]
)
def _compute_step(x: float | None) -> float: # type: ignore
"""For number inputs: compute an increment size from some number.
Example inputs/outputs:
100 => 1
12 => 1
12.1 => 0.1
12.02 => 0.01
0.004 => 0.001
"""
return 1 if x is None else 10 ** (-_compute_precision_digits(x))
def _build_slider_marks(
marks: tuple[float | tuple[float, str], ...] | None,
) -> tuple[GuiSliderMark, ...] | None:
"""Normalize a slider's ``marks`` argument into ``GuiSliderMark`` tuples.
Shared by add_slider and add_multi_slider so the two can't diverge."""
if marks is None:
return None
return tuple(
GuiSliderMark(value=float(x[0]), label=x[1])
if isinstance(x, tuple)
else GuiSliderMark(value=x, label=None)
for x in marks
)
def _infer_vector_step(
value: tuple[float, ...],
min: tuple[float, ...] | None,
max: tuple[float, ...] | None,
step: float | None,
) -> float:
"""Pick a default step for a vector input from its value/min/max components
when the caller didn't pass one. Shared by add_vector2 and add_vector3 so
the inference can't drift between the two."""
if step is not None:
return step
possible_steps: list[float] = []
possible_steps.extend([_compute_step(x) for x in value])
if min is not None:
possible_steps.extend([_compute_step(x) for x in min])
if max is not None:
possible_steps.extend([_compute_step(x) for x in max])
return float(np.min(possible_steps))
def _compute_precision_digits(x: float) -> int:
"""For number inputs: compute digits of precision from some number.
Example inputs/outputs:
100 => 0
12 => 0
12.1 => 1
10.2 => 1
0.007 => 3
"""
digits = 0
while x != round(x, ndigits=digits) and digits < 7:
digits += 1
return digits
@dataclasses.dataclass
class _RootGuiContainer:
_children: dict[str, SupportsRemoveProtocol]
_global_order_counter = 0
def _apply_default_order(order: float | None) -> float:
"""Apply default ordering logic for GUI elements.
If `order` is set to a float, this function is a no-op and returns it back.
Otherwise, we increment and return the value of a global counter.
"""
if order is not None:
return order
global _global_order_counter
_global_order_counter += 1
return _global_order_counter
@functools.lru_cache(maxsize=None)
def get_type_hints_cached(cls: type[Any]) -> dict[str, Any]:
return get_type_hints(cls) # type: ignore
class _FileUploadState(TypedDict):
client_id: ClientId
filename: str
mime_type: str
part_count: int
parts: dict[int, bytes]
total_bytes: int
transferred_bytes: int
lock: threading.Lock
[docs]
class GuiApi:
"""Interface for working with the 2D GUI in viser.
Used by both our global server object, for sharing the same GUI elements
with all clients, and by individual client handles."""
_target_container_from_thread_id: dict[int, str]
"""ID of container to put GUI elements into. Per-instance (NOT a shared
class attribute) -- otherwise a thread inside a ``with some_gui.add_folder()``
block would leak that container target into a *different* GuiApi instance
(e.g. server.gui vs a client.gui) and raise KeyError on the foreign uuid."""
def __init__(
self,
owner: ViserServer | ClientHandle, # Who do I belong to?
thread_executor: ThreadPoolExecutor,
event_loop: AbstractEventLoop,
) -> None:
from ._viser import ViserServer
self._owner = owner
"""Entity that owns this API."""
self._target_container_from_thread_id = {}
self._thread_executor = thread_executor
self._event_loop = event_loop
self._websock_interface = (
owner._websock_server
if isinstance(owner, ViserServer)
else owner._websock_connection
)
"""Interface for sending and listening to messages."""
self._gui_input_handle_from_uuid: dict[str, _GuiInputHandle[Any]] = {}
self._container_handle_from_uuid: dict[str, GuiContainerProtocol] = {
"root": _RootGuiContainer({})
}
self._modal_handle_from_uuid: dict[str, GuiModalHandle] = {}
self._panel_handle_from_uuid: dict[str, PanelHandle] = {}
# Layout-update counter, bumped on every placement command (any panel)
# and stamped onto the placement message (see
# _PlacementMixin._queue_placement / GuiSetPanelPositionMessage.counter).
# Lets the client ignore replayed placement for a panel the user has
# rearranged. GLOBAL across panels within this GuiApi (D50): collapse
# is container-scoped, so when stacked panels' collapse axes conflict,
# a late joiner must replay them in COMMAND order -- per-panel counters
# cannot order commands across panels that share a container.
self._layout_counter: int = 0
# Guards the counter's read-modify-write so concurrent placement calls
# from different threads can't stamp duplicate counters.
self._layout_update_lock = threading.Lock()
# Serializes placement commands against panel removal: the removed
# check + placement enqueue and remove()'s tombstone + purge must not
# interleave, or a racing dock_left() could re-enqueue a placement
# update for a just-removed panel (immortal buffer residue).
self._panel_lifecycle_lock = threading.Lock()
# Random id identifying THIS GuiApi instance (fresh per server process,
# and distinct for each client-scoped `client.gui`). Stamped on every
# placement message alongside the counter: counters are only comparable
# within one run/scope, so the client treats a placement whose run_id
# differs from the last applied as a fresh, deliberate command (a
# restarted server's counter restart would otherwise read as stale).
self._layout_run_id = uuid.uuid4().hex[:8]
self._command_handle_from_uuid: dict[str, CommandHandle] = {}
self._current_file_upload_states: dict[str, _FileUploadState] = {}
# Set to True when plotly.min.js has been sent to client.
self._setup_plotly_js: bool = False
self._websock_interface.register_handler(
_messages.GuiUpdateMessage, self._handle_gui_updates
)
self._websock_interface.register_handler(
_messages.GuiButtonHoldMessage, self._handle_gui_button_hold
)
self._websock_interface.register_handler(
_messages.GuiFormSubmitMessage, self._handle_gui_form_submit
)
self._websock_interface.register_handler(
_messages.GuiFormDirtyMessage, self._handle_gui_form_dirty
)
self._websock_interface.register_handler(
_messages.FileTransferStartUpload, self._handle_file_transfer_start
)
self._websock_interface.register_handler(
_messages.FileTransferPart,
self._handle_file_transfer_part,
)
self._websock_interface.register_handler(
_messages.CommandTriggerMessage, self._handle_command_trigger
)
def _resolve_client(self, client_id: ClientId) -> ClientHandle | None:
"""Resolve the ClientHandle for a given client_id. Returns None when
the client has disconnected between queuing and dispatch -- callers
should early-return."""
# Runtime import to break the circular edge with `_viser`.
from ._viser import ClientHandle, ViserServer
if isinstance(self._owner, ClientHandle):
return self._owner
if isinstance(self._owner, ViserServer):
return self._owner._connected_clients.get(client_id, None)
assert_never(self._owner)
async def _handle_gui_updates(
self, client_id: ClientId, message: _messages.GuiUpdateMessage
) -> None:
"""Callback for handling GUI messages."""
handle = self._gui_input_handle_from_uuid.get(message.uuid, None)
if handle is None or handle._impl.removed:
return
handle_state = handle._impl
has_changed = False
updates_cast = {}
for prop_name, prop_value in message.updates.items():
assert hasattr(handle_state, prop_name)
current_value = getattr(handle_state, prop_name)
# Do some type casting. This is brittle, but necessary (1) when we
# expect floats but the Javascript side gives us integers or (2)
# when we expect tuples but the Javascript side gives us lists.
if prop_name == "value":
if isinstance(handle_state.value, tuple):
if len(handle_state.value) > 0:
# We currently assume non-empty tuple types have length
# greater than 0, and contents are all the same type.
typ = type(handle_state.value[0])
assert all([type(x) == typ for x in handle_state.value])
prop_value = tuple([typ(new) for new in prop_value])
else:
# Empty tuple.
prop_value = tuple(prop_value)
else:
prop_value = type(handle_state.value)(prop_value)
# Update handle property.
if current_value != prop_value:
has_changed = True
setattr(handle_state, prop_name, prop_value)
# Save value, which might have been cast.
updates_cast[prop_name] = prop_value
# Only call update when value has actually changed.
if not handle_state.is_button and not has_changed:
return
# GUI element has been updated!
handle_state.update_timestamp = time.time()
# Broadcast to the other clients BEFORE awaiting update callbacks, so
# the mutation above and its broadcast are adjacent on the event loop:
# with the broadcast after the awaits, (a) two near-simultaneous
# updates could interleave at a callback's await point and leave the
# buffer coalesced to a different value than the server state, and
# (b) a callback that writes back (e.g. clamps handle.value) had its
# own broadcast clobbered by the stale echo of the pre-clamp value --
# every OTHER client (and every late joiner) kept the un-clamped
# value while the server and the acting client held the clamped one.
if handle_state.sync_cb is not None:
handle_state.sync_cb(client_id, updates_cast)
client = self._resolve_client(client_id)
if client is None:
return
for cb in handle_state.update_cb:
if asyncio.iscoroutinefunction(cb):
await cb(GuiEvent(client, client_id, handle))
else:
self._thread_executor.submit(
cb, GuiEvent(client, client_id, handle)
).add_done_callback(print_threadpool_errors)
async def _handle_gui_button_hold(
self, client_id: ClientId, message: _messages.GuiButtonHoldMessage
) -> None:
"""Callback for handling button hold messages."""
handle = self._gui_input_handle_from_uuid.get(message.uuid, None)
if handle is None or handle._impl.removed:
return
# Ensure this is a button handle with hold callbacks.
if not isinstance(handle, GuiButtonHandle):
return
# Get callbacks registered for this frequency.
callbacks = handle._button_impl.hold_cbs_from_freq.get(message.frequency, [])
if not callbacks:
return
client = self._resolve_client(client_id)
if client is None:
return
# Call all callbacks for this frequency.
for cb in callbacks:
if asyncio.iscoroutinefunction(cb):
await cb(GuiEvent(client, client_id, handle))
else:
self._thread_executor.submit(
cb, GuiEvent(client, client_id, handle)
).add_done_callback(print_threadpool_errors)
async def _handle_gui_form_submit(
self, client_id: ClientId, message: _messages.GuiFormSubmitMessage
) -> None:
"""Callback for client-initiated form submits (Cmd/Ctrl+Enter).
Fires the form's on_submit callbacks and broadcasts the message back
to all clients so they reset their dirty indicators.
"""
handle = self._container_handle_from_uuid.get(message.uuid, None)
if not isinstance(handle, GuiFormHandle):
return
client = self._resolve_client(client_id)
if client is None:
return
for cb in handle._submit_cb:
if asyncio.iscoroutinefunction(cb):
await cb(GuiEvent(client, client_id, handle))
else:
self._thread_executor.submit(
cb, GuiEvent(client, client_id, handle)
).add_done_callback(print_threadpool_errors)
# Broadcast to clients so they clear their dirty indicators.
self._websock_interface.queue_message(
_messages.GuiFormSubmitMessage(uuid=message.uuid)
)
async def _handle_gui_form_dirty(
self, client_id: ClientId, message: _messages.GuiFormDirtyMessage
) -> None:
"""Broadcast form dirty signal to all other clients."""
message.excluded_self_client = client_id
self._websock_interface.queue_message(message)
def _handle_file_transfer_start(
self, client_id: ClientId, message: _messages.FileTransferStartUpload
) -> None:
if message.source_component_uuid not in self._gui_input_handle_from_uuid:
return
self._current_file_upload_states[message.transfer_uuid] = {
# Owner, so a disconnect can purge this client's in-flight
# transfers -- entries are otherwise only removed on completion,
# and a tab closed mid-upload leaked its buffered parts forever.
"client_id": client_id,
"filename": message.filename,
"mime_type": message.mime_type,
"part_count": message.part_count,
"parts": {},
"total_bytes": message.size_bytes,
"transferred_bytes": 0,
"lock": threading.Lock(),
}
# A zero-byte file is sent with part_count == 0, so no FileTransferPart
# messages ever arrive to drive completion. Finish it here -- otherwise
# on_upload never fires and the transfer state leaks forever.
if message.part_count == 0:
self._websock_interface.queue_message(
FileTransferPartAck(
source_component_uuid=message.source_component_uuid,
transfer_uuid=message.transfer_uuid,
transferred_bytes=0,
total_bytes=0,
)
)
self._finish_file_upload(
client_id, message.transfer_uuid, message.source_component_uuid
)
def _drop_uploads_from_client(self, client_id: ClientId) -> None:
"""Purge in-flight upload buffers owned by a disconnected client.
Entries are otherwise only removed on completion, so a tab closed
mid-upload would leak its accumulated parts forever (and a hostile
client could grow that leak without bound)."""
for transfer_uuid in [
tid
for tid, state in self._current_file_upload_states.items()
if state["client_id"] == client_id
]:
self._current_file_upload_states.pop(transfer_uuid, None)
def _finish_file_upload(
self,
client_id: ClientId,
transfer_uuid: str,
# None on the wire for server->client downloads; upload transfers carry
# the upload button's uuid, but that handle may have been removed
# mid-transfer -- both degrade to the same no-op below.
source_component_uuid: str | None,
) -> None:
"""Finalize a completed upload by assembling the file contents and
firing the handle's update callbacks. Shared by the normal multi-part
path and the zero-byte path (which has no parts)."""
state = self._current_file_upload_states.pop(transfer_uuid, None)
if state is None:
return
if source_component_uuid is None:
return
handle = self._gui_input_handle_from_uuid.get(source_component_uuid, None)
if handle is None or handle._impl.removed:
return
handle_state = handle._impl
content = b"".join(state["parts"][i] for i in range(state["part_count"]))
if len(content) != state["total_bytes"]:
# All part slots filled but the bytes don't match the declared
# size: a buggy/hostile client. Discard rather than surface a
# file that lies about its length (state is already popped above,
# so nothing leaks).
warnings.warn(
f"[viser] Dropping upload {state['filename']!r}: received "
f"{len(content)} bytes but the client declared "
f"{state['total_bytes']}."
)
return
value = UploadedFile(
name=state["filename"],
content=content,
)
# Update state.
handle_state.value = value
handle_state.update_timestamp = time.time()
# Trigger callbacks.
client = self._resolve_client(client_id)
if client is None:
return
for cb in handle_state.update_cb:
if asyncio.iscoroutinefunction(cb):
self._event_loop.create_task(cb(GuiEvent(client, client_id, handle)))
else:
self._thread_executor.submit(
cb, GuiEvent(client, client_id, handle)
).add_done_callback(print_threadpool_errors)
def _handle_file_transfer_part(
self, client_id: ClientId, message: _messages.FileTransferPart
) -> None:
if message.transfer_uuid not in self._current_file_upload_states:
return
# NOTE: the source component may legitimately be gone from
# `_gui_input_handle_from_uuid` here -- `button.remove()` during a
# multi-part upload pops it. Keep buffering and acking parts anyway
# (asserting would raise in an asyncio task: no ack, and the
# `_current_file_upload_states` entry would leak forever); completion
# degrades cleanly in `_finish_file_upload`, which pops the transfer
# state and then no-ops on the removed/missing handle.
state = self._current_file_upload_states[message.transfer_uuid]
part_count = state["part_count"]
total_bytes = state["total_bytes"]
with state["lock"]:
# Drop duplicate or out-of-range parts instead of letting them
# corrupt the transfer: a repeated part_index used to inflate the
# byte-count completion gate, so assembly ran with missing part
# slots (KeyError) or overshot the declared size (assert) -- both
# crashing the handler task and leaking the transfer state.
if message.part_index in state["parts"] or not (
0 <= message.part_index < part_count
):
return
state["parts"][message.part_index] = message.content
state["transferred_bytes"] += len(message.content)
# Send ack to the server.
self._websock_interface.queue_message(
FileTransferPartAck(
source_component_uuid=message.source_component_uuid,
transfer_uuid=message.transfer_uuid,
transferred_bytes=state["transferred_bytes"],
total_bytes=total_bytes,
)
)
# Complete only when every part slot is filled: the byte count
# alone can't distinguish "all parts arrived" from a client whose
# parts don't add up to its declared size.
if len(state["parts"]) < part_count:
return
# Finish the upload.
self._finish_file_upload(
client_id, message.transfer_uuid, message.source_component_uuid
)
async def _handle_command_trigger(
self, client_id: ClientId, message: _messages.CommandTriggerMessage
) -> None:
"""Callback for handling command trigger messages from the command palette."""
handle = self._command_handle_from_uuid.get(message.uuid, None)
if handle is None or handle._impl.removed:
return
handle_state = handle._impl
if handle_state.props.disabled:
return
client = self._resolve_client(client_id)
if client is None:
return
for cb in handle_state.trigger_cb:
if asyncio.iscoroutinefunction(cb):
await cb(CommandEvent(client, client_id, handle))
else:
self._thread_executor.submit(
cb, CommandEvent(client, client_id, handle)
).add_done_callback(print_threadpool_errors)
def _get_container_uuid(self) -> str:
"""Get container ID associated with the current thread."""
return self._target_container_from_thread_id.get(threading.get_ident(), "root")
def _set_container_uuid(self, container_uuid: str) -> None:
"""Set container ID associated with the current thread."""
self._target_container_from_thread_id[threading.get_ident()] = container_uuid
def _next_layout_counter(self) -> int:
"""Bump and return the layout-update counter. THE single home of the
lock-guarded read-modify-write (used by every placement command and by
reset), so concurrent calls can't stamp duplicate counters. Global
across panels (D50): the client replays conflicting container-scoped
collapse axes in counter order."""
with self._layout_update_lock:
self._layout_counter += 1
return self._layout_counter
[docs]
def reset(self) -> None:
"""Reset the GUI."""
from ._viser import ViserServer
root_container = self._container_handle_from_uuid["root"]
while root_container._children:
next(iter(root_container._children.values())).remove()
while self._modal_handle_from_uuid:
next(iter(self._modal_handle_from_uuid.values())).close()
# Panels are top-level entities (not under `root`), so drain them
# explicitly -- otherwise they leak and replay to late joiners.
while self._panel_handle_from_uuid:
next(iter(self._panel_handle_from_uuid.values())).remove()
while self._command_handle_from_uuid:
next(iter(self._command_handle_from_uuid.values())).remove()
# Reset any server-authored main-panel placement (from `main_panel`
# commands or the deprecated `control_layout`) back to the default. The
# per-axis placement messages persist in the broadcast buffer and replay
# to clients that connect after the reset; sending the defaults here
# coalesces over the stale ones (same redundancy key per message type) so
# late joiners -- and connected clients -- get the default control panel
# (a top-right float) instead of a layout the user never asked
# for. Placement is write-only, so we just send; there's no state to read.
# Bump the main panel's layout counter once and stamp it on all four
# reset messages, so a connected client that had rearranged the control
# panel still sees this deliberate reset (counter increment beats its
# last-applied), while a normal reconnect replay -- same counter -- is
# ignored. (None width/height clears any override -> default/theme.)
#
# SERVER-SCOPED ONLY: a client-scoped `client.gui.reset()` resets the
# GUI elements it owns but must NOT touch the main panel's placement.
# A client-scoped GuiApi mints its own `_layout_run_id`, so the four
# default CONTROL_PANEL_ID messages below would reach the client's
# placement gate with an unseen run_id -- which the gate treats as a
# fresh, deliberate command -- clobbering server-authored placement
# (e.g. undocking a `main_panel.dock_left()` control panel to the
# default top-right float, for that one client).
if not isinstance(self._owner, ViserServer):
return
reset_counter = self._next_layout_counter()
self._websock_interface.queue_message(
_messages.GuiSetPanelPositionMessage(
CONTROL_PANEL_ID,
{"kind": "float", "x": None, "y": None},
counter=reset_counter,
run_id=self._layout_run_id,
)
)
self._websock_interface.queue_message(
_messages.GuiSetPanelWidthMessage(
CONTROL_PANEL_ID,
None,
counter=reset_counter,
run_id=self._layout_run_id,
)
)
self._websock_interface.queue_message(
_messages.GuiSetPanelHeightMessage(
CONTROL_PANEL_ID,
None,
counter=reset_counter,
run_id=self._layout_run_id,
)
)
# Collapsed is the fourth independent axis with its own redundancy
# slot: without this, a prior `main_panel.minimize()` survives the
# reset in the buffer and late joiners replay a minimized "default"
# control panel.
self._websock_interface.queue_message(
_messages.GuiSetPanelCollapsedMessage(
CONTROL_PANEL_ID,
False,
counter=reset_counter,
run_id=self._layout_run_id,
)
)
[docs]
def set_panel_label(self, label: str | None) -> None:
"""Set the main label that appears in the GUI panel.
Args:
label: The new label.
"""
self._websock_interface.queue_message(_messages.SetGuiPanelLabelMessage(label))
[docs]
def add_command(
self,
label: str,
*,
description: str | None = None,
hotkey: _messages.HotkeyKey | None = None,
modifier: _messages.KeyModifier | None = None,
icon: IconName | None = None,
disabled: bool = False,
) -> CommandHandle:
"""Register a command for the command palette.
Scope follows the owner of this :class:`GuiApi`, matching the
rest of the GUI API: ``server.gui.add_command(...)`` registers
the command for every connected client (and any that connect
later), while ``client.gui.add_command(...)`` registers it only
for that client.
(Experimental) The command palette API may change in future
releases.
Args:
label: Label displayed in the command palette.
description: Optional description displayed below the label.
hotkey: Optional hotkey key, e.g. ``"K"`` or ``"R"``. ``None``
disables the hotkey.
modifier: Modifier-combo to require with the hotkey, as a
canonically ordered ``"+"``-separated string like
``"cmd/ctrl"``, ``"shift"``, or ``"cmd/ctrl+shift"``.
``None`` matches "no modifiers held". ``cmd/ctrl``
matches whenever either Cmd or Ctrl is held. Must be
``None`` when ``hotkey`` is ``None``; passing a
modifier without a hotkey raises ``ValueError``.
icon: Optional icon to display next to the command label.
disabled: If True, the command is visible but not triggerable.
Returns:
A handle that can be used to attach callbacks via
:meth:`CommandHandle.on_trigger`, update properties, or remove the
command.
"""
if hotkey is None and modifier is not None:
raise ValueError(
"add_command(modifier=...) requires hotkey= to also be set."
)
# Validate + canonicalize the modifier string. Raises on bad input.
normalized_modifier = _messages._normalize_key_modifier(modifier)
command_uuid = _make_uuid()
props = _messages.CommandProps(
label=label,
description=description,
hotkey=hotkey,
modifier=normalized_modifier,
disabled=disabled,
_icon_html=None if icon is None else svg_from_icon(icon),
)
# Register in the local map before publishing, so an immediate
# trigger from the client can be resolved here.
handle_state = _CommandHandleState(
uuid=command_uuid,
gui_api=self,
props=props,
icon=icon,
)
handle = CommandHandle(handle_state)
self._command_handle_from_uuid[command_uuid] = handle
self._websock_interface.queue_message(
_messages.RegisterCommandMessage(
uuid=command_uuid,
props=props,
)
)
return handle
[docs]
@deprecated_positional_shim
def add_folder(
self,
label: str | None,
*,
order: float | None = None,
expand_by_default: bool = True,
visible: bool = True,
) -> GuiFolderHandle:
"""Add a folder: a single **inline** collapsible section. Return a handle
that can be used to populate it.
For inline *tabs* use :meth:`add_tab_group`; for content in a *movable*
window (dockable / floating) use :meth:`add_panel`.
Args:
label: Label to display on the folder. If ``None``, the folder is
rendered without a header or border, which is useful for pure
layout grouping.
order: Optional ordering, smallest values will be displayed first.
expand_by_default: Open the folder by default. Set to False to collapse it by
default. Ignored when ``label`` is ``None``.
visible: Whether the component is visible.
Returns:
A handle that can be used as a context to populate the folder.
"""
# TODO: consider an imperative collapse/expand method on the folder
# handle; today only the `expand_by_default` creation kwarg exists.
folder_container_id = _make_uuid()
order = _apply_default_order(order)
props = _messages.GuiFolderProps(
order=order,
label=label,
expand_by_default=expand_by_default,
visible=visible,
)
self._websock_interface.queue_message(
_messages.GuiFolderMessage(
uuid=folder_container_id,
container_uuid=self._get_container_uuid(),
props=props,
)
)
return GuiFolderHandle(
_GuiHandleState(
folder_container_id,
self,
None,
props=props,
parent_container_id=self._get_container_uuid(),
)
)
[docs]
@deprecated_positional_shim
def add_modal(
self,
title: str,
*,
order: float | None = None,
) -> GuiModalHandle:
"""Show a modal window, which can be useful for popups and messages, then return
a handle that can be used to populate it.
Args:
title: Title to display on the modal.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used as a context to populate the modal.
"""
modal_container_id = _make_uuid()
order = _apply_default_order(order)
self._websock_interface.queue_message(
_messages.GuiModalMessage(
order=order,
uuid=modal_container_id,
title=title,
)
)
return GuiModalHandle(
_gui_api=self,
_uuid=modal_container_id,
)
[docs]
@deprecated_positional_shim
def add_tab_group(
self,
*,
order: float | None = None,
visible: bool = True,
) -> GuiTabGroupHandle:
"""Add a tab group: **inline** tabs that stay put where they are added
(in the control panel or a folder).
For tabs in a *movable* window the user can dock or float, use
:meth:`add_panel` instead; for a single inline collapsible section, use
:meth:`add_folder`.
Args:
order: Optional ordering, smallest values will be displayed first.
visible: Whether the component is visible.
Returns:
A handle that can be used as a context to populate the tab group.
"""
tab_group_id = _make_uuid()
order = _apply_default_order(order)
message = _messages.GuiTabGroupMessage(
uuid=tab_group_id,
container_uuid=self._get_container_uuid(),
props=_messages.GuiTabGroupProps(
order=order,
_tab_labels=(),
visible=visible,
_tab_icons_html=(),
_tab_container_ids=(),
),
)
self._websock_interface.queue_message(message)
return GuiTabGroupHandle(
_GuiHandleState(
message.uuid,
self,
value=None,
props=message.props,
parent_container_id=message.container_uuid,
)
)
[docs]
def add_panel(
self,
*,
order: float | None = None,
visible: bool = True,
) -> PanelHandle:
"""Add a standalone panel: a **movable** window (dockable / floating)
that lives outside the main control panel. A panel is the *container*;
its tabs (added with :meth:`PanelHandle.add_tab`) hold the content.
Choosing a container: use :meth:`add_folder` for an inline collapsible
section, :meth:`add_tab_group` for inline tabs that stay put inside the
control panel, and ``add_panel`` for tabs in a window the user can move,
dock, or float. (A panel is essentially a tab group you can place.)
A panel is a top-level entity (like :meth:`add_modal`): it is not placed
in the current container context. Add content with
:meth:`PanelHandle.add_tab`, place it with the imperative ``dock_*`` /
:meth:`PanelHandle.float` commands, and remove it with
:meth:`PanelHandle.remove` (there is no UI close button). See also
:attr:`main_panel` to place the main control panel.
Panels start expanded -- except on the mobile bottom sheet, where
panels render as sections that start collapsed (one tap opens the
panel; the sheet is wayfinding chrome). :meth:`PanelHandle.minimize` /
:meth:`PanelHandle.expand` collapse or reveal them imperatively,
applied to the panel's containing window or docked column like the
on-screen minimize control.
Args:
order: Optional ordering, smallest values will be displayed first.
Used for the mobile bottom sheet's section order;
docked/floating placement is set with the ``dock_*`` /
``float`` commands, not ``order``.
visible: Whether the panel is visible.
Returns:
A handle used to add tabs to and place the panel.
Example::
panel = server.gui.add_panel()
with panel.add_tab("Stats", viser.Icon.CHART_BAR):
server.gui.add_number("Counter", 0, disabled=True)
panel.dock_right()
panel.set_width(320)
"""
panel_id = _make_uuid()
message = _messages.GuiPanelMessage(
uuid=panel_id,
props=_messages.GuiPanelProps(
order=_apply_default_order(order),
_tab_labels=(),
visible=visible,
_tab_icons_html=(),
_tab_container_ids=(),
),
)
self._websock_interface.queue_message(message)
return PanelHandle(
_GuiHandleState(
message.uuid,
self,
value=None,
props=message.props,
# A panel is a top-level entity (not nested in a container); it
# registers itself in `_panel_handle_from_uuid`. The
# parent_container_id is unused for panels, but the handle state
# requires one -- "root" is a harmless placeholder.
parent_container_id="root",
)
)
@property
def main_panel(self) -> MainPanelHandle:
"""Handle for the main control panel.
Supports the same placement / sizing / minimize commands as a standalone
panel (see :class:`MainPanelHandle` and :meth:`add_panel`). It can be a
dock anchor for :meth:`PanelHandle.dock_above` / :meth:`dock_below` from
any scope, but ONLY while it is itself docked (e.g. after
``main_panel.dock_left()``); the control panel floats by default, and
splitting against a floating anchor falls back to a right-edge dock. A
fresh handle is returned on each access. This is the supported replacement
for the deprecated ``configure_theme(control_layout=...)``."""
return MainPanelHandle(self)
[docs]
@deprecated_positional_shim
def add_markdown(
self,
content: str,
*,
image_root: Path | None = None,
order: float | None = None,
visible: bool = True,
) -> GuiMarkdownHandle:
"""Add markdown to the GUI.
Args:
content: Markdown content to display.
image_root: Optional root directory to resolve relative image paths.
order: Optional ordering, smallest values will be displayed first.
visible: Whether the component is visible.
Returns:
A handle that can be used to interact with the GUI element.
"""
message = _messages.GuiMarkdownMessage(
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiMarkdownProps(
order=_apply_default_order(order),
_markdown="",
visible=visible,
),
)
self._websock_interface.queue_message(message)
handle = GuiMarkdownHandle(
_GuiHandleState(
message.uuid,
self,
None,
props=message.props,
parent_container_id=message.container_uuid,
),
_content=content,
_image_root=image_root,
)
# Logic for processing markdown, handling images, etc is all in the
# `.content` setter, which should send a GuiUpdateMessage.
handle.content = content
return handle
[docs]
@deprecated_positional_shim
def add_html(
self,
content: str,
*,
order: float | None = None,
visible: bool = True,
) -> GuiHtmlHandle:
"""Add HTML to the GUI.
Args:
content: HTML content to display.
order: Optional ordering, smallest values will be displayed first.
visible: Whether the component is visible.
Returns:
A handle that can be used to interact with the GUI element.
"""
message = _messages.GuiHtmlMessage(
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiHtmlProps(
order=_apply_default_order(order),
content=content,
visible=visible,
),
)
self._websock_interface.queue_message(message)
handle = GuiHtmlHandle(
_GuiHandleState(
message.uuid,
self,
None,
props=message.props,
parent_container_id=message.container_uuid,
),
)
return handle
[docs]
@deprecated_positional_shim
def add_divider(
self,
*,
order: float | None = None,
visible: bool = True,
) -> GuiDividerHandle:
"""Add a horizontal divider line to the GUI.
Args:
order: Optional ordering, smallest values will be displayed first.
visible: Whether the component is visible.
Returns:
A handle that can be used to interact with the GUI element.
"""
message = _messages.GuiDividerMessage(
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiDividerProps(
order=_apply_default_order(order),
visible=visible,
),
)
self._websock_interface.queue_message(message)
handle = GuiDividerHandle(
_GuiHandleState(
message.uuid,
self,
None,
props=message.props,
parent_container_id=message.container_uuid,
),
)
return handle
[docs]
@deprecated_positional_shim
def add_image(
self,
image: np.ndarray,
*,
label: str | None = None,
format: Literal["auto", "png", "jpeg"] = "auto",
jpeg_quality: int | None = None,
order: float | None = None,
visible: bool = True,
) -> GuiImageHandle:
"""Add an image element to the GUI.
Args:
image: A numpy array representing the image to display.
label: Label to display on the image element.
format: Format to transport and display the image using. 'auto' will use PNG for RGBA images and JPEG for RGB.
jpeg_quality: Quality of the jpeg image (if jpeg format is used).
order: Order of the element for sorting.
visible: Whether the image element is visible initially.
Returns:
Handle for manipulating the image element.
"""
# Resolve format if auto.
if format == "auto":
resolved_format = "png" if image.shape[2] == 4 else "jpeg"
else:
resolved_format = format
message = _messages.GuiImageMessage(
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiImageProps(
_data=None, # Sent in prop update later.
label=label,
_format=resolved_format,
order=_apply_default_order(order),
visible=visible,
),
)
self._websock_interface.queue_message(message)
handle = GuiImageHandle(
_GuiHandleState(
message.uuid,
self,
None,
props=message.props,
parent_container_id=message.container_uuid,
),
_image=image,
_jpeg_quality=jpeg_quality,
)
handle._user_format = format
handle.image = image
return handle
[docs]
@deprecated_positional_shim
def add_plotly(
self,
figure: go.Figure,
*,
config: Mapping[str, Any] | None = None,
aspect: float = 1.0,
order: float | None = None,
visible: bool = True,
) -> GuiPlotlyHandle:
"""Add a Plotly figure to the GUI. Requires the `plotly` package to be
installed.
.. note::
Updates to Plotly figures can be slow when you have many plots or frequent updates. For real-time visualization, consider using
:meth:`add_uplot` instead.
Args:
figure: Plotly figure to display.
config: Plotly config dict merged into the figure JSON. Controls
display options like ``{"displayModeBar": False}``. Values
must be JSON-serializable. See
https://plotly.com/javascript/configuration-options/
aspect: Aspect ratio of the plot in the control panel (width/height).
order: Optional ordering, smallest values will be displayed first.
visible: Whether the component is visible.
Returns:
A handle that can be used to interact with the GUI element.
"""
# If plotly.min.js hasn't been sent to the client yet, the client won't be able
# to render the plot. Send this large file now! (~3MB)
if not self._setup_plotly_js:
# Check if plotly is installed.
try:
import plotly
except ImportError:
raise ImportError(
"You must have the `plotly` package installed to use the Plotly GUI element."
)
# Check that plotly.min.js exists.
plotly_path = (
Path(plotly.__file__).parent / "package_data" / "plotly.min.js"
)
assert plotly_path.exists(), (
f"Could not find plotly.min.js at {plotly_path}."
)
# Send it over!
plotly_js = plotly_path.read_text(encoding="utf-8")
self._websock_interface.queue_message(
_messages.RunJavascriptMessage(source=plotly_js)
)
# Update the flag so we don't send it again.
self._setup_plotly_js = True
# After plotly.min.js has been sent, we can send the plotly figure.
# Empty string for `plotly_json_str` is a signal to the client to render nothing.
message = _messages.GuiPlotlyMessage(
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiPlotlyProps(
order=_apply_default_order(order),
_plotly_json_str="",
aspect=1.0,
visible=visible,
),
)
self._websock_interface.queue_message(message)
handle = GuiPlotlyHandle(
_GuiHandleState(
message.uuid,
self,
value=None,
props=message.props,
parent_container_id=message.container_uuid,
),
_figure=figure,
_config=config,
)
# Set the plotly handle properties.
handle.figure = figure
handle.aspect = aspect
return handle
[docs]
@deprecated_positional_shim
def add_uplot(
self,
data: tuple[np.ndarray, ...],
series: tuple[uplot.Series, ...],
*,
mode: Literal[1, 2] | None = None,
title: str | None = None,
bands: tuple[uplot.Band, ...] | None = None,
scales: dict[str, uplot.Scale] | None = None,
axes: tuple[uplot.Axis, ...] | None = None,
legend: uplot.Legend | None = None,
cursor: uplot.Cursor | None = None,
focus: uplot.Focus | None = None,
aspect: float = 1.0,
height: int | None = None,
padding: tuple[int, int, int, int] | None = None,
order: float | None = None,
visible: bool = True,
) -> GuiUplotHandle:
"""Add a uPlot chart to the GUI for high-performance time-series visualization.
uPlot is optimized for plotting large datasets with smooth pan/zoom interactions.
All configuration options follow the standard uPlot API. For comprehensive
documentation, see: https://github.com/leeoniya/uPlot/tree/1.6.32/docs
.. note::
Configuration types are exposed under the :mod:`viser.uplot` module for convenience:
:class:`viser.uplot.Series`, :class:`viser.uplot.Scale`, :class:`viser.uplot.Axis`,
:class:`viser.uplot.Band`, :class:`viser.uplot.Legend`, :class:`viser.uplot.Cursor`,
and :class:`viser.uplot.Focus`. These are :py:class:`~typing.TypedDict` types;
standard dictionaries can also be used.
Args:
data: Tuple of 1D numpy arrays containing chart data. The first array provides
x-axis values, and subsequent arrays contain y-axis data for
each series. All arrays must have identical length. Minimum 2
arrays.
series: Series configuration objects defining visual appearance and behavior.
Must match the length of data tuple.
mode: Chart layout mode. 1 = aligned (default) where all series share axes,
2 = faceted where each series gets its own subplot panel.
title: Chart title displayed at the top.
bands: High/low range visualizations between data series. Useful for showing
confidence intervals, error bounds, or min/max ranges. Each band connects
two adjacent series indices.
scales: Scale definitions controlling data-to-pixel mapping and axis ranges.
Key features include auto-ranging, manual min/max, time-based scaling,
and logarithmic distributions. Multiple scales enable dual-axis charts.
axes: Axis configuration for labels, ticks, grids, and positioning.
Controls which side axes appear (top/right/bottom/left), tick formatting,
grid line styling, and spacing between tick marks.
legend: Legend display options including positioning, styling, and custom
value formatting functions for hover states.
cursor: Interactive cursor behavior including hover proximity detection,
drag-to-zoom, and crosshair appearance. Controls how users interact
with the chart through mouse/touch.
focus: Visual highlighting when hovering over series. Controls the alpha
transparency of non-focused series to emphasize the active one.
aspect: Width-to-height ratio for the chart display in the control panel.
1.0 creates a square chart, values > 1.0 create wider charts.
Used when height is None.
height: Fixed height in pixels. Overrides aspect ratio when set.
padding: Chart padding (top, right, bottom, left) in pixels. Defaults
to (0, 24, 0, 0) when omitted.
order: Display ordering relative to other GUI elements (lower values first).
visible: Whether the chart is visible in the interface.
Returns:
A handle for programmatically updating chart properties and data.
"""
# Validate data structure.
assert len(data) >= 2, (
"data must have at least 2 arrays (x-data + at least one y-data series)"
)
assert all(isinstance(arr, np.ndarray) for arr in data), (
"all data elements must be numpy arrays"
)
# Validate data dimensions and shapes.
for i, arr in enumerate(data):
assert arr.ndim == 1, f"data[{i}] must be a 1D array, got shape {arr.shape}"
# Check that all arrays have the same length.
lengths = [len(arr) for arr in data]
if not all(length == lengths[0] for length in lengths):
raise ValueError(
f"All data arrays must have the same length. Got lengths: {lengths}"
)
# Validate series configuration.
assert len(series) > 0, "series must not be empty"
if len(series) != len(data):
raise ValueError(
f"Length of series ({len(series)}) must match length of data ({len(data)}). "
f"Each array in data needs a corresponding series configuration."
)
# Convert arrays to float64 as expected by GuiUplotProps.
data_float64 = tuple(arr.astype(np.float64) for arr in data)
message = _messages.GuiUplotMessage(
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiUplotProps(
order=_apply_default_order(order),
data=data_float64,
mode=mode,
title=title,
series=series,
bands=bands,
scales=scales,
axes=axes,
legend=legend,
cursor=cursor,
focus=focus,
aspect=aspect,
height=height,
padding=padding,
visible=visible,
),
)
self._websock_interface.queue_message(message)
return GuiUplotHandle(
_GuiHandleState(
message.uuid,
self,
value=None,
props=message.props,
parent_container_id=message.container_uuid,
),
)
[docs]
@deprecated_positional_shim
def add_checkbox(
self,
label: str,
initial_value: bool,
*,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiCheckboxHandle:
"""Add a checkbox to the GUI.
Args:
label: Label to display on the checkbox.
initial_value: Initial value of the checkbox.
disabled: Whether the checkbox is disabled.
visible: Whether the checkbox is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value = initial_value
assert isinstance(value, bool)
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiCheckboxHandle(
self._create_gui_input(
value,
message=_messages.GuiCheckboxMessage(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiCheckboxProps(
order=order,
label=label,
hint=hint,
disabled=disabled,
visible=visible,
),
),
)
)
[docs]
@deprecated_positional_shim
def add_text(
self,
label: str,
initial_value: str,
*,
multiline: bool = False,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiTextHandle:
r"""Add a text input to the GUI.
Args:
label: Label to display on the text input.
initial_value: Initial value of the text input.
multiline: Whether the text input supports multiple lines, delimited with
the \n character.
disabled: Whether the text input is disabled.
visible: Whether the text input is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value = initial_value
assert isinstance(value, str)
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiTextHandle(
self._create_gui_input(
value,
message=_messages.GuiTextMessage(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiTextProps(
order=order,
label=label,
hint=hint,
disabled=disabled,
visible=visible,
multiline=multiline,
),
),
)
)
[docs]
@deprecated_positional_shim
def add_number(
self,
label: str,
initial_value: IntOrFloat,
*,
min: IntOrFloat | None = None,
max: IntOrFloat | None = None,
step: IntOrFloat | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiNumberHandle[IntOrFloat]:
"""Add a number input to the GUI, with user-specifiable bound and precision parameters.
Args:
label: Label to display on the number input.
initial_value: Initial value of the number input.
min: Optional minimum value of the number input.
max: Optional maximum value of the number input.
step: Optional step size of the number input. Computed automatically if not
specified.
disabled: Whether the number input is disabled.
visible: Whether the number input is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value = initial_value
assert isinstance(value, (int, float))
if min is not None and max is not None and max < min:
raise ValueError(f"add_number: max ({max}) must be >= min ({min}).")
# `not (value >= min)` (rather than `value < min`) so a NaN initial
# value is rejected too -- matching add_slider, whose `not (max >=
# value >= min)` already does. A bare `<`/`>` is False for NaN.
if (min is not None and not (value >= min)) or (
max is not None and not (value <= max)
):
raise ValueError(
f"add_number: initial_value ({value}) is outside of "
f"[min, max] = [{min}, {max}]."
)
if step is not None and step <= 0:
raise ValueError(f"add_number: step ({step}) must be > 0.")
if step is None:
# It's ok that `step` is always a float, even if the value is an integer,
# because things all become `number` types after serialization.
step = float( # type: ignore
np.min(
[
_compute_step(value),
_compute_step(min),
_compute_step(max),
]
)
)
assert step is not None
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiNumberHandle(
self._create_gui_input(
value,
message=_messages.GuiNumberMessage(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiNumberProps(
order=order,
label=label,
hint=hint,
min=min,
max=max,
precision=_compute_precision_digits(step),
step=step,
disabled=disabled,
visible=visible,
),
),
is_button=False,
)
)
[docs]
@deprecated_positional_shim
def add_vector2(
self,
label: str,
initial_value: tuple[float, float] | np.ndarray,
*,
min: tuple[float, float] | np.ndarray | None = None,
max: tuple[float, float] | np.ndarray | None = None,
step: float | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiVector2Handle:
"""Add a length-2 vector input to the GUI.
Args:
label: Label to display on the vector input.
initial_value: Initial value of the vector input.
min: Optional minimum value of the vector input.
max: Optional maximum value of the vector input.
step: Optional step size of the vector input. Computed automatically if not
disabled: Whether the vector input is disabled.
visible: Whether the vector input is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value = initial_value
value = cast_vector(value, 2)
min = cast_vector(min, 2) if min is not None else None
max = cast_vector(max, 2) if max is not None else None
if (
min is not None
and max is not None
and any(mn > mx for mn, mx in zip(min, max))
):
raise ValueError(
f"add_vector2: min {min} must be component-wise <= max {max}."
)
if (min is not None and any(not (v >= mn) for v, mn in zip(value, min))) or (
max is not None and any(not (v <= mx) for v, mx in zip(value, max))
):
raise ValueError(
f"add_vector2: initial_value {value} has components outside "
f"[min, max] = [{min}, {max}]."
)
uuid = _make_uuid()
order = _apply_default_order(order)
step = _infer_vector_step(value, min, max, step)
return GuiVector2Handle(
self._create_gui_input(
value,
message=_messages.GuiVector2Message(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiVector2Props(
order=order,
label=label,
hint=hint,
min=min,
max=max,
step=step,
precision=_compute_precision_digits(step),
disabled=disabled,
visible=visible,
),
),
)
)
[docs]
@deprecated_positional_shim
def add_vector3(
self,
label: str,
initial_value: tuple[float, float, float] | np.ndarray,
*,
min: tuple[float, float, float] | np.ndarray | None = None,
max: tuple[float, float, float] | np.ndarray | None = None,
step: float | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiVector3Handle:
"""Add a length-3 vector input to the GUI.
Args:
label: Label to display on the vector input.
initial_value: Initial value of the vector input.
min: Optional minimum value of the vector input.
max: Optional maximum value of the vector input.
step: Optional step size of the vector input. Computed automatically if not
disabled: Whether the vector input is disabled.
visible: Whether the vector input is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value = initial_value
value = cast_vector(value, 3)
min = cast_vector(min, 3) if min is not None else None
max = cast_vector(max, 3) if max is not None else None
if (
min is not None
and max is not None
and any(mn > mx for mn, mx in zip(min, max))
):
raise ValueError(
f"add_vector3: min {min} must be component-wise <= max {max}."
)
if (min is not None and any(not (v >= mn) for v, mn in zip(value, min))) or (
max is not None and any(not (v <= mx) for v, mx in zip(value, max))
):
raise ValueError(
f"add_vector3: initial_value {value} has components outside "
f"[min, max] = [{min}, {max}]."
)
uuid = _make_uuid()
order = _apply_default_order(order)
step = _infer_vector_step(value, min, max, step)
return GuiVector3Handle(
self._create_gui_input(
value,
message=_messages.GuiVector3Message(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiVector3Props(
order=order,
label=label,
hint=hint,
min=min,
max=max,
step=step,
precision=_compute_precision_digits(step),
disabled=disabled,
visible=visible,
),
),
)
)
# See add_dropdown for notes on overloads.
@overload
def add_dropdown(
self,
label: str,
options: Sequence[TLiteralString],
*,
initial_value: TLiteralString | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiDropdownHandle[TLiteralString]: ...
@overload
def add_dropdown(
self,
label: str,
options: Sequence[TString],
*,
initial_value: TString | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiDropdownHandle[TString]: ...
[docs]
@deprecated_positional_shim
def add_dropdown(
self,
label: str,
options: Sequence[TLiteralString] | Sequence[TString],
*,
initial_value: TLiteralString | TString | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiDropdownHandle[Any]: # Output type is specified in overloads.
"""Add a dropdown to the GUI.
Args:
label: Label to display on the dropdown.
options: Sequence of options to display in the dropdown.
initial_value: Initial value of the dropdown.
disabled: Whether the dropdown is disabled.
visible: Whether the dropdown is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
# Materialize once so a one-shot iterable isn't consumed by the checks
# below and again by the message construction.
options_tuple = tuple(options)
if len(options_tuple) == 0:
raise ValueError("add_dropdown requires at least one option.")
value = initial_value
if value is None:
value = options_tuple[0]
elif value not in options_tuple:
raise ValueError(
f"Dropdown initial_value {value!r} is not one of the options "
f"{options_tuple!r}."
)
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiDropdownHandle(
self._create_gui_input(
value,
message=_messages.GuiDropdownMessage(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiDropdownProps(
order=order,
label=label,
hint=hint,
options=options_tuple,
disabled=disabled,
visible=visible,
),
),
),
)
[docs]
@deprecated_positional_shim
def add_progress_bar(
self,
value: float,
*,
visible: bool = True,
animated: bool = False,
color: LiteralColor | tuple[int, int, int] | None = None,
order: float | None = None,
) -> GuiProgressBarHandle:
"""Add a progress bar to the GUI.
Args:
value: Value of the progress bar. (0 - 100)
visible: Whether the progress bar is visible.
animated: Whether the progress bar is in a loading state (animated, striped).
color: The color of the progress bar.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
assert value >= 0 and value <= 100
message = _messages.GuiProgressBarMessage(
value=value,
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiProgressBarProps(
order=_apply_default_order(order),
animated=animated,
color=color,
visible=visible,
),
)
self._websock_interface.queue_message(message)
handle = GuiProgressBarHandle(
_GuiHandleState(
message.uuid,
self,
value=value,
props=message.props,
parent_container_id=message.container_uuid,
),
)
return handle
[docs]
@deprecated_positional_shim
def add_slider(
self,
label: str,
min: IntOrFloat,
max: IntOrFloat,
step: IntOrFloat,
initial_value: IntOrFloat,
*,
marks: tuple[IntOrFloat | tuple[IntOrFloat, str], ...] | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiSliderHandle[IntOrFloat]:
"""Add a slider to the GUI. Types of the min, max, step, and initial value should match.
Args:
label: Label to display on the slider.
min: Minimum value of the slider.
max: Maximum value of the slider.
step: Step size of the slider.
initial_value: Initial value of the slider.
marks: tuple of marks to display below the slider. Each mark should
either be a numerical or a (number, label) tuple, where the
label is provided as a string.
disabled: Whether the slider is disabled.
visible: Whether the slider is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value: IntOrFloat = initial_value
if max < min:
raise ValueError(f"add_slider: max ({max}) must be >= min ({min}).")
if step <= 0:
raise ValueError(f"add_slider: step ({step}) must be > 0.")
if max > min:
# Clamped only for a non-degenerate range: min == max is allowed
# (an inert slider), but a clamped step of 0 must never reach the
# client.
step = builtins.min(step, max - min)
if not (max >= value >= min):
raise ValueError(
f"add_slider: initial_value ({value}) is outside of "
f"[min, max] = [{min}, {max}]."
)
# GUI callbacks cast incoming values to match the type of the initial value. If
# the min, max, or step is a float, we should cast to a float.
#
# This should also match what the IntOrFloat TypeVar resolves to.
if type(value) is int and (
type(min) is float or type(max) is float or type(step) is float
):
value = float(value) # type: ignore
# TODO: as of 6/5/2023, this assert will break something in nerfstudio. (at
# least LERF)
#
# assert type(min) == type(max) == type(step) == type(value)
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiSliderHandle(
self._create_gui_input(
value,
message=_messages.GuiSliderMessage(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiSliderProps(
order=order,
label=label,
hint=hint,
min=min,
max=max,
step=step,
precision=_compute_precision_digits(step),
visible=visible,
disabled=disabled,
_marks=_build_slider_marks(marks),
),
),
is_button=False,
)
)
[docs]
@deprecated_positional_shim
def add_multi_slider(
self,
label: str,
min: IntOrFloat,
max: IntOrFloat,
step: IntOrFloat,
initial_value: tuple[IntOrFloat, ...],
*,
min_range: IntOrFloat | None = None,
fixed_endpoints: bool = False,
marks: tuple[IntOrFloat | tuple[IntOrFloat, str], ...] | None = None,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiMultiSliderHandle[IntOrFloat]:
"""Add a multi slider to the GUI. Types of the min, max, step, and initial value should match.
Args:
label: Label to display on the slider.
min: Minimum value of the slider.
max: Maximum value of the slider.
step: Step size of the slider.
initial_value: Initial values of the slider.
min_range: Optional minimum difference between two values of the slider.
fixed_endpoints: Whether the endpoints of the slider are fixed.
marks: tuple of marks to display below the slider. Each mark should
either be a numerical or a (number, label) tuple, where the
label is provided as a string.
disabled: Whether the slider is disabled.
visible: Whether the slider is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
if max < min:
raise ValueError(f"add_multi_slider: max ({max}) must be >= min ({min}).")
if step <= 0:
raise ValueError(f"add_multi_slider: step ({step}) must be > 0.")
if min_range is not None and min_range > max - min:
raise ValueError(
f"add_multi_slider: min_range ({min_range}) exceeds the value "
f"range ({max - min}); the handle spacing would be unsatisfiable."
)
if max > min:
step = builtins.min(step, max - min)
if not all(max >= x >= min for x in initial_value):
raise ValueError(
f"add_multi_slider: initial_value {initial_value} has entries "
f"outside of [min, max] = [{min}, {max}]."
)
if any(b < a for a, b in zip(initial_value, initial_value[1:])):
# The client constrains each handle against its neighbors assuming
# sorted order; unsorted values made the first drag snap handles to
# surprising positions.
raise ValueError(
f"add_multi_slider: initial_value {initial_value} must be "
"sorted in non-decreasing order."
)
# GUI callbacks cast incoming values to match the type of the initial value. If
# any of the arguments are floats, we should always use a float value.
#
# This should also match what the IntOrFloat TypeVar resolves to.
if (
type(min) is float
or type(max) is float
or type(step) is float
or type(min_range) is float
):
initial_value = tuple(float(x) for x in initial_value) # type: ignore
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiMultiSliderHandle(
self._create_gui_input(
value=initial_value,
message=_messages.GuiMultiSliderMessage(
value=initial_value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiMultiSliderProps(
order=order,
label=label,
hint=hint,
min=min,
min_range=min_range,
max=max,
step=step,
visible=visible,
disabled=disabled,
fixed_endpoints=fixed_endpoints,
precision=_compute_precision_digits(step),
_marks=_build_slider_marks(marks),
),
),
is_button=False,
)
)
[docs]
@deprecated_positional_shim
def add_rgb(
self,
label: str,
initial_value: tuple[int, int, int],
*,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiRgbHandle:
"""Add an RGB picker to the GUI.
Integer channels are in [0, 255]; float channels in [0, 1] are scaled to
match (matplotlib convention), so ``1.0`` is white.
Args:
label: Label to display on the RGB picker.
initial_value: Initial value of the RGB picker.
disabled: Whether the RGB picker is disabled.
visible: Whether the RGB picker is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value = cast(
"tuple[int, int, int]",
# warn_stacklevel: user -> deprecated_positional_shim wrapper ->
# add_rgb -> _colors_to_int_tuple.
_colors_to_int_tuple(initial_value, warn_stacklevel=4),
)
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiRgbHandle(
self._create_gui_input(
value,
message=_messages.GuiRgbMessage(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiRgbProps(
order=order,
label=label,
hint=hint,
disabled=disabled,
visible=visible,
),
),
)
)
[docs]
@deprecated_positional_shim
def add_rgba(
self,
label: str,
initial_value: tuple[int, int, int, int],
*,
disabled: bool = False,
visible: bool = True,
hint: str | None = None,
order: float | None = None,
) -> GuiRgbaHandle:
"""Add an RGBA picker to the GUI.
Integer channels are in [0, 255]; float channels in [0, 1] are scaled to
match (matplotlib convention), so ``1.0`` is white/opaque.
Args:
label: Label to display on the RGBA picker.
initial_value: Initial value of the RGBA picker.
disabled: Whether the RGBA picker is disabled.
visible: Whether the RGBA picker is visible.
hint: Optional hint to display on hover.
order: Optional ordering, smallest values will be displayed first.
Returns:
A handle that can be used to interact with the GUI element.
"""
value = cast(
"tuple[int, int, int, int]",
# warn_stacklevel: user -> deprecated_positional_shim wrapper ->
# add_rgba -> _colors_to_int_tuple.
_colors_to_int_tuple(initial_value, warn_stacklevel=4),
)
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiRgbaHandle(
self._create_gui_input(
value,
message=_messages.GuiRgbaMessage(
value=value,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiRgbaProps(
order=order,
label=label,
hint=hint,
disabled=disabled,
visible=visible,
),
),
)
)
class _GuiMessage(Protocol[GuiInputPropsType]):
uuid: str
props: GuiInputPropsType
def _create_gui_input(
self,
value: T,
message: _GuiMessage,
is_button: bool = False,
) -> _GuiHandleState[T]:
"""Private helper for adding a simple GUI element."""
# Send add GUI input message.
assert isinstance(message, _messages.Message)
self._websock_interface.queue_message(message)
# Construct handle.
handle_state = _GuiHandleState(
props=message.props,
gui_api=self,
value=value,
update_timestamp=time.time(),
parent_container_id=self._get_container_uuid(),
update_cb=[],
is_button=is_button,
sync_cb=None,
uuid=message.uuid,
)
# For broadcasted GUI handles, we should synchronize all clients.
# This will be a no-op for client handles.
if not is_button:
def sync_other_clients(
client_id: ClientId, updates: dict[str, Any]
) -> None:
message = _messages.GuiUpdateMessage(handle_state.uuid, updates)
message.excluded_self_client = client_id
self._websock_interface.queue_message(message)
handle_state.sync_cb = sync_other_clients
return handle_state