Revamp the syntax to #[hard(a..b)] and #[soft(a..b)] bounds on node definitions (#4307)

* Revamp the syntax to #[hard(a..b)] and #[soft(a..b)] bounds on node definitions

* Address review feedback: error on empty bounds ranges and remove redundant count clamps

* Fix NaN transform from Repeat Array with a count of 1

* Remove redundant manual clamps already enforced by #[hard(...)] bounds
This commit is contained in:
Keavon Chambers
2026-07-03 18:48:58 -07:00
committed by GitHub
parent 95c1ab81f3
commit f05a644bd9
17 changed files with 341 additions and 197 deletions
@@ -1664,15 +1664,18 @@ fn static_input_properties() -> InputProperties {
if let Some(unit) = field.unit { if let Some(unit) = field.unit {
number_input = number_input.unit(unit); number_input = number_input.unit(unit);
} }
if let Some(number_min) = field.number_min { // Typing is clamped only by the hard bounds; the slider extent prefers the soft bounds (see `property_from_type`)
number_input = number_input.min(number_min); if let Some(hard_min) = field.number_hard_min {
number_input = number_input.min(hard_min);
} }
if let Some(number_max) = field.number_max { if let Some(hard_max) = field.number_hard_max {
number_input = number_input.max(number_max); number_input = number_input.max(hard_max);
} }
if let Some((range_min, range_max)) = field.number_mode_range { if field.number_mode_range {
number_input = number_input.range_min(Some(range_min)); number_input = number_input
number_input = number_input.range_max(Some(range_max)); .mode_range()
.range_min(field.number_soft_min.or(field.number_hard_min))
.range_max(field.number_soft_max.or(field.number_hard_max));
} }
number_input = number_input.is_integer(false); number_input = number_input.is_integer(false);
if let Some(number_step) = field.number_step { if let Some(number_step) = field.number_step {
@@ -149,22 +149,36 @@ pub fn start_widgets(parameter_widgets_info: ParameterWidgetsInfo) -> Vec<Widget
widgets widgets
} }
/// The numeric bounds and widget mode of a number parameter, sourced from the node's field metadata.
#[derive(Clone, Copy, Default)]
pub(crate) struct NumberOptions {
pub soft_min: Option<f64>,
pub soft_max: Option<f64>,
pub hard_min: Option<f64>,
pub hard_max: Option<f64>,
pub slider: bool,
}
pub(crate) fn property_from_type( pub(crate) fn property_from_type(
node_id: NodeId, node_id: NodeId,
index: usize, index: usize,
ty: &Type, ty: &Type,
number_options: (Option<f64>, Option<f64>, Option<(f64, f64)>), number_options: NumberOptions,
unit: Option<&str>, unit: Option<&str>,
display_decimal_places: Option<u32>, display_decimal_places: Option<u32>,
step: Option<f64>, step: Option<f64>,
context: &mut NodePropertiesContext, context: &mut NodePropertiesContext,
) -> Result<Vec<LayoutGroup>, Vec<LayoutGroup>> { ) -> Result<Vec<LayoutGroup>, Vec<LayoutGroup>> {
let (mut number_min, mut number_max, range) = number_options; let NumberOptions {
soft_min,
soft_max,
hard_min,
hard_max,
slider,
} = number_options;
let mut number_input = NumberInput::default(); let mut number_input = NumberInput::default();
if let Some((range_start, range_end)) = range { if slider {
number_min = Some(range_start); number_input = number_input.mode_range();
number_max = Some(range_end);
number_input = number_input.mode_range().min(range_start).max(range_end);
} }
if let Some(unit) = unit { if let Some(unit) = unit {
number_input = number_input.unit(unit); number_input = number_input.unit(unit);
@@ -176,8 +190,22 @@ pub(crate) fn property_from_type(
number_input = number_input.step(step); number_input = number_input.step(step);
} }
let min = |x: f64| number_min.unwrap_or(x); // Applies the parameter's typing clamp and slider extent to the widget, given the type's own default bounds.
let max = |x: f64| number_max.unwrap_or(x); // Per end: the clamp is the hard bound (or unbounded if only a soft bound is given, since soft is a suggested
// extent rather than a limit), and the slider extent is the soft bound, each falling back to the hard bound
// and then to the type default when unspecified. An end with any explicit bound ignores the type default.
let bounded = |number_input: NumberInput, type_min: f64, type_max: f64| {
let clamp_min = hard_min.unwrap_or(if soft_min.is_some() { f64::NEG_INFINITY } else { type_min });
let clamp_max = hard_max.unwrap_or(if soft_max.is_some() { f64::INFINITY } else { type_max });
let extent_min = soft_min.or(hard_min).unwrap_or(type_min);
let extent_max = soft_max.or(hard_max).unwrap_or(type_max);
number_input
.min(clamp_min)
.max(clamp_max)
.range_min(Some(extent_min).filter(|bound| bound.is_finite()))
.range_max(Some(extent_max).filter(|bound| bound.is_finite()))
};
let default_info = ParameterWidgetsInfo::new(node_id, index, true, context); let default_info = ParameterWidgetsInfo::new(node_id, index, true, context);
@@ -186,16 +214,16 @@ pub(crate) fn property_from_type(
Type::Concrete(concrete_type) => { Type::Concrete(concrete_type) => {
match concrete_type.alias.as_ref().map(|x| x.as_ref()) { match concrete_type.alias.as_ref().map(|x| x.as_ref()) {
// Aliased types (ambiguous values) // Aliased types (ambiguous values)
Some("Percentage") | Some("PercentageF32") => number_widget(default_info, number_input.percentage().min(min(0.)).max(max(100.))).into(), Some("Percentage") | Some("PercentageF32") => number_widget(default_info, bounded(number_input.percentage(), 0., 100.)).into(),
Some("SignedPercentage") | Some("SignedPercentageF32") => number_widget(default_info, number_input.percentage().min(min(-100.)).max(max(100.))).into(), Some("SignedPercentage") | Some("SignedPercentageF32") => number_widget(default_info, bounded(number_input.percentage(), -100., 100.)).into(),
Some("Angle") | Some("AngleF32") => number_widget(default_info, number_input.mode_range().min(min(-180.)).max(max(180.)).unit(unit.unwrap_or("°"))).into(), Some("Angle") | Some("AngleF32") => number_widget(default_info, bounded(number_input.mode_range(), -180., 180.).unit(unit.unwrap_or("°"))).into(),
Some("Multiplier") => number_widget(default_info, number_input.unit(unit.unwrap_or("x"))).into(), Some("Multiplier") => number_widget(default_info, bounded(number_input, f64::NEG_INFINITY, f64::INFINITY).unit(unit.unwrap_or("x"))).into(),
Some("PixelLength") => number_widget(default_info, number_input.min(min(0.)).unit(unit.unwrap_or(" px"))).into(), Some("PixelLength") => number_widget(default_info, bounded(number_input, 0., f64::INFINITY).unit(unit.unwrap_or(" px"))).into(),
Some("Length") => number_widget(default_info, number_input.min(min(0.))).into(), Some("Length") => number_widget(default_info, bounded(number_input, 0., f64::INFINITY)).into(),
Some("Fraction") => number_widget(default_info, number_input.mode_range().min(min(0.)).max(max(1.))).into(), Some("Fraction") => number_widget(default_info, bounded(number_input.mode_range(), 0., 1.)).into(),
Some("Progression") => progression_widget(default_info, number_input.min(min(0.))).into(), Some("Progression") => progression_widget(default_info, bounded(number_input, 0., f64::INFINITY)).into(),
Some("SignedInteger") => number_widget(default_info, number_input.int()).into(), Some("SignedInteger") => number_widget(default_info, bounded(number_input.int(), f64::NEG_INFINITY, f64::INFINITY)).into(),
Some("SeedValue") => number_widget(default_info, number_input.int().min(min(0.))).into(), Some("SeedValue") => number_widget(default_info, bounded(number_input.int(), 0., f64::INFINITY)).into(),
Some("PixelSize") => vec2_widget(default_info, "X", "Y", unit.unwrap_or(" px"), None, false), Some("PixelSize") => vec2_widget(default_info, "X", "Y", unit.unwrap_or(" px"), None, false),
Some("TextArea") => text_area_widget(default_info).into(), Some("TextArea") => text_area_widget(default_info).into(),
@@ -206,9 +234,9 @@ pub(crate) fn property_from_type(
// =============== // ===============
// PRIMITIVE TYPES // PRIMITIVE TYPES
// =============== // ===============
Some(x) if x == TypeId::of::<f64>() || x == TypeId::of::<f32>() => number_widget(default_info, number_input.min(min(f64::NEG_INFINITY)).max(max(f64::INFINITY))).into(), Some(x) if x == TypeId::of::<f64>() || x == TypeId::of::<f32>() => number_widget(default_info, bounded(number_input, f64::NEG_INFINITY, f64::INFINITY)).into(),
Some(x) if x == TypeId::of::<u32>() => number_widget(default_info, number_input.int().min(min(0.)).max(max(f64::from(u32::MAX)))).into(), Some(x) if x == TypeId::of::<u32>() => number_widget(default_info, bounded(number_input.int(), 0., f64::from(u32::MAX))).into(),
Some(x) if x == TypeId::of::<u64>() => number_widget(default_info, number_input.int().min(min(0.))).into(), Some(x) if x == TypeId::of::<u64>() => number_widget(default_info, bounded(number_input.int(), 0., f64::INFINITY)).into(),
Some(x) if x == TypeId::of::<bool>() => bool_widget(default_info, CheckboxInput::default()).into(), Some(x) if x == TypeId::of::<bool>() => bool_widget(default_info, CheckboxInput::default()).into(),
Some(x) if x == TypeId::of::<String>() => text_widget(default_info).into(), Some(x) if x == TypeId::of::<String>() => text_widget(default_info).into(),
Some(x) if x == TypeId::of::<DVec2>() => vec2_widget(default_info, "X", "Y", "", None, false), Some(x) if x == TypeId::of::<DVec2>() => vec2_widget(default_info, "X", "Y", "", None, false),
@@ -2295,7 +2323,7 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper
return Vec::new(); return Vec::new();
}; };
let mut number_options = (None, None, None); let mut number_options = NumberOptions::default();
let mut display_decimal_places = None; let mut display_decimal_places = None;
let mut step = None; let mut step = None;
let mut unit_suffix = None; let mut unit_suffix = None;
@@ -2307,7 +2335,13 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper
.get(proto_node_identifier) .get(proto_node_identifier)
.and_then(|metadata| metadata.fields.get(input_index)) .and_then(|metadata| metadata.fields.get(input_index))
{ {
number_options = (field.number_min, field.number_max, field.number_mode_range); number_options = NumberOptions {
soft_min: field.number_soft_min,
soft_max: field.number_soft_max,
hard_min: field.number_hard_min,
hard_max: field.number_hard_max,
slider: field.number_mode_range,
};
display_decimal_places = field.number_display_decimal_places; display_decimal_places = field.number_display_decimal_places;
unit_suffix = field.unit; unit_suffix = field.unit;
step = field.number_step; step = field.number_step;
+2 -2
View File
@@ -102,7 +102,7 @@ Instead of manually implementing the `Node` trait with complex generics, one can
```rs ```rs
#[node_macro::node(category("Raster: Adjustments"))] #[node_macro::node(category("Raster: Adjustments"))]
fn opacity(_input: (), #[default(424242)] color: Color, #[soft_min(0.1)] opacity_multiplier: f64) -> Color { fn opacity(_input: (), #[default(424242)] color: Color, #[range] #[soft(0..100)] opacity_multiplier: f64) -> Color {
let opacity_multiplier = opacity_multiplier as f32 / 100.; let opacity_multiplier = opacity_multiplier as f32 / 100.;
Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), color.a() * opacity_multiplier) Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), color.a() * opacity_multiplier)
} }
@@ -110,7 +110,7 @@ fn opacity(_input: (), #[default(424242)] color: Color, #[soft_min(0.1)] opacity
## Additional Macro Options ## Additional Macro Options
The macro invocation can be extended with additional attributes. The currently supported attributes are (`name`, `path`, `skip_impl`, `category`). When using generics the `#[implementations()]` attribute can be used to automatically populate the node_registry for you. You can also use the `default`, `expose`, `min`, `max` and `range_mode` attributes to influence how the properties are generated. The macro invocation can be extended with additional attributes. The currently supported attributes are (`name`, `path`, `skip_impl`, `category`). When using generics the `#[implementations()]` attribute can be used to automatically populate the node_registry for you. You can also use the `default`, `expose`, `soft`, `hard`, and `range` attributes to influence how the properties are generated. The `#[soft(a..b)]` and `#[hard(a..b)]` attributes set the slider's suggested extent and its enforced clamp, respectively (either endpoint may be omitted, e.g. `0..` or `..100`; both endpoints are inclusive, so there is no `..=` form), and `#[range]` renders the input as a draggable slider. Values typed into the input may exceed the soft extent but are clamped to the hard bounds, so `#[soft]` is only meaningful together with `#[range]`.
## Executing a document `NodeNetwork` ## Executing a document `NodeNetwork`
@@ -30,9 +30,13 @@ pub struct FieldMetadata {
pub widget_override: RegistryWidgetOverride, pub widget_override: RegistryWidgetOverride,
pub value_source: RegistryValueSource, pub value_source: RegistryValueSource,
pub default_type: Option<Type>, pub default_type: Option<Type>,
pub number_min: Option<f64>, /// The slider's suggested extent, from `#[soft(a..b)]`. Typed values may exceed it.
pub number_max: Option<f64>, pub number_soft_min: Option<f64>,
pub number_mode_range: Option<(f64, f64)>, pub number_soft_max: Option<f64>,
/// The enforced clamp, from `#[hard(a..b)]`. Applied to typed values and at eval time.
pub number_hard_min: Option<f64>,
pub number_hard_max: Option<f64>,
pub number_mode_range: bool,
pub number_display_decimal_places: Option<u32>, pub number_display_decimal_places: Option<u32>,
pub number_step: Option<f64>, pub number_step: Option<f64>,
pub unit: Option<&'static str>, pub unit: Option<&'static str>,
+19 -29
View File
@@ -196,36 +196,24 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
}) })
.collect(); .collect();
let number_min_values: Vec<_> = regular_fields let bound_values = |select: fn(&RegularParsedField) -> &Option<NumberBound>| -> Vec<_> {
.iter() regular_fields
.map(|field| match &field.ty { .iter()
ParsedFieldType::Regular(RegularParsedField { number_soft_min, number_hard_min, .. }) => match (number_soft_min, number_hard_min) { .map(|field| match &field.ty {
(Some(soft_min), _) => quote!(Some(#soft_min)), ParsedFieldType::Regular(regular) => select(regular).as_ref().map_or(quote!(None), |bound| quote!(Some(#bound))),
(None, Some(hard_min)) => quote!(Some(#hard_min)), _ => quote!(None),
(None, None) => quote!(None), })
}, .collect()
_ => quote!(None), };
}) let number_soft_min_values = bound_values(|field| &field.number_soft_min);
.collect(); let number_soft_max_values = bound_values(|field| &field.number_soft_max);
let number_max_values: Vec<_> = regular_fields let number_hard_min_values = bound_values(|field| &field.number_hard_min);
.iter() let number_hard_max_values = bound_values(|field| &field.number_hard_max);
.map(|field| match &field.ty {
ParsedFieldType::Regular(RegularParsedField { number_soft_max, number_hard_max, .. }) => match (number_soft_max, number_hard_max) {
(Some(soft_max), _) => quote!(Some(#soft_max)),
(None, Some(hard_max)) => quote!(Some(#hard_max)),
(None, None) => quote!(None),
},
_ => quote!(None),
})
.collect();
let number_mode_range_values: Vec<_> = regular_fields let number_mode_range_values: Vec<_> = regular_fields
.iter() .iter()
.map(|field| match &field.ty { .map(|field| match &field.ty {
ParsedFieldType::Regular(RegularParsedField { ParsedFieldType::Regular(RegularParsedField { number_mode_range, .. }) => quote!(#number_mode_range),
number_mode_range: Some(number_mode_range), _ => quote!(false),
..
}) => quote!(Some(#number_mode_range)),
_ => quote!(None),
}) })
.collect(); .collect();
let number_display_decimal_places: Vec<_> = regular_fields let number_display_decimal_places: Vec<_> = regular_fields
@@ -518,8 +506,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
exposed: #exposed, exposed: #exposed,
value_source: #value_sources, value_source: #value_sources,
default_type: #default_types, default_type: #default_types,
number_min: #number_min_values, number_soft_min: #number_soft_min_values,
number_max: #number_max_values, number_soft_max: #number_soft_max_values,
number_hard_min: #number_hard_min_values,
number_hard_max: #number_hard_max_values,
number_mode_range: #number_mode_range_values, number_mode_range: #number_mode_range_values,
number_display_decimal_places: #number_display_decimal_places, number_display_decimal_places: #number_display_decimal_places,
number_step: #number_step, number_step: #number_step,
+100 -58
View File
@@ -7,8 +7,8 @@ use syn::punctuated::Punctuated;
use syn::spanned::Spanned; use syn::spanned::Spanned;
use syn::token::{Comma, RArrow}; use syn::token::{Comma, RArrow};
use syn::{ use syn::{
AttrStyle, Attribute, Error, Expr, ExprTuple, FnArg, GenericParam, Ident, ItemFn, Lit, LitFloat, LitInt, LitStr, Meta, Pat, PatIdent, PatType, Path, ReturnType, TraitBound, Type, TypeImplTrait, AttrStyle, Attribute, Error, Expr, FnArg, GenericParam, Ident, ItemFn, Lit, LitFloat, LitInt, LitStr, Meta, Pat, PatIdent, PatType, Path, ReturnType, TraitBound, Type, TypeImplTrait, TypeParam,
TypeParam, TypeParamBound, Visibility, WhereClause, parse_quote, TypeParamBound, Visibility, WhereClause, parse_quote,
}; };
use crate::codegen::generate_node_code; use crate::codegen::generate_node_code;
@@ -126,7 +126,7 @@ pub enum ParsedFieldType {
Node(NodeParsedField), Node(NodeParsedField),
} }
/// A numeric bound value accepted by attributes like `#[soft_min]`, `#[hard_min]`, `#[soft_max]`, and `#[hard_max]`. /// A single numeric endpoint within a `#[soft(..)]` or `#[hard(..)]` bounds range.
/// Accepts both integer literals (e.g. `1`, `-1`) and float literals (e.g. `1.`, `-500.`). /// Accepts both integer literals (e.g. `1`, `-1`) and float literals (e.g. `1.`, `-500.`).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct NumberBound { pub struct NumberBound {
@@ -180,6 +180,52 @@ impl ToTokens for NumberBound {
} }
} }
/// A pair of numeric bounds parsed from the `#[soft(a..b)]` and `#[hard(a..b)]` attributes.
/// Either endpoint may be omitted for an open-ended bound (`a..` or `..b`), and each endpoint
/// independently accepts an integer or float literal (each cast to `f64`), so a mixed range like
/// `0..3.14159` is valid.
///
/// The operator is always the bare `..`; both endpoints are treated as inclusive (clamping reaches them).
/// Unlike a Rust range there is no `..=` form, `..` is purely this attribute DSL's bounds operator.
#[derive(Clone, Debug)]
pub struct NumberRange {
start: Option<NumberBound>,
end: Option<NumberBound>,
}
impl Parse for NumberRange {
fn parse(input: ParseStream) -> syn::Result<Self> {
if input.is_empty() {
return Err(input.error("expected a range like `0..100`, `..100`, or `0..`"));
}
// A leading endpoint is present unless the range opens directly into the `..` operator.
let start = if input.peek(syn::Token![..=]) || input.peek(syn::Token![..]) {
None
} else {
Some(input.parse::<NumberBound>()?)
};
// Only the bare `..` is accepted. `..=` is rejected even though both endpoints are inclusive here:
// this DSL treats `..` as its own bounds operator, deliberately diverging from Rust's range semantics.
if input.peek(syn::Token![..=]) {
return Err(input.error("use `..` rather than `..=` for number bounds; both endpoints are always inclusive (e.g. `0..100`)"));
}
if !input.peek(syn::Token![..]) {
return Err(input.error("expected a range like `0..100`, `..100`, or `0..`"));
}
input.parse::<syn::Token![..]>()?;
let end = if input.is_empty() { None } else { Some(input.parse::<NumberBound>()?) };
if start.is_none() && end.is_none() {
return Err(input.error("a bounds range must specify at least a lower or upper bound"));
}
Ok(NumberRange { start, end })
}
}
/// a param of any kind, either a concrete type or a generic type with a set of possible types specified via /// a param of any kind, either a concrete type or a generic type with a set of possible types specified via
/// `#[implementation(type)]` /// `#[implementation(type)]`
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -191,7 +237,8 @@ pub struct RegularParsedField {
pub number_soft_max: Option<NumberBound>, pub number_soft_max: Option<NumberBound>,
pub number_hard_min: Option<NumberBound>, pub number_hard_min: Option<NumberBound>,
pub number_hard_max: Option<NumberBound>, pub number_hard_max: Option<NumberBound>,
pub number_mode_range: Option<ExprTuple>, /// Whether the number input renders as a draggable slider (the `#[range]` attribute) rather than the default increment field.
pub number_mode_range: bool,
pub implementations: Punctuated<Type, Comma>, pub implementations: Punctuated<Type, Comma>,
pub gpu_image: bool, pub gpu_image: bool,
} }
@@ -680,47 +727,27 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul
_ => ParsedValueSource::None, _ => ParsedValueSource::None,
}; };
let number_soft_min = extract_attribute(attrs, "soft_min") // The slider's interactive extent (`#[soft(a..b)]`) and the enforced clamp (`#[hard(a..b)]`), each an
// optionally open-ended range. They decompose into the four bound values used by codegen and the UI.
let number_soft_bounds = extract_attribute(attrs, "soft")
.map(|attr| { .map(|attr| {
attr.parse_args() attr.parse_args::<NumberRange>()
.map_err(|e| Error::new_spanned(attr, format!("Invalid numerical `soft_min` value for argument '{ident}': {e}"))) .map_err(|e| Error::new_spanned(attr, format!("Invalid `soft` bounds for argument '{ident}': {e}\nUSAGE EXAMPLE: #[soft(0..100)]")))
}) })
.transpose()?; .transpose()?;
let number_soft_max = extract_attribute(attrs, "soft_max") let number_hard_bounds = extract_attribute(attrs, "hard")
.map(|attr| { .map(|attr| {
attr.parse_args() attr.parse_args::<NumberRange>()
.map_err(|e| Error::new_spanned(attr, format!("Invalid numerical `soft_max` value for argument '{ident}': {e}"))) .map_err(|e| Error::new_spanned(attr, format!("Invalid `hard` bounds for argument '{ident}': {e}\nUSAGE EXAMPLE: #[hard(0..100)]")))
}) })
.transpose()?; .transpose()?;
let number_soft_min = number_soft_bounds.as_ref().and_then(|range| range.start.clone());
let number_soft_max = number_soft_bounds.as_ref().and_then(|range| range.end.clone());
let number_hard_min = number_hard_bounds.as_ref().and_then(|range| range.start.clone());
let number_hard_max = number_hard_bounds.as_ref().and_then(|range| range.end.clone());
let number_hard_min = extract_attribute(attrs, "hard_min") // The `#[range]` marker selects the slider widget; its extent is derived from the soft (then hard) bounds.
.map(|attr| { let number_mode_range = extract_attribute(attrs, "range").is_some();
attr.parse_args()
.map_err(|e| Error::new_spanned(attr, format!("Invalid numerical `hard_min` value for argument '{ident}': {e}")))
})
.transpose()?;
let number_hard_max = extract_attribute(attrs, "hard_max")
.map(|attr| {
attr.parse_args()
.map_err(|e| Error::new_spanned(attr, format!("Invalid numerical `hard_max` value for argument '{ident}': {e}")))
})
.transpose()?;
let number_mode_range = extract_attribute(attrs, "range")
.map(|attr| {
attr.parse_args::<ExprTuple>().map_err(|e| {
Error::new_spanned(
attr,
format!("Invalid `range` tuple of min and max range slider values for argument '{ident}': {e}\nUSAGE EXAMPLE: #[range((0., 100.))]"),
)
})
})
.transpose()?;
if let Some(range) = &number_mode_range
&& range.elems.len() != 2
{
return Err(Error::new_spanned(range, "Expected a tuple of two values for `range` for the min and max, respectively"));
}
let unit = extract_attribute(attrs, "unit") let unit = extract_attribute(attrs, "unit")
.map(|attr| attr.parse_args::<LitStr>().map_err(|_e| Error::new_spanned(attr, "Expected a unit type as string".to_string()))) .map(|attr| attr.parse_args::<LitStr>().map_err(|_e| Error::new_spanned(attr, "Expected a unit type as string".to_string())))
@@ -810,15 +837,15 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul
.transpose()? .transpose()?
.unwrap_or_default(); .unwrap_or_default();
// Error if a float literal is given for a bound attribute on an integer-typed field // Error if a float literal is given for a bound on an integer-typed field
if is_integer_type(&ty) { if is_integer_type(&ty) {
let bound_attrs = [ let bound_attrs = [
(&number_soft_min, "soft_min"), (&number_soft_min, "soft", "lower"),
(&number_hard_min, "hard_min"), (&number_soft_max, "soft", "upper"),
(&number_soft_max, "soft_max"), (&number_hard_min, "hard", "lower"),
(&number_hard_max, "hard_max"), (&number_hard_max, "hard", "upper"),
]; ];
for (bound, attr_name) in bound_attrs { for (bound, attr_name, end) in bound_attrs {
if let Some(NumberBound { if let Some(NumberBound {
literal: NumberBoundLiteral::Float(_), literal: NumberBoundLiteral::Float(_),
.. ..
@@ -826,7 +853,7 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul
{ {
return Err(Error::new_spanned( return Err(Error::new_spanned(
&pat_ident, &pat_ident,
format!("Attribute `#[{attr_name}]` on `{ident}` has a float literal, but `{ident}` is an integer type. Use an integer literal without a decimal point."), format!("The {end} `#[{attr_name}]` bound on `{ident}` is a float literal, but `{ident}` is an integer type. Use an integer literal without a decimal point."),
)); ));
} }
} }
@@ -1082,7 +1109,7 @@ mod tests {
number_soft_max: None, number_soft_max: None,
number_hard_min: None, number_hard_min: None,
number_hard_max: None, number_hard_max: None,
number_mode_range: None, number_mode_range: false,
implementations: Punctuated::new(), implementations: Punctuated::new(),
gpu_image: false, gpu_image: false,
}), }),
@@ -1168,7 +1195,7 @@ mod tests {
number_soft_max: None, number_soft_max: None,
number_hard_min: None, number_hard_min: None,
number_hard_max: None, number_hard_max: None,
number_mode_range: None, number_mode_range: false,
implementations: Punctuated::new(), implementations: Punctuated::new(),
gpu_image: false, gpu_image: false,
}), }),
@@ -1236,7 +1263,7 @@ mod tests {
number_soft_max: None, number_soft_max: None,
number_hard_min: None, number_hard_min: None,
number_hard_max: None, number_hard_max: None,
number_mode_range: None, number_mode_range: false,
implementations: Punctuated::new(), implementations: Punctuated::new(),
gpu_image: false, gpu_image: false,
}), }),
@@ -1302,7 +1329,7 @@ mod tests {
number_soft_max: None, number_soft_max: None,
number_hard_min: None, number_hard_min: None,
number_hard_max: None, number_hard_max: None,
number_mode_range: None, number_mode_range: false,
implementations: { implementations: {
let mut p = Punctuated::new(); let mut p = Punctuated::new();
p.push(parse_quote!(f32)); p.push(parse_quote!(f32));
@@ -1330,9 +1357,9 @@ mod tests {
fn add( fn add(
a: f64, a: f64,
/// b /// b
#[range((0., 100.))] #[range]
#[soft_min(-500.)] #[soft(0..100)]
#[soft_max(500.)] #[hard(-500..500)]
b: f64, b: f64,
) -> f64 { ) -> f64 {
a + b a + b
@@ -1376,11 +1403,11 @@ mod tests {
ty: parse_quote!(f64), ty: parse_quote!(f64),
exposed: false, exposed: false,
value_source: ParsedValueSource::None, value_source: ParsedValueSource::None,
number_soft_min: Some(parse_quote!(-500.)), number_soft_min: Some(parse_quote!(0)),
number_soft_max: Some(parse_quote!(500.)), number_soft_max: Some(parse_quote!(100)),
number_hard_min: None, number_hard_min: Some(parse_quote!(-500)),
number_hard_max: None, number_hard_max: Some(parse_quote!(500)),
number_mode_range: Some(parse_quote!((0., 100.))), number_mode_range: true,
implementations: Punctuated::new(), implementations: Punctuated::new(),
gpu_image: false, gpu_image: false,
}), }),
@@ -1396,6 +1423,21 @@ mod tests {
assert_parsed_node_fn(&parsed, &expected); assert_parsed_node_fn(&parsed, &expected);
} }
#[test]
fn test_empty_bounds_range() {
let attr = quote!(category("Math: Arithmetic"));
let input = quote!(
fn add(a: f64, #[soft()] b: f64) -> f64 {
a + b
}
);
let result = parse_node_fn(attr, input);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("expected a range like `0..100`, `..100`, or `0..`"));
}
#[test] #[test]
fn test_async_node() { fn test_async_node() {
let attr = quote!(category("IO")); let attr = quote!(category("IO"));
@@ -1446,7 +1488,7 @@ mod tests {
number_soft_max: None, number_soft_max: None,
number_hard_min: None, number_hard_min: None,
number_hard_max: None, number_hard_max: None,
number_mode_range: None, number_mode_range: false,
implementations: Punctuated::new(), implementations: Punctuated::new(),
gpu_image: false, gpu_image: false,
}), }),
@@ -238,7 +238,7 @@ impl PerPixelAdjustCodegen<'_> {
number_soft_max: None, number_soft_max: None,
number_hard_min: None, number_hard_min: None,
number_hard_max: None, number_hard_max: None,
number_mode_range: None, number_mode_range: false,
implementations: Default::default(), implementations: Default::default(),
gpu_image: false, gpu_image: false,
}), }),
+52 -13
View File
@@ -10,6 +10,7 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> {
validate_implementations_for_generics, validate_implementations_for_generics,
validate_primary_input_expose, validate_primary_input_expose,
validate_min_max, validate_min_max,
validate_range_slider_bounds,
]; ];
for validator in validators { for validator in validators {
@@ -39,18 +40,18 @@ fn validate_min_max(parsed: &ParsedNodeFn) {
if soft_min_value == hard_min_value { if soft_min_value == hard_min_value {
emit_error!( emit_error!(
pat_ident.span(), pat_ident.span(),
"Unnecessary #[soft_min] attribute on `{}`, as #[hard_min] has the same value.", "Redundant lower bound on `{}`: the #[soft] and #[hard] lower bounds are equal.",
pat_ident.ident; pat_ident.ident;
help = "You can safely remove the #[soft_min] attribute from this field."; help = "Drop the lower bound from #[soft] and let the slider fall back to #[hard].";
note = "#[soft_min] is redundant when it equals #[hard_min].", note = "A soft bound only matters when it sits inside the corresponding hard bound.",
); );
} else if soft_min_value < hard_min_value { } else if soft_min_value < hard_min_value {
emit_error!( emit_error!(
pat_ident.span(), pat_ident.span(),
"The #[soft_min] attribute on `{}` is incorrectly greater than #[hard_min].", "The #[soft] lower bound on `{}` is below the #[hard] lower bound.",
pat_ident.ident; pat_ident.ident;
help = "You probably meant to reverse the two attribute values."; help = "The soft (slider) range must stay within the hard (clamped) range.";
note = "Allowing the possible slider range to preceed #[hard_min] doesn't make sense.", note = "Letting the slider range precede #[hard]'s lower bound doesn't make sense.",
); );
} }
} }
@@ -61,18 +62,18 @@ fn validate_min_max(parsed: &ParsedNodeFn) {
if soft_max_value == hard_max_value { if soft_max_value == hard_max_value {
emit_error!( emit_error!(
pat_ident.span(), pat_ident.span(),
"Unnecessary #[soft_max] attribute on `{}`, as #[hard_max] has the same value.", "Redundant upper bound on `{}`: the #[soft] and #[hard] upper bounds are equal.",
pat_ident.ident; pat_ident.ident;
help = "You can safely remove the #[soft_max] attribute from this field."; help = "Drop the upper bound from #[soft] and let the slider fall back to #[hard].";
note = "#[soft_max] is redundant when it equals #[hard_max].", note = "A soft bound only matters when it sits inside the corresponding hard bound.",
); );
} else if soft_max_value < hard_max_value { } else if soft_max_value > hard_max_value {
emit_error!( emit_error!(
pat_ident.span(), pat_ident.span(),
"The #[soft_max] attribute on `{}` is incorrectly greater than #[hard_max].", "The #[soft] upper bound on `{}` is above the #[hard] upper bound.",
pat_ident.ident; pat_ident.ident;
help = "You probably meant to reverse the two attribute values."; help = "The soft (slider) range must stay within the hard (clamped) range.";
note = "Allowing the possible slider range to exceed #[hard_max] doesn't make sense.", note = "Letting the slider range exceed #[hard]'s upper bound doesn't make sense.",
); );
} }
} }
@@ -80,6 +81,44 @@ fn validate_min_max(parsed: &ParsedNodeFn) {
} }
} }
/// A `#[range]` slider needs a defined extent on both ends. The extent comes from `#[soft]` when present,
/// otherwise it falls back to `#[hard]`, so each end must be covered by at least one of the two attributes.
fn validate_range_slider_bounds(parsed: &ParsedNodeFn) {
for field in &parsed.fields {
if let ParsedField {
ty: ParsedFieldType::Regular(RegularParsedField {
number_mode_range: true,
number_soft_min,
number_soft_max,
number_hard_min,
number_hard_max,
..
}),
pat_ident,
..
} = field
{
let min_bounded = number_soft_min.is_some() || number_hard_min.is_some();
let max_bounded = number_soft_max.is_some() || number_hard_max.is_some();
let missing = match (min_bounded, max_bounded) {
(true, true) => continue,
(false, false) => "lower and upper bounds",
(false, true) => "a lower bound",
(true, false) => "an upper bound",
};
emit_error!(
pat_ident.span(),
"The #[range] slider on `{}` is missing {}.",
pat_ident.ident, missing;
help = "A slider needs both ends defined; add the missing bound via #[soft(..)] or #[hard(..)], e.g. #[soft(0..100)].";
note = "The slider's extent comes from #[soft] if present, otherwise #[hard].",
);
}
}
}
fn validate_primary_input_expose(parsed: &ParsedNodeFn) { fn validate_primary_input_expose(parsed: &ParsedNodeFn) {
if let Some(ParsedField { if let Some(ParsedField {
ty: ParsedFieldType::Regular(RegularParsedField { exposed: true, .. }), ty: ParsedFieldType::Regular(RegularParsedField { exposed: true, .. }),
+3 -1
View File
@@ -156,7 +156,9 @@ async fn mirror<T: 'n + Send + Clone>(
content: List<T>, content: List<T>,
#[default(ReferencePoint::Center)] relative_to_bounds: ReferencePoint, #[default(ReferencePoint::Center)] relative_to_bounds: ReferencePoint,
#[unit(" px")] offset: f64, #[unit(" px")] offset: f64,
#[range((-90., 90.))] angle: Angle, #[range]
#[soft(-90..90)]
angle: Angle,
#[default(true)] keep_original: bool, #[default(true)] keep_original: bool,
) -> List<T> ) -> List<T>
where where
+5 -6
View File
@@ -22,13 +22,13 @@ fn text(
/// The font size used to draw the text. /// The font size used to draw the text.
#[unit(" px")] #[unit(" px")]
#[default(24.)] #[default(24.)]
#[hard_min(1.)] #[hard(1..)]
size: f64, size: f64,
/// The line height ratio, relative to the font size. Each line is drawn lower than its previous line by the distance of *Size* × *Line Height*. /// The line height ratio, relative to the font size. Each line is drawn lower than its previous line by the distance of *Size* × *Line Height*.
/// ///
/// 0 means all lines overlap. 1 means all lines are spaced by just the font size. 1.2 is a common default for readable text. 2 means double-spaced text. /// 0 means all lines overlap. 1 means all lines are spaced by just the font size. 1.2 is a common default for readable text. 2 means double-spaced text.
#[unit("x")] #[unit("x")]
#[hard_min(0.)] #[hard(0..)]
#[step(0.1)] #[step(0.1)]
#[default(1.2)] #[default(1.2)]
line_height: f64, line_height: f64,
@@ -38,15 +38,14 @@ fn text(
letter_spacing: f64, letter_spacing: f64,
/// The angle of faux italic slant applied to each glyph. /// The angle of faux italic slant applied to each glyph.
#[unit("°")] #[unit("°")]
#[hard_min(-85.)] #[hard(-85..85)]
#[hard_max(85.)]
letter_tilt: f64, letter_tilt: f64,
/// Enables the maximum width constraint so lines can wrap. /// Enables the maximum width constraint so lines can wrap.
#[widget(ParsedWidgetOverride::Hidden)] #[widget(ParsedWidgetOverride::Hidden)]
has_max_width: bool, has_max_width: bool,
/// The maximum width that the text block can occupy before wrapping to a new line. Otherwise, lines do not wrap. /// The maximum width that the text block can occupy before wrapping to a new line. Otherwise, lines do not wrap.
#[unit(" px")] #[unit(" px")]
#[hard_min(1.)] #[hard(1..)]
#[widget(ParsedWidgetOverride::Custom = "optional_f64")] #[widget(ParsedWidgetOverride::Custom = "optional_f64")]
max_width: f64, max_width: f64,
/// Whether the *Max Height* property is enabled so that lines beyond it are not drawn. /// Whether the *Max Height* property is enabled so that lines beyond it are not drawn.
@@ -54,7 +53,7 @@ fn text(
has_max_height: bool, has_max_height: bool,
/// The maximum height that the text block can occupy. Excess lines are not drawn. /// The maximum height that the text block can occupy. Excess lines are not drawn.
#[unit(" px")] #[unit(" px")]
#[hard_min(1.)] #[hard(1..)]
#[widget(ParsedWidgetOverride::Custom = "optional_f64")] #[widget(ParsedWidgetOverride::Custom = "optional_f64")]
max_height: f64, max_height: f64,
/// The horizontal alignment of each line of text within its surrounding box. To have an effect on a single line of text, *Max Width* must be set. /// The horizontal alignment of each line of text within its surrounding box. To have an effect on a single line of text, *Max Width* must be set.
+20 -13
View File
@@ -85,8 +85,9 @@ fn gamma_correction<T: Adjust<Color>>(
#[gpu_image] #[gpu_image]
mut input: T, mut input: T,
#[default(2.2)] #[default(2.2)]
#[range((0.01, 10.))] #[range]
#[hard_min(0.0001)] #[hard(0.0001..)]
#[soft(0.01..10)]
gamma: f32, gamma: f32,
inverse: bool, inverse: bool,
) -> T { ) -> T {
@@ -344,22 +345,28 @@ fn black_and_white<T: Adjust<Color>>(
mut image: T, mut image: T,
#[default(Color::BLACK)] tint: Color, #[default(Color::BLACK)] tint: Color,
#[default(40.)] #[default(40.)]
#[range((-200., 300.))] #[range]
#[soft(-200..300)]
reds: PercentageF32, reds: PercentageF32,
#[default(60.)] #[default(60.)]
#[range((-200., 300.))] #[range]
#[soft(-200..300)]
yellows: PercentageF32, yellows: PercentageF32,
#[default(40.)] #[default(40.)]
#[range((-200., 300.))] #[range]
#[soft(-200..300)]
greens: PercentageF32, greens: PercentageF32,
#[default(60.)] #[default(60.)]
#[range((-200., 300.))] #[range]
#[soft(-200..300)]
cyans: PercentageF32, cyans: PercentageF32,
#[default(20.)] #[default(20.)]
#[range((-200., 300.))] #[range]
#[soft(-200..300)]
blues: PercentageF32, blues: PercentageF32,
#[default(80.)] #[default(80.)]
#[range((-200., 300.))] #[range]
#[soft(-200..300)]
magentas: PercentageF32, magentas: PercentageF32,
) -> T { ) -> T {
image.adjust(|color| { image.adjust(|color| {
@@ -997,12 +1004,11 @@ fn posterize<T: Adjust<Color>>(
#[gpu_image] #[gpu_image]
mut input: T, mut input: T,
#[default(4)] #[default(4)]
#[hard_min(2)] #[hard(2..)]
levels: u32, levels: u32,
) -> T { ) -> T {
let levels = levels as f32;
input.adjust(|color| { input.adjust(|color| {
// `hard_min(2)` constrains the widget but doesn't bind the data-flow input (a saved doc or upstream node could still feed 0 or 1, producing inf/NaN below).
let levels = (levels as f32).max(2.);
let number_of_areas = levels.recip(); let number_of_areas = levels.recip();
let size_of_areas = (levels - 1.).recip(); let size_of_areas = (levels - 1.).recip();
color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas) color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas)
@@ -1029,8 +1035,9 @@ fn exposure<T: Adjust<Color>>(
exposure: f32, exposure: f32,
offset: f32, offset: f32,
#[default(1.)] #[default(1.)]
#[range((0.01, 10.))] #[range]
#[hard_min(0.0001)] #[hard(0.0001..)]
#[soft(0.01..10)]
gamma_correction: f32, gamma_correction: f32,
) -> T { ) -> T {
input.adjust(|color| { input.adjust(|color| {
+6 -4
View File
@@ -92,8 +92,9 @@ async fn blur(
/// The image to be blurred. /// The image to be blurred.
image_frame: List<Raster<CPU>>, image_frame: List<Raster<CPU>>,
/// The radius of the blur kernel. /// The radius of the blur kernel.
#[range((0., 100.))] #[range]
#[hard_min(0.)] #[hard(0..)]
#[soft(..100)]
radius: PixelLength, radius: PixelLength,
/// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts. /// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts.
box_blur: bool, box_blur: bool,
@@ -128,8 +129,9 @@ async fn median_filter(
/// The image to be filtered. /// The image to be filtered.
image_frame: List<Raster<CPU>>, image_frame: List<Raster<CPU>>,
/// The radius of the filter kernel. Larger values remove more noise but may blur fine details. /// The radius of the filter kernel. Larger values remove more noise but may blur fine details.
#[range((0., 50.))] #[range]
#[hard_min(0.)] #[hard(0..)]
#[soft(..50)]
radius: PixelLength, radius: PixelLength,
) -> List<Raster<CPU>> { ) -> List<Raster<CPU>> {
image_frame image_frame
@@ -8,7 +8,7 @@ async fn image_color_palette(
_: impl Ctx, _: impl Ctx,
image: List<Raster<CPU>>, image: List<Raster<CPU>>,
#[default(4)] #[default(4)]
#[hard_min(1)] #[hard(1..)]
count: u32, count: u32,
) -> List<Color> { ) -> List<Color> {
const GRID: f32 = 3.; const GRID: f32 = 3.;
+26 -8
View File
@@ -20,13 +20,13 @@ async fn repeat<T: Into<Graphic> + Default + Send + Clone + 'static>(
)] )]
content: impl Node<'n, Context<'static>, Output = List<T>>, content: impl Node<'n, Context<'static>, Output = List<T>>,
#[default(1)] #[default(1)]
#[hard_min(1)] #[hard(1..)]
count: u32, count: u32,
reverse: bool, reverse: bool,
) -> List<T> { ) -> List<T> {
// Someday this node can have the option to generate infinitely instead of a fixed count (basically `std::iter::repeat`). // Someday this node can have the option to generate infinitely instead of a fixed count (basically `std::iter::repeat`).
let count = count.max(1) as usize; let count = count as usize;
let mut result_list = List::new(); let mut result_list = List::new();
@@ -60,12 +60,12 @@ pub async fn repeat_array<T: Into<Graphic> + Default + Send + Clone + 'static>(
direction: PixelSize, direction: PixelSize,
angle: Angle, angle: Angle,
#[default(5)] #[default(5)]
#[hard_min(1)] #[hard(1..)]
count: u32, count: u32,
) -> List<T> { ) -> List<T> {
let angle = angle.to_radians(); let angle = angle.to_radians();
let count = count.max(1); // A single copy has no steps between copies, so the denominator is kept at 1 to avoid `0. / 0.` producing a NaN transform
let total = (count - 1) as f64; let total = (count - 1).max(1) as f64;
let mut result_list = List::new(); let mut result_list = List::new();
@@ -108,11 +108,9 @@ async fn repeat_radial<T: Into<Graphic> + Default + Send + Clone + 'static>(
#[default(5)] #[default(5)]
radius: f64, radius: f64,
#[default(5)] #[default(5)]
#[hard_min(1)] #[hard(1..)]
count: u32, count: u32,
) -> List<T> { ) -> List<T> {
let count = count.max(1);
let mut result_list = List::new(); let mut result_list = List::new();
for index in 0..count { for index in 0..count {
@@ -265,6 +263,26 @@ mod test {
} }
} }
#[tokio::test]
async fn repeat_single_copy() {
let context = OwnedContextImpl::default().into_context();
let repeated = super::repeat_array(
context,
&FutureWrapperNode(vector_node_from_bezpath(Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY))),
DVec2::new(12., 10.),
45.,
1,
)
.await;
let vector_list = vector_nodes::flatten_path(Footprint::default(), repeated).await;
let vector = vector_list.element(0).unwrap();
assert_eq!(vector.region_manipulator_groups().count(), 1);
let (_, manipulator_groups) = vector.region_manipulator_groups().next().unwrap();
let anchor = manipulator_groups[0].anchor;
assert!(anchor.length() < 1e-5, "Expected the single copy to be untransformed, found anchor {anchor}");
}
#[tokio::test] #[tokio::test]
async fn repeat_transform_position() { async fn repeat_transform_position() {
let direction = DVec2::new(12., 10.); let direction = DVec2::new(12., 10.);
+2 -2
View File
@@ -424,7 +424,7 @@ fn string_repeat(
string: String, string: String,
/// The number of times the string should appear in the output. /// The number of times the string should appear in the output.
#[default(2)] #[default(2)]
#[hard_min(1)] #[hard(1..)]
count: u32, count: u32,
/// The string placed between each repetition. /// The string placed between each repetition.
#[default("\\n")] #[default("\\n")]
@@ -436,7 +436,7 @@ fn string_repeat(
) -> String { ) -> String {
let separator = if separator_escaping { unescape_string(separator) } else { separator }; let separator = if separator_escaping { unescape_string(separator) } else { separator };
let count = count.max(1) as usize; let count = count as usize;
let mut result = String::with_capacity((string.len() + separator.len()) * count); let mut result = String::with_capacity((string.len() + separator.len()) * count);
for i in 0..count { for i in 0..count {
@@ -77,7 +77,8 @@ fn arc(
radius: f64, radius: f64,
start_angle: Angle, start_angle: Angle,
#[default(270.)] #[default(270.)]
#[range((0., 360.))] #[range]
#[soft(0..360)]
sweep_angle: Angle, sweep_angle: Angle,
arc_type: ArcType, arc_type: ArcType,
) -> List<Vector> { ) -> List<Vector> {
@@ -167,7 +168,7 @@ fn regular_polygon<T: AsU64>(
_: impl Ctx, _: impl Ctx,
_primary: (), _primary: (),
#[default(6)] #[default(6)]
#[hard_min(3.)] #[hard(3..)]
#[implementations(u32, u64, f64)] #[implementations(u32, u64, f64)]
sides: T, sides: T,
#[unit(" px")] #[unit(" px")]
@@ -185,7 +186,7 @@ fn star<T: AsU64>(
_: impl Ctx, _: impl Ctx,
_primary: (), _primary: (),
#[default(5)] #[default(5)]
#[hard_min(2.)] #[hard(2..)]
#[implementations(u32, u64, f64)] #[implementations(u32, u64, f64)]
sides: T, sides: T,
#[unit(" px")] #[unit(" px")]
@@ -228,7 +229,7 @@ fn qr_code(
text: String, text: String,
#[widget(ParsedWidgetOverride::Hidden)] has_size: bool, #[widget(ParsedWidgetOverride::Hidden)] has_size: bool,
#[unit(" px")] #[unit(" px")]
#[hard_min(1.)] #[hard(1..)]
#[widget(ParsedWidgetOverride::Custom = "optional_f64")] #[widget(ParsedWidgetOverride::Custom = "optional_f64")]
size: f64, size: f64,
error_correction: QRCodeErrorCorrectionLevel, error_correction: QRCodeErrorCorrectionLevel,
@@ -267,7 +268,7 @@ fn qr_code(
}; };
if has_size { if has_size {
vector.transform(glam::DAffine2::from_scale(DVec2::splat(size.max(1.) / qr_code.size() as f64))); vector.transform(glam::DAffine2::from_scale(DVec2::splat(size / qr_code.size() as f64)));
} }
List::new_from_element(vector) List::new_from_element(vector)
@@ -312,7 +313,7 @@ fn grid<T: GridSpacing>(
_primary: (), _primary: (),
grid_type: GridType, grid_type: GridType,
#[unit(" px")] #[unit(" px")]
#[hard_min(0.)] #[hard(0..)]
#[default(10)] #[default(10)]
#[implementations(f64, DVec2)] #[implementations(f64, DVec2)]
spacing: T, spacing: T,
+23 -20
View File
@@ -263,21 +263,25 @@ async fn copy_to_points<I: 'n + Send + Clone>(
content: List<I>, content: List<I>,
/// Minimum range of randomized sizes given to each placed copy. /// Minimum range of randomized sizes given to each placed copy.
#[default(1)] #[default(1)]
#[range((0., 2.))] #[range]
#[soft(0..2)]
#[unit("x")] #[unit("x")]
random_scale_min: Multiplier, random_scale_min: Multiplier,
/// Maximum range of randomized sizes given to each placed copy. /// Maximum range of randomized sizes given to each placed copy.
#[default(1)] #[default(1)]
#[range((0., 2.))] #[range]
#[soft(0..2)]
#[unit("x")] #[unit("x")]
random_scale_max: Multiplier, random_scale_max: Multiplier,
/// Bias for the probability distribution of randomized sizes (0 is uniform, negatives favor more of small sizes, positives favor more of large sizes). /// Bias for the probability distribution of randomized sizes (0 is uniform, negatives favor more of small sizes, positives favor more of large sizes).
#[range((-50., 50.))] #[range]
#[soft(-50..50)]
random_scale_bias: f64, random_scale_bias: f64,
/// Seed to determine unique variations on all the randomized copy sizes. /// Seed to determine unique variations on all the randomized copy sizes.
random_scale_seed: SeedValue, random_scale_seed: SeedValue,
/// Range of randomized angles given to each placed copy, in degrees ranging from furthest clockwise to counterclockwise. /// Range of randomized angles given to each placed copy, in degrees ranging from furthest clockwise to counterclockwise.
#[range((0., 360.))] #[range]
#[soft(0..360)]
random_rotation: Angle, random_rotation: Angle,
/// Seed to determine unique variations on all the randomized copy angles. /// Seed to determine unique variations on all the randomized copy angles.
random_rotation_seed: SeedValue, random_rotation_seed: SeedValue,
@@ -337,18 +341,16 @@ async fn copy_to_points<I: 'n + Send + Clone>(
async fn round_corners( async fn round_corners(
_: impl Ctx, _: impl Ctx,
source: List<Vector>, source: List<Vector>,
#[hard_min(0.)] #[hard(0..)]
#[default(10.)] #[default(10.)]
radius: PixelLength, radius: PixelLength,
#[range((0., 1.))] #[range]
#[hard_min(0.)] #[hard(0..1)]
#[hard_max(1.)]
#[default(0.5)] #[default(0.5)]
roundness: f64, roundness: f64,
#[default(100.)] edge_length_limit: Percentage, #[default(100.)] edge_length_limit: Percentage,
#[range((0., 180.))] #[range]
#[hard_min(0.)] #[hard(0..180)]
#[hard_max(180.)]
#[default(5.)] #[default(5.)]
min_angle_threshold: Angle, min_angle_threshold: Angle,
) -> List<Vector> { ) -> List<Vector> {
@@ -452,7 +454,7 @@ pub fn merge_by_distance(
_: impl Ctx, _: impl Ctx,
content: List<Vector>, content: List<Vector>,
#[default(0.1)] #[default(0.1)]
#[hard_min(0.0001)] #[hard(0.0001..)]
distance: PixelLength, distance: PixelLength,
algorithm: MergeByDistanceAlgorithm, algorithm: MergeByDistanceAlgorithm,
) -> List<Vector> { ) -> List<Vector> {
@@ -892,8 +894,8 @@ async fn auto_tangents(
source: List<Vector>, source: List<Vector>,
/// The amount of spread for the auto-tangents, from 0 (sharp corner) to 1 (full spread). /// The amount of spread for the auto-tangents, from 0 (sharp corner) to 1 (full spread).
#[default(0.5)] #[default(0.5)]
// TODO: Make this a soft range to allow any value to be typed in outside the slider range of 0 to 1 #[range]
#[range((0., 1.))] #[soft(0..1)]
spread: f64, spread: f64,
/// If active, existing non-zero handles won't be affected. /// If active, existing non-zero handles won't be affected.
#[default(true)] #[default(true)]
@@ -1376,16 +1378,16 @@ async fn sample_polyline(
content: List<Vector>, content: List<Vector>,
spacing: PointSpacingType, spacing: PointSpacingType,
#[default(100.)] #[default(100.)]
#[hard_min(0.)] #[hard(0..)]
#[unit(" px")] #[unit(" px")]
separation: f64, separation: f64,
#[default(100)] #[default(100)]
#[hard_min(2)] #[hard(2..)]
quantity: u32, quantity: u32,
#[hard_min(0.)] #[hard(0..)]
#[unit(" px")] #[unit(" px")]
start_offset: f64, start_offset: f64,
#[hard_min(0.)] #[hard(0..)]
#[unit(" px")] #[unit(" px")]
stop_offset: f64, stop_offset: f64,
adaptive_spacing: bool, adaptive_spacing: bool,
@@ -1830,8 +1832,9 @@ async fn scatter_points(
content: List<Vector>, content: List<Vector>,
#[unit(" px")] #[unit(" px")]
#[default(10.)] #[default(10.)]
#[hard_min(0.01)] #[range]
#[range((1., 100.))] #[hard(0.01..)]
#[soft(1..100)]
separation: f64, separation: f64,
seed: SeedValue, seed: SeedValue,
) -> List<Vector> { ) -> List<Vector> {