diff --git a/examples/02_gui.py b/examples/02_gui.py index 0e7ccc1f2..7344323a4 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -1,3 +1,8 @@ +# mypy: disable-error-code="assignment" +# +# Asymmetric properties are supported in Pyright, but not yet in mypy. +# - https://github.com/python/mypy/issues/3004 +# - https://github.com/python/mypy/pull/11643 """GUI basics Examples of basic GUI elements that we can create, read from, and write to.""" @@ -57,6 +62,21 @@ def main() -> None: "Color", initial_value=(255, 255, 0), ) + gui_multi_slider = server.add_gui_multi_slider( + "Multi slider", + min=0, + max=100, + step=1, + initial_value=(0, 30, 100), + ) + gui_slider_positions = server.add_gui_slider( + "# sliders", + min=0, + max=10, + step=1, + initial_value=3, + marks=((0, "0"), (5, "5"), (7, "7"), 10), + ) # Pre-generate a point cloud to send. point_positions = onp.random.uniform(low=-1.0, high=1.0, size=(5000, 3)) @@ -87,6 +107,12 @@ def main() -> None: gui_button.visible = not gui_checkbox_hide.value gui_rgb.disabled = gui_checkbox_disable.value + # Update the number of handles in the multi-slider. + if gui_slider_positions.value != len(gui_multi_slider.value): + gui_multi_slider.value = onp.linspace( + 0, 100, gui_slider_positions.value, dtype=onp.int64 + ) + counter += 1 time.sleep(0.01) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 53e0f46b5..a1b6ae4b3 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -793,6 +793,7 @@ def add_gui_slider( max: IntOrFloat, step: IntOrFloat, initial_value: IntOrFloat, + marks: Optional[Tuple[IntOrFloat | Tuple[IntOrFloat, str], ...]] = None, disabled: bool = False, visible: bool = True, hint: Optional[str] = None, @@ -806,6 +807,9 @@ def add_gui_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. @@ -821,6 +825,8 @@ def add_gui_slider( # 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(initial_value) is int and ( type(min) is float or type(max) is float or type(step) is float ): @@ -846,6 +852,98 @@ def add_gui_slider( step=step, initial_value=initial_value, precision=_compute_precision_digits(step), + marks=tuple( + {"value": float(x[0]), "label": x[1]} + if isinstance(x, tuple) + else {"value": float(x)} + for x in marks + ) + if marks is not None + else None, + ), + disabled=disabled, + visible=visible, + is_button=False, + ) + + def add_gui_multi_slider( + self, + label: str, + min: IntOrFloat, + max: IntOrFloat, + step: IntOrFloat, + initial_value: Tuple[IntOrFloat, ...], + min_range: Optional[IntOrFloat] = None, + fixed_endpoints: bool = False, + marks: Optional[Tuple[IntOrFloat | Tuple[IntOrFloat, str], ...]] = None, + disabled: bool = False, + visible: bool = True, + hint: Optional[str] = None, + order: Optional[float] = None, + ) -> GuiInputHandle[Tuple[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. + """ + assert max >= min + if step > max - min: + step = max - min + assert all(max >= x >= min for x in initial_value) + + # 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 + + id = _make_unique_id() + order = _apply_default_order(order) + return self._create_gui_input( + initial_value=initial_value, + message=_messages.GuiAddMultiSliderMessage( + order=order, + id=id, + label=label, + container_id=self._get_container_id(), + hint=hint, + min=min, + min_range=min_range, + max=max, + step=step, + initial_value=initial_value, + fixed_endpoints=fixed_endpoints, + precision=_compute_precision_digits(step), + marks=tuple( + {"value": float(x[0]), "label": x[1]} + if isinstance(x, tuple) + else {"value": float(x)} + for x in marks + ) + if marks is not None + else None, ), disabled=disabled, visible=visible, diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index f150ae604..110430795 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -19,7 +19,6 @@ Tuple, Type, TypeVar, - Union, ) import imageio.v3 as iio @@ -130,7 +129,7 @@ def value(self) -> T: return self._impl.value @value.setter - def value(self, value: Union[T, onp.ndarray]) -> None: + def value(self, value: T | onp.ndarray) -> None: if isinstance(value, onp.ndarray): assert len(value.shape) <= 1, f"{value.shape} should be at most 1D!" value = tuple(map(float, value)) # type: ignore diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 9cf8cd411..26d567b68 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -8,10 +8,12 @@ import numpy as onp import numpy.typing as onpt -from typing_extensions import Literal, override +from typing_extensions import Literal, NotRequired, TypedDict, override from . import infra, theme +GuiSliderMark = TypedDict("GuiSliderMark", {"value": float, "label": NotRequired[str]}) + class Message(infra.Message): @override @@ -430,6 +432,19 @@ class GuiAddSliderMessage(_GuiAddInputBase): step: Optional[float] initial_value: float precision: int + marks: Optional[Tuple[GuiSliderMark, ...]] = None + + +@dataclasses.dataclass +class GuiAddMultiSliderMessage(_GuiAddInputBase): + min: float + max: float + step: Optional[float] + min_range: Optional[float] + initial_value: Tuple[float, ...] + precision: int + fixed_endpoints: bool = False + marks: Optional[Tuple[GuiSliderMark, ...]] = None @dataclasses.dataclass diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index 9419eb2a1..fa2c592bb 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -27,6 +27,7 @@ import { TextInput, Tooltip, } from "@mantine/core"; +import { MultiSlider } from "./MultiSlider"; import React from "react"; import Markdown from "../Markdown"; import { ErrorBoundary } from "react-error-boundary"; @@ -148,6 +149,7 @@ function GeneratedInput({ let labeled = true; let input = null; + let containerProps = {}; switch (conf.type) { case "GuiAddButtonMessage": labeled = false; @@ -205,43 +207,92 @@ function GeneratedInput({ case "GuiAddSliderMessage": input = ( - - ({ - thumb: { - background: theme.fn.primaryColor(), - borderRadius: "0.1em", - height: "0.75em", - width: "0.625em", - }, - })} - pt="0.2em" - showLabelOnHover={false} - min={conf.min} - max={conf.max} - step={conf.step ?? undefined} - precision={conf.precision} - value={value} - onChange={updateValue} - marks={[{ value: conf.min }, { value: conf.max }]} - disabled={disabled} - /> - - {parseInt(conf.min.toFixed(6))} - {parseInt(conf.max.toFixed(6))} - - + ({ + thumb: { + background: theme.fn.primaryColor(), + borderRadius: "0.1rem", + height: "0.75rem", + width: "0.625rem", + }, + trackContainer: { + zIndex: 3, + position: "relative", + }, + markLabel: { + transform: "translate(-50%, 0.03rem)", + fontSize: "0.6rem", + textAlign: "center", + }, + marksContainer: { + left: "0.2rem", + right: "0.2rem", + }, + markWrapper: { + position: "absolute", + top: `0.03rem`, + ...(conf.marks === null + ? /* Shift the mark labels so they don't spill too far out the left/right when we only have min and max marks. */ + { + ":first-child": { + "div:nth-child(2)": { + transform: "translate(-0.2rem, 0.03rem)", + }, + }, + ":last-child": { + "div:nth-child(2)": { + transform: "translate(-90%, 0.03rem)", + }, + }, + } + : {}), + }, + mark: { + border: "0px solid transparent", + background: + theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2], + width: "0.42rem", + height: "0.42rem", + transform: `translateX(-50%)`, + }, + markFilled: { + background: disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.fn.primaryColor(), + }, + })} + pt="0.2em" + showLabelOnHover={false} + min={conf.min} + max={conf.max} + step={conf.step ?? undefined} + precision={conf.precision} + value={value} + onChange={updateValue} + marks={ + conf.marks === null + ? [ + { + value: conf.min, + label: `${parseInt(conf.min.toFixed(6))}`, + }, + { + value: conf.max, + label: `${parseInt(conf.max.toFixed(6))}`, + }, + ] + : conf.marks + } + disabled={disabled} + /> { @@ -268,6 +319,100 @@ function GeneratedInput({ ); break; + case "GuiAddMultiSliderMessage": + input = ( + ({ + thumb: { + background: theme.fn.primaryColor(), + borderRadius: "0.1rem", + height: "0.75rem", + width: "0.625rem", + }, + trackContainer: { + zIndex: 3, + position: "relative", + }, + markLabel: { + transform: "translate(-50%, 0.03rem)", + fontSize: "0.6rem", + textAlign: "center", + }, + marksContainer: { + left: "0.2rem", + right: "0.2rem", + }, + markWrapper: { + position: "absolute", + top: `0.03rem`, + ...(conf.marks === null + ? /* Shift the mark labels so they don't spill too far out the left/right when we only have min and max marks. */ + { + ":first-child": { + "div:nth-child(2)": { + transform: "translate(-0.2rem, 0.03rem)", + }, + }, + ":last-child": { + "div:nth-child(2)": { + transform: "translate(-90%, 0.03rem)", + }, + }, + } + : {}), + }, + mark: { + border: "0px solid transparent", + background: + theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2], + width: "0.42rem", + height: "0.42rem", + transform: `translateX(-50%)`, + }, + markFilled: { + background: disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.fn.primaryColor(), + }, + })} + pt="0.2em" + showLabelOnHover={false} + min={conf.min} + max={conf.max} + step={conf.step ?? undefined} + precision={conf.precision} + value={value} + onChange={updateValue} + marks={ + conf.marks === null + ? [ + { + value: conf.min, + label: `${parseInt(conf.min.toFixed(6))}`, + }, + { + value: conf.max, + label: `${parseInt(conf.max.toFixed(6))}`, + }, + ] + : conf.marks + } + disabled={disabled} + fixedEndpoints={conf.fixed_endpoints} + minRange={conf.min_range || undefined} + /> + ); + + if (conf.marks?.some((x) => x.label) || conf.marks === null) + containerProps = { ...containerProps, mb: "xs" }; + break; case "GuiAddNumberMessage": input = ( + {input} ); diff --git a/src/viser/client/src/ControlPanel/GuiState.tsx b/src/viser/client/src/ControlPanel/GuiState.tsx index e70dc4e18..540eecf6e 100644 --- a/src/viser/client/src/ControlPanel/GuiState.tsx +++ b/src/viser/client/src/ControlPanel/GuiState.tsx @@ -17,6 +17,7 @@ export type GuiConfig = | Messages.GuiAddRgbMessage | Messages.GuiAddRgbaMessage | Messages.GuiAddSliderMessage + | Messages.GuiAddMultiSliderMessage | Messages.GuiAddButtonGroupMessage | Messages.GuiAddTextMessage | Messages.GuiAddVector2Message diff --git a/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx new file mode 100644 index 000000000..f10295e3e --- /dev/null +++ b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx @@ -0,0 +1,276 @@ +import { createStyles, rem } from "@mantine/styles"; +import { MantineColor, getSize, MantineNumberSize } from "@mantine/styles"; + +export const sizes = { + xs: rem(4), + sm: rem(6), + md: rem(8), + lg: rem(10), + xl: rem(12), +}; + +export const useSliderRootStyles = createStyles((theme) => ({ + root: { + ...theme.fn.fontStyles(), + WebkitTapHighlightColor: "transparent", + outline: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + touchAction: "none", + position: "relative", + }, +})); + +interface ThumbStyles { + color: MantineColor; + disabled: boolean; + thumbSize: number | string; +} + +export const useThumbStyles = createStyles( + (theme, { color, disabled, thumbSize }: ThumbStyles, { size }) => ({ + label: { + position: "absolute", + top: rem(-36), + backgroundColor: + theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[9], + fontSize: theme.fontSizes.xs, + color: theme.white, + padding: `calc(${theme.spacing.xs} / 2)`, + borderRadius: theme.radius.sm, + whiteSpace: "nowrap", + pointerEvents: "none", + userSelect: "none", + touchAction: "none", + }, + + thumb: { + ...theme.fn.focusStyles(), + boxSizing: "border-box", + position: "absolute", + display: "flex", + height: thumbSize + ? rem(thumbSize) + : `calc(${getSize({ sizes, size })} * 2)`, + width: thumbSize + ? rem(thumbSize) + : `calc(${getSize({ sizes, size })} * 2)`, + backgroundColor: disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.colorScheme === "dark" + ? theme.fn.themeColor(color, theme.fn.primaryShade()) + : theme.white, + border: `${rem(4)} solid ${ + disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.colorScheme === "dark" + ? theme.white + : theme.fn.themeColor(color, theme.fn.primaryShade()) + }`, + color: + theme.colorScheme === "dark" + ? theme.white + : theme.fn.themeColor(color, theme.fn.primaryShade()), + transform: "translate(-50%, -50%)", + top: "50%", + cursor: disabled ? "not-allowed" : "pointer", + borderRadius: 1000, + alignItems: "center", + justifyContent: "center", + transitionDuration: "100ms", + transitionProperty: "box-shadow, transform", + transitionTimingFunction: theme.transitionTimingFunction, + zIndex: 3, + userSelect: "none", + touchAction: "none", + }, + + dragging: { + transform: "translate(-50%, -50%) scale(1.05)", + boxShadow: theme.shadows.sm, + }, + }), +); + +interface TrackStyles { + radius: MantineNumberSize; + color: MantineColor; + disabled: boolean; + inverted: boolean; + thumbSize?: number; +} + +export const useTrackStyles = createStyles( + ( + theme, + { radius, color, disabled, inverted, thumbSize }: TrackStyles, + { size }, + ) => ({ + trackContainer: { + display: "flex", + alignItems: "center", + width: "100%", + height: `calc(${getSize({ sizes, size })} * 2)`, + cursor: "pointer", + + "&:has(~ input:disabled)": { + "&": { + pointerEvents: "none", + }, + + "& .mantine-Slider-thumb": { + display: "none", + }, + + "& .mantine-Slider-track::before": { + content: '""', + backgroundColor: inverted + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2], + }, + + "& .mantine-Slider-bar": { + backgroundColor: inverted + ? theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2] + : theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], + }, + }, + }, + + track: { + position: "relative", + height: getSize({ sizes, size }), + width: "100%", + marginRight: thumbSize ? rem(thumbSize / 2) : getSize({ size, sizes }), + marginLeft: thumbSize ? rem(thumbSize / 2) : getSize({ size, sizes }), + + "&::before": { + content: '""', + position: "absolute", + top: 0, + bottom: 0, + borderRadius: theme.fn.radius(radius), + right: `calc(${ + thumbSize ? rem(thumbSize / 2) : getSize({ size, sizes }) + } * -1)`, + left: `calc(${ + thumbSize ? rem(thumbSize / 2) : getSize({ size, sizes }) + } * -1)`, + backgroundColor: inverted + ? disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.fn.variant({ variant: "filled", color }).background + : theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2], + zIndex: 0, + }, + }, + + bar: { + position: "absolute", + zIndex: 1, + top: 0, + bottom: 0, + backgroundColor: inverted + ? theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2] + : disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.fn.variant({ variant: "filled", color }).background, + borderRadius: theme.fn.radius(radius), + }, + }), +); + +interface MarksStyles { + color: MantineColor; + disabled: boolean; + thumbSize?: number; +} + +export const useMarksStyles = createStyles( + (theme, { color, disabled, thumbSize }: MarksStyles, { size }) => ({ + marksContainer: { + position: "absolute", + right: thumbSize ? rem(thumbSize / 2) : getSize({ sizes, size }), + left: thumbSize ? rem(thumbSize / 2) : getSize({ sizes, size }), + + "&:has(~ input:disabled)": { + "& .mantine-Slider-markFilled": { + border: `${rem(2)} solid ${ + theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2] + }`, + borderColor: + theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], + }, + }, + }, + + markWrapper: { + position: "absolute", + top: `calc(${rem(getSize({ sizes, size }))} / 2)`, + zIndex: 2, + height: 0, + }, + + mark: { + boxSizing: "border-box", + border: `${rem(2)} solid ${ + theme.colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[2] + }`, + height: getSize({ sizes, size }), + width: getSize({ sizes, size }), + borderRadius: 1000, + transform: `translateX(calc(-${getSize({ sizes, size })} / 2))`, + backgroundColor: theme.white, + pointerEvents: "none", + }, + + markFilled: { + borderColor: disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.fn.variant({ variant: "filled", color }).background, + }, + + markLabel: { + transform: `translate(-50%, calc(${theme.spacing.xs} / 2))`, + fontSize: theme.fontSizes.sm, + color: + theme.colorScheme === "dark" + ? theme.colors.dark[2] + : theme.colors.gray[6], + whiteSpace: "nowrap", + cursor: "pointer", + userSelect: "none", + }, + }), +); diff --git a/src/viser/client/src/ControlPanel/MultiSlider.tsx b/src/viser/client/src/ControlPanel/MultiSlider.tsx new file mode 100644 index 000000000..e093d5fe4 --- /dev/null +++ b/src/viser/client/src/ControlPanel/MultiSlider.tsx @@ -0,0 +1,821 @@ +import React, { useRef, useState, forwardRef, useEffect } from "react"; +import { useMove, useUncontrolled } from "@mantine/hooks"; +import { + DefaultProps, + MantineNumberSize, + MantineColor, + useMantineTheme, + useComponentDefaultProps, + Selectors, +} from "@mantine/styles"; +import { MantineTransition, Box, Transition } from "@mantine/core"; +import { + useSliderRootStyles, + useThumbStyles, + useTrackStyles, + useMarksStyles, +} from "./MultiSlider.styles"; + +function getClientPosition(event: any) { + if ("TouchEvent" in window && event instanceof window.TouchEvent) { + const touch = event.touches[0]; + return touch.clientX; + } + + return event.clientX; +} + +interface GetPosition { + value: number; + min: number; + max: number; +} + +function getPosition({ value, min, max }: GetPosition) { + const position = ((value - min) / (max - min)) * 100; + return Math.min(Math.max(position, 0), 100); +} + +interface GetChangeValue { + value: number; + containerWidth?: number; + min: number; + max: number; + step: number; + precision?: number; +} + +function getChangeValue({ + value, + containerWidth, + min, + max, + step, + precision, +}: GetChangeValue) { + const left = !containerWidth + ? value + : Math.min(Math.max(value, 0), containerWidth) / containerWidth; + const dx = left * (max - min); + const nextValue = (dx !== 0 ? Math.round(dx / step) * step : 0) + min; + + const nextValueWithinStep = Math.max(nextValue, min); + + if (precision !== undefined) { + return Number(nextValueWithinStep.toFixed(precision)); + } + + return nextValueWithinStep; +} + +export type SliderRootStylesNames = Selectors; + +export interface SliderRootProps + extends DefaultProps, + React.ComponentPropsWithoutRef<"div"> { + size: MantineNumberSize; + children: React.ReactNode; + disabled: boolean; + variant: string; +} + +export const SliderRoot = forwardRef( + ( + { + className, + size, + classNames, + styles, + disabled, // eslint-disable-line @typescript-eslint/no-unused-vars + unstyled, + variant, + ...others + }: SliderRootProps, + ref, + ) => { + const { classes, cx } = useSliderRootStyles(null as unknown as void, { + name: "Slider", + classNames, + styles, + unstyled, + variant, + size, + }); + return ( + + ); + }, +); + +SliderRoot.displayName = "@mantine/core/SliderRoot"; + +export type ThumbStylesNames = Selectors; + +export interface ThumbProps extends DefaultProps { + max: number; + min: number; + value: number; + position: number; + dragging: boolean; + clicked: boolean; + color: MantineColor; + size: MantineNumberSize; + label: React.ReactNode; + onKeyDownCapture?(event: React.KeyboardEvent): void; + onMouseDown?( + event: React.MouseEvent | React.TouchEvent, + ): void; + labelTransition?: MantineTransition; + labelTransitionDuration?: number; + labelTransitionTimingFunction?: string; + labelAlwaysOn: boolean; + thumbLabel: string; + onFocus?(): void; + onBlur?(): void; + showLabelOnHover?: boolean; + isHovered?: boolean; + children?: React.ReactNode; + disabled: boolean; + thumbSize: number; + variant: string; +} + +export const Thumb = forwardRef( + ( + { + max, + min, + value, + position, + label, + dragging, + clicked, + onMouseDown, + onKeyDownCapture, + color, + classNames, + styles, + size, + labelTransition, + labelTransitionDuration, + labelTransitionTimingFunction, + labelAlwaysOn, + thumbLabel, + onFocus, + onBlur, + showLabelOnHover, + isHovered, + children = null, + disabled, + unstyled, + thumbSize, + variant, + }: ThumbProps, + ref, + ) => { + const { classes, cx, theme } = useThumbStyles( + { color, disabled, thumbSize }, + { name: "Slider", classNames, styles, unstyled, variant, size }, + ); + const [focused, setFocused] = useState(false); + + const isVisible = + labelAlwaysOn || dragging || focused || (showLabelOnHover && isHovered); + + return ( + + tabIndex={0} + role="slider" + aria-label={thumbLabel} + aria-valuemax={max} + aria-valuemin={min} + aria-valuenow={value} + ref={ref} + className={cx(classes.thumb, { [classes.dragging]: dragging })} + onFocus={() => { + setFocused(true); + typeof onFocus === "function" && onFocus(); + }} + onBlur={() => { + setFocused(false); + typeof onBlur === "function" && onBlur(); + }} + onTouchStart={onMouseDown} + onMouseDown={onMouseDown} + onKeyDownCapture={onKeyDownCapture} + onClick={(event) => event.stopPropagation()} + style={{ + [theme.dir === "rtl" ? "right" : "left"]: `${position}%`, + zIndex: clicked ? 1000 : undefined, + }} + > + {children} + + {(transitionStyles) => ( +
+ {label} +
+ )} +
+
+ ); + }, +); + +Thumb.displayName = "@mantine/core/SliderThumb"; + +export type MarksStylesNames = Selectors; + +export interface MarksProps extends DefaultProps { + marks: { value: number; label?: React.ReactNode }[]; + size: MantineNumberSize; + thumbSize?: number; + color: MantineColor; + min: number; + max: number; + onChange(value: number): void; + disabled: boolean; + variant: string; +} + +export function Marks({ + marks, + color, + size, + thumbSize, + min, + max, + classNames, + styles, + onChange, + disabled, + unstyled, + variant, +}: MarksProps) { + const { classes, cx } = useMarksStyles( + { color, disabled, thumbSize }, + { name: "Slider", classNames, styles, unstyled, variant, size }, + ); + + const items = marks.map((mark, index) => ( + +
+ {mark.label && ( +
{ + event.stopPropagation(); + !disabled && onChange(mark.value); + }} + onTouchStart={(event) => { + event.stopPropagation(); + !disabled && onChange(mark.value); + }} + > + {mark.label} +
+ )} + + )); + + return
{items}
; +} + +Marks.displayName = "@mantine/core/SliderMarks"; + +export type TrackStylesNames = + | Selectors + | MarksStylesNames; + +export interface TrackProps extends DefaultProps { + marks: { value: number; label?: React.ReactNode }[]; + size: MantineNumberSize; + thumbSize?: number; + radius: MantineNumberSize; + color: MantineColor; + min: number; + max: number; + children: React.ReactNode; + onChange(value: number): void; + disabled: boolean; + variant: string; + containerProps?: React.PropsWithRef>; +} + +export function Track({ + size, + thumbSize, + color, + classNames, + styles, + radius, + children, + disabled, + unstyled, + variant, + containerProps, + ...others +}: TrackProps) { + const { classes } = useTrackStyles( + { color, radius, disabled, inverted: false }, + { name: "Slider", classNames, styles, unstyled, variant, size }, + ); + + return ( + <> +
+
{children}
+
+ + + + ); +} + +Track.displayName = "@mantine/core/SliderTrack"; + +export type MultiSliderStylesNames = + | SliderRootStylesNames + | ThumbStylesNames + | TrackStylesNames + | MarksStylesNames; + +type Value = number[]; + +export interface MultiSliderProps + extends DefaultProps, + Omit< + React.ComponentPropsWithoutRef<"div">, + "value" | "onChange" | "defaultValue" + > { + variant?: string; + + /** Color from theme.colors */ + color?: MantineColor; + + /** Key of theme.radius or any valid CSS value to set border-radius, theme.defaultRadius by default */ + radius?: MantineNumberSize; + + /** Predefined track and thumb size, number to set sizes */ + size?: MantineNumberSize; + + /** Minimal possible value */ + min?: number; + + /** Maximum possible value */ + max: number; + + /** Minimal range interval */ + minRange?: number; + + /** Number by which value will be incremented/decremented with thumb drag and arrows */ + step?: number; + + /** Amount of digits after the decimal point */ + precision?: number; + + /** Current value for controlled slider */ + value?: Value; + + /** Default value for uncontrolled slider */ + defaultValue?: Value; + + /** Called each time value changes */ + onChange?(value: Value): void; + + /** Called when user stops dragging slider or changes value with arrows */ + onChangeEnd?(value: Value): void; + + /** Hidden input name, use with uncontrolled variant */ + name?: string; + + /** Marks which will be placed on the track */ + marks: { value: number; label?: React.ReactNode }[]; + + /** Function to generate label or any react node to render instead, set to null to disable label */ + label?: React.ReactNode | ((value: number) => React.ReactNode); + + /** Label appear/disappear transition */ + labelTransition?: MantineTransition; + + /** Label appear/disappear transition duration in ms */ + labelTransitionDuration?: number; + + /** Label appear/disappear transition timing function, defaults to theme.transitionRimingFunction */ + labelTransitionTimingFunction?: string; + + /** If true label will be not be hidden when user stops dragging */ + labelAlwaysOn?: boolean; + + /** Thumb aria-label */ + thumbLabels?: string[]; + + /**If true slider label will appear on hover */ + showLabelOnHover?: boolean; + + /** Thumbs children, can be used to add icons */ + thumbChildren?: React.ReactNode | React.ReactNode[] | null; + + /** Disables slider */ + disabled?: boolean; + + /** Thumb width and height */ + thumbSize?: number; + + /** A transformation function, to change the scale of the slider */ + scale?: (value: number) => number; + + fixedEndpoints: boolean; +} + +const defaultProps: Partial = { + size: "md", + radius: "xl", + min: 0, + max: 100, + step: 1, + marks: [], + label: (f) => f, + labelTransition: "skew-down", + labelTransitionDuration: 0, + labelAlwaysOn: false, + thumbChildren: null, + showLabelOnHover: true, + disabled: false, + scale: (v) => v, + fixedEndpoints: false, +}; + +export const MultiSlider = forwardRef( + (props, ref) => { + const { + classNames, + styles, + color, + value, + onChange, + onChangeEnd, + size, + radius, + min, + max, + minRange, + step, + precision, + defaultValue, + name, + marks, + label, + labelTransition, + labelTransitionDuration, + labelTransitionTimingFunction, + labelAlwaysOn, + thumbLabels, + showLabelOnHover, + thumbChildren, + disabled, + unstyled, + thumbSize, + scale, + variant, + fixedEndpoints, + ...others + } = useComponentDefaultProps("MultiSlider", defaultProps, props) as any; + const _minRange = minRange || step; + + const theme = useMantineTheme(); + const [focused, setFocused] = useState(-1); + const [hovered, setHovered] = useState(false); + const [_value, setValue] = useUncontrolled({ + value, + defaultValue, + finalValue: [min, max], + onChange, + }); + const valueRef = useRef(_value); + const thumbs = useRef<(HTMLDivElement | null)[]>([]); + const thumbIndex = useRef(-1); + const positions = _value.map((x) => getPosition({ value: x, min, max })); + + const _setValue = (val: Value) => { + setValue(val); + valueRef.current = val; + }; + + useEffect( + () => { + if (Array.isArray(value)) { + valueRef.current = value; + } + }, + Array.isArray(value) ? [value[0], value[1]] : [null, null], + ); + + const setRangedValue = ( + val: number, + index: number, + triggerChangeEnd: boolean, + ) => { + const clone: Value = [...valueRef.current]; + clone[index] = val; + + if (index < clone.length - 1) { + if (val > clone[index + 1] - (_minRange - 0.000000001)) { + clone[index] = Math.max(min, clone[index + 1] - _minRange); + } + + if (val > (max - (_minRange - 0.000000001) || min)) { + clone[index] = valueRef.current[index]; + } + } + + if (index > 0) { + if (val < clone[index - 1] + _minRange) { + clone[index] = Math.min(max, clone[index - 1] + _minRange); + } + } + + if (fixedEndpoints && (index === 0 || index == clone.length - 1)) { + clone[index] = valueRef.current[index]; + } + + _setValue(clone); + + if (triggerChangeEnd) { + onChangeEnd?.(valueRef.current); + } + }; + + const handleChange = (val: number) => { + if (!disabled) { + const nextValue = getChangeValue({ + value: val, + min, + max, + step, + precision, + }); + setRangedValue(nextValue, thumbIndex.current, false); + } + }; + + const { ref: container, active } = useMove( + ({ x }) => handleChange(x), + { onScrubEnd: () => onChangeEnd?.(valueRef.current) }, + theme.dir, + ); + + function handleThumbMouseDown(index: number) { + thumbIndex.current = index; + } + + const handleTrackMouseDownCapture = ( + event: + | React.MouseEvent + | React.TouchEvent, + ) => { + container.current.focus(); + const rect = container.current.getBoundingClientRect(); + const changePosition = getClientPosition(event.nativeEvent); + const changeValue = getChangeValue({ + value: changePosition - rect.left, + max, + min, + step, + containerWidth: rect.width, + }); + + const _nearestHandle = _value + .map((v) => Math.abs(v - changeValue)) + .indexOf(Math.min(..._value.map((v) => Math.abs(v - changeValue)))); + + thumbIndex.current = _nearestHandle; + }; + + const getFocusedThumbIndex = () => { + if (focused !== 1 && focused !== 0) { + setFocused(0); + return 0; + } + + return focused; + }; + + const handleTrackKeydownCapture = ( + event: React.KeyboardEvent, + ) => { + if (!disabled) { + switch (event.key) { + case "ArrowUp": { + event.preventDefault(); + const focusedIndex = getFocusedThumbIndex(); + thumbs.current[focusedIndex]?.focus(); + setRangedValue( + Math.min( + Math.max(valueRef.current[focusedIndex] + step, min), + max, + ), + focusedIndex, + true, + ); + break; + } + case "ArrowRight": { + event.preventDefault(); + const focusedIndex = getFocusedThumbIndex(); + thumbs.current[focusedIndex]?.focus(); + setRangedValue( + Math.min( + Math.max( + theme.dir === "rtl" + ? valueRef.current[focusedIndex] - step + : valueRef.current[focusedIndex] + step, + min, + ), + max, + ), + focusedIndex, + true, + ); + break; + } + + case "ArrowDown": { + event.preventDefault(); + const focusedIndex = getFocusedThumbIndex(); + thumbs.current[focusedIndex]?.focus(); + setRangedValue( + Math.min( + Math.max(valueRef.current[focusedIndex] - step, min), + max, + ), + focusedIndex, + true, + ); + break; + } + case "ArrowLeft": { + event.preventDefault(); + const focusedIndex = getFocusedThumbIndex(); + thumbs.current[focusedIndex]?.focus(); + setRangedValue( + Math.min( + Math.max( + theme.dir === "rtl" + ? valueRef.current[focusedIndex] + step + : valueRef.current[focusedIndex] - step, + min, + ), + max, + ), + focusedIndex, + true, + ); + break; + } + + default: { + break; + } + } + } + }; + + const sharedThumbProps = { + max, + min, + color, + size, + labelTransition, + labelTransitionDuration, + labelTransitionTimingFunction, + labelAlwaysOn, + onBlur: () => setFocused(-1), + classNames, + styles, + }; + + const hasArrayThumbChildren = Array.isArray(thumbChildren); + + return ( + + { + const nearestValue = + Math.abs(_value[0] - val) > Math.abs(_value[1] - val) ? 1 : 0; + const clone: Value = [..._value]; + clone[nearestValue] = val; + _setValue(clone); + }} + disabled={disabled} + unstyled={unstyled} + variant={variant} + containerProps={{ + ref: container, + onMouseEnter: showLabelOnHover ? () => setHovered(true) : undefined, + onMouseLeave: showLabelOnHover + ? () => setHovered(false) + : undefined, + onTouchStartCapture: handleTrackMouseDownCapture, + onTouchEndCapture: () => { + thumbIndex.current = -1; + }, + onMouseDownCapture: handleTrackMouseDownCapture, + onMouseUpCapture: () => { + thumbIndex.current = -1; + }, + onKeyDownCapture: handleTrackKeydownCapture, + }} + > + {_value.map((value, index) => ( + { + thumbs.current[index] = node; + }} + thumbLabel={thumbLabels ? thumbLabels[index] : ""} + onMouseDown={() => handleThumbMouseDown(index)} + onFocus={() => setFocused(index)} + showLabelOnHover={showLabelOnHover} + isHovered={hovered} + disabled={disabled} + unstyled={unstyled} + thumbSize={thumbSize} + variant={variant} + > + {hasArrayThumbChildren ? thumbChildren[index] : thumbChildren} + + ))} + + {_value.map((value, index) => ( + + ))} + + ); + }, +); + +MultiSlider.displayName = "MultiSlider"; diff --git a/src/viser/client/src/WebsocketMessages.tsx b/src/viser/client/src/WebsocketMessages.tsx index e59b51859..608b26921 100644 --- a/src/viser/client/src/WebsocketMessages.tsx +++ b/src/viser/client/src/WebsocketMessages.tsx @@ -361,7 +361,7 @@ export interface _GuiAddInputBase { hint: string | null; initial_value: any; } -/** GuiAddButtonMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'bool', color: "Optional[Literal['dark', 'gray', 'red', 'pink', 'grape', 'violet', 'indigo', 'blue', 'cyan', 'green', 'lime', 'yellow', 'orange', 'teal']]", icon_base64: 'Optional[str]') +/** GuiAddButtonMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'bool', color: "Optional[Literal[('dark', 'gray', 'red', 'pink', 'grape', 'violet', 'indigo', 'blue', 'cyan', 'green', 'lime', 'yellow', 'orange', 'teal')]]", icon_base64: 'Optional[str]') * * (automatically generated) */ @@ -391,7 +391,7 @@ export interface GuiAddButtonMessage { | null; icon_base64: string | null; } -/** GuiAddSliderMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'float', min: 'float', max: 'float', step: 'Optional[float]', precision: 'int') +/** GuiAddSliderMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'float', min: 'float', max: 'float', step: 'Optional[float]', precision: 'int', marks: 'Optional[Tuple[GuiSliderMark, ...]]' = None) * * (automatically generated) */ @@ -407,6 +407,27 @@ export interface GuiAddSliderMessage { max: number; step: number | null; precision: number; + marks: { value: number; label?: string }[] | null; +} +/** GuiAddMultiSliderMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'Tuple[float, ...]', min: 'float', max: 'float', step: 'Optional[float]', min_range: 'Optional[float]', precision: 'int', fixed_endpoints: 'bool' = False, marks: 'Optional[Tuple[GuiSliderMark, ...]]' = None) + * + * (automatically generated) + */ +export interface GuiAddMultiSliderMessage { + type: "GuiAddMultiSliderMessage"; + order: number; + id: string; + label: string; + container_id: string; + hint: string | null; + initial_value: number[]; + min: number; + max: number; + step: number | null; + min_range: number | null; + precision: number; + fixed_endpoints: boolean; + marks: { value: number; label?: string }[] | null; } /** GuiAddNumberMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'float', precision: 'int', step: 'float', min: 'Optional[float]', max: 'Optional[float]') * @@ -776,6 +797,7 @@ export type Message = | _GuiAddInputBase | GuiAddButtonMessage | GuiAddSliderMessage + | GuiAddMultiSliderMessage | GuiAddNumberMessage | GuiAddRgbMessage | GuiAddRgbaMessage diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 84ba23c23..e08a9229b 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -2,7 +2,7 @@ from typing import Any, ClassVar, Type, Union, cast, get_type_hints import numpy as onp -from typing_extensions import Literal, get_args, get_origin, is_typeddict +from typing_extensions import Literal, NotRequired, get_args, get_origin, is_typeddict try: from typing import Literal as LiteralAlt @@ -52,12 +52,19 @@ def _get_ts_type(typ: Type[Any]) -> str: ) + ")" ) + elif origin_typ is list: + args = get_args(typ) + return _get_ts_type(args[0]) + "[]" elif is_typeddict(typ): hints = get_type_hints(typ) + optional_keys = getattr(typ, "__optional_keys__", []) def fmt(key): val = hints[key] - ret = f"'{key}'" + ": " + _get_ts_type(val) + optional = key in optional_keys + if get_origin(val) is NotRequired: + val = get_args(val)[0] + ret = f"'{key}'{'?' if optional else ''}" + ": " + _get_ts_type(val) return ret ret = "{" + ", ".join(map(fmt, hints)) + "}"