Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions examples/02_gui/00_basic_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
**Interactive Elements:**

* :meth:`viser.GuiApi.add_button` with custom icons from :class:`viser.Icon`
* :meth:`viser.GuiButtonHandle.on_hold` for continuous actions while a button is held
* :meth:`viser.GuiApi.add_checkbox` for boolean toggles
* :meth:`viser.GuiApi.add_multi_slider` for multi-handle range controls
* :meth:`viser.GuiApi.add_upload_button` for file uploads
Expand Down Expand Up @@ -65,6 +66,25 @@ def main() -> None:
initial_value=(1.0, 1.0, 1.0),
step=0.25,
)

# Buttons with on_hold for continuous size adjustment while held.
gui_grow = server.gui.add_button("Grow (hold)", icon=viser.Icon.PLUS)
gui_shrink = server.gui.add_button("Shrink (hold)", icon=viser.Icon.MINUS)

@gui_grow.on_hold(callback_hz=10.0)
def _(_: viser.GuiEvent[viser.GuiButtonHandle]) -> None:
x, y, z = gui_vector3.value
gui_vector3.value = (x + 0.05, y + 0.05, z + 0.05)

@gui_shrink.on_hold(callback_hz=10.0)
def _(_: viser.GuiEvent[viser.GuiButtonHandle]) -> None:
x, y, z = gui_vector3.value
gui_vector3.value = (
max(0.1, x - 0.05),
max(0.1, y - 0.05),
max(0.1, z - 0.05),
)

with server.gui.add_folder("Text toggle"):
gui_checkbox_hide = server.gui.add_checkbox(
"Hide",
Expand Down
17 changes: 15 additions & 2 deletions examples/02_gui/01_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
def main() -> None:
server = viser.ViserServer()

gui_reset_scene = server.gui.add_button("Reset Scene")
gui_reset_scene_click = server.gui.add_button("Reset Scene on Click")

gui_reset_scene_hold = server.gui.add_button("Reset Scene on Hold")

gui_plane = server.gui.add_dropdown(
"Grid plane", ("xz", "xy", "yx", "yz", "zx", "zy")
Expand Down Expand Up @@ -99,7 +101,7 @@ def draw_points() -> None:
gui_location.on_update(lambda _: draw_frame())
gui_num_points.on_update(lambda _: draw_points())

@gui_reset_scene.on_click
@gui_reset_scene_click.on_click
def _(_) -> None:
"""Reset the scene when the reset button is clicked."""
gui_show_frame.value = True
Expand All @@ -110,6 +112,17 @@ def _(_) -> None:
draw_frame()
draw_points()

@gui_reset_scene_hold.on_hold
def _(_) -> None:
"""Reset the scene when the reset button is held."""
gui_show_frame.value = True
gui_location.value = 0.0
gui_axis.value = "x"
gui_num_points.value = 10_000

draw_frame()
draw_points()

# Finally, let's add the initial frame + point cloud and just loop infinitely. :)
update_plane()
draw_frame()
Expand Down
96 changes: 76 additions & 20 deletions src/viser/_gui_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
GuiVector3Handle,
SupportsRemoveProtocol,
UploadedFile,
_GuiButtonHandleState,
_GuiHandleState,
_GuiInputHandle,
_make_uuid,
Expand Down Expand Up @@ -219,6 +220,9 @@ def __init__(
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.FileTransferStartUpload, self._handle_file_transfer_start
)
Expand Down Expand Up @@ -296,6 +300,44 @@ async def _handle_gui_updates(
if handle_state.sync_cb is not None:
handle_state.sync_cb(client_id, updates_cast)

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:
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

# Get the client handle.
from ._viser import ClientHandle, ViserServer

if isinstance(self._owner, ClientHandle):
client = self._owner
elif isinstance(self._owner, ViserServer):
client = self._owner._connected_clients.get(client_id, None)
if client is None:
return
else:
assert False

# 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)

def _handle_file_transfer_start(
self, client_id: ClientId, message: _messages.FileTransferStartUpload
) -> None:
Expand Down Expand Up @@ -985,28 +1027,42 @@ def add_button(
# Re-wrap the GUI handle with a button interface.
uuid = _make_uuid()
order = _apply_default_order(order)
return GuiButtonHandle(
self._create_gui_input(
value=False,
message=_messages.GuiButtonMessage(
value=False,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=_messages.GuiButtonProps(
order=order,
label=label,
hint=hint,
color=color,
_icon_html=None if icon is None else svg_from_icon(icon),
disabled=disabled,
visible=visible,
),
),
is_button=True,
),
_icon=icon,
props = _messages.GuiButtonProps(
order=order,
label=label,
hint=hint,
color=color,
_icon_html=None if icon is None else svg_from_icon(icon),
_hold_callback_freqs=(),
disabled=disabled,
visible=visible,
)
message = _messages.GuiButtonMessage(
value=False,
uuid=uuid,
container_uuid=self._get_container_uuid(),
props=props,
)

# Send the message.
self._websock_interface.queue_message(message)

# Construct button-specific handle state with hold callback support.
handle_state = _GuiButtonHandleState(
props=props,
gui_api=self,
value=False,
update_timestamp=time.time(),
parent_container_id=self._get_container_uuid(),
update_cb=[],
is_button=True,
sync_cb=None,
uuid=uuid,
hold_cbs_from_freq={},
)

return GuiButtonHandle(handle_state, _icon=icon)

@deprecated_positional_shim
def add_upload_button(
self,
Expand Down
87 changes: 86 additions & 1 deletion src/viser/_gui_handles.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Literal,
Tuple,
TypeVar,
overload,
)

import imageio.v3 as iio
Expand Down Expand Up @@ -113,6 +114,16 @@ class _GuiHandleState(Generic[T]):
removed: bool = False


@dataclasses.dataclass
class _GuiButtonHandleState(_GuiHandleState[bool]):
"""Internal API for button GUI elements with hold callback support."""

hold_cbs_from_freq: dict[float, list[Callable[[GuiEvent], None | Coroutine]]] = (
dataclasses.field(default_factory=dict)
)
"""Mapping from frequency (Hz) to list of callbacks to call when button is held."""


# Not exported for now because some GUI handles don't currently inhert from
# `_GuiHandle`: notably `GuiModalHandle` and `GuiTabHandle`. These would fail
# isinstance checks, which would be confusing!
Expand Down Expand Up @@ -371,10 +382,16 @@ class GuiButtonHandle(_GuiInputHandle[bool], GuiButtonProps):
Value of the button. Set to `True` when the button is pressed. Can be manually set back to `False`.
"""

def __init__(self, _impl: _GuiHandleState[bool], _icon: IconName | None):
def __init__(self, _impl: _GuiButtonHandleState, _icon: IconName | None):
super().__init__(impl=_impl)
self._icon = _icon

@property
def _button_impl(self) -> _GuiButtonHandleState:
"""Access the button-specific implementation state."""
assert isinstance(self._impl, _GuiButtonHandleState)
return self._impl

@property
def icon(self) -> IconName | None:
"""Icon to display on the button. When set to None, no icon is displayed."""
Expand All @@ -399,6 +416,74 @@ def on_click(
self._impl.update_cb.append(func)
return func

# Type alias for button hold callbacks.
_HoldCallback = Callable[["GuiEvent[GuiButtonHandle]"], "None | Coroutine"]

@overload
def on_hold(
self,
func: None = None,
callback_hz: float = 10.0,
) -> Callable[[_HoldCallback], _HoldCallback]: ...

@overload
def on_hold(
self,
func: _HoldCallback,
callback_hz: float = 10.0,
) -> _HoldCallback: ...

def on_hold(
self,
func: _HoldCallback | None = None,
callback_hz: float = 10.0,
) -> Callable[[_HoldCallback], _HoldCallback] | _HoldCallback:
"""Attach a function to call repeatedly while a button is held down.

The callback will be triggered immediately when the button is pressed,
and then repeatedly at the specified frequency until released.

Can be used as a decorator with or without arguments:
@button.on_hold
def callback(event): ...

@button.on_hold(callback_hz=30.0)
def callback(event): ...

Or called directly:
button.on_hold(callback)
button.on_hold(callback, callback_hz=30.0)

Args:
func: The callback function to attach. If None, returns a decorator.
callback_hz: The frequency in Hz at which to call the callback while
the button is held. Defaults to 10.0 Hz.

Note:
- If `func` is a regular function (defined with `def`), it will be executed in a thread pool.
- If `func` is an async function (defined with `async def`), it will be executed in the event loop.

Using async functions can be useful for reducing race conditions.
"""
button_impl = self._button_impl

def register_callback(
f: GuiButtonHandle._HoldCallback,
) -> GuiButtonHandle._HoldCallback:
# Add callback to the frequency-specific list.
if callback_hz not in button_impl.hold_cbs_from_freq:
button_impl.hold_cbs_from_freq[callback_hz] = []
button_impl.hold_cbs_from_freq[callback_hz].append(f)

# Update the prop to notify client of new frequency.
self._hold_callback_freqs = tuple(button_impl.hold_cbs_from_freq.keys())

return f

if func is not None:
return register_callback(func)
return register_callback


@dataclasses.dataclass
class UploadedFile:
Expand Down
13 changes: 13 additions & 0 deletions src/viser/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,8 @@ class GuiButtonProps(GuiBaseProps):
"""Color of the button."""
_icon_html: Optional[str]
"""(Private) HTML string for the icon to be displayed on the button."""
_hold_callback_freqs: Tuple[float, ...]
"""(Private) Tuple of frequencies (Hz) at which hold callbacks should be triggered."""


@dataclasses.dataclass
Expand All @@ -1308,6 +1310,17 @@ class GuiButtonMessage(_CreateGuiComponentMessage):
props: GuiButtonProps


@dataclasses.dataclass
class GuiButtonHoldMessage(Message):
"""Message sent from client->server when a button is being held.

Sent periodically at the specified frequency while the button is pressed."""

uuid: str
frequency: float
"""The frequency (Hz) at which this hold message was triggered."""


@dataclasses.dataclass
class GuiUploadButtonProps(GuiBaseProps):
color: Union[LiteralColor, Tuple[int, int, int], None]
Expand Down
13 changes: 13 additions & 0 deletions src/viser/client/src/WebsocketMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@ export interface GuiButtonMessage {
| [number, number, number]
| null;
_icon_html: string | null;
_hold_callback_freqs: number[];
};
}
/** GuiUploadButtonMessage(uuid: 'str', container_uuid: 'str', props: 'GuiUploadButtonProps')
Expand Down Expand Up @@ -1366,6 +1367,17 @@ export interface GuiCloseModalMessage {
type: "GuiCloseModalMessage";
uuid: string;
}
/** Message sent from client->server when a button is being held.
*
* Sent periodically at the specified frequency while the button is pressed.
*
* (automatically generated)
*/
export interface GuiButtonHoldMessage {
type: "GuiButtonHoldMessage";
uuid: string;
frequency: number;
}
/** Sent client<->server when any property of a GUI component is changed.
*
* (automatically generated)
Expand Down Expand Up @@ -1611,6 +1623,7 @@ export type Message =
| ResetGuiMessage
| GuiModalMessage
| GuiCloseModalMessage
| GuiButtonHoldMessage
| GuiUpdateMessage
| SceneNodeUpdateMessage
| ThemeConfigurationMessage
Expand Down
Loading