Add dashed links between spectrum markers and typed marker scales to the shared spectrum sections (#4534)

This commit is contained in:
Keavon Chambers
2026-09-14 20:53:06 -07:00
committed by GitHub
parent e38926004f
commit 0bede7969c
3 changed files with 290 additions and 63 deletions

View File

@@ -705,12 +705,25 @@ pub struct SpectrumMarker {
/// discarding any transparency so the handle always shows the RGB that steers the interpolation.
#[serde(rename = "handleColorCSS")]
handle_color_css: String,
/// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers.
#[serde(rename = "dashedToNext")]
dashed_to_next: bool,
}
impl SpectrumMarker {
pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self {
let handle_color_css = format!("#{}", SRGBA8::from(handle_color).to_rgb_hex());
Self { position, midpoint, handle_color_css }
Self {
position,
midpoint,
handle_color_css,
dashed_to_next: false,
}
}
pub fn dash_to_next(mut self) -> Self {
self.dashed_to_next = true;
self
}
}

View File

@@ -1399,36 +1399,101 @@ pub(crate) fn transfer_curves_properties(node_id: NodeId, context: &mut NodeProp
pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::levels::*;
// (parameter, marker handle color, default percentage for double-click reset)
let input_range_params = [
(ShadowsInput.into(), Color::BLACK, 0.),
(MidtonesInput.into(), Color::MIDDLE_GRAY, 50.),
(HighlightsInput.into(), Color::WHITE, 100.),
SpectrumSectionParam::new(ShadowsInput, Color::BLACK, 0., MarkerScale::Percent),
SpectrumSectionParam::new(MidtonesInput, Color::MIDDLE_GRAY, 50., MarkerScale::Percent),
SpectrumSectionParam::new(HighlightsInput, Color::WHITE, 100., MarkerScale::Percent),
];
let output_range_params = [
SpectrumSectionParam::new(OutputMinimumsInput, Color::BLACK, 0., MarkerScale::Percent),
SpectrumSectionParam::new(OutputMaximumsInput, Color::WHITE, 100., MarkerScale::Percent),
];
let output_range_params = [(OutputMinimumsInput.into(), Color::BLACK, 0.), (OutputMaximumsInput.into(), 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);
build_shared_spectrum_section(node_id, context, &bw_track(), &input_range_params, &mut layout);
build_shared_spectrum_section(node_id, context, &bw_track(), &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
/// How a shared spectrum marker's value maps onto its track.
#[derive(Clone, Copy)]
enum MarkerScale {
/// A 0..100 percentage, placed linearly.
Percent,
/// A gamma of 0.01..9.99 running from 9.99 at the left to 0.01 at the right, logarithmic on each side of the 1 at its center.
Gamma,
}
impl MarkerScale {
fn position(self, value: f64) -> f64 {
match self {
Self::Percent => value / 100.,
Self::Gamma if value >= 1. => 0.5 - 0.5 * value.log10() / 9.99_f64.log10(),
Self::Gamma => 0.5 + 0.5 * value.log10() / 0.01_f64.log10(),
}
.clamp(0., 1.)
}
fn value(self, position: f64) -> f64 {
match self {
Self::Percent => (position * 100.).clamp(0., 100.),
Self::Gamma if position <= 0.5 => 9.99_f64.powf(1. - 2. * position).clamp(1., 9.99),
Self::Gamma => 0.01_f64.powf(2. * position - 1.).clamp(0.01, 1.),
}
}
fn number_input(self) -> NumberInput {
match self {
Self::Percent => NumberInput::default().mode_increment().unit("%").min(0.).max(100.).display_decimal_places(0),
Self::Gamma => NumberInput::default().mode_increment().min(0.01).max(9.99).display_decimal_places(2),
}
}
}
/// One parameter of a shared spectrum section and how its marker sits on the track.
struct SpectrumSectionParam {
parameter: ParameterRef,
handle_color: Color,
/// The value a double-click resets to.
default_value: f64,
scale: MarkerScale,
/// Whether a dashed line joins the marker to the next parameter's marker.
dash_to_next: bool,
}
impl SpectrumSectionParam {
fn new(parameter: impl Into<ParameterRef>, handle_color: Color, default_value: f64, scale: MarkerScale) -> Self {
Self {
parameter: parameter.into(),
handle_color,
default_value,
scale,
dash_to_next: false,
}
}
fn dash_to_next(mut self) -> Self {
self.dash_to_next = true;
self
}
}
/// Append a section of related parameters as rows: a shared spectrum over `track` (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: &[(ParameterRef, Color, f64)], layout: &mut Vec<LayoutGroup>) {
fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesContext, track: &Gradient, params: &[SpectrumSectionParam], layout: &mut Vec<LayoutGroup>) {
// 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(|(parameter, _, _)| {
let input = document_node.inputs.get(parameter.input_index);
.map(|param| {
let input = document_node.inputs.get(param.parameter.input_index);
let exposed = input.is_some_and(|input| input.is_exposed());
let percent = input
let value = 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)
(exposed, value)
})
.collect(),
Err(err) => {
@@ -1437,20 +1502,23 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
}
};
// Build markers for all non-exposed params
// Build markers for all non-exposed params, linking one to the next only when both have markers
let mut marker_input_indices = Vec::new();
let mut marker_default_percents = Vec::new();
let mut marker_default_positions = Vec::new();
let mut marker_scales = Vec::new();
let mut marker_positions = Vec::new();
let mut handle_colors = Vec::new();
for (i, &(ref parameter, handle_color, default_percent)) in params.iter().enumerate() {
let (exposed, percent) = exposure_and_value[i];
let mut marker_colors_and_links = Vec::new();
for (i, param) in params.iter().enumerate() {
let (exposed, value) = exposure_and_value[i];
if exposed {
continue;
}
marker_positions.push((percent / 100.).clamp(0., 1.));
marker_input_indices.push(parameter.input_index);
marker_default_percents.push(default_percent);
handle_colors.push(handle_color);
let next_has_marker = exposure_and_value.get(i + 1).is_some_and(|&(next_exposed, _)| !next_exposed);
marker_positions.push(param.scale.position(value));
marker_input_indices.push(param.parameter.input_index);
marker_default_positions.push(param.scale.position(param.default_value));
marker_scales.push(param.scale);
marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker));
}
// Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence
@@ -1460,13 +1528,16 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
let spectrum_markers: Vec<SpectrumMarker> = marker_positions
.iter()
.zip(&handle_colors)
.map(|(&position, &handle_color)| SpectrumMarker::new(position, 0.5, handle_color))
.zip(&marker_colors_and_links)
.map(|(&position, &(handle_color, dashed))| {
let marker = SpectrumMarker::new(position, 0.5, handle_color);
if dashed { marker.dash_to_next() } else { marker }
})
.collect();
// Build the shared spectrum widget (placed on the first non-exposed row)
let spectrum_widget = (!spectrum_markers.is_empty()).then(|| {
SpectrumInput::new(GradientStops::from(&bw_track()))
SpectrumInput::new(GradientStops::from(track))
.track_space(GradientSpace::RgbGamma)
.markers(spectrum_markers)
.show_midpoints(false)
@@ -1476,31 +1547,31 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
.narrow(true)
.on_update({
let marker_input_indices = marker_input_indices.clone();
let marker_default_percents = marker_default_percents.clone();
let marker_default_positions = marker_default_positions.clone();
let marker_scales = marker_scales.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.)
}
let i = match update {
SpectrumInputUpdate::MoveMarker { index, .. } | SpectrumInputUpdate::ResetMarker { index } => *index as usize,
_ => return Message::NoOp,
};
let (Some(&input_index), Some(&scale), Some(&default_position)) = (marker_input_indices.get(i), marker_scales.get(i), marker_default_positions.get(i)) else {
return Message::NoOp;
};
let left = if i == 0 { 0. } else { marker_positions[i - 1] };
let right = marker_positions.get(i + 1).copied().unwrap_or(1.);
let scale_position = match update {
SpectrumInputUpdate::MoveMarker { position, .. } => *position,
// A default that would cross a neighbor falls back to the midpoint between them
SpectrumInputUpdate::ResetMarker { .. } if (left..=right).contains(&default_position) => default_position,
SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2.,
_ => return Message::NoOp,
};
NodeGraphMessage::SetInputValue {
node_id,
input_index,
value: TaggedValue::F32(percent.clamp(0., 100.) as f32).into(),
value: TaggedValue::F32(scale.value(scale_position) as f32).into(),
}
.into()
}
@@ -1510,12 +1581,11 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
});
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, (parameter, _, _)) in params.iter().enumerate() {
for (i, param) in params.iter().enumerate() {
let (exposed, current) = exposure_and_value[i];
let input_index = parameter.input_index;
let input_index = param.parameter.input_index;
let number_input = param.scale.number_input();
if exposed {
let row = number_widget(ParameterWidgetsInfo::at_index(node_id, input_index, true, context), number_input.clone());
@@ -1537,7 +1607,6 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
.value(Some(current))
.min_width(60)
.max_width(60)
.display_decimal_places(0)
.on_update(update_value_at_index(
move |widget: &NumberInput| TaggedValue::F32(widget.value.unwrap_or(0.) as f32),
node_id,
@@ -1772,10 +1841,13 @@ fn spectrum_slider_row(
pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::threshold::*;
let params: &[(ParameterRef, Color, f64)] = &[(MinLuminanceInput.into(), Color::BLACK, 50.), (MaxLuminanceInput.into(), Color::WHITE, 100.)];
let params = [
SpectrumSectionParam::new(MinLuminanceInput, Color::WHITE, 50., MarkerScale::Percent).dash_to_next(),
SpectrumSectionParam::new(MaxLuminanceInput, Color::WHITE, 100., MarkerScale::Percent),
];
let mut layout = Vec::with_capacity(2);
build_shared_spectrum_section(node_id, context, params, &mut layout);
build_shared_spectrum_section(node_id, context, &bw_track(), &params, &mut layout);
layout
}
@@ -2143,11 +2215,31 @@ pub(crate) fn sample_polyline_properties(node_id: NodeId, context: &mut NodeProp
pub(crate) fn exposure_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::exposure::*;
let exposure = number_widget(ParameterWidgetsInfo::new(node_id, ExposureInput, true, context), NumberInput::default().min(-20.).max(20.));
let offset = number_widget(ParameterWidgetsInfo::new(node_id, OffsetInput, true, context), NumberInput::default().min(-0.5).max(0.5));
let gamma_correction = number_widget(
let exposure = range_slider_widget(
ParameterWidgetsInfo::new(node_id, ExposureInput, true, context),
NumberInput::default().min(-20.).max(20.),
SliderRange {
min: -20.,
max: 20.,
default: Some(0.),
},
);
let offset = range_slider_widget(
ParameterWidgetsInfo::new(node_id, OffsetInput, true, context),
NumberInput::default().min(-0.5).max(0.5),
SliderRange {
min: -0.5,
max: 0.5,
default: Some(0.),
},
);
let gamma_correction = slider_row(
ParameterWidgetsInfo::new(node_id, GammaCorrectionInput, true, context),
NumberInput::default().min(0.01).max(9.99).increment_step(0.1),
MarkerScale::Gamma.number_input().increment_step(0.1),
Some(1.),
|gamma| MarkerScale::Gamma.position(gamma),
|position| MarkerScale::Gamma.value(position),
);
vec![LayoutGroup::row(exposure), LayoutGroup::row(offset), LayoutGroup::row(gamma_correction)]

View File

@@ -50,7 +50,9 @@
// Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker`
// that would otherwise race the structural change before Rust has reported the dragged marker's new index.
let skipNextMove = false;
// The hovered marker, highlighted ahead of the drag.
// Set while a run of markers drags together: its bounds, the first's offset from the pointer, each member's gap from the first, and their start positions for cancelling.
let dragRun: { first: number; last: number; offset: number; spacings: number[]; restore: number[] } | undefined = undefined;
// The run a hovered marker or dashed link would carry, highlighted ahead of the drag.
let hoverRun: [number, number] | undefined = undefined;
// The marker being dragged, or the left marker of the interval when a midpoint is dragged, and which of the two it is.
// Where selecting is allowed, these follow the selection, which Rust renumbers across structural changes.
@@ -60,9 +62,10 @@
dragIndex = activeMarkerIndex;
dragIsMidpoint = activeMarkerIsMidpoint;
}
// The marker highlighted: the one hovered, else the one being dragged (or selected, where selecting is allowed).
// The markers highlighted: the run being dragged or hovered, else the marker dragged alone (or selected, where selecting is allowed).
let highlightedRun: [number, number] | undefined;
$: highlightedRun = hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined;
$: highlightedRun =
dragRun !== undefined ? [dragRun.first, dragRun.last] : hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined;
function emit(intent: SpectrumInputUpdate) {
dispatch("update", intent);
@@ -91,12 +94,30 @@
return clamp ? Math.max(0, Math.min(1, ratio)) : ratio;
}
function clampToNeighbors(index: number, position: number): number {
const lower = markers[index - 1]?.position ?? 0;
const upper = markers[index + 1]?.position ?? 1;
// Holds markers `first..=last` (spanning `spacing`) between their neighbors as they move to `position`
function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number {
// Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors
if (allowReorder && allowSelect) return position;
const lower = markers[first - 1]?.position ?? 0;
const upper = (markers[last + 1]?.position ?? 1) - spacing;
return Math.max(lower, Math.min(upper, position));
}
// The spans from each marker passing `linked` to its successor
function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] {
const spans: { index: number; left: number; width: number }[] = [];
markers.forEach((marker, index) => {
const next = markers[index + 1];
if (!linked(marker) || next === undefined || next.position === marker.position) return;
const [left, right] = next.position > marker.position ? [marker.position, next.position] : [next.position, marker.position];
spans.push({ index, left, width: right - left });
});
return spans;
}
// 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;
@@ -140,6 +161,43 @@
addEvents();
}
// Drags markers `first..=last` as one, keeping the pointer's offset from the first when `grabbed` and otherwise carrying the run to the pointer
function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean) {
const pointer = pointerPosition(e);
if (pointer === undefined) return;
const start = markers[first].position;
const spacings: number[] = [];
const restore: number[] = [];
for (let index = first; index <= last; index += 1) {
const position = markers[index].position;
spacings.push(position - start);
restore.push(position);
}
activeMarkerIndexRestore = activeMarkerIndex;
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
dragRestorePosition = start;
dragInsertedMarker = false;
dragMoved = false;
duplicateRequested = false;
duplicateActive = false;
dragRun = { first, last, offset: grabbed ? start - pointer : 0, spacings, restore };
setActive(first, false);
addEvents();
}
// The run a dashed link from `index` carries: the two markers it joins
function dashedRun(index: number): [number, number] {
return [index, index + 1];
}
function dashPointerDown(e: PointerEvent, index: number) {
if (disabled || e.button !== BUTTON_LEFT) return;
const [first, last] = dashedRun(index);
beginRunDrag(e, first, last, true);
}
// Picks up the marker at `index` and carries it to the pointer
function pickUpMarker(e: PointerEvent, index: number) {
beginMarkerDrag(e, index);
@@ -168,6 +226,11 @@
emit({ ResetMarker: { index } });
}
function resetRun(first: number, last: number) {
if (disabled || dragMoved) return;
for (let index = first; index <= last; index += 1) emit({ ResetMarker: { index } });
}
function trackPointerDown(e: PointerEvent) {
if (disabled) return;
if (e.button !== BUTTON_LEFT) return;
@@ -297,14 +360,32 @@
let position = pointerPosition(e);
if (position === undefined) return;
// Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors
if (!allowReorder || !allowSelect) position = clampToNeighbors(dragIndex, position);
position = holdBetweenNeighbors(dragIndex, dragIndex, 0, position);
dragMoved = true;
if (!dragInsertedMarker) dispatch("dragging", true);
emit({ MoveMarker: { index: dragIndex, position } });
}
function moveRun(e: PointerEvent) {
if (disabled || dragRun === undefined) return;
if (e.buttons === 0) {
endDrag();
return;
}
const { first, last, offset, spacings } = dragRun;
const pointer = pointerPosition(e);
if (pointer === undefined) return;
const span = spacings[spacings.length - 1];
const start = holdBetweenNeighbors(first, last, span, pointer + offset);
dragMoved = true;
dispatch("dragging", true);
spacings.forEach((spacing, i) => emit({ MoveMarker: { index: first + i, position: start + spacing } }));
}
function moveActiveMidpoint(e: PointerEvent) {
if (disabled || dragIndex === undefined) return;
if (e.buttons === 0) {
@@ -352,6 +433,10 @@
} else if (anchor !== undefined) {
// A duplicated pre-existing marker: the frozen copy already sits at the start position, so deleting the dragged copy restores the original.
emit({ DeleteMarker: { index: dragged } });
} else if (dragRun !== undefined) {
// A run drag: return every member to where it began.
const { first, restore } = dragRun;
restore.forEach((position, i) => emit({ MoveMarker: { index: first + i, position } }));
} else if (dragRestorePosition !== undefined) {
// Plain drag: return the marker (or midpoint) to where it began.
if (dragIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } });
@@ -376,6 +461,7 @@
duplicateRequested = false;
duplicateActive = false;
skipNextMove = false;
dragRun = undefined;
// Without selection nothing stays active once the drag ends
if (!allowSelect) {
dragIndex = undefined;
@@ -386,6 +472,7 @@
function onPointerMove(e: PointerEvent) {
if (dragIsMidpoint) moveActiveMidpoint(e);
else if (dragRun !== undefined) moveRun(e);
else moveActiveMarker(e);
}
@@ -457,6 +544,7 @@
return positions;
}
$: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic, trackInterpolation);
$: dashes = markerSpans(markers, (marker) => marker.dashedToNext);
onMount(() => {
document.addEventListener("keydown", deleteShortcut);
@@ -507,6 +595,18 @@
{/each}
</LayoutRow>
<LayoutRow class="marker-track" classes={{ interactive: !allowInsert }} bind:this={markerTrackElement} on:pointerdown={markerTrackPointerDown}>
{#each dashes as dash}
<div
class="dashed-link"
class:active={highlightedRun !== undefined && dash.index >= highlightedRun[0] && dash.index < highlightedRun[1]}
style:--span-left={dash.left}
style:--span-width={dash.width}
on:pointerenter={() => (hoverRun = dashedRun(dash.index))}
on:pointerleave={() => (hoverRun = undefined)}
on:pointerdown={(e) => dashPointerDown(e, dash.index)}
on:dblclick={() => resetRun(...dashedRun(dash.index))}
></div>
{/each}
{#each markers as marker, index}
{#if marker.position >= 0 && marker.position <= 1}
<svg
@@ -646,6 +746,20 @@
pointer-events: auto;
}
// Full width and clipped to its span, so the dashes keep the lane's phase rather than walking with the left handle.
// The whole band is the grab area, with the 1px line drawn through its middle in the same neutral as the strip's edges.
.dashed-link {
--link-color: var(--color-5-dullgray);
position: absolute;
top: 4px;
left: 0;
width: 100%;
height: 8px;
background: repeating-linear-gradient(to right, var(--link-color) 0 2px, transparent 2px 4px) 0 4px / auto 1px repeat-x;
clip-path: inset(0 calc((1 - var(--span-left) - var(--span-width)) * 100%) 0 calc(var(--span-left) * 100%));
pointer-events: auto;
}
.marker {
position: absolute;
transform: translateX(-50%);
@@ -677,6 +791,14 @@
}
}
&.disabled .marker-track .dashed-link {
--link-color: var(--color-4-dimgray);
}
&:not(.disabled) .marker-track .dashed-link.active {
--link-color: var(--color-e-nearwhite);
}
&:not(.disabled) .marker-track .marker {
&:not(.active) {
.inner-fill:hover + .outer-border,