From 5e705aaa047b634227fb947901cb782c96a00a62 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 14 Sep 2026 18:03:31 -0700 Subject: [PATCH] Add a range slider mode to the spectrum widget and slider rows to node properties (#4531) * Add a range slider mode to the spectrum widget and slider rows to node properties * Reset the weighted strength slider to the node's default of zero and skip markers off the track when picking up the nearest one --- .../utility_types/widgets/input_widgets.rs | 5 +- .../node_graph/document_node_definitions.rs | 19 +- .../document/node_graph/node_properties.rs | 210 +++++++++++++----- .../widgets/inputs/SpectrumInput.svelte | 130 ++++++++--- 4 files changed, 270 insertions(+), 94 deletions(-) diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 6850cc4788..e0d1731f4c 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -662,7 +662,7 @@ pub struct SpectrumInput { /// Whether to render midpoint diamonds between adjacent markers (only meaningful for gradient-like uses). #[serde(rename = "showMidpoints")] pub show_midpoints: bool, - /// Whether clicking the track inserts a new marker at the click position. + /// Whether clicking the track inserts a new marker at the click position. Otherwise the click picks up the nearest marker. #[serde(rename = "allowInsert")] pub allow_insert: bool, /// Whether right-click or pressing Delete removes a marker. The handler still has the final say on whether the deletion goes through (e.g., enforcing a minimum count). @@ -673,6 +673,9 @@ pub struct SpectrumInput { pub allow_reorder: bool, /// Compact mode: 8px track height with 8px top padding, for use in rows alongside other widgets. pub narrow: bool, + /// Plain range-slider mode, for a number beside its number input: a flat 4px track is drawn in place of the gradient, so `track` is never shown. + #[serde(rename = "rangeSlider")] + pub range_slider: bool, /// Whether the input is disabled (dimmed and read-only). pub disabled: bool, diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 78e9019a62..ef403d33d9 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -1,7 +1,7 @@ mod document_node_derive; use super::node_properties::choice::enum_choice; -use super::node_properties::{self, ParameterWidgetsInfo}; +use super::node_properties::{self, ParameterWidgetsInfo, SliderRange}; use super::utility_types::{FrontendNodeType, InputTypeConstraint}; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::network_interface::{ @@ -1073,19 +1073,26 @@ fn static_input_properties() -> InputProperties { ParameterWidgetsInfo::at_index(node_id, index, false, context), index - 1, number_input, + None, ))]) }), ); map.insert( - // Like `optional_f64`, but the number input is configured as a percentage with a 0-100 range. + // Like `optional_f64`, but with a 0-100% range slider beside the number input, double-click restoring the full 100%. // As with `optional_f64`, the bool input must be at the input index directly before the f64 input. "optional_percentage".to_string(), Box::new(|node_id, index, context| { - let number_input = NumberInput::default().percentage().min(0.).max(100.); + let number_input = NumberInput::default().mode_increment().unit("%").min(0.).max(100.); + let slider = SliderRange { + min: 0., + max: 100., + default: Some(100.), + }; Ok(vec![LayoutGroup::row(node_properties::optional_f64_widget( ParameterWidgetsInfo::at_index(node_id, index, false, context), index - 1, number_input, + Some(slider), ))]) }), ); @@ -1241,13 +1248,13 @@ fn static_input_properties() -> InputProperties { "noise_properties_fractal_weighted_strength".to_string(), Box::new(|node_id, index, context| { let (fractal_active, coherent_noise_active, _, _, _, domain_warp_only_fractal_type_wrongly_active) = node_properties::query_noise_pattern_state(node_id, context)?; - let fractal_weighted_strength = node_properties::number_widget( + let fractal_weighted_strength = node_properties::range_slider_widget( ParameterWidgetsInfo::at_index(node_id, index, true, context), NumberInput::default() - .mode_range() .min(0.) - .max(1.) // Defined for the 0-1 range + .max(1.) .disabled(!coherent_noise_active || !fractal_active || domain_warp_only_fractal_type_wrongly_active), + SliderRange { min: 0., max: 1., default: Some(0.) }, ); Ok(vec![fractal_weighted_strength.into()]) }), diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index ab4f0cac6c..c9406ab329 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -178,6 +178,24 @@ pub(crate) struct NumberOptions { pub slider: bool, } +/// The values a range slider's two ends map to linearly and the one its double-click restores, if known. +#[derive(Clone, Copy)] +pub struct SliderRange { + pub min: f64, + pub max: f64, + pub default: Option, +} + +impl SliderRange { + fn position(self, value: f64) -> f64 { + ((value - self.min) / (self.max - self.min)).clamp(0., 1.) + } + + fn value(self, position: f64) -> f64 { + (self.min + position * (self.max - self.min)).clamp(self.min, self.max) + } +} + pub(crate) fn property_from_type( node_id: NodeId, index: usize, @@ -991,43 +1009,37 @@ pub fn progression_widget(parameter_widgets_info: ParameterWidgetsInfo, number_p } /// `parameter_widgets_info` is for the f64 parameter. `bool_input_index` is the input index of the bool parameter for the checkbox. -pub fn optional_f64_widget(parameter_widgets_info: ParameterWidgetsInfo, bool_input_index: usize, number_props: NumberInput) -> Vec { - let ParameterWidgetsInfo { - document_node, - node_id, - index: number_input_index, - .. - } = parameter_widgets_info; +/// A number row gated by the bool input at `bool_input_index`, drawn as a checkbox in the assist slot after the label like the +/// Opacity node's toggles, so the caller passes `blank_assist = false`. Given a `slider`, a range slider spanning it sits between them. +pub fn optional_f64_widget(parameter_widgets_info: ParameterWidgetsInfo, bool_input_index: usize, number_props: NumberInput, slider: Option) -> Vec { + let node_id = parameter_widgets_info.node_id; + let enabled = parameter_widgets_info + .document_node + .and_then(|document_node| document_node.inputs.get(bool_input_index)) + .and_then(|input| input.as_non_exposed_value()) + .and_then(|value| if let TaggedValue::Bool(enabled) = value { Some(*enabled) } else { None }); + let label_count = start_widgets(¶meter_widgets_info).len(); + let exposed = parameter_widgets_info.is_exposed(); - let mut widgets = start_widgets(¶meter_widgets_info); + let number_props = number_props.disabled(enabled == Some(false)); + let mut widgets = match slider { + Some(slider) => range_slider_widget(parameter_widgets_info, number_props, slider), + None => number_widget(parameter_widgets_info, number_props), + }; - let Some(document_node) = document_node else { return Vec::new() }; - let Some(number_input) = document_node.inputs.get(number_input_index) else { - log::warn!("A widget failed to be built because its node's input index is invalid."); - return vec![]; - }; - let Some(bool_input) = document_node.inputs.get(bool_input_index) else { - log::warn!("A widget failed to be built because its node's input index is invalid."); - return vec![]; - }; - if let (Some(&TaggedValue::Bool(enabled)), Some(&TaggedValue::F64(number))) = (bool_input.as_non_exposed_value(), number_input.as_non_exposed_value()) { - widgets.extend_from_slice(&[ + if let Some(enabled) = enabled + && !exposed + { + let checkbox = [ Separator::new(SeparatorStyle::Unrelated).widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), - // The checkbox toggles if the value is Some or None CheckboxInput::new(enabled) .on_update(update_value_at_index(|x: &CheckboxInput| TaggedValue::Bool(x.checked), node_id, bool_input_index)) .on_commit(commit_value) .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - number_props - .value(Some(number)) - .on_update(update_value_at_index(move |x: &NumberInput| TaggedValue::F64(x.value.unwrap_or_default()), node_id, number_input_index)) - .disabled(!enabled) - .on_commit(commit_value) - .widget_instance(), - ]); + ]; + widgets.splice(label_count..label_count, checkbox); } widgets @@ -1567,6 +1579,103 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope ] } +/// A single-marker `SpectrumInput` over `track` driving the number at `input_index`: the marker sits at `position`, double-click +/// returns it to `default_position`, and each move sets the input to `value_at` the new position. +fn value_slider( + node_id: NodeId, + input_index: usize, + track: GradientStops, + handle_color: Color, + position: f64, + default_position: Option, + value_at: impl Fn(f64) -> TaggedValue + 'static + Send + Sync, +) -> SpectrumInput { + SpectrumInput::new(track) + .track_space(GradientSpace::RgbGamma) + .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) + .show_midpoints(false) + .allow_insert(false) + .allow_delete(false) + .allow_reorder(false) + .on_update(move |update: &SpectrumInputUpdate| { + let new_position = match update { + SpectrumInputUpdate::MoveMarker { index: 0, position } => Some(*position), + SpectrumInputUpdate::ResetMarker { index: 0 } => default_position, + _ => None, + }; + let Some(new_position) = new_position else { return Message::NoOp }; + + NodeGraphMessage::SetInputValue { + node_id, + input_index, + value: value_at(new_position).into(), + } + .into() + }) + .on_commit(commit_value) +} + +/// A row with a range slider and a 60px number input for the number at `parameter_widgets_info`. The slider's 0..1 position maps +/// to the number through `position_of` and `value_at`, and double-click restores `default`. +fn slider_row( + parameter_widgets_info: ParameterWidgetsInfo, + number_props: NumberInput, + default: Option, + position_of: impl Fn(f64) -> f64, + value_at: impl Fn(f64) -> f64 + 'static + Send + Sync, +) -> Vec { + let mut widgets = start_widgets(¶meter_widgets_info); + + let Some(input) = parameter_widgets_info.input() else { + log::warn!("A widget failed to be built because its node's input index is invalid."); + return vec![]; + }; + // An exposed input shows only its label and source + let (current, tagged_value): (f64, fn(f64) -> TaggedValue) = match input.as_non_exposed_value() { + Some(&TaggedValue::F64(value)) => (value, TaggedValue::F64), + Some(&TaggedValue::F32(value)) => (value as f64, |value| TaggedValue::F32(value as f32)), + _ => return widgets, + }; + let ParameterWidgetsInfo { node_id, index, .. } = parameter_widgets_info; + + widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + value_slider( + node_id, + index, + GradientStops::default(), + Color::WHITE, + position_of(current), + default.map(position_of), + move |position| tagged_value(value_at(position)), + ) + .range_slider(true) + .disabled(number_props.disabled) + .widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + number_props + .value(Some(current)) + .min_width(60) + .max_width(60) + .on_update(update_value_at_index(move |x: &NumberInput| tagged_value(x.value.unwrap_or_default()), node_id, index)) + .on_commit(commit_value) + .widget_instance(), + ]); + + widgets +} + +/// A slider row running linearly across `slider`'s bounds. +pub(crate) fn range_slider_widget(parameter_widgets_info: ParameterWidgetsInfo, number_props: NumberInput, slider: SliderRange) -> Vec { + slider_row( + parameter_widgets_info, + number_props, + slider.default, + move |value| slider.position(value), + move |position| slider.value(position), + ) +} + /// Build a row with a single-marker `SpectrumInput` and a 60px `NumberInput`. The marker maps `value_min..value_max` to position 0..1, and double-click resets to `default_value`. fn spectrum_slider_row( node_id: NodeId, @@ -1590,37 +1699,26 @@ fn spectrum_slider_row( // Only add the spectrum and number widgets when the input is not exposed if let Some(current) = current { - let value_range = value_max - value_min; - let position = ((current - value_min) / value_range).clamp(0., 1.); - let default_position = ((default_value - value_min) / value_range).clamp(0., 1.); + let slider = SliderRange { + min: value_min, + max: value_max, + default: Some(default_value), + }; + let value_at = move |position| TaggedValue::F32(slider.value(position) as f32); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - let position_to_value = move |position: f64| value_min + position * value_range; row.push( - SpectrumInput::new(GradientStops::from(&track)) - .track_space(GradientSpace::RgbGamma) - .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) - .show_midpoints(false) - .allow_insert(false) - .allow_delete(false) - .allow_reorder(false) - .narrow(true) - .on_update(move |update: &SpectrumInputUpdate| { - let new_position = match update { - SpectrumInputUpdate::MoveMarker { index: 0, position } => *position, - SpectrumInputUpdate::ResetMarker { index: 0 } => default_position, - _ => return Message::NoOp, - }; - NodeGraphMessage::SetInputValue { - node_id, - input_index, - value: TaggedValue::F32(position_to_value(new_position).clamp(value_min, value_max) as f32).into(), - } - .into() - }) - .on_commit(commit_value) - .widget_instance(), + value_slider( + node_id, + input_index, + GradientStops::from(&track), + handle_color, + slider.position(current), + Some(slider.position(default_value)), + value_at, + ) + .narrow(true) + .widget_instance(), ); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); row.push( diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index d3e60ec581..8416e06e01 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -26,6 +26,7 @@ export let allowDelete = true; export let allowReorder = true; export let narrow = false; + export let rangeSlider = false; export let disabled = false; // Reference to the marker track DOM element so we can convert pointer coordinates to a 0..1 position along the track. @@ -39,8 +40,8 @@ // Active marker selection at drag start, restored if the drag is cancelled. let activeMarkerIndexRestore: number | undefined = undefined; let activeMarkerIsMidpointRestore = false; - // Tracks whether a midpoint drag has actually moved by at least one frame, to distinguish click-to-select from drag. - let midpointDragged = false; + // Whether the current or last drag moved anything, so the double-click a drag's second press can produce resets nothing. + let dragMoved = false; // Mirrors whether Alt is currently held during the drag (the desired state). let duplicateRequested = false; // Mirrors whether a frozen copy currently exists in the gradient (the materialized state). @@ -72,19 +73,28 @@ return Math.max(lower, Math.min(upper, position)); } + // The nearest of the markers drawn on the track, skipping any outside 0..1 as the template does + function nearestMarkerIndex(position: number): number | undefined { + let nearest: number | undefined = undefined; + let nearestDistance = Number.POSITIVE_INFINITY; + + markers.forEach((marker, index) => { + if (marker.position < 0 || marker.position > 1) return; + const distance = Math.abs(marker.position - position); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = index; + } + }); + + return nearest; + } + function markerPointerDown(e: PointerEvent, index: number) { if (disabled) return; if (e.button === BUTTON_LEFT) { - activeMarkerIndexRestore = activeMarkerIndex; - activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; - dragRestorePosition = markers[index].position; - dragInsertedMarker = false; - // Only offer duplication where new stops are allowed. Don't materialize yet: wait for the first move so an Alt-click without a drag leaves no stray copy. - duplicateRequested = allowInsert && e.altKey; - duplicateActive = false; - setActive(index, false); - addEvents(); + beginMarkerDrag(e, index); return; } @@ -93,11 +103,30 @@ } } + function beginMarkerDrag(e: PointerEvent, index: number) { + activeMarkerIndexRestore = activeMarkerIndex; + activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; + dragRestorePosition = markers[index].position; + dragInsertedMarker = false; + dragMoved = false; + // Only offer duplication where new stops are allowed. Don't materialize yet: wait for the first move so an Alt-click without a drag leaves no stray copy. + duplicateRequested = allowInsert && e.altKey; + duplicateActive = false; + setActive(index, false); + addEvents(); + } + + // Picks up the marker at `index` and carries it to the pointer + function pickUpMarker(e: PointerEvent, index: number) { + beginMarkerDrag(e, index); + moveActiveMarker(e); + } + function midpointPointerDown(e: PointerEvent, index: number) { if (disabled) return; if (e.button !== BUTTON_LEFT) return; - midpointDragged = false; + dragMoved = false; activeMarkerIndexRestore = activeMarkerIndex; activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; dragRestorePosition = markers[index].midpoint; @@ -106,23 +135,29 @@ } function midpointDoubleClick(index: number) { - if (disabled || midpointDragged) return; + if (disabled || dragMoved) return; emit({ ResetMidpoint: { index } }); } function markerDoubleClick(index: number) { - if (disabled) return; + if (disabled || dragMoved) return; emit({ ResetMarker: { index } }); } function trackPointerDown(e: PointerEvent) { if (disabled) return; if (e.button !== BUTTON_LEFT) return; - if (!allowInsert) return; const position = pointerPosition(e); if (position === undefined) return; + // Where nothing can be inserted, the click picks up the nearest marker instead + if (!allowInsert) { + const index = nearestMarkerIndex(position); + if (index !== undefined) pickUpMarker(e, index); + return; + } + // Compute the index this marker will land at after Rust inserts it (matches Rust's `insert_stop` logic). let insertIndex = markers.findIndex((m) => m.position > position); if (insertIndex === -1) insertIndex = markers.length; @@ -133,6 +168,7 @@ activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; dragRestorePosition = position; dragInsertedMarker = true; + dragMoved = false; // A stop being created by this drag can't be duplicated; duplication is only for dragging an existing stop. duplicateRequested = false; duplicateActive = false; @@ -142,6 +178,12 @@ addEvents(); } + // The lane the handles hang in picks up the nearest marker like the strip does, except where the click landed on a marker itself + function markerTrackPointerDown(e: PointerEvent) { + if (e.target !== e.currentTarget) return; + trackPointerDown(e); + } + function deleteShortcut(e: KeyboardEvent) { if (disabled) return; if (e.key !== "Delete" && e.key !== "Backspace") return; @@ -231,6 +273,7 @@ if (position === undefined) return; if (!allowReorder) position = clampToNeighbors(activeMarkerIndex, position); + dragMoved = true; if (!dragInsertedMarker) dispatch("dragging", true); emit({ MoveMarker: { index: activeMarkerIndex, position } }); } @@ -265,7 +308,7 @@ } const local = absolute < deadZoneSplit ? absolute + 1 - left : absolute - left; - midpointDragged = true; + dragMoved = true; dispatch("dragging", true); emit({ MoveMidpoint: { index: activeMarkerIndex, position: local / range } }); } @@ -303,7 +346,6 @@ dragInsertedMarker = false; activeMarkerIndexRestore = undefined; activeMarkerIsMidpointRestore = false; - midpointDragged = false; duplicateRequested = false; duplicateActive = false; skipNextMove = false; @@ -395,22 +437,24 @@ - - - - {#each trackSamples as sample} - - {/each} - - - + {#if !rangeSlider} + + + + {#each trackSamples as sample} + + {/each} + + + + {/if} {#each midpointPositions as midpoint, index} @@ -430,7 +474,7 @@ {/if} {/each} - + {#each markers as marker, index} {#if marker.position >= 0 && marker.position <= 1}