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
6 changes: 4 additions & 2 deletions examples/02_gui/02_layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
**Features:**

* :meth:`viser.GuiApi.add_folder` for grouping related controls
* :meth:`viser.GuiApi.add_row` for side-by-side controls
* :meth:`viser.GuiApi.add_form` for groups that commit together on submit
* :meth:`viser.GuiApi.add_tab_group` and :meth:`viser.GuiTabGroupHandle.add_tab` for tabbed interfaces
* :meth:`viser.GuiApi.add_divider` for separating sections with a horizontal line
Expand Down Expand Up @@ -48,8 +49,9 @@ def _(_) -> None:

server.gui.add_divider()

show_axes = server.gui.add_checkbox("Show Coordinate Axes", initial_value=True)
server.gui.add_checkbox("Show Grid", initial_value=False)
with server.gui.add_row():
show_axes = server.gui.add_checkbox("Show Coordinate Axes", initial_value=True)
server.gui.add_checkbox("Show Grid", initial_value=False)

with server.gui.add_folder("Sphere"):
sphere_radius = server.gui.add_slider(
Expand Down
1 change: 1 addition & 0 deletions src/viser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ._gui_handles import GuiPlotlyHandle as GuiPlotlyHandle
from ._gui_handles import GuiRgbaHandle as GuiRgbaHandle
from ._gui_handles import GuiRgbHandle as GuiRgbHandle
from ._gui_handles import GuiRowHandle as GuiRowHandle
from ._gui_handles import GuiSliderHandle as GuiSliderHandle
from ._gui_handles import GuiTabGroupHandle as GuiTabGroupHandle
from ._gui_handles import GuiTabHandle as GuiTabHandle
Expand Down
46 changes: 46 additions & 0 deletions src/viser/_gui_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
GuiProgressBarHandle,
GuiRgbaHandle,
GuiRgbHandle,
GuiRowHandle,
GuiSliderHandle,
GuiTabGroupHandle,
GuiTabHandle,
Expand Down Expand Up @@ -709,6 +710,51 @@ def add_folder(
)
)

@deprecated_positional_shim
def add_row(
self,
*,
order: float | None = None,
visible: bool = True,
) -> GuiRowHandle:
"""Add a row, and return a handle that can be used to populate it.

GUI elements added inside the returned context are laid out
horizontally with equal-width slots, matching the built-in controls for
Save Canvas / Reset View in configuration and diagnostics panel.

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 row.
"""
row_container_id = _make_uuid()
order = _apply_default_order(order)
props = _messages.GuiFolderProps(
order=order,
label=None,
expand_by_default=True,
visible=visible,
)
self._websock_interface.queue_message(
_messages.GuiRowMessage(
uuid=row_container_id,
container_uuid=self._get_container_uuid(),
props=props,
)
)
return GuiRowHandle(
_GuiHandleState(
row_container_id,
self,
None,
props=props,
parent_container_id=self._get_container_uuid(),
)
)

@deprecated_positional_shim
def add_form(
self,
Expand Down
4 changes: 4 additions & 0 deletions src/viser/_gui_handles.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,10 @@ def remove(self) -> None:
gui_api._container_handle_from_uuid.pop(self._impl.uuid)


class GuiRowHandle(GuiFolderHandle):
"""Use as a context to place GUI elements side by side."""


class GuiFormHandle(GuiFolderHandle):
"""Use as a context to place GUI elements into a form.

Expand Down
8 changes: 8 additions & 0 deletions src/viser/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,14 @@ class GuiFolderMessage(_CreateGuiComponentMessage):
props: GuiFolderProps


@dataclasses.dataclass
class GuiRowMessage(_CreateGuiComponentMessage):
"""A row lays out its children horizontally."""

container_uuid: str
props: GuiFolderProps


@dataclasses.dataclass
class GuiFormMessage(_CreateGuiComponentMessage):
"""A form is a folder whose children's values can be committed together.
Expand Down
16 changes: 15 additions & 1 deletion src/viser/client/src/ControlPanel/Generated.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ViewerContext } from "../ViewerContext";
import { GuiRowMessage } from "../WebsocketMessages";
import { useThrottledMessageSender } from "../WebsocketUtils";
import { GuiComponentContext } from "./GuiComponentContext";
import { shallowObjectKeysEqual } from "../utils/shallowObjectKeysEqual";

import { Box } from "@mantine/core";
import { Box, Group } from "@mantine/core";
import React from "react";
import ButtonComponent from "../components/Button";
import SliderComponent from "../components/Slider";
Expand Down Expand Up @@ -64,11 +65,13 @@ export default function GeneratedGuiContainer({
function GuiContainer({
containerUuid,
unwrapped = false,
layout = "stack",
}: {
containerUuid: string;
/** If true, don't wrap children in a padded Box. Used by label=null
* folders and forms, which should be transparent for layout purposes. */
unwrapped?: boolean;
layout?: "stack" | "row";
}) {
const viewer = React.useContext(ViewerContext)!;

Expand Down Expand Up @@ -97,6 +100,9 @@ function GuiContainer({
nextGuiUuid={guiUuidOrderPairArray[index + 1]?.uuid ?? null}
/>
));
if (layout === "row") {
return <Group gap="0.5em">{children}</Group>;
}
if (unwrapped) {
return <>{children}</>;
}
Expand All @@ -117,6 +123,8 @@ function GeneratedInput(props: {
switch (conf.type) {
case "GuiFolderMessage":
return <FolderComponent {...conf} nextGuiUuid={props.nextGuiUuid} />;
case "GuiRowMessage":
return <RowComponent {...conf} />;
case "GuiFormMessage":
return <FormComponent {...conf} nextGuiUuid={props.nextGuiUuid} />;
case "GuiTabGroupMessage":
Expand Down Expand Up @@ -166,6 +174,12 @@ function GeneratedInput(props: {
}
}

function RowComponent({ uuid, props: { visible } }: GuiRowMessage) {
const guiContext = React.useContext(GuiComponentContext)!;
if (!visible) return null;
return <guiContext.GuiContainer containerUuid={uuid} layout="row" />;
}

function assertNeverType(x: never): never {
throw new Error("Unexpected object: " + (x as any).type);
}
6 changes: 5 additions & 1 deletion src/viser/client/src/ControlPanel/GuiComponentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ interface GuiComponentContext {
folderDepth: number;
setValue: (id: string, value: NonNullable<unknown>) => void;
messageSender: (message: Messages.Message) => void;
GuiContainer: React.FC<{ containerUuid: string; unwrapped?: boolean }>;
GuiContainer: React.FC<{
containerUuid: string;
unwrapped?: boolean;
layout?: "stack" | "row";
}>;
}

export const GuiComponentContext = React.createContext<GuiComponentContext>({
Expand Down
16 changes: 8 additions & 8 deletions src/viser/client/src/VersionInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ export const GITHUB_CONTRIBUTORS: Contributor[] = [
login: "brentyi",
html_url: "https://github.com/brentyi",
},
{
login: "chungmin99",
html_url: "https://github.com/chungmin99",
},
{
login: "kerrj",
html_url: "https://github.com/kerrj",
},
{
login: "chungmin99",
html_url: "https://github.com/chungmin99",
},
{
login: "tancik",
html_url: "https://github.com/tancik",
Expand Down Expand Up @@ -61,6 +61,10 @@ export const GITHUB_CONTRIBUTORS: Contributor[] = [
login: "ethanweber",
html_url: "https://github.com/ethanweber",
},
{
login: "zoechann",
html_url: "https://github.com/zoechann",
},
{
login: "zerolover",
html_url: "https://github.com/zerolover",
Expand Down Expand Up @@ -89,10 +93,6 @@ export const GITHUB_CONTRIBUTORS: Contributor[] = [
login: "AdamRashid96",
html_url: "https://github.com/AdamRashid96",
},
{
login: "zoechann",
html_url: "https://github.com/zoechann",
},
{
login: "slecleach",
html_url: "https://github.com/slecleach",
Expand Down
18 changes: 18 additions & 0 deletions src/viser/client/src/WebsocketMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,21 @@ export interface GuiFolderMessage {
expand_by_default: boolean;
};
}
/** A row lays out its children horizontally.
*
* (automatically generated)
*/
export interface GuiRowMessage {
type: "GuiRowMessage";
uuid: string;
container_uuid: string;
props: {
order: number;
label: string | null;
visible: boolean;
expand_by_default: boolean;
};
}
/** A form is a folder whose children's values can be committed together.
*
* Reuses ``GuiFolderProps`` because the visual shape is identical to a
Expand Down Expand Up @@ -1954,6 +1969,7 @@ export type Message =
| GaussianSplatsMessage
| RemoveSceneNodeMessage
| GuiFolderMessage
| GuiRowMessage
| GuiFormMessage
| GuiMarkdownMessage
| GuiHtmlMessage
Expand Down Expand Up @@ -2060,6 +2076,7 @@ export type SceneNodeMessage =
| GaussianSplatsMessage;
export type GuiComponentMessage =
| GuiFolderMessage
| GuiRowMessage
| GuiFormMessage
| GuiMarkdownMessage
| GuiHtmlMessage
Expand Down Expand Up @@ -2119,6 +2136,7 @@ export function isSceneNodeMessage(
}
const typeSetGuiComponentMessage = new Set([
"GuiFolderMessage",
"GuiRowMessage",
"GuiFormMessage",
"GuiMarkdownMessage",
"GuiHtmlMessage",
Expand Down
26 changes: 26 additions & 0 deletions tests/e2e/test_gui_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,32 @@ def test_folder_collapse_toggle(
expect(inner_label).to_be_visible(timeout=3_000)


def test_row_lays_out_children_side_by_side(
viser_server: viser.ViserServer,
viser_page: Page,
) -> None:
"""A row should render child GUI handles side by side."""
with viser_server.gui.add_row():
viser_server.gui.add_button("Apply")
viser_server.gui.add_checkbox("Enabled", initial_value=True)

button = viser_page.get_by_role("button", name="Apply")
checkbox = viser_page.get_by_role("checkbox", name="Enabled")
expect(button).to_be_visible(timeout=5_000)
expect(checkbox).to_be_visible(timeout=5_000)

viser_page.wait_for_timeout(300)
button_box = button.bounding_box()
checkbox_box = checkbox.bounding_box()
assert button_box is not None
assert checkbox_box is not None

assert abs(button_box["y"] - checkbox_box["y"]) < max(
button_box["height"], checkbox_box["height"]
)
assert button_box["x"] < checkbox_box["x"]


def test_multiple_gui_elements_order(
viser_server: viser.ViserServer,
viser_page: Page,
Expand Down