mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
Make Data panel scalars selectable inline, denoise float display, and standardize breadcrumb string truncation (#4470)
* Make Data panel scalars selectable inline, denoise float display, and move breadcrumb truncation to Rust * Code review fixes * Remove denoising from f32
This commit is contained in:
@@ -233,6 +233,24 @@ pub struct ColorInput {
|
||||
pub on_commit: WidgetCallback<()>,
|
||||
}
|
||||
|
||||
/// Shortens a breadcrumb label to fit the trail, replacing the tail with an ellipsis.
|
||||
/// Quotes delimit a string value, so a quoted label budgets only the text between them.
|
||||
pub fn truncate_breadcrumb_label(label: &str) -> String {
|
||||
const MAX_CHARACTERS: usize = 40;
|
||||
|
||||
let quoted = label.len() >= 2 && label.starts_with('"') && label.ends_with('"');
|
||||
let content = if quoted { &label[1..label.len() - 1] } else { label };
|
||||
|
||||
if content.chars().count() <= MAX_CHARACTERS {
|
||||
return label.to_string();
|
||||
}
|
||||
|
||||
let mut truncated: String = content.chars().take(MAX_CHARACTERS - 1).collect();
|
||||
truncated.push('…');
|
||||
|
||||
if quoted { format!("\"{truncated}\"") } else { truncated }
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
|
||||
@@ -64,6 +64,8 @@ pub struct TextLabel {
|
||||
pub italic: bool,
|
||||
pub monospace: bool,
|
||||
pub multiline: bool,
|
||||
pub enquote: bool,
|
||||
pub selectable: bool,
|
||||
#[serde(rename = "centerAlign")]
|
||||
pub center_align: bool,
|
||||
#[serde(rename = "tableAlign")]
|
||||
|
||||
@@ -13,6 +13,7 @@ use graphene_std::color::SRGBA8;
|
||||
use graphene_std::extract_xy::XY;
|
||||
use graphene_std::gradient::Gradient;
|
||||
use graphene_std::list::{Item, List, NodeIdPath};
|
||||
use graphene_std::math::float_noise::round_away_float_noise;
|
||||
use graphene_std::memo::IORecord;
|
||||
use graphene_std::raster::{
|
||||
CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice,
|
||||
@@ -150,7 +151,8 @@ impl DataPanelMessageHandler {
|
||||
|
||||
// Element path breadcrumbs
|
||||
if !layout_data.breadcrumbs.is_empty() {
|
||||
let breadcrumb = BreadcrumbTrailButtons::new(layout_data.breadcrumbs)
|
||||
let labels = layout_data.breadcrumbs.iter().map(|label| truncate_breadcrumb_label(label)).collect();
|
||||
let breadcrumb = BreadcrumbTrailButtons::new(labels)
|
||||
.on_update(|&len| DataPanelMessage::TruncateElementPath { len: len as usize }.into())
|
||||
.widget_instance();
|
||||
widgets.push(breadcrumb);
|
||||
@@ -796,18 +798,41 @@ impl TableItemLayout for Gradient {
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_table_item_layout_for_number {
|
||||
($($ty:ty => $type_name:literal),* $(,)?) => {
|
||||
$(
|
||||
impl TableItemLayout for $ty {
|
||||
fn type_name() -> &'static str {
|
||||
$type_name
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self}")
|
||||
}
|
||||
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
|
||||
vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()]
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
impl_table_item_layout_for_number!(
|
||||
f32 => "Number (f32)",
|
||||
u32 => "Number (u32)",
|
||||
i32 => "Number (i32)",
|
||||
u64 => "Number (u64)",
|
||||
i64 => "Number (i64)",
|
||||
);
|
||||
|
||||
// Denoised so 0.1 + 0.2 reads as 0.3 rather than 0.30000000000000004. We don't do this for f32 because it lacks precision to reliably distinguish between intentional digits and noise.
|
||||
impl TableItemLayout for f64 {
|
||||
fn type_name() -> &'static str {
|
||||
"Number (f64)"
|
||||
"Number"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self}")
|
||||
format!("{}", round_away_float_noise(*self))
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![
|
||||
NumberInput::new(Some(*self)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
|
||||
])]
|
||||
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
|
||||
vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,88 +841,10 @@ impl TableItemLayout for u8 {
|
||||
"Byte"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self:02X}")
|
||||
format!("0x{self:02X} ({self})")
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled with the hex string via `identifier`); the value page shows the same hex value as a label.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![TextLabel::new(self.identifier()).widget_instance()])]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableItemLayout for f32 {
|
||||
fn type_name() -> &'static str {
|
||||
"Number (f32)"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self}")
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![
|
||||
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
|
||||
])]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableItemLayout for u32 {
|
||||
fn type_name() -> &'static str {
|
||||
"Number (u32)"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self}")
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![
|
||||
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
|
||||
])]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableItemLayout for i32 {
|
||||
fn type_name() -> &'static str {
|
||||
"Number (i32)"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self}")
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![
|
||||
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
|
||||
])]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableItemLayout for i64 {
|
||||
fn type_name() -> &'static str {
|
||||
"Number (i64)"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self}")
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
|
||||
// TODO: Make this robust for large i64 values that don't fit in f64 (beyond roughly 2^53), as with u64.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![
|
||||
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
|
||||
])]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableItemLayout for u64 {
|
||||
fn type_name() -> &'static str {
|
||||
"Number (u64)"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
format!("{self}")
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
|
||||
// TODO: Make this robust for large u64 values that don't fit in f64 (above roughly 2^53). Perhaps using a bigint kind of approach through the widget's data flow.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![
|
||||
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
|
||||
])]
|
||||
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
|
||||
vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,20 +868,50 @@ impl TableItemLayout for String {
|
||||
"String"
|
||||
}
|
||||
fn identifier(&self) -> String {
|
||||
// Show the first line, and if there are more, indicate that with an ellipsis
|
||||
let first_line = self.lines().next().unwrap_or("");
|
||||
if self.lines().count() > 1 {
|
||||
format!("\"{} …\"", first_line)
|
||||
} else {
|
||||
format!("\"{}\"", first_line)
|
||||
}
|
||||
format!("\"{}\"", string_preview(self))
|
||||
}
|
||||
fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
data.breadcrumbs.push(character_count_label(self));
|
||||
self.value_page(data)
|
||||
}
|
||||
// The preview truncates for length and line breaks, so a button beside it reaches the full text, labeled by length
|
||||
fn value_widgets(&self, target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
|
||||
vec![
|
||||
TextLabel::new(string_preview(self)).enquote(true).selectable(true).monospace(true).narrow(true).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
TextButton::new(character_count_label(self))
|
||||
.on_update(move |_| DataPanelMessage::PushToElementPath { step: target.clone() }.into())
|
||||
.narrow(true)
|
||||
.widget_instance(),
|
||||
]
|
||||
}
|
||||
// Values fall back to the default drill-in button (labeled with the truncated quoted preview via `identifier`); the value page shows the full multi-line text in a `TextAreaInput`.
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(vec![TextAreaInput::new(self.to_string()).monospace(true).disabled(true).widget_instance()])]
|
||||
}
|
||||
}
|
||||
|
||||
fn character_count_label(value: &str) -> String {
|
||||
let character_count = value.chars().count();
|
||||
|
||||
format!("{character_count} Char{}", if character_count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
/// Shortens a string to fit a table cell, cutting at the first line break or 40 characters with an ellipsis.
|
||||
/// The 40 matches `truncate_breadcrumb_label`, so a preview reaching the trail isn't cut twice.
|
||||
fn string_preview(value: &str) -> String {
|
||||
const MAX_CHARACTERS: usize = 40;
|
||||
|
||||
let first_line = value.lines().next().unwrap_or_default();
|
||||
let cut_by_line_break = value.contains('\n');
|
||||
let cut_by_length = first_line.chars().count() > MAX_CHARACTERS;
|
||||
|
||||
if !cut_by_line_break && !cut_by_length {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
first_line.chars().take(MAX_CHARACTERS - 1).chain(['…']).collect()
|
||||
}
|
||||
|
||||
impl TableItemLayout for Option<f64> {
|
||||
fn type_name() -> &'static str {
|
||||
"Option<f64>"
|
||||
@@ -943,7 +920,12 @@ impl TableItemLayout for Option<f64> {
|
||||
"Option<f64>".to_string()
|
||||
}
|
||||
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
|
||||
vec![TextLabel::new(format!("{self:?}")).narrow(true).widget_instance()]
|
||||
let text = match self {
|
||||
Some(value) => format!("Some({})", round_away_float_noise(*value)),
|
||||
None => "None".to_string(),
|
||||
};
|
||||
|
||||
vec![TextLabel::new(text).selectable(true).narrow(true).widget_instance()]
|
||||
}
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(self.value_widgets(PathStep::Element(0), _data))]
|
||||
@@ -988,7 +970,7 @@ impl TableItemLayout for DAffine2 {
|
||||
"Transform".to_string()
|
||||
}
|
||||
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
|
||||
vec![TextLabel::new(format_transform_matrix(*self)).narrow(true).widget_instance()]
|
||||
transform_widgets(*self)
|
||||
}
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(self.value_widgets(PathStep::Element(0), _data))]
|
||||
@@ -1003,8 +985,7 @@ impl TableItemLayout for Affine2 {
|
||||
"Transform".to_string()
|
||||
}
|
||||
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
|
||||
let matrix = DAffine2::from_cols_array(&self.to_cols_array().map(|x| x as f64));
|
||||
vec![TextLabel::new(format_transform_matrix(matrix)).narrow(true).widget_instance()]
|
||||
transform_widgets(DAffine2::from_cols_array(&self.to_cols_array().map(|x| x as f64)))
|
||||
}
|
||||
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
|
||||
vec![LayoutGroup::row(self.value_widgets(PathStep::Element(0), _data))]
|
||||
@@ -1385,7 +1366,8 @@ fn drilldown_attribute_layout(any: &dyn Any, data: &mut LayoutData) -> Option<Ve
|
||||
None
|
||||
}
|
||||
|
||||
fn format_transform_matrix(transform: DAffine2) -> String {
|
||||
/// Decomposes a transform into location, rotation, and scale, each headed by the icon of its Layer menu action.
|
||||
fn transform_widgets(transform: DAffine2) -> Vec<WidgetInstance> {
|
||||
let (scale, angle, translation) = if transform.matrix2.determinant().abs() <= f64::EPSILON {
|
||||
let [col_0, col_1] = transform.matrix2.to_cols_array_2d().map(|[x, y]| DVec2::new(x, y));
|
||||
|
||||
@@ -1403,15 +1385,24 @@ fn format_transform_matrix(transform: DAffine2) -> String {
|
||||
} else {
|
||||
transform.to_scale_angle_translation()
|
||||
};
|
||||
let rotation = format_rounded(angle.to_degrees(), 3);
|
||||
|
||||
format!(
|
||||
"Location: ({} px, {} px) — Rotation: {rotation}° — Scale: ({}x, {}x)",
|
||||
format_rounded(translation.x, 3),
|
||||
format_rounded(translation.y, 3),
|
||||
format_rounded(scale.x, 3),
|
||||
format_rounded(scale.y, 3)
|
||||
)
|
||||
let location_text = format!("({} px, {} px)", format_rounded(translation.x, 3), format_rounded(translation.y, 3));
|
||||
let rotation_text = format!("{}°", format_rounded(angle.to_degrees(), 3));
|
||||
let scale_text = format!("({}x, {}x)", format_rounded(scale.x, 3), format_rounded(scale.y, 3));
|
||||
|
||||
vec![
|
||||
IconLabel::new("TransformationGrab").tooltip_label("Location").widget_instance(),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
TextLabel::new(location_text).narrow(true).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
IconLabel::new("TransformationRotate").tooltip_label("Rotation").widget_instance(),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
TextLabel::new(rotation_text).narrow(true).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
IconLabel::new("TransformationScale").tooltip_label("Scale").widget_instance(),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
TextLabel::new(scale_text).narrow(true).widget_instance(),
|
||||
]
|
||||
}
|
||||
|
||||
fn format_dvec2(value: DVec2) -> String {
|
||||
|
||||
@@ -2429,7 +2429,7 @@ impl NodeGraphMessageHandler {
|
||||
if subgraph_path_names_length >= 2 {
|
||||
widgets.extend([
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
BreadcrumbTrailButtons::new(subgraph_path_names)
|
||||
BreadcrumbTrailButtons::new(subgraph_path_names.iter().map(|name| truncate_breadcrumb_label(name)).collect())
|
||||
.on_update(move |index| {
|
||||
DocumentMessage::ExitNestedNetwork {
|
||||
steps_back: subgraph_path_names_length - (*index as usize) - 1,
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::messages::tool::utility_types::*;
|
||||
use glam::{DAffine2, DMat2, DVec2};
|
||||
use graph_craft::document::NodeInput;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graphene_std::math::float_noise::round_away_float_noise;
|
||||
use graphene_std::vector::algorithms::shapes::{arc_bezpath, regular_polygon_bezpath, star_polygon_bezpath};
|
||||
use graphene_std::vector::click_target::ClickTargetType;
|
||||
use graphene_std::vector::misc::{ArcType, GridType, SpiralType, dvec2_to_point};
|
||||
@@ -570,26 +571,6 @@ pub fn format_rounded(value: f64, precision: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recovers the intended number from floating point imprecision noise when that can be done reliably, e.g. 0.30000000000000004 -> 0.3.
|
||||
/// Rounding to each significant digit count from 1 to 12, the first candidate within a relative 1e-13 of the original is accepted.
|
||||
/// Actual high-precision values (like 0.3333333333333333) never pass the tolerance and are returned unchanged.
|
||||
pub fn round_away_float_noise(value: f64) -> f64 {
|
||||
if value == 0. || !value.is_finite() {
|
||||
return if value == 0. { 0. } else { value };
|
||||
}
|
||||
|
||||
let exponent = value.abs().log10().floor() as i32;
|
||||
for significant_digits in 1..=12 {
|
||||
let scale = 10_f64.powi(significant_digits - 1 - exponent);
|
||||
let rounded = (value * scale).round() / scale;
|
||||
if ((rounded - value) / value).abs() < 1e-13 {
|
||||
return rounded;
|
||||
}
|
||||
}
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
/// Gives the approximated angle to display in degrees, given an angle in degrees.
|
||||
pub fn calculate_display_angle(angle: f64) -> f64 {
|
||||
if angle.is_sign_positive() {
|
||||
@@ -634,37 +615,6 @@ pub fn extract_grid_parameters(layer: LayerNodeIdentifier, document: &DocumentMe
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_snaps_noisy_values() {
|
||||
assert_eq!(round_away_float_noise(0.1 + 0.2), 0.3);
|
||||
assert_eq!(round_away_float_noise(0.3000000000000012), 0.3);
|
||||
assert_eq!(round_away_float_noise(2.99999999999993), 3.);
|
||||
assert_eq!(round_away_float_noise(45.00000000000001), 45.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_keeps_honest_values() {
|
||||
assert_eq!(round_away_float_noise(1. / 3.), 1. / 3.);
|
||||
assert_eq!(round_away_float_noise(0.2394023940209349), 0.2394023940209349);
|
||||
assert_eq!(round_away_float_noise(0.25), 0.25);
|
||||
assert_eq!(round_away_float_noise(-17.5), -17.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_keeps_deliberate_values_with_zero_runs() {
|
||||
assert_eq!(round_away_float_noise(0.30000005), 0.30000005);
|
||||
assert_eq!(round_away_float_noise(0.3000000000001), 0.3000000000001);
|
||||
assert_eq!(round_away_float_noise(1.00000001), 1.00000001);
|
||||
assert_eq!(round_away_float_noise(2.9999993), 2.9999993);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_normalizes_zero() {
|
||||
let result = round_away_float_noise(-0.);
|
||||
assert_eq!(result, 0.);
|
||||
assert!(result.is_sign_positive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_rounded_trims_trailing_zeros_when_exact() {
|
||||
assert_eq!(format_rounded(0.25, 2), "0.25");
|
||||
|
||||
@@ -12,31 +12,11 @@
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (index: number) => void;
|
||||
|
||||
function truncate(label: string): string {
|
||||
let maxLength = 40;
|
||||
|
||||
if (label.length <= maxLength) return label;
|
||||
|
||||
let truncated = label;
|
||||
const hasQuotes = label.startsWith(`"`) && label.endsWith(`"`);
|
||||
|
||||
if (hasQuotes) {
|
||||
truncated = label.slice(1, -1);
|
||||
maxLength -= 2;
|
||||
}
|
||||
|
||||
truncated = truncated.slice(0, maxLength - 1) + "…";
|
||||
|
||||
if (hasQuotes) truncated = `"${truncated}"`;
|
||||
|
||||
return truncated;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="breadcrumb-trail-buttons" {tooltipLabel} {tooltipDescription} {tooltipShortcut}>
|
||||
{#each labels as label, index}
|
||||
<TextButton label={truncate(label)} emphasized={index === labels.length - 1} {disabled} action={() => !disabled && index !== labels.length - 1 && action(index)} />
|
||||
<TextButton {label} emphasized={index === labels.length - 1} {disabled} action={() => !disabled && index !== labels.length - 1 && action(index)} />
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
|
||||
@@ -205,6 +205,10 @@
|
||||
textarea {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
input {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
export let italic = false;
|
||||
export let monospace = false;
|
||||
export let multiline = false;
|
||||
export let enquote = false;
|
||||
export let selectable = false;
|
||||
export let centerAlign = false;
|
||||
export let tableAlign = false;
|
||||
// Sizing
|
||||
@@ -76,6 +78,8 @@
|
||||
class:italic
|
||||
class:monospace
|
||||
class:multiline
|
||||
class:enquote
|
||||
class:selectable
|
||||
class:center-align={centerAlign}
|
||||
class:table-align={tableAlign}
|
||||
style:min-width={minWidthCharacters ? `${minWidthCharacters}ch` : minWidth > 0 ? `${minWidth}px` : undefined}
|
||||
@@ -125,6 +129,17 @@
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
&.enquote::before,
|
||||
&.enquote::after {
|
||||
content: '"';
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.selectable {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
&.center-align {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ export function onPointerMove(e: PointerEvent, editor: EditorWrapper, documentSt
|
||||
|
||||
export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStore: DialogStore) {
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
potentiallyClearTextSelection(e);
|
||||
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
const isTargetingCanvas = !inFloatingMenu && e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
@@ -351,6 +352,20 @@ function targetIsTextField(target: EventTarget | HTMLElement | undefined): boole
|
||||
);
|
||||
}
|
||||
|
||||
function potentiallyClearTextSelection(e: PointerEvent) {
|
||||
const target = e.target instanceof Element ? e.target : undefined;
|
||||
if (target && (targetIsTextField(target) || window.getComputedStyle(target).userSelect !== "none")) return;
|
||||
|
||||
// A text control's selection lives in its shadow tree, which the document's `Selection` reports as collapsed and cannot clear, so each control holding one is collapsed through its own API
|
||||
const controls = window.document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("textarea, input[type='text']");
|
||||
controls.forEach((control) => {
|
||||
const caret = control.selectionStart;
|
||||
if (typeof caret === "number" && caret !== control.selectionEnd) control.setSelectionRange(caret, caret);
|
||||
});
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
function potentiallyRestoreCanvasFocus(e: Event) {
|
||||
const appElement = window.document.querySelector("[data-app-container]");
|
||||
const app = appElement instanceof HTMLElement ? appElement : undefined;
|
||||
|
||||
71
node-graph/libraries/core-types/src/math/float_noise.rs
Normal file
71
node-graph/libraries/core-types/src/math/float_noise.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
/// Recovers the intended number from floating point imprecision noise when that can be done reliably, e.g. 0.30000000000000004 -> 0.3.
|
||||
/// Rounding to each significant digit count from 1 to 12, the first candidate within a relative 1e-13 of the original is accepted.
|
||||
/// Actual high-precision values (like 0.3333333333333333) never pass the tolerance and are returned unchanged.
|
||||
/// f64 only, as f32 lacks precision to reliably distinguish between intentional digits and noise.
|
||||
pub fn round_away_float_noise(value: f64) -> f64 {
|
||||
if value == 0. || !value.is_finite() {
|
||||
return if value == 0. { 0. } else { value };
|
||||
}
|
||||
|
||||
// Candidates come from decimal formatting rather than scaling by a power of ten, which is inexact enough to invent
|
||||
// noise of its own: it turns 1e300 into 9.999999999999999e299 and 999999.9999999 into 999999.9999999999.
|
||||
// One buffer serves every candidate, since the digit counts are tried in turn.
|
||||
let mut buffer = String::with_capacity(32);
|
||||
for significant_digits in 1..=12 {
|
||||
buffer.clear();
|
||||
let _ = write!(buffer, "{value:.*e}", significant_digits - 1);
|
||||
|
||||
let Ok(rounded) = buffer.parse::<f64>() else { continue };
|
||||
if ((rounded - value) / value).abs() < 1e-13 {
|
||||
return rounded;
|
||||
}
|
||||
}
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_snaps_noisy_values() {
|
||||
assert_eq!(round_away_float_noise(0.1 + 0.2), 0.3);
|
||||
assert_eq!(round_away_float_noise(0.3000000000000012), 0.3);
|
||||
assert_eq!(round_away_float_noise(2.99999999999993), 3.);
|
||||
assert_eq!(round_away_float_noise(45.00000000000001), 45.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_keeps_honest_values() {
|
||||
assert_eq!(round_away_float_noise(1. / 3.), 1. / 3.);
|
||||
assert_eq!(round_away_float_noise(0.2394023940209349), 0.2394023940209349);
|
||||
assert_eq!(round_away_float_noise(0.25), 0.25);
|
||||
assert_eq!(round_away_float_noise(-17.5), -17.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_keeps_deliberate_values_with_zero_runs() {
|
||||
assert_eq!(round_away_float_noise(0.30000005), 0.30000005);
|
||||
assert_eq!(round_away_float_noise(0.3000000000001), 0.3000000000001);
|
||||
assert_eq!(round_away_float_noise(1.00000001), 1.00000001);
|
||||
assert_eq!(round_away_float_noise(2.9999993), 2.9999993);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_normalizes_zero() {
|
||||
let result = round_away_float_noise(-0.);
|
||||
assert_eq!(result, 0.);
|
||||
assert!(result.is_sign_positive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_away_float_noise_keeps_extreme_magnitudes_exact() {
|
||||
assert_eq!(round_away_float_noise(1e300), 1e300);
|
||||
assert_eq!(round_away_float_noise(1.5e300), 1.5e300);
|
||||
assert_eq!(round_away_float_noise(1e-300), 1e-300);
|
||||
assert_eq!(round_away_float_noise(f64::MIN_POSITIVE), f64::MIN_POSITIVE);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod bbox;
|
||||
pub mod float_noise;
|
||||
pub mod polynomial;
|
||||
pub mod quad;
|
||||
pub mod rect;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::Node;
|
||||
use crate::math::float_noise::round_away_float_noise;
|
||||
use crate::transform::Footprint;
|
||||
use glam::DVec2;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use std::future::Future;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
@@ -45,11 +46,26 @@ pub trait Convert<T, C>: Sized {
|
||||
fn convert(self, footprint: Footprint, converter: C) -> impl Future<Output = T> + Send;
|
||||
}
|
||||
|
||||
impl<T: ToString + Send> Convert<String, ()> for T {
|
||||
/// Converts this type into a `String` using its `ToString` implementation.
|
||||
/// Implements the [`Convert`] trait for formatting a type into a `String` via [`ToString`].
|
||||
macro_rules! impl_convert_to_string {
|
||||
($($from:ty),* $(,)?) => {
|
||||
$(
|
||||
impl Convert<String, ()> for $from {
|
||||
#[inline]
|
||||
async fn convert(self, _: Footprint, _converter: ()) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
impl_convert_to_string!(f32, u32, u64, i32, i64, bool, DVec2, DAffine2);
|
||||
|
||||
// Denoised so 0.1 + 0.2 reaches the string as "0.3" rather than "0.30000000000000004"
|
||||
impl Convert<String, ()> for f64 {
|
||||
#[inline]
|
||||
async fn convert(self, _: Footprint, _converter: ()) -> String {
|
||||
self.to_string()
|
||||
round_away_float_noise(self).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ pub mod repeat {
|
||||
}
|
||||
|
||||
pub mod math {
|
||||
pub use core_types::math::float_noise;
|
||||
pub use core_types::math::quad;
|
||||
|
||||
pub mod math_ext {
|
||||
|
||||
@@ -9,6 +9,7 @@ mod to_path;
|
||||
use convert_case::{Boundary, Converter, pattern};
|
||||
use core_types::graphene_hash::CacheHash;
|
||||
use core_types::list::{Item, List};
|
||||
use core_types::math::float_noise::round_away_float_noise;
|
||||
use core_types::registry::types::{SignedInteger, TextArea};
|
||||
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractVarArgs, OwnedContextImpl};
|
||||
use dyn_any::DynAny;
|
||||
@@ -299,6 +300,7 @@ fn format_number(
|
||||
start_at_10000: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let (number, attributes) = number.into_parts();
|
||||
let number = round_away_float_noise(number);
|
||||
let (decimal_places, fixed_decimals, use_thousands_separator, start_at_10000) =
|
||||
(*decimal_places.element(), *fixed_decimals.element(), *use_thousands_separator.element(), *start_at_10000.element());
|
||||
let decimal_separator = decimal_separator.element().clone();
|
||||
|
||||
Reference in New Issue
Block a user