diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs index 9af4410b..c9799a0e 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -354,6 +354,22 @@ impl ColorPickerMessageHandler { SpectrumInputUpdate::ResetMidpoint { index } => { gradient.reset_midpoint(index as usize); } + SpectrumInputUpdate::ResetMarker { index } => { + let i = index as usize; + let count = gradient.position.len(); + if i >= count { + return; + } + // Each stop's "natural" position is its evenly-spaced fraction along 0..1, e.g., for 5 stops: 0, 0.25, 0.5, 0.75, 1. Falls back to the midpoint between neighbors when the natural position would push the stop past another. + let left = if i == 0 { 0. } else { gradient.position[i - 1] }; + let right = gradient.position.get(i + 1).copied().unwrap_or(1.); + let natural = if count <= 1 { 0. } else { i as f64 / (count - 1) as f64 }; + let new_position = if (left..=right).contains(&natural) { natural } else { (left + right) / 2. }; + let new_index = gradient.move_stop(i, new_position); + if Some(index) == self.active_marker_index { + self.active_marker_index = Some(new_index as u32); + } + } SpectrumInputUpdate::ActiveMarker { .. } => unreachable!("handled above"), } @@ -393,7 +409,7 @@ impl ColorPickerMessageHandler { .show_midpoints(true) .allow_insert(!self.disabled) .allow_delete(!self.disabled) - .allow_swap(true) + .allow_reorder(true) .disabled(self.disabled) .on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into()) .widget_instance(), 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 8bf4a3df..19e47406 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -584,8 +584,10 @@ pub struct SpectrumInput { #[serde(rename = "allowDelete")] pub allow_delete: bool, /// Whether dragging a marker past another reorders them. If false, the dragged marker is clamped between its neighbors. - #[serde(rename = "allowSwap")] - pub allow_swap: bool, + #[serde(rename = "allowReorder")] + pub allow_reorder: bool, + /// Compact mode: 8px track height with 8px top padding, for use in rows alongside other widgets. + pub narrow: bool, /// Whether the input is disabled (dimmed and read-only). pub disabled: bool, @@ -634,6 +636,10 @@ pub enum SpectrumInputUpdate { DeleteMarker { index: u32, }, + /// Emitted when the user double-clicks a marker. The consumer decides what (if anything) to reset the marker to. + ResetMarker { + index: u32, + }, ResetMidpoint { index: u32, }, 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 44ad9d8c..b92e7741 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 @@ -1535,6 +1535,11 @@ fn static_node_properties() -> NodeProperties { let mut map: NodeProperties = HashMap::new(); map.insert("brightness_contrast_properties".to_string(), Box::new(node_properties::brightness_contrast_properties)); map.insert("channel_mixer_properties".to_string(), Box::new(node_properties::channel_mixer_properties)); + map.insert("levels_properties".to_string(), Box::new(node_properties::levels_properties)); + map.insert("hue_saturation_properties".to_string(), Box::new(node_properties::hue_saturation_properties)); + map.insert("black_and_white_properties".to_string(), Box::new(node_properties::black_and_white_properties)); + map.insert("threshold_properties".to_string(), Box::new(node_properties::threshold_properties)); + map.insert("vibrance_properties".to_string(), Box::new(node_properties::vibrance_properties)); map.insert("fill_properties".to_string(), Box::new(node_properties::fill_properties)); map.insert("stroke_properties".to_string(), Box::new(node_properties::stroke_properties)); map.insert("offset_path_properties".to_string(), Box::new(node_properties::offset_path_properties)); 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 91f423a2..69c5c127 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1276,57 +1276,434 @@ pub fn query_assign_colors_randomize(node_id: NodeId, context: &NodePropertiesCo }) } +/// 2-stop black-to-white gradient track for spectrum sliders that map a value to a grayscale axis. +fn bw_track() -> GradientStops { + GradientStops { + position: vec![0., 1.], + midpoint: vec![0.5, 0.5], + color: vec![Color::BLACK, Color::WHITE], + } +} + +/// 3-stop black-to-color-to-white gradient track for spectrum sliders that map a value to a hue's full luminance range. +fn color_track(color: Color) -> GradientStops { + GradientStops { + position: vec![0., 0.5, 1.], + midpoint: vec![0.5; 3], + color: vec![Color::BLACK, color, Color::WHITE], + } +} + pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::brightness_contrast::*; - // Use Classic - let use_classic = bool_widget(ParameterWidgetsInfo::new(node_id, UseClassicInput::INDEX, true, context), CheckboxInput::default()); - - let document_node = match get_document_node(node_id, context) { - Ok(document_node) => document_node, - Err(err) => { - log::error!("Could not get document node in brightness_contrast_properties: {err}"); - return Vec::new(); - } - }; - let use_classic_value = document_node.inputs.get(UseClassicInput::INDEX); + // Use Classic toggle changes the brightness range + let use_classic_value = get_document_node(node_id, context) + .ok() + .and_then(|document_node| document_node.inputs.get(UseClassicInput::INDEX).and_then(|input| input.as_value())) + .and_then(|tagged| if let TaggedValue::Bool(value) = tagged { Some(*value) } else { None }); let includes_use_classic = use_classic_value.is_some(); - let use_classic_value = match use_classic_value.and_then(|input| input.as_value()) { - Some(TaggedValue::Bool(use_classic_choice)) => *use_classic_choice, - _ => false, + let use_classic_value = use_classic_value.unwrap_or(false); + + let brightness_min = if use_classic_value { -100. } else { -150. }; + let brightness_max = if use_classic_value { 100. } else { 150. }; + + let brightness = spectrum_slider_row( + node_id, + context, + BrightnessInput::INDEX, + bw_track(), + Color::WHITE, + brightness_min, + brightness_max, + 0., + NumberInput::default().mode_increment().unit("%").min(brightness_min).max(brightness_max), + ); + + let contrast_min = if use_classic_value { -100. } else { -50. }; + let zero_position = -contrast_min / (100. - contrast_min); + let contrast_track = GradientStops { + position: vec![0., zero_position, 1.], + midpoint: vec![0.5; 3], + color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), Color::BLACK, Color::from_rgbf32_unchecked(0.5, 0.5, 0.5)], }; - - // Brightness - let brightness = number_widget( - ParameterWidgetsInfo::new(node_id, BrightnessInput::INDEX, true, context), - NumberInput::default() - .unit("%") - .mode_range() - .display_decimal_places(2) - .range_min(Some(if use_classic_value { -100. } else { -150. })) - .range_max(Some(if use_classic_value { 100. } else { 150. })), + let contrast = spectrum_slider_row( + node_id, + context, + ContrastInput::INDEX, + contrast_track, + Color::WHITE, + contrast_min, + 100., + 0., + NumberInput::default().mode_increment().unit("%").min(contrast_min).max(100.), ); - // Contrast - let contrast = number_widget( - ParameterWidgetsInfo::new(node_id, ContrastInput::INDEX, true, context), - NumberInput::default() - .unit("%") - .mode_range() - .display_decimal_places(2) - .range_min(Some(if use_classic_value { -100. } else { -50. })) - .range_max(Some(100.)), - ); - - let mut layout = vec![LayoutGroup::row(brightness), LayoutGroup::row(contrast)]; + let mut layout = vec![brightness, contrast]; if includes_use_classic { // TODO: When we no longer use this function in the temporary "Brightness/Contrast Classic" node, remove this conditional pushing and just always include this + let use_classic = bool_widget(ParameterWidgetsInfo::new(node_id, UseClassicInput::INDEX, true, context), CheckboxInput::default()); layout.push(LayoutGroup::row(use_classic)); } layout } +pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::levels::*; + + // (input index, marker handle color, default percentage for double-click reset) + let input_range_params = [ + (ShadowsInput::INDEX, Color::BLACK, 0.), + (MidtonesInput::INDEX, Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), 50.), + (HighlightsInput::INDEX, Color::WHITE, 100.), + ]; + let output_range_params = [(OutputMinimumsInput::INDEX, Color::BLACK, 0.), (OutputMaximumsInput::INDEX, Color::WHITE, 100.)]; + + let mut layout = Vec::with_capacity(5); + build_shared_spectrum_section(node_id, context, &input_range_params, &mut layout); + build_shared_spectrum_section(node_id, context, &output_range_params, &mut layout); + layout +} + +/// Append a section of related percentage parameters as rows: a shared black-to-white spectrum (with one marker per non-exposed parameter) sits on the first non-exposed row +/// alongside its 60px number input, and the remaining non-exposed rows show only their 60px number input. Exposed parameters render as the standard exposed-row display. +/// Marker positions are clamped to non-decreasing display order so they never visually cross even if the underlying values do. +fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesContext, params: &[(usize, Color, f64)], layout: &mut Vec) { + // Snapshot exposure and values before the mutable-borrow loop + let exposure_and_value: Vec<(bool, f64)> = match get_document_node(node_id, context) { + Ok(document_node) => params + .iter() + .map(|&(input_index, _, _)| { + let input = document_node.inputs.get(input_index); + let exposed = input.is_some_and(|input| input.is_exposed()); + let percent = input + .and_then(|input| input.as_value()) + .and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value as f64) } else { None }) + .unwrap_or(0.); + (exposed, percent) + }) + .collect(), + Err(err) => { + log::error!("Could not get document node in build_shared_spectrum_section: {err}"); + return; + } + }; + + // Build markers for all non-exposed params + let mut marker_input_indices = Vec::new(); + let mut marker_default_percents = Vec::new(); + let mut marker_positions = Vec::new(); + let mut handle_colors = Vec::new(); + for (i, &(input_index, handle_color, default_percent)) in params.iter().enumerate() { + let (exposed, percent) = exposure_and_value[i]; + if exposed { + continue; + } + marker_positions.push((percent / 100.).clamp(0., 1.)); + marker_input_indices.push(input_index); + marker_default_percents.push(default_percent); + handle_colors.push(handle_color); + } + + // Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence + for i in 1..marker_positions.len() { + marker_positions[i] = marker_positions[i].max(marker_positions[i - 1]); + } + + let spectrum_markers: Vec = marker_positions + .iter() + .zip(&handle_colors) + .map(|(&position, &handle_color)| SpectrumMarker::new(position, 0.5, handle_color)) + .collect(); + + // Build the shared spectrum widget (placed on the first non-exposed row) + let spectrum_widget = (!spectrum_markers.is_empty()).then(|| { + SpectrumInput::new(bw_track()) + .markers(spectrum_markers) + .show_midpoints(false) + .allow_insert(false) + .allow_delete(false) + .allow_reorder(false) + .narrow(true) + .on_update({ + let marker_input_indices = marker_input_indices.clone(); + let marker_default_percents = marker_default_percents.clone(); + let marker_positions = marker_positions.clone(); + move |update: &SpectrumInputUpdate| { + let (input_index, percent) = match update { + SpectrumInputUpdate::MoveMarker { index, position } => match marker_input_indices.get(*index as usize) { + Some(&input_index) => (input_index, *position * 100.), + None => return Message::NoOp, + }, + SpectrumInputUpdate::ResetMarker { index } => { + let i = *index as usize; + let Some(&input_index) = marker_input_indices.get(i) else { return Message::NoOp }; + let Some(&default_percent) = marker_default_percents.get(i) else { return Message::NoOp }; + // Falls back to midpoint between neighbors if the default would cross one + let left = if i == 0 { 0. } else { marker_positions[i - 1] }; + let right = marker_positions.get(i + 1).copied().unwrap_or(1.); + let default_position = default_percent / 100.; + let new_position = if (left..=right).contains(&default_position) { default_position } else { (left + right) / 2. }; + (input_index, new_position * 100.) + } + _ => return Message::NoOp, + }; + NodeGraphMessage::SetInputValue { + node_id, + input_index, + value: TaggedValue::F32(percent.clamp(0., 100.) as f32), + } + .into() + } + }) + .on_commit(commit_value) + .widget_instance() + }); + let spectrum_owner = marker_input_indices.first().copied(); + + let number_input = NumberInput::default().mode_increment().unit("%").min(0.).max(100.); + + // One row per parameter: first non-exposed carries the shared spectrum, others get just a number input + for (i, &(input_index, _, _)) in params.iter().enumerate() { + let (exposed, current) = exposure_and_value[i]; + + if exposed { + let row = number_widget(ParameterWidgetsInfo::new(node_id, input_index, true, context), number_input.clone()); + layout.push(LayoutGroup::row(row)); + } else { + let mut row = start_widgets(ParameterWidgetsInfo::new(node_id, input_index, true, context)); + row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + + if Some(input_index) == spectrum_owner + && let Some(spectrum) = &spectrum_widget + { + row.push(spectrum.clone()); + row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + } + + row.push( + number_input + .clone() + .value(Some(current)) + .min_width(60) + .max_width(60) + .display_decimal_places(0) + .on_update(update_value(move |widget: &NumberInput| TaggedValue::F32(widget.value.unwrap_or(0.) as f32), node_id, input_index)) + .on_commit(commit_value) + .widget_instance(), + ); + layout.push(LayoutGroup::row(row)); + } + } +} + +pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::hue_saturation::*; + + // Current hue position on the rainbow track, used for the saturation track's right-end color + let current_hue_shift = get_document_node(node_id, context) + .ok() + .and_then(|document_node| document_node.inputs.get(HueShiftInput::INDEX).and_then(|input| input.as_value())) + .and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value) } else { None }) + .unwrap_or(0.); + // The rainbow has cyan at position 0.5 (hue_shift=0), so offset by +180 to align + let marker_hue = ((current_hue_shift + 180.) / 360.).rem_euclid(1.); + let saturated_current_hue = Color::from_hsva(marker_hue, 1., 1., 1.); + + // Hue: cyclic rainbow + let hue_track = GradientStops { + position: vec![0., 1. / 6., 2. / 6., 3. / 6., 4. / 6., 5. / 6., 1.], + midpoint: vec![0.5; 7], + color: vec![Color::RED, Color::YELLOW, Color::GREEN, Color::CYAN, Color::BLUE, Color::MAGENTA, Color::RED], + }; + // Saturation: gray to the fully saturated current hue + let saturation_track = GradientStops { + position: vec![0., 1.], + midpoint: vec![0.5, 0.5], + color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), saturated_current_hue], + }; + // Lightness: black to white + let lightness_track = bw_track(); + + vec![ + spectrum_slider_row( + node_id, + context, + HueShiftInput::INDEX, + hue_track, + Color::WHITE, + -180., + 180., + 0., + NumberInput::default().mode_increment().unit("°").min(-180.).max(180.), + ), + spectrum_slider_row( + node_id, + context, + SaturationShiftInput::INDEX, + saturation_track, + Color::WHITE, + -100., + 100., + 0., + NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), + ), + spectrum_slider_row( + node_id, + context, + LightnessShiftInput::INDEX, + lightness_track, + Color::WHITE, + -100., + 100., + 0., + NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), + ), + ] +} + +/// 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, + context: &mut NodePropertiesContext, + input_index: usize, + track: GradientStops, + handle_color: Color, + value_min: f64, + value_max: f64, + default_value: f64, + number_input: NumberInput, +) -> LayoutGroup { + let mut row = start_widgets(ParameterWidgetsInfo::new(node_id, input_index, true, context)); + + let current = get_document_node(node_id, context) + .ok() + .and_then(|document_node| document_node.inputs.get(input_index)) + .and_then(|input| input.as_non_exposed_value()) + .and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value as f64) } else { None }); + + // 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.); + + row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + + let position_to_value = move |position: f64| value_min + position * value_range; + row.push( + SpectrumInput::new(track) + .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() + }) + .on_commit(commit_value) + .widget_instance(), + ); + row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + row.push( + number_input + .value(Some(current)) + .min_width(60) + .max_width(60) + .display_decimal_places(0) + .on_update(update_value(move |widget: &NumberInput| TaggedValue::F32(widget.value.unwrap_or(0.) as f32), node_id, input_index)) + .on_commit(commit_value) + .widget_instance(), + ); + } + + LayoutGroup::row(row) +} + +pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::threshold::*; + + let params: &[(usize, Color, f64)] = &[(MinLuminanceInput::INDEX, Color::BLACK, 50.), (MaxLuminanceInput::INDEX, Color::WHITE, 100.)]; + + let mut layout = Vec::with_capacity(3); + build_shared_spectrum_section(node_id, context, params, &mut layout); + + let luminance_calc = { + let mut info = ParameterWidgetsInfo::new(node_id, LuminanceCalcInput::INDEX, true, context); + info.exposable = false; + enum_choice::().for_socket(info).property_row() + }; + layout.push(luminance_calc); + + layout +} + +pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::vibrance::*; + + let track = GradientStops { + position: vec![0., 1.], + midpoint: vec![0.5, 0.5], + color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), Color::RED], + }; + vec![spectrum_slider_row( + node_id, + context, + VibranceInput::INDEX, + track, + Color::WHITE, + -100., + 100., + 0., + NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), + )] +} + +pub(crate) fn black_and_white_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::black_and_white::*; + + let number_input = NumberInput::default().mode_increment().unit("%").min(-200.).max(300.); + + let tint = color_widget(ParameterWidgetsInfo::new(node_id, TintInput::INDEX, true, context), ColorInput::default()); + + let mut layout = vec![tint]; + let params: &[(usize, Color, f64)] = &[ + (RedsInput::INDEX, Color::RED, 40.), + (YellowsInput::INDEX, Color::YELLOW, 60.), + (GreensInput::INDEX, Color::GREEN, 40.), + (CyansInput::INDEX, Color::CYAN, 60.), + (BluesInput::INDEX, Color::BLUE, 20.), + (MagentasInput::INDEX, Color::MAGENTA, 80.), + ]; + for &(input_index, color, default) in params { + layout.push(spectrum_slider_row( + node_id, + context, + input_index, + color_track(color), + Color::WHITE, + -200., + 300., + default, + number_input.clone(), + )); + } + + layout +} + pub(crate) fn channel_mixer_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::channel_mixer::*; @@ -1356,27 +1733,38 @@ pub(crate) fn channel_mixer_properties(node_id: NodeId, context: &mut NodeProper } }; - // Output Channel modes - let (red_output_index, green_output_index, blue_output_index, constant_output_index) = match (is_monochrome_value, output_channel_value) { - (true, _) => (MonochromeRInput::INDEX, MonochromeGInput::INDEX, MonochromeBInput::INDEX, MonochromeCInput::INDEX), - (false, RedGreenBlue::Red) => (RedRInput::INDEX, RedGInput::INDEX, RedBInput::INDEX, RedCInput::INDEX), - (false, RedGreenBlue::Green) => (GreenRInput::INDEX, GreenGInput::INDEX, GreenBInput::INDEX, GreenCInput::INDEX), - (false, RedGreenBlue::Blue) => (BlueRInput::INDEX, BlueGInput::INDEX, BlueBInput::INDEX, BlueCInput::INDEX), + // Input indices and defaults depend on monochrome toggle and output channel selection + let (indices, defaults) = match (is_monochrome_value, output_channel_value) { + (true, _) => ( + [MonochromeRInput::INDEX, MonochromeGInput::INDEX, MonochromeBInput::INDEX, MonochromeCInput::INDEX], + [40., 40., 20., 0.], + ), + (false, RedGreenBlue::Red) => ([RedRInput::INDEX, RedGInput::INDEX, RedBInput::INDEX, RedCInput::INDEX], [100., 0., 0., 0.]), + (false, RedGreenBlue::Green) => ([GreenRInput::INDEX, GreenGInput::INDEX, GreenBInput::INDEX, GreenCInput::INDEX], [0., 100., 0., 0.]), + (false, RedGreenBlue::Blue) => ([BlueRInput::INDEX, BlueGInput::INDEX, BlueBInput::INDEX, BlueCInput::INDEX], [0., 0., 100., 0.]), }; - let number_input = NumberInput::default().mode_range().min(-200.).max(200.).unit("%"); - let red = number_widget(ParameterWidgetsInfo::new(node_id, red_output_index, true, context), number_input.clone()); - let green = number_widget(ParameterWidgetsInfo::new(node_id, green_output_index, true, context), number_input.clone()); - let blue = number_widget(ParameterWidgetsInfo::new(node_id, blue_output_index, true, context), number_input.clone()); - let constant = number_widget(ParameterWidgetsInfo::new(node_id, constant_output_index, true, context), number_input); - // Monochrome + let number_input = NumberInput::default().mode_increment().unit("%").min(-200.).max(200.); + let tracks = [color_track(Color::RED), color_track(Color::GREEN), color_track(Color::BLUE), bw_track()]; + let mut layout = vec![LayoutGroup::row(is_monochrome)]; - // Output channel choice if !is_monochrome_value { layout.push(output_channel); } - // Channel values - layout.extend([LayoutGroup::row(red), LayoutGroup::row(green), LayoutGroup::row(blue), LayoutGroup::row(constant)]); + for (i, (&input_index, &default)) in indices.iter().zip(defaults.iter()).enumerate() { + layout.push(spectrum_slider_row( + node_id, + context, + input_index, + tracks[i].clone(), + Color::WHITE, + -200., + 200., + default, + number_input.clone(), + )); + } + layout } @@ -1403,39 +1791,43 @@ pub(crate) fn selective_color_properties(node_id: NodeId, context: &mut NodeProp } }; // CMYK - let (c_index, m_index, y_index, k_index) = match colors_choice { - SelectiveColorChoice::Reds => (RCInput::INDEX, RMInput::INDEX, RYInput::INDEX, RKInput::INDEX), - SelectiveColorChoice::Yellows => (YCInput::INDEX, YMInput::INDEX, YYInput::INDEX, YKInput::INDEX), - SelectiveColorChoice::Greens => (GCInput::INDEX, GMInput::INDEX, GYInput::INDEX, GKInput::INDEX), - SelectiveColorChoice::Cyans => (CCInput::INDEX, CMInput::INDEX, CYInput::INDEX, CKInput::INDEX), - SelectiveColorChoice::Blues => (BCInput::INDEX, BMInput::INDEX, BYInput::INDEX, BKInput::INDEX), - SelectiveColorChoice::Magentas => (MCInput::INDEX, MMInput::INDEX, MYInput::INDEX, MKInput::INDEX), - SelectiveColorChoice::Whites => (WCInput::INDEX, WMInput::INDEX, WYInput::INDEX, WKInput::INDEX), - SelectiveColorChoice::Neutrals => (NCInput::INDEX, NMInput::INDEX, NYInput::INDEX, NKInput::INDEX), - SelectiveColorChoice::Blacks => (KCInput::INDEX, KMInput::INDEX, KYInput::INDEX, KKInput::INDEX), + let indices = match colors_choice { + SelectiveColorChoice::Reds => [RCInput::INDEX, RMInput::INDEX, RYInput::INDEX, RKInput::INDEX], + SelectiveColorChoice::Yellows => [YCInput::INDEX, YMInput::INDEX, YYInput::INDEX, YKInput::INDEX], + SelectiveColorChoice::Greens => [GCInput::INDEX, GMInput::INDEX, GYInput::INDEX, GKInput::INDEX], + SelectiveColorChoice::Cyans => [CCInput::INDEX, CMInput::INDEX, CYInput::INDEX, CKInput::INDEX], + SelectiveColorChoice::Blues => [BCInput::INDEX, BMInput::INDEX, BYInput::INDEX, BKInput::INDEX], + SelectiveColorChoice::Magentas => [MCInput::INDEX, MMInput::INDEX, MYInput::INDEX, MKInput::INDEX], + SelectiveColorChoice::Whites => [WCInput::INDEX, WMInput::INDEX, WYInput::INDEX, WKInput::INDEX], + SelectiveColorChoice::Neutrals => [NCInput::INDEX, NMInput::INDEX, NYInput::INDEX, NKInput::INDEX], + SelectiveColorChoice::Blacks => [KCInput::INDEX, KMInput::INDEX, KYInput::INDEX, KKInput::INDEX], }; - let number_input = NumberInput::default().mode_range().min(-100.).max(100.).unit("%"); - let cyan = number_widget(ParameterWidgetsInfo::new(node_id, c_index, true, context), number_input.clone()); - let magenta = number_widget(ParameterWidgetsInfo::new(node_id, m_index, true, context), number_input.clone()); - let yellow = number_widget(ParameterWidgetsInfo::new(node_id, y_index, true, context), number_input.clone()); - let black = number_widget(ParameterWidgetsInfo::new(node_id, k_index, true, context), number_input); + + let tracks = [color_track(Color::CYAN), color_track(Color::MAGENTA), color_track(Color::YELLOW), bw_track()]; + let number_input = NumberInput::default().mode_increment().unit("%").min(-100.).max(100.); // Mode let mode = enum_choice::() .for_socket(ParameterWidgetsInfo::new(node_id, ModeInput::INDEX, true, context)) .property_row(); - vec![ - // Colors choice - colors, - // CMYK - LayoutGroup::row(cyan), - LayoutGroup::row(magenta), - LayoutGroup::row(yellow), - LayoutGroup::row(black), - // Mode - mode, - ] + let mut layout = vec![colors]; + for (i, &input_index) in indices.iter().enumerate() { + layout.push(spectrum_slider_row( + node_id, + context, + input_index, + tracks[i].clone(), + Color::WHITE, + -100., + 100., + 0., + number_input.clone(), + )); + } + layout.push(mode); + + layout } pub(crate) fn grid_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { diff --git a/frontend/src/components/widgets/WidgetSection.svelte b/frontend/src/components/widgets/WidgetSection.svelte index 42d69192..ab435dd8 100644 --- a/frontend/src/components/widgets/WidgetSection.svelte +++ b/frontend/src/components/widgets/WidgetSection.svelte @@ -179,6 +179,10 @@ > .radio-input button { flex: 1 1 100%; } + + > .parameter-expose-button + .text-label ~ .number-input:last-child { + margin-left: auto; + } } } } diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 3ffb6ad9..68a9142b 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -19,7 +19,8 @@ export let showMidpoints = true; export let allowInsert = true; export let allowDelete = true; - export let allowSwap = true; + export let allowReorder = true; + export let narrow = 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. @@ -94,6 +95,11 @@ emit({ ResetMidpoint: { index } }); } + function markerDoubleClick(index: number) { + if (disabled) return; + emit({ ResetMarker: { index } }); + } + function trackPointerDown(e: PointerEvent) { if (disabled) return; if (e.button !== BUTTON_LEFT) return; @@ -137,7 +143,7 @@ let position = pointerPosition(e); if (position === undefined) return; - if (!allowSwap) position = clampToNeighbors(activeMarkerIndex, position); + if (!allowReorder) position = clampToNeighbors(activeMarkerIndex, position); if (!dragInsertedMarker) dispatch("dragging", true); emit({ MoveMarker: { index: activeMarkerIndex, position } }); @@ -239,7 +245,7 @@ markerPointerDown(e, index)} + on:dblclick={() => markerDoubleClick(index)} data-gradient-marker xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" @@ -317,6 +324,11 @@ border-radius: 2px; } + &.narrow .gradient-strip { + margin-top: 8px; + height: 8px; + } + &.disabled .gradient-strip { transition: opacity 0.1s; diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 32abe108..c80f02c1 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -140,12 +140,8 @@ fn make_opaque>( } // TODO: Remove this once GPU shader nodes are able to support the non-classic algorithm -#[node_macro::node( - name("Brightness/Contrast Classic"), - category("Raster: Adjustment"), - properties("brightness_contrast_properties"), - shader_node(PerPixelAdjust) -)] +// TODO: Maybe re-add the "Raster: Adjustment" category to make this user-facing if we care to make this not just for testing +#[node_macro::node(name("Brightness/Contrast Classic"), category(""), properties("brightness_contrast_properties"), shader_node(PerPixelAdjust))] fn brightness_contrast_classic>( _: impl Ctx, #[implementations( @@ -257,7 +253,7 @@ fn brightness_contrast>( // // Some further analysis available at: // https://geraldbakker.nl/psnumbers/levels.html -#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] +#[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))] fn levels>( _: impl Ctx, #[implementations( @@ -325,7 +321,8 @@ fn levels>( // Algorithm from: // https://stackoverflow.com/a/55233732/775283 // Works the same for gamma and linear color -#[node_macro::node(name("Black & White"), category("Raster: Adjustment"), shader_node(PerPixelAdjust))] +// TODO: Currently the un-Table-wrapped `tint` Color is causing a type error. Put this back in the "Raster: Adjustment" category once that's fixed. +#[node_macro::node(name("Black & White"), category(""), properties("black_and_white_properties"), shader_node(PerPixelAdjust))] fn black_and_white>( _: impl Ctx, #[implementations( @@ -398,7 +395,7 @@ fn black_and_white>( // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0 // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings -#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), shader_node(PerPixelAdjust))] +#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))] fn hue_saturation>( _: impl Ctx, #[implementations( @@ -456,7 +453,7 @@ fn invert>( // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient -#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] +#[node_macro::node(category("Raster: Adjustment"), properties("threshold_properties"), shader_node(PerPixelAdjust))] fn threshold>( _: impl Ctx, #[implementations( @@ -502,7 +499,7 @@ fn threshold>( // It's not the same as the saturation component of Hue/Saturation/Value. Vibrance and Saturation are both separable. // When both parameters are set, it is equivalent to running this adjustment twice, with only vibrance set and then only saturation set. // (Except for some noise probably due to rounding error.) -#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] +#[node_macro::node(category("Raster: Adjustment"), properties("vibrance_properties"), shader_node(PerPixelAdjust))] fn vibrance>( _: impl Ctx, #[implementations(