From a6124bae297cf171e71ab3e14f57ce76f3573cfd Mon Sep 17 00:00:00 2001 From: Jonas Kulhanek Date: Fri, 26 Jan 2024 16:16:19 +0100 Subject: [PATCH 1/8] Add multi-slider --- examples/02_gui.py | 8 + src/viser/_gui_api.py | 77 ++ src/viser/_messages.py | 22 +- .../client/src/ControlPanel/Generated.tsx | 26 +- .../client/src/ControlPanel/GuiState.tsx | 1 + .../src/ControlPanel/MultiSlider.styles.tsx | 243 ++++++ .../client/src/ControlPanel/MultiSlider.tsx | 774 ++++++++++++++++++ src/viser/client/src/WebsocketMessages.tsx | 24 +- src/viser/infra/_typescript_interface_gen.py | 11 +- 9 files changed, 1179 insertions(+), 7 deletions(-) create mode 100644 src/viser/client/src/ControlPanel/MultiSlider.styles.tsx create mode 100644 src/viser/client/src/ControlPanel/MultiSlider.tsx diff --git a/examples/02_gui.py b/examples/02_gui.py index 0e7ccc1f2..e2be31e65 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -57,6 +57,14 @@ def main() -> None: "Color", initial_value=(255, 255, 0), ) + server.add_gui_multi_slider( + "Multi slider", + min=0, + max=100, + step=1, + initial_value=(0, 30, 100), + marks=[0, 25, 50, 75, (100, "max")], + ) # Pre-generate a point cloud to send. point_positions = onp.random.uniform(low=-1.0, high=1.0, size=(5000, 3)) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 53e0f46b5..2f1667d7c 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -797,6 +797,7 @@ def add_gui_slider( visible: bool = True, hint: Optional[str] = None, order: Optional[float] = None, + marks: Optional[List[Tuple[IntOrFloat, Optional[str]]]] = None, ) -> GuiInputHandle[IntOrFloat]: """Add a slider to the GUI. Types of the min, max, step, and initial value should match. @@ -810,6 +811,7 @@ def add_gui_slider( visible: Whether the slider is visible. hint: Optional hint to display on hover. order: Optional ordering, smallest values will be displayed first. + marks: List of marks to display below the slider. Returns: A handle that can be used to interact with the GUI element. @@ -846,6 +848,81 @@ def add_gui_slider( step=step, initial_value=initial_value, precision=_compute_precision_digits(step), + marks=[ + {"value": x[0], "label": x[1]} if isinstance(x, tuple) else {"value": 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: List[IntOrFloat], + disabled: bool = False, + visible: bool = True, + min_range: Optional[IntOrFloat] = None, + hint: Optional[str] = None, + order: Optional[float] = None, + fixed_endpoints: bool = False, + marks: Optional[List[Tuple[IntOrFloat, Optional[str]]]] = None, + ) -> GuiInputHandle[List[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. + disabled: Whether the slider is disabled. + visible: Whether the slider is visible. + min_range: Optional minimum difference between two values of the slider. + hint: Optional hint to display on hover. + order: Optional ordering, smallest values will be displayed first. + fixed_endpoints: Whether the endpoints of the slider are fixed. + marks: List of marks to display below the slider. + 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 + # the min, max, or step is a float, we should cast to a float. + if len(initial_value) > 0 and (type(initial_value[0]) is int and ( + type(min) is float or type(max) is float or type(step) is float + )): + initial_value = [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=[ + {"value": x[0], "label": x[1]} if isinstance(x, tuple) else {"value": x} + for x in marks + ] if marks is not None else None, ), disabled=disabled, visible=visible, diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 9cf8cd411..337a3c290 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -4,15 +4,18 @@ from __future__ import annotations import dataclasses -from typing import Any, Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union, List import numpy as onp import numpy.typing as onpt -from typing_extensions import Literal, override +from typing_extensions import Literal, TypedDict, NotRequired, override from . import infra, theme +GuiSliderMark = TypedDict("GuiSliderMark", {"value": float, "label": NotRequired[Optional[str]]}) + + class Message(infra.Message): @override def redundancy_key(self) -> str: @@ -430,6 +433,21 @@ class GuiAddSliderMessage(_GuiAddInputBase): step: Optional[float] initial_value: float precision: int + marks: Optional[List[GuiSliderMark]] = None + + +@dataclasses.dataclass +class GuiAddMultiSliderMessage(_GuiAddInputBase): + min: float + max: float + step: Optional[float] + min_range: Optional[float] + initial_value: List[float] + precision: int + fixed_endpoints: bool = False + marks: Optional[List[GuiSliderMark]] = None + + @dataclasses.dataclass diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index 9419eb2a1..c4c75a326 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"; @@ -226,7 +227,7 @@ function GeneratedInput({ precision={conf.precision} value={value} onChange={updateValue} - marks={[{ value: conf.min }, { value: conf.max }]} + marks={conf.marks === null ? [{ value: conf.min }, { value: conf.max }] : conf.marks} disabled={disabled} /> ); break; + case "GuiAddMultiSliderMessage": + return ( + + {conf.label !== undefined && ({conf.label})} + x.label) && "xl" || undefined} + minRange={conf.min_range ?? undefined} + step={conf.step ?? undefined} + precision={conf.precision} + fixedEndpoints={conf.fixed_endpoints} + value={value} + onChange={updateValue} + marks={conf.marks || [{ value: conf.min }, { value: conf.max }]} + disabled={disabled} + /> + + ); + break; case "GuiAddNumberMessage": input = ( ({ + 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: disabled ? 'none' : 'flex', + height: thumbSize ? rem(thumbSize) : `calc(${getSize({ sizes, size })} * 2)`, + width: thumbSize ? rem(thumbSize) : `calc(${getSize({ sizes, size })} * 2)`, + backgroundColor: + theme.colorScheme === 'dark' + ? theme.fn.themeColor(color, theme.fn.primaryShade()) + : theme.white, + border: `${rem(4)} solid ${ + 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: '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', + }, +})); \ No newline at end of file diff --git a/src/viser/client/src/ControlPanel/MultiSlider.tsx b/src/viser/client/src/ControlPanel/MultiSlider.tsx new file mode 100644 index 000000000..acc3bf67a --- /dev/null +++ b/src/viser/client/src/ControlPanel/MultiSlider.tsx @@ -0,0 +1,774 @@ +import React, { useRef, useState, forwardRef, useEffect } from 'react'; +import { useMove, useUncontrolled } from '@mantine/hooks'; +import { + DefaultProps, + MantineNumberSize, + MantineColor, + useMantineTheme, + useComponentDefaultProps, + Selectors, + getSize, + rem, +} from '@mantine/styles'; +import { + MantineTransition, + Box, + Transition, +} from '@mantine/core'; +import { sizes, useSliderRootStyles, useThumbStyles, useTrackStyles, useMarksStyles } from './MultiSlider.styles'; + +export 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, + 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; + 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, + 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}%` }} + > + {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, '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; + + /** First thumb aria-label */ + thumbFromLabel?: string; + + /** Second thumb aria-label */ + thumbToLabel?: 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, + thumbFromLabel: '', + thumbChildren: null, + thumbToLabel: '', + 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, + thumbFromLabel, + thumbToLabel, + 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={thumbFromLabel} + 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'; \ No newline at end of file diff --git a/src/viser/client/src/WebsocketMessages.tsx b/src/viser/client/src/WebsocketMessages.tsx index e59b51859..dc86557bf 100644 --- a/src/viser/client/src/WebsocketMessages.tsx +++ b/src/viser/client/src/WebsocketMessages.tsx @@ -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[List[GuiSliderMark]]' = None) * * (automatically generated) */ @@ -407,6 +407,27 @@ export interface GuiAddSliderMessage { max: number; step: number | null; precision: number; + marks: { value: number; label?: string | null }[] | null; +} +/** GuiAddMultiSliderMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'List[float]', min: 'float', max: 'float', step: 'Optional[float]', min_range: 'Optional[float]', precision: 'int', fixed_endpoints: 'bool' = False, marks: 'Optional[List[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 }[] | 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..defc532d0 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -1,8 +1,8 @@ import dataclasses -from typing import Any, ClassVar, Type, Union, cast, get_type_hints +from typing import Any, ClassVar, Type, Union, cast, get_type_hints, List 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,17 @@ 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 + ret = f"'{key}'{'?' if optional else ''}" + ": " + _get_ts_type(val) return ret ret = "{" + ", ".join(map(fmt, hints)) + "}" From 96cd06618b79bff1eb72cf40270e14f7f5d5004e Mon Sep 17 00:00:00 2001 From: Jonas Kulhanek Date: Fri, 26 Jan 2024 16:48:04 +0100 Subject: [PATCH 2/8] Fix mypy issues --- examples/02_gui.py | 4 ++-- src/viser/_gui_api.py | 19 +++++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/examples/02_gui.py b/examples/02_gui.py index e2be31e65..635633c34 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -58,11 +58,11 @@ def main() -> None: initial_value=(255, 255, 0), ) server.add_gui_multi_slider( - "Multi slider", + "Multi slider", # type: ignore min=0, max=100, step=1, - initial_value=(0, 30, 100), + initial_value=[0, 30, 100], marks=[0, 25, 50, 75, (100, "max")], ) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 2f1667d7c..f86949507 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -15,6 +15,7 @@ Any, Dict, List, + Union, Optional, Sequence, Tuple, @@ -797,7 +798,7 @@ def add_gui_slider( visible: bool = True, hint: Optional[str] = None, order: Optional[float] = None, - marks: Optional[List[Tuple[IntOrFloat, Optional[str]]]] = None, + marks: Optional[List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]]] = None, ) -> GuiInputHandle[IntOrFloat]: """Add a slider to the GUI. Types of the min, max, step, and initial value should match. @@ -849,7 +850,7 @@ def add_gui_slider( initial_value=initial_value, precision=_compute_precision_digits(step), marks=[ - {"value": x[0], "label": x[1]} if isinstance(x, tuple) else {"value": x} + {"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, ), @@ -871,7 +872,7 @@ def add_gui_multi_slider( hint: Optional[str] = None, order: Optional[float] = None, fixed_endpoints: bool = False, - marks: Optional[List[Tuple[IntOrFloat, Optional[str]]]] = None, + marks: Optional[List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]]] = None, ) -> GuiInputHandle[List[IntOrFloat]]: """Add a multi slider to the GUI. Types of the min, max, step, and initial value should match. Args: @@ -894,13 +895,7 @@ def add_gui_multi_slider( 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 - # the min, max, or step is a float, we should cast to a float. - if len(initial_value) > 0 and (type(initial_value[0]) is int and ( - type(min) is float or type(max) is float or type(step) is float - )): - initial_value = [float(x) for x in initial_value] # type: ignore + _initial_value = [float(x) for x in initial_value] id = _make_unique_id() order = _apply_default_order(order) @@ -916,11 +911,11 @@ def add_gui_multi_slider( min_range=min_range, max=max, step=step, - initial_value=initial_value, + initial_value=_initial_value, fixed_endpoints=fixed_endpoints, precision=_compute_precision_digits(step), marks=[ - {"value": x[0], "label": x[1]} if isinstance(x, tuple) else {"value": x} + {"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, ), From 32a8d5f09796a0c0feabead9742d13cce64fd87c Mon Sep 17 00:00:00 2001 From: Jonas Kulhanek Date: Fri, 26 Jan 2024 17:38:49 +0100 Subject: [PATCH 3/8] Fix formatting --- src/viser/_gui_api.py | 26 ++++++++++++++----- src/viser/_messages.py | 11 ++++---- .../src/ControlPanel/MultiSlider.styles.tsx | 18 ++++++------- .../client/src/ControlPanel/MultiSlider.tsx | 6 ++--- src/viser/infra/_typescript_interface_gen.py | 4 +-- 5 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index f86949507..59eb6d182 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -15,11 +15,11 @@ Any, Dict, List, - Union, Optional, Sequence, Tuple, TypeVar, + Union, overload, ) @@ -798,7 +798,9 @@ def add_gui_slider( visible: bool = True, hint: Optional[str] = None, order: Optional[float] = None, - marks: Optional[List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]]] = None, + marks: Optional[ + List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]] + ] = None, ) -> GuiInputHandle[IntOrFloat]: """Add a slider to the GUI. Types of the min, max, step, and initial value should match. @@ -850,9 +852,13 @@ def add_gui_slider( initial_value=initial_value, precision=_compute_precision_digits(step), marks=[ - {"value": float(x[0]), "label": x[1]} if isinstance(x, tuple) else {"value": float(x)} + {"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, + ] + if marks is not None + else None, ), disabled=disabled, visible=visible, @@ -872,7 +878,9 @@ def add_gui_multi_slider( hint: Optional[str] = None, order: Optional[float] = None, fixed_endpoints: bool = False, - marks: Optional[List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]]] = None, + marks: Optional[ + List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]] + ] = None, ) -> GuiInputHandle[List[IntOrFloat]]: """Add a multi slider to the GUI. Types of the min, max, step, and initial value should match. Args: @@ -915,9 +923,13 @@ def add_gui_multi_slider( fixed_endpoints=fixed_endpoints, precision=_compute_precision_digits(step), marks=[ - {"value": float(x[0]), "label": x[1]} if isinstance(x, tuple) else {"value": float(x)} + {"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, + ] + if marks is not None + else None, ), disabled=disabled, visible=visible, diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 337a3c290..a5ec79a0a 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -4,16 +4,17 @@ from __future__ import annotations import dataclasses -from typing import Any, Optional, Tuple, Union, List +from typing import Any, List, Optional, Tuple, Union import numpy as onp import numpy.typing as onpt -from typing_extensions import Literal, TypedDict, NotRequired, override +from typing_extensions import Literal, NotRequired, TypedDict, override from . import infra, theme - -GuiSliderMark = TypedDict("GuiSliderMark", {"value": float, "label": NotRequired[Optional[str]]}) +GuiSliderMark = TypedDict( + "GuiSliderMark", {"value": float, "label": NotRequired[Optional[str]]} +) class Message(infra.Message): @@ -448,8 +449,6 @@ class GuiAddMultiSliderMessage(_GuiAddInputBase): marks: Optional[List[GuiSliderMark]] = None - - @dataclasses.dataclass class GuiAddNumberMessage(_GuiAddInputBase): initial_value: float diff --git a/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx index 779925ee6..42f7a70ad 100644 --- a/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx +++ b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx @@ -93,7 +93,7 @@ interface TrackStyles { inverted: boolean; thumbSize?: number; } - + export const useTrackStyles = createStyles( (theme, { radius, color, disabled, inverted, thumbSize }: TrackStyles, { size }) => ({ trackContainer: { @@ -102,16 +102,16 @@ interface TrackStyles { 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 @@ -122,7 +122,7 @@ interface TrackStyles { ? theme.colors.dark[4] : theme.colors.gray[2], }, - + '& .mantine-Slider-bar': { backgroundColor: inverted ? theme.colorScheme === 'dark' @@ -134,14 +134,14 @@ interface TrackStyles { }, }, }, - + 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', @@ -162,7 +162,7 @@ interface TrackStyles { zIndex: 0, }, }, - + bar: { position: 'absolute', zIndex: 1, @@ -240,4 +240,4 @@ export const useMarksStyles = createStyles((theme, { color, disabled, thumbSize cursor: 'pointer', userSelect: 'none', }, -})); \ No newline at end of file +})); diff --git a/src/viser/client/src/ControlPanel/MultiSlider.tsx b/src/viser/client/src/ControlPanel/MultiSlider.tsx index acc3bf67a..be32c5249 100644 --- a/src/viser/client/src/ControlPanel/MultiSlider.tsx +++ b/src/viser/client/src/ControlPanel/MultiSlider.tsx @@ -10,8 +10,8 @@ import { getSize, rem, } from '@mantine/styles'; -import { - MantineTransition, +import { + MantineTransition, Box, Transition, } from '@mantine/core'; @@ -771,4 +771,4 @@ export const MultiSlider = forwardRef((props, ); }); -MultiSlider.displayName = 'MultiSlider'; \ No newline at end of file +MultiSlider.displayName = 'MultiSlider'; diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index defc532d0..54aacc57d 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -1,8 +1,8 @@ import dataclasses -from typing import Any, ClassVar, Type, Union, cast, get_type_hints, List +from typing import Any, ClassVar, Type, Union, cast, get_type_hints import numpy as onp -from typing_extensions import Literal, NotRequired, get_args, get_origin, is_typeddict +from typing_extensions import Literal, get_args, get_origin, is_typeddict try: from typing import Literal as LiteralAlt From 92154c5b8ef72e41eaabe77f685f19000cad253e Mon Sep 17 00:00:00 2001 From: Jonas Kulhanek Date: Mon, 29 Jan 2024 09:07:30 +0100 Subject: [PATCH 4/8] Update multislider --- examples/02_gui.py | 4 +- src/viser/_gui_api.py | 18 +++--- src/viser/_messages.py | 8 +-- .../client/src/ControlPanel/Generated.tsx | 55 ++++++++++++------- .../client/src/ControlPanel/MultiSlider.tsx | 22 +++----- src/viser/client/src/WebsocketMessages.tsx | 4 +- src/viser/infra/_typescript_interface_gen.py | 4 +- 7 files changed, 61 insertions(+), 54 deletions(-) diff --git a/examples/02_gui.py b/examples/02_gui.py index 635633c34..b19b99175 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -62,8 +62,8 @@ def main() -> None: min=0, max=100, step=1, - initial_value=[0, 30, 100], - marks=[0, 25, 50, 75, (100, "max")], + initial_value=(0, 30, 100), + marks=(0, 25, 50, 75, (100, "max")), ) # Pre-generate a point cloud to send. diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 59eb6d182..a0c4efe6b 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -799,7 +799,7 @@ def add_gui_slider( hint: Optional[str] = None, order: Optional[float] = None, marks: Optional[ - List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]] + Tuple[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat], ...] ] = None, ) -> GuiInputHandle[IntOrFloat]: """Add a slider to the GUI. Types of the min, max, step, and initial value should match. @@ -851,12 +851,12 @@ def add_gui_slider( step=step, initial_value=initial_value, precision=_compute_precision_digits(step), - marks=[ + 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, ), @@ -871,7 +871,7 @@ def add_gui_multi_slider( min: IntOrFloat, max: IntOrFloat, step: IntOrFloat, - initial_value: List[IntOrFloat], + initial_value: Tuple[IntOrFloat, ...], disabled: bool = False, visible: bool = True, min_range: Optional[IntOrFloat] = None, @@ -879,9 +879,9 @@ def add_gui_multi_slider( order: Optional[float] = None, fixed_endpoints: bool = False, marks: Optional[ - List[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat]] + Tuple[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat], ...] ] = None, - ) -> GuiInputHandle[List[IntOrFloat]]: + ) -> 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. @@ -903,7 +903,7 @@ def add_gui_multi_slider( if step > max - min: step = max - min assert all(max >= x >= min for x in initial_value) - _initial_value = [float(x) for x in initial_value] + _initial_value = tuple(float(x) for x in initial_value) id = _make_unique_id() order = _apply_default_order(order) @@ -922,12 +922,12 @@ def add_gui_multi_slider( initial_value=_initial_value, fixed_endpoints=fixed_endpoints, precision=_compute_precision_digits(step), - marks=[ + 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, ), diff --git a/src/viser/_messages.py b/src/viser/_messages.py index a5ec79a0a..4974e8d1c 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -4,7 +4,7 @@ from __future__ import annotations import dataclasses -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union import numpy as onp import numpy.typing as onpt @@ -434,7 +434,7 @@ class GuiAddSliderMessage(_GuiAddInputBase): step: Optional[float] initial_value: float precision: int - marks: Optional[List[GuiSliderMark]] = None + marks: Optional[Tuple[GuiSliderMark, ...]] = None @dataclasses.dataclass @@ -443,10 +443,10 @@ class GuiAddMultiSliderMessage(_GuiAddInputBase): max: float step: Optional[float] min_range: Optional[float] - initial_value: List[float] + initial_value: Tuple[float, ...] precision: int fixed_endpoints: bool = False - marks: Optional[List[GuiSliderMark]] = None + 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 c4c75a326..fd673b6d3 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -149,6 +149,7 @@ function GeneratedInput({ let labeled = true; let input = null; + let containerProps = {}; switch (conf.type) { case "GuiAddButtonMessage": labeled = false; @@ -268,29 +269,41 @@ function GeneratedInput({ /> ); + + if (conf.marks?.some(x => x.label)) + containerProps = { ...containerProps, "mb": "md" }; break; case "GuiAddMultiSliderMessage": - return ( - - {conf.label !== undefined && ({conf.label})} - x.label) && "xl" || undefined} - minRange={conf.min_range ?? undefined} - step={conf.step ?? undefined} - precision={conf.precision} - fixedEndpoints={conf.fixed_endpoints} - value={value} - onChange={updateValue} - marks={conf.marks || [{ value: conf.min }, { value: conf.max }]} - disabled={disabled} - /> - + 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={conf.marks === null ? [{ value: conf.min }, { value: conf.max }] : conf.marks} + disabled={disabled} + fixedEndpoints={conf.fixed_endpoints} + minRange={conf.min_range || undefined} + /> ); + + if (conf.marks?.some(x => x.label)) + containerProps = { ...containerProps, "mb": "md" }; break; case "GuiAddNumberMessage": input = ( @@ -511,7 +524,7 @@ function GeneratedInput({ ); return ( - + {input} ); diff --git a/src/viser/client/src/ControlPanel/MultiSlider.tsx b/src/viser/client/src/ControlPanel/MultiSlider.tsx index be32c5249..99ed21edc 100644 --- a/src/viser/client/src/ControlPanel/MultiSlider.tsx +++ b/src/viser/client/src/ControlPanel/MultiSlider.tsx @@ -7,17 +7,15 @@ import { useMantineTheme, useComponentDefaultProps, Selectors, - getSize, - rem, } from '@mantine/styles'; import { MantineTransition, Box, Transition, } from '@mantine/core'; -import { sizes, useSliderRootStyles, useThumbStyles, useTrackStyles, useMarksStyles } from './MultiSlider.styles'; +import { useSliderRootStyles, useThumbStyles, useTrackStyles, useMarksStyles } from './MultiSlider.styles'; -export function getClientPosition(event: any) { +function getClientPosition(event: any) { if ('TouchEvent' in window && event instanceof window.TouchEvent) { const touch = event.touches[0]; return touch.clientX; @@ -87,7 +85,7 @@ export const SliderRoot = forwardRef( size, classNames, styles, - disabled, + disabled, // eslint-disable-line @typescript-eslint/no-unused-vars unstyled, variant, ...others @@ -423,11 +421,8 @@ export interface MultiSliderProps /** If true label will be not be hidden when user stops dragging */ labelAlwaysOn?: boolean; - /** First thumb aria-label */ - thumbFromLabel?: string; - - /** Second thumb aria-label */ - thumbToLabel?: string; + /** Thumb aria-label */ + thumbLabels?: string[]; /**If true slider label will appear on hover */ showLabelOnHover?: boolean; @@ -458,9 +453,7 @@ const defaultProps: Partial = { labelTransition: 'skew-down', labelTransitionDuration: 0, labelAlwaysOn: false, - thumbFromLabel: '', thumbChildren: null, - thumbToLabel: '', showLabelOnHover: true, disabled: false, scale: (v) => v, @@ -490,8 +483,7 @@ export const MultiSlider = forwardRef((props, labelTransitionDuration, labelTransitionTimingFunction, labelAlwaysOn, - thumbFromLabel, - thumbToLabel, + thumbLabels, showLabelOnHover, thumbChildren, disabled, @@ -751,7 +743,7 @@ export const MultiSlider = forwardRef((props, ref={(node) => { thumbs.current[index] = node; }} - thumbLabel={thumbFromLabel} + thumbLabel={thumbLabels ? thumbLabels[index] : ''} onMouseDown={() => handleThumbMouseDown(index)} onFocus={() => setFocused(index)} showLabelOnHover={showLabelOnHover} diff --git a/src/viser/client/src/WebsocketMessages.tsx b/src/viser/client/src/WebsocketMessages.tsx index dc86557bf..e18ec6b67 100644 --- a/src/viser/client/src/WebsocketMessages.tsx +++ b/src/viser/client/src/WebsocketMessages.tsx @@ -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', marks: 'Optional[List[GuiSliderMark]]' = None) +/** 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) */ @@ -409,7 +409,7 @@ export interface GuiAddSliderMessage { precision: number; marks: { value: number; label?: string | null }[] | null; } -/** GuiAddMultiSliderMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'List[float]', min: 'float', max: 'float', step: 'Optional[float]', min_range: 'Optional[float]', precision: 'int', fixed_endpoints: 'bool' = False, marks: 'Optional[List[GuiSliderMark]]' = None) +/** GuiAddMultiSliderMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'List[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) */ diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 54aacc57d..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 @@ -62,6 +62,8 @@ def _get_ts_type(typ: Type[Any]) -> str: def fmt(key): val = hints[key] 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 From 17aa0048438f90f4d1efbd8a039b4028bbe45935 Mon Sep 17 00:00:00 2001 From: Jonas Kulhanek Date: Mon, 29 Jan 2024 10:56:43 +0100 Subject: [PATCH 5/8] Add multislider disabled styles --- .../client/src/ControlPanel/Generated.tsx | 2 +- .../src/ControlPanel/MultiSlider.styles.tsx | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index fd673b6d3..f6ff8bacf 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -281,10 +281,10 @@ function GeneratedInput({ thumbSize={0} styles={(theme) => ({ thumb: { - background: theme.fn.primaryColor(), borderRadius: "0.1em", height: "0.75em", width: "0.625em", + background: disabled ? undefined : theme.fn.primaryColor(), }, })} pt="0.2em" diff --git a/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx index 42f7a70ad..946a22bf0 100644 --- a/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx +++ b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx @@ -49,17 +49,25 @@ export const useThumbStyles = createStyles((theme, { color, disabled, thumbSize ...theme.fn.focusStyles(), boxSizing: 'border-box', position: 'absolute', - display: disabled ? 'none' : 'flex', + display: 'flex', height: thumbSize ? rem(thumbSize) : `calc(${getSize({ sizes, size })} * 2)`, width: thumbSize ? rem(thumbSize) : `calc(${getSize({ sizes, size })} * 2)`, backgroundColor: - theme.colorScheme === 'dark' - ? theme.fn.themeColor(color, theme.fn.primaryShade()) - : theme.white, + 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 ${ - theme.colorScheme === 'dark' - ? theme.white - : theme.fn.themeColor(color, theme.fn.primaryShade()) + 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' @@ -67,7 +75,7 @@ export const useThumbStyles = createStyles((theme, { color, disabled, thumbSize : theme.fn.themeColor(color, theme.fn.primaryShade()), transform: 'translate(-50%, -50%)', top: '50%', - cursor: 'pointer', + cursor: disabled ? 'not-allowed' : 'pointer', borderRadius: 1000, alignItems: 'center', justifyContent: 'center', From 1ca82f7b5ec2bd39f6978fd2dbfdb3b141f02ce4 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Tue, 30 Jan 2024 02:17:37 -0800 Subject: [PATCH 6/8] Various minor tweaks: API nits, thumb label z-index, updated example --- examples/02_gui.py | 18 +- src/viser/_gui_api.py | 44 +- src/viser/_gui_handles.py | 3 +- src/viser/_messages.py | 4 +- .../client/src/ControlPanel/Generated.tsx | 20 +- .../src/ControlPanel/MultiSlider.styles.tsx | 419 ++++++----- .../client/src/ControlPanel/MultiSlider.tsx | 690 ++++++++++-------- src/viser/client/src/WebsocketMessages.tsx | 8 +- 8 files changed, 655 insertions(+), 551 deletions(-) diff --git a/examples/02_gui.py b/examples/02_gui.py index b19b99175..57c54ddd3 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -57,14 +57,22 @@ def main() -> None: "Color", initial_value=(255, 255, 0), ) - server.add_gui_multi_slider( - "Multi slider", # type: ignore + gui_multi_slider = server.add_gui_multi_slider( + "Multi slider", min=0, max=100, step=1, initial_value=(0, 30, 100), marks=(0, 25, 50, 75, (100, "max")), ) + gui_slider_positions = server.add_gui_slider( + "Multi slider positions", + min=0, + max=10, + step=1, + initial_value=3, + marks=(0, 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)) @@ -95,6 +103,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 a0c4efe6b..a1b6ae4b3 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -19,7 +19,6 @@ Sequence, Tuple, TypeVar, - Union, overload, ) @@ -794,13 +793,11 @@ 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, order: Optional[float] = None, - marks: Optional[ - Tuple[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat], ...] - ] = None, ) -> GuiInputHandle[IntOrFloat]: """Add a slider to the GUI. Types of the min, max, step, and initial value should match. @@ -810,11 +807,13 @@ 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. order: Optional ordering, smallest values will be displayed first. - marks: List of marks to display below the slider. Returns: A handle that can be used to interact with the GUI element. @@ -826,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 ): @@ -872,30 +873,32 @@ def add_gui_multi_slider( 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, - min_range: Optional[IntOrFloat] = None, hint: Optional[str] = None, order: Optional[float] = None, - fixed_endpoints: bool = False, - marks: Optional[ - Tuple[Union[Tuple[IntOrFloat, Optional[str]], IntOrFloat], ...] - ] = 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. - min_range: Optional minimum difference between two values of the slider. hint: Optional hint to display on hover. order: Optional ordering, smallest values will be displayed first. - fixed_endpoints: Whether the endpoints of the slider are fixed. - marks: List of marks to display below the slider. + Returns: A handle that can be used to interact with the GUI element. """ @@ -903,7 +906,18 @@ def add_gui_multi_slider( if step > max - min: step = max - min assert all(max >= x >= min for x in initial_value) - _initial_value = tuple(float(x) 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) @@ -919,7 +933,7 @@ def add_gui_multi_slider( min_range=min_range, max=max, step=step, - initial_value=_initial_value, + initial_value=initial_value, fixed_endpoints=fixed_endpoints, precision=_compute_precision_digits(step), marks=tuple( 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 4974e8d1c..26d567b68 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -12,9 +12,7 @@ from . import infra, theme -GuiSliderMark = TypedDict( - "GuiSliderMark", {"value": float, "label": NotRequired[Optional[str]]} -) +GuiSliderMark = TypedDict("GuiSliderMark", {"value": float, "label": NotRequired[str]}) class Message(infra.Message): diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index f6ff8bacf..be35ca7dc 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -228,7 +228,11 @@ function GeneratedInput({ precision={conf.precision} value={value} onChange={updateValue} - marks={conf.marks === null ? [{ value: conf.min }, { value: conf.max }] : conf.marks} + marks={ + conf.marks === null + ? [{ value: conf.min }, { value: conf.max }] + : conf.marks + } disabled={disabled} /> ); - if (conf.marks?.some(x => x.label)) - containerProps = { ...containerProps, "mb": "md" }; + if (conf.marks?.some((x) => x.label)) + containerProps = { ...containerProps, mb: "md" }; break; case "GuiAddMultiSliderMessage": input = ( @@ -295,15 +299,19 @@ function GeneratedInput({ precision={conf.precision} value={value} onChange={updateValue} - marks={conf.marks === null ? [{ value: conf.min }, { value: conf.max }] : conf.marks} + marks={ + conf.marks === null + ? [{ value: conf.min }, { value: conf.max }] + : conf.marks + } disabled={disabled} fixedEndpoints={conf.fixed_endpoints} minRange={conf.min_range || undefined} /> ); - if (conf.marks?.some(x => x.label)) - containerProps = { ...containerProps, "mb": "md" }; + if (conf.marks?.some((x) => x.label)) + containerProps = { ...containerProps, mb: "md" }; break; case "GuiAddNumberMessage": input = ( diff --git a/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx index 946a22bf0..f10295e3e 100644 --- a/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx +++ b/src/viser/client/src/ControlPanel/MultiSlider.styles.tsx @@ -1,6 +1,5 @@ -import { createStyles, rem } from '@mantine/styles'; -import { MantineColor, getSize, MantineNumberSize } from '@mantine/styles'; - +import { createStyles, rem } from "@mantine/styles"; +import { MantineColor, getSize, MantineNumberSize } from "@mantine/styles"; export const sizes = { xs: rem(4), @@ -13,239 +12,265 @@ export const sizes = { export const useSliderRootStyles = createStyles((theme) => ({ root: { ...theme.fn.fontStyles(), - WebkitTapHighlightColor: 'transparent', + WebkitTapHighlightColor: "transparent", outline: 0, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - touchAction: 'none', - position: 'relative', + 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', - }, +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' + 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, - }, -})); + }`, + 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], - }, + 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", }, - }, - 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 }), + "& .mantine-Slider-thumb": { + display: "none", + }, - '&::before': { + "& .mantine-Slider-track::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.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[2], - zIndex: 0, + }, + + "& .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 }), - bar: { - position: 'absolute', - zIndex: 1, + "&::before": { + content: '""', + position: "absolute", 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), + 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, }, - }) - ); + }, - interface MarksStyles { + 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], +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, - }, + 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', - }, + 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, - }, + 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', - }, -})); + 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 index 99ed21edc..a42ecadb1 100644 --- a/src/viser/client/src/ControlPanel/MultiSlider.tsx +++ b/src/viser/client/src/ControlPanel/MultiSlider.tsx @@ -1,5 +1,5 @@ -import React, { useRef, useState, forwardRef, useEffect } from 'react'; -import { useMove, useUncontrolled } from '@mantine/hooks'; +import React, { useRef, useState, forwardRef, useEffect } from "react"; +import { useMove, useUncontrolled } from "@mantine/hooks"; import { DefaultProps, MantineNumberSize, @@ -7,16 +7,17 @@ import { useMantineTheme, useComponentDefaultProps, Selectors, -} from '@mantine/styles'; +} from "@mantine/styles"; +import { MantineTransition, Box, Transition } from "@mantine/core"; import { - MantineTransition, - Box, - Transition, -} from '@mantine/core'; -import { useSliderRootStyles, useThumbStyles, useTrackStyles, useMarksStyles } from './MultiSlider.styles'; + useSliderRootStyles, + useThumbStyles, + useTrackStyles, + useMarksStyles, +} from "./MultiSlider.styles"; function getClientPosition(event: any) { - if ('TouchEvent' in window && event instanceof window.TouchEvent) { + if ("TouchEvent" in window && event instanceof window.TouchEvent) { const touch = event.touches[0]; return touch.clientX; } @@ -71,7 +72,7 @@ export type SliderRootStylesNames = Selectors; export interface SliderRootProps extends DefaultProps, - React.ComponentPropsWithoutRef<'div'> { + React.ComponentPropsWithoutRef<"div"> { size: MantineNumberSize; children: React.ReactNode; disabled: boolean; @@ -85,27 +86,33 @@ export const SliderRoot = forwardRef( size, classNames, styles, - disabled, // eslint-disable-line @typescript-eslint/no-unused-vars + disabled, // eslint-disable-line @typescript-eslint/no-unused-vars unstyled, variant, ...others }: SliderRootProps, - ref + ref, ) => { - const { classes, cx } = useSliderRootStyles((null as unknown) as void,{ - name: 'Slider', + const { classes, cx } = useSliderRootStyles(null as unknown as void, { + name: "Slider", classNames, styles, unstyled, variant, size, }); - return ; - } + return ( + + ); + }, ); -SliderRoot.displayName = '@mantine/core/SliderRoot'; - +SliderRoot.displayName = "@mantine/core/SliderRoot"; export type ThumbStylesNames = Selectors; @@ -115,11 +122,14 @@ export interface ThumbProps extends DefaultProps { 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; + onMouseDown?( + event: React.MouseEvent | React.TouchEvent, + ): void; labelTransition?: MantineTransition; labelTransitionDuration?: number; labelTransitionTimingFunction?: string; @@ -144,6 +154,7 @@ export const Thumb = forwardRef( position, label, dragging, + clicked, onMouseDown, onKeyDownCapture, color, @@ -165,18 +176,19 @@ export const Thumb = forwardRef( thumbSize, variant, }: ThumbProps, - ref + ref, ) => { const { classes, cx, theme } = useThumbStyles( { color, disabled, thumbSize }, - { name: 'Slider', classNames, styles, unstyled, variant, size } + { name: "Slider", classNames, styles, unstyled, variant, size }, ); const [focused, setFocused] = useState(false); - const isVisible = labelAlwaysOn || dragging || focused || (showLabelOnHover && isHovered); + const isVisible = + labelAlwaysOn || dragging || focused || (showLabelOnHover && isHovered); return ( - + tabIndex={0} role="slider" aria-label={thumbLabel} @@ -187,37 +199,42 @@ export const Thumb = forwardRef( className={cx(classes.thumb, { [classes.dragging]: dragging })} onFocus={() => { setFocused(true); - typeof onFocus === 'function' && onFocus(); + typeof onFocus === "function" && onFocus(); }} onBlur={() => { setFocused(false); - typeof onBlur === 'function' && onBlur(); + typeof onBlur === "function" && onBlur(); }} onTouchStart={onMouseDown} onMouseDown={onMouseDown} onKeyDownCapture={onKeyDownCapture} onClick={(event) => event.stopPropagation()} - style={{ [theme.dir === 'rtl' ? 'right' : 'left']: `${position}%` }} + style={{ + [theme.dir === "rtl" ? "right" : "left"]: `${position}%`, + zIndex: clicked ? 1000 : undefined, + }} > {children} {(transitionStyles) => ( -
+
{label}
)} ); - } + }, ); -Thumb.displayName = '@mantine/core/SliderThumb'; +Thumb.displayName = "@mantine/core/SliderThumb"; export type MarksStylesNames = Selectors; @@ -249,7 +266,7 @@ export function Marks({ }: MarksProps) { const { classes, cx } = useMarksStyles( { color, disabled, thumbSize }, - { name: 'Slider', classNames, styles, unstyled, variant, size } + { name: "Slider", classNames, styles, unstyled, variant, size }, ); const items = marks.map((mark, index) => ( @@ -284,11 +301,11 @@ export function Marks({ return
{items}
; } -Marks.displayName = '@mantine/core/SliderMarks'; - +Marks.displayName = "@mantine/core/SliderMarks"; - -export type TrackStylesNames = Selectors | MarksStylesNames; +export type TrackStylesNames = + | Selectors + | MarksStylesNames; export interface TrackProps extends DefaultProps { marks: { value: number; label?: React.ReactNode }[]; @@ -302,7 +319,7 @@ export interface TrackProps extends DefaultProps { onChange(value: number): void; disabled: boolean; variant: string; - containerProps?: React.PropsWithRef>; + containerProps?: React.PropsWithRef>; } export function Track({ @@ -321,15 +338,13 @@ export function Track({ }: TrackProps) { const { classes } = useTrackStyles( { color, radius, disabled, inverted: false }, - { name: 'Slider', classNames, styles, unstyled, variant, size } + { name: "Slider", classNames, styles, unstyled, variant, size }, ); return ( <>
-
- {children} -
+
{children}
, - Omit, 'value' | 'onChange' | 'defaultValue'> { + Omit< + React.ComponentPropsWithoutRef<"div">, + "value" | "onChange" | "defaultValue" + > { variant?: string; /** Color from theme.colors */ @@ -443,14 +459,14 @@ export interface MultiSliderProps } const defaultProps: Partial = { - size: 'md', - radius: 'xl', + size: "md", + radius: "xl", min: 0, max: 100, step: 1, marks: [], label: (f) => f, - labelTransition: 'skew-down', + labelTransition: "skew-down", labelTransitionDuration: 0, labelAlwaysOn: false, thumbChildren: null, @@ -460,307 +476,337 @@ const defaultProps: Partial = { 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] - ); +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; + 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 (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 (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 (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]; } - } - if (fixedEndpoints && (index === 0 || index == clone.length - 1)) { - clone[index] = valueRef.current[index]; - } + _setValue(clone); - _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); + } + }; - if (triggerChangeEnd) { - onChangeEnd?.(valueRef.current); - } - }; + const { ref: container, active } = useMove( + ({ x }) => handleChange(x), + { onScrubEnd: () => onChangeEnd?.(valueRef.current) }, + theme.dir, + ); - const handleChange = (val: number) => { - if (!disabled) { - const nextValue = getChangeValue({ value: val, min, max, step, precision }); - setRangedValue(nextValue, thumbIndex.current, false); + function handleThumbMouseDown(index: number) { + thumbIndex.current = index; } - }; - - 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; - } + 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 + 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, ), - 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 + 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, ), - max - ), - focusedIndex, - true - ); - break; - } - - default: { - break; + 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 ( - - 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; + > + { + const nearestValue = + Math.abs(_value[0] - val) > Math.abs(_value[1] - val) ? 1 : 0; + const clone: Value = [..._value]; + clone[nearestValue] = val; + _setValue(clone); }} - thumbLabel={thumbLabels ? thumbLabels[index] : ''} - onMouseDown={() => handleThumbMouseDown(index)} - onFocus={() => setFocused(index)} - showLabelOnHover={showLabelOnHover} - isHovered={hovered} disabled={disabled} unstyled={unstyled} - thumbSize={thumbSize} 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, + }} > - {hasArrayThumbChildren ? thumbChildren[index] : thumbChildren} - ))} - - {_value.map((value, index) => ( - - ))} - - ); -}); + {_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'; +MultiSlider.displayName = "MultiSlider"; diff --git a/src/viser/client/src/WebsocketMessages.tsx b/src/viser/client/src/WebsocketMessages.tsx index e18ec6b67..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) */ @@ -407,9 +407,9 @@ export interface GuiAddSliderMessage { max: number; step: number | null; precision: number; - marks: { value: number; label?: string | null }[] | null; + marks: { value: number; label?: string }[] | null; } -/** GuiAddMultiSliderMessage(order: 'float', id: 'str', label: 'str', container_id: 'str', hint: 'Optional[str]', initial_value: 'List[float]', min: 'float', max: 'float', step: 'Optional[float]', min_range: 'Optional[float]', precision: 'int', fixed_endpoints: 'bool' = False, marks: 'Optional[Tuple[GuiSliderMark, ...]]' = None) +/** 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) */ @@ -427,7 +427,7 @@ export interface GuiAddMultiSliderMessage { min_range: number | null; precision: number; fixed_endpoints: boolean; - marks: { value: number; label?: string | null }[] | null; + 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]') * From b23db036e3dcfce4f32cee1b9088a87cc3d35a1e Mon Sep 17 00:00:00 2001 From: Jonas Kulhanek Date: Tue, 30 Jan 2024 14:29:45 +0100 Subject: [PATCH 7/8] MultiSlider: add styles for marks --- examples/02_gui.py | 2 +- .../client/src/ControlPanel/Generated.tsx | 160 ++++++++++++------ 2 files changed, 110 insertions(+), 52 deletions(-) diff --git a/examples/02_gui.py b/examples/02_gui.py index 57c54ddd3..8b562921f 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -71,7 +71,7 @@ def main() -> None: max=10, step=1, initial_value=3, - marks=(0, 5, (7, "7"), 10), + marks=((0, "0"), (5, "5"), (7, "7"), 10), ) # Pre-generate a point cloud to send. diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index be35ca7dc..b954323b0 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -207,47 +207,71 @@ 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={ - conf.marks === null - ? [{ value: conf.min }, { value: conf.max }] - : conf.marks - } - 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.0938rem)', + fontSize: "0.6rem", + textAlign: "center", + }, + marksContainer: { + left: '0.2rem', + right: '0.2rem', + }, + markWrapper: { + position: "absolute", + top: `0.06rem` + }, + mark: { + opacity: disabled ? "0" : undefined, + 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: { + opacity: "0", + }, + })} + 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} + /> { @@ -273,9 +297,6 @@ function GeneratedInput({ /> ); - - if (conf.marks?.some((x) => x.label)) - containerProps = { ...containerProps, mb: "md" }; break; case "GuiAddMultiSliderMessage": input = ( @@ -285,10 +306,41 @@ function GeneratedInput({ thumbSize={0} styles={(theme) => ({ thumb: { - borderRadius: "0.1em", - height: "0.75em", - width: "0.625em", - background: disabled ? undefined : theme.fn.primaryColor(), + background: theme.fn.primaryColor(), + borderRadius: "0.1rem", + height: "0.75rem", + width: "0.625rem", + }, + trackContainer: { + zIndex: 3, + position: "relative", + }, + markLabel: { + transform: 'translate(-50%, 0.0938rem)', + fontSize: "0.6rem", + textAlign: "center", + }, + marksContainer: { + left: '0.2rem', + right: '0.2rem', + }, + markWrapper: { + position: "absolute", + top: `0.06rem` + }, + mark: { + opacity: disabled ? "0" : undefined, + 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: { + opacity: "0", }, })} pt="0.2em" @@ -301,7 +353,13 @@ function GeneratedInput({ onChange={updateValue} marks={ conf.marks === null - ? [{ value: conf.min }, { value: conf.max }] + ? [{ + value: conf.min, + label: `${parseInt(conf.min.toFixed(6))}` + }, { + value: conf.max, + label: `${parseInt(conf.max.toFixed(6))}` + }] : conf.marks } disabled={disabled} @@ -310,8 +368,8 @@ function GeneratedInput({ /> ); - if (conf.marks?.some((x) => x.label)) - containerProps = { ...containerProps, mb: "md" }; + if (conf.marks?.some((x) => x.label) || conf.marks === null) + containerProps = { ...containerProps, mb: "xs" }; break; case "GuiAddNumberMessage": input = ( From da5c19a76157a7305812f05d884c9bdce040d7bd Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Tue, 30 Jan 2024 15:13:53 -0800 Subject: [PATCH 8/8] Minor mark styling tweaks, special-case the default min/max marks --- examples/02_gui.py | 8 +- .../client/src/ControlPanel/Generated.tsx | 96 +++++++++++++------ .../client/src/ControlPanel/MultiSlider.tsx | 11 ++- 3 files changed, 85 insertions(+), 30 deletions(-) diff --git a/examples/02_gui.py b/examples/02_gui.py index 8b562921f..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.""" @@ -63,10 +68,9 @@ def main() -> None: max=100, step=1, initial_value=(0, 30, 100), - marks=(0, 25, 50, 75, (100, "max")), ) gui_slider_positions = server.add_gui_slider( - "Multi slider positions", + "# sliders", min=0, max=10, step=1, diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index b954323b0..fa2c592bb 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -211,7 +211,7 @@ function GeneratedInput({ id={conf.id} size="xs" thumbSize={0} - style={{flexGrow: 1}} + style={{ flexGrow: 1 }} styles={(theme) => ({ thumb: { background: theme.fn.primaryColor(), @@ -224,20 +224,34 @@ function GeneratedInput({ position: "relative", }, markLabel: { - transform: 'translate(-50%, 0.0938rem)', + transform: "translate(-50%, 0.03rem)", fontSize: "0.6rem", textAlign: "center", }, marksContainer: { - left: '0.2rem', - right: '0.2rem', + left: "0.2rem", + right: "0.2rem", }, markWrapper: { position: "absolute", - top: `0.06rem` + 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: { - opacity: disabled ? "0" : undefined, border: "0px solid transparent", background: theme.colorScheme === "dark" @@ -248,7 +262,11 @@ function GeneratedInput({ transform: `translateX(-50%)`, }, markFilled: { - opacity: "0", + background: disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.fn.primaryColor(), }, })} pt="0.2em" @@ -261,13 +279,16 @@ function GeneratedInput({ onChange={updateValue} marks={ conf.marks === null - ? [{ - value: conf.min, - label: `${parseInt(conf.min.toFixed(6))}` - }, { - value: conf.max, - label: `${parseInt(conf.max.toFixed(6))}` - }] + ? [ + { + value: conf.min, + label: `${parseInt(conf.min.toFixed(6))}`, + }, + { + value: conf.max, + label: `${parseInt(conf.max.toFixed(6))}`, + }, + ] : conf.marks } disabled={disabled} @@ -316,20 +337,34 @@ function GeneratedInput({ position: "relative", }, markLabel: { - transform: 'translate(-50%, 0.0938rem)', + transform: "translate(-50%, 0.03rem)", fontSize: "0.6rem", textAlign: "center", }, marksContainer: { - left: '0.2rem', - right: '0.2rem', + left: "0.2rem", + right: "0.2rem", }, markWrapper: { position: "absolute", - top: `0.06rem` + 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: { - opacity: disabled ? "0" : undefined, border: "0px solid transparent", background: theme.colorScheme === "dark" @@ -340,7 +375,11 @@ function GeneratedInput({ transform: `translateX(-50%)`, }, markFilled: { - opacity: "0", + background: disabled + ? theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4] + : theme.fn.primaryColor(), }, })} pt="0.2em" @@ -353,13 +392,16 @@ function GeneratedInput({ onChange={updateValue} marks={ conf.marks === null - ? [{ - value: conf.min, - label: `${parseInt(conf.min.toFixed(6))}` - }, { - value: conf.max, - label: `${parseInt(conf.max.toFixed(6))}` - }] + ? [ + { + value: conf.min, + label: `${parseInt(conf.min.toFixed(6))}`, + }, + { + value: conf.max, + label: `${parseInt(conf.max.toFixed(6))}`, + }, + ] : conf.marks } disabled={disabled} diff --git a/src/viser/client/src/ControlPanel/MultiSlider.tsx b/src/viser/client/src/ControlPanel/MultiSlider.tsx index a42ecadb1..e093d5fe4 100644 --- a/src/viser/client/src/ControlPanel/MultiSlider.tsx +++ b/src/viser/client/src/ControlPanel/MultiSlider.tsx @@ -224,7 +224,16 @@ export const Thumb = forwardRef( } > {(transitionStyles) => ( -
+
{label}
)}