diff --git a/examples/02_gui/02_layouts.py b/examples/02_gui/02_layouts.py
index 57570be39..02680da24 100644
--- a/examples/02_gui/02_layouts.py
+++ b/examples/02_gui/02_layouts.py
@@ -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
@@ -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(
diff --git a/src/viser/__init__.py b/src/viser/__init__.py
index d53193f4c..ca02943b9 100644
--- a/src/viser/__init__.py
+++ b/src/viser/__init__.py
@@ -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
diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py
index 3c42424c8..8398d40e9 100644
--- a/src/viser/_gui_api.py
+++ b/src/viser/_gui_api.py
@@ -58,6 +58,7 @@
GuiProgressBarHandle,
GuiRgbaHandle,
GuiRgbHandle,
+ GuiRowHandle,
GuiSliderHandle,
GuiTabGroupHandle,
GuiTabHandle,
@@ -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,
diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py
index b9de5df03..38caba104 100644
--- a/src/viser/_gui_handles.py
+++ b/src/viser/_gui_handles.py
@@ -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.
diff --git a/src/viser/_messages.py b/src/viser/_messages.py
index 511d60c4f..cb69261f7 100644
--- a/src/viser/_messages.py
+++ b/src/viser/_messages.py
@@ -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.
diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx
index 9ffff97f3..5faa43402 100644
--- a/src/viser/client/src/ControlPanel/Generated.tsx
+++ b/src/viser/client/src/ControlPanel/Generated.tsx
@@ -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";
@@ -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)!;
@@ -97,6 +100,9 @@ function GuiContainer({
nextGuiUuid={guiUuidOrderPairArray[index + 1]?.uuid ?? null}
/>
));
+ if (layout === "row") {
+ return {children};
+ }
if (unwrapped) {
return <>{children}>;
}
@@ -117,6 +123,8 @@ function GeneratedInput(props: {
switch (conf.type) {
case "GuiFolderMessage":
return ;
+ case "GuiRowMessage":
+ return ;
case "GuiFormMessage":
return ;
case "GuiTabGroupMessage":
@@ -166,6 +174,12 @@ function GeneratedInput(props: {
}
}
+function RowComponent({ uuid, props: { visible } }: GuiRowMessage) {
+ const guiContext = React.useContext(GuiComponentContext)!;
+ if (!visible) return null;
+ return ;
+}
+
function assertNeverType(x: never): never {
throw new Error("Unexpected object: " + (x as any).type);
}
diff --git a/src/viser/client/src/ControlPanel/GuiComponentContext.ts b/src/viser/client/src/ControlPanel/GuiComponentContext.ts
index 02ac7560b..59f31d69b 100644
--- a/src/viser/client/src/ControlPanel/GuiComponentContext.ts
+++ b/src/viser/client/src/ControlPanel/GuiComponentContext.ts
@@ -5,7 +5,11 @@ interface GuiComponentContext {
folderDepth: number;
setValue: (id: string, value: NonNullable) => 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({
diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts
index c36a16a9a..85d8151e2 100644
--- a/src/viser/client/src/VersionInfo.ts
+++ b/src/viser/client/src/VersionInfo.ts
@@ -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",
@@ -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",
@@ -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",
diff --git a/src/viser/client/src/WebsocketMessages.ts b/src/viser/client/src/WebsocketMessages.ts
index aa52bab78..195455836 100644
--- a/src/viser/client/src/WebsocketMessages.ts
+++ b/src/viser/client/src/WebsocketMessages.ts
@@ -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
@@ -1954,6 +1969,7 @@ export type Message =
| GaussianSplatsMessage
| RemoveSceneNodeMessage
| GuiFolderMessage
+ | GuiRowMessage
| GuiFormMessage
| GuiMarkdownMessage
| GuiHtmlMessage
@@ -2060,6 +2076,7 @@ export type SceneNodeMessage =
| GaussianSplatsMessage;
export type GuiComponentMessage =
| GuiFolderMessage
+ | GuiRowMessage
| GuiFormMessage
| GuiMarkdownMessage
| GuiHtmlMessage
@@ -2119,6 +2136,7 @@ export function isSceneNodeMessage(
}
const typeSetGuiComponentMessage = new Set([
"GuiFolderMessage",
+ "GuiRowMessage",
"GuiFormMessage",
"GuiMarkdownMessage",
"GuiHtmlMessage",
diff --git a/tests/e2e/test_gui_controls.py b/tests/e2e/test_gui_controls.py
index c06d58cbc..b928c1a90 100644
--- a/tests/e2e/test_gui_controls.py
+++ b/tests/e2e/test_gui_controls.py
@@ -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,