diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 2fd2053cf7..a2280f8395 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -668,6 +668,7 @@ impl Diffable for WidgetInstance { && button1.tooltip_description == button2.tooltip_description && button1.tooltip_shortcut == button2.tooltip_shortcut && button1.popover_min_width == button2.popover_min_width + && button1.popover_layout.0.len() == button2.popover_layout.0.len() { // Only the popover layout differs, diff it recursively for (i, (a, b)) in button1.popover_layout.0.iter_mut().zip(button2.popover_layout.0.iter()).enumerate() { diff --git a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs index 8a3512ab89..a1975b9793 100644 --- a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs @@ -72,6 +72,8 @@ pub struct TextLabel { // Sizing #[serde(rename = "minWidth")] pub min_width: u32, + #[serde(rename = "maxWidth")] + pub max_width: u32, #[serde(rename = "minWidthCharacters")] pub min_width_characters: u32, diff --git a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs index f3683c24da..4bc5348f5f 100644 --- a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs +++ b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs @@ -231,7 +231,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { widgets.push(LayoutGroup::row(vec![TextLabel::new("Grid").bold(true).widget_instance()])); widgets.push(LayoutGroup::row(vec![ - TextLabel::new("Type").table_align(true).widget_instance(), + TextLabel::new("Type").min_width(60).max_width(60).table_align(true).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), RadioInput::new(vec![ RadioEntryData::new("rectangular").label("Rectangular").on_update(update_val(grid, |grid, _| { @@ -262,7 +262,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { ])); let mut color_widgets = vec![ - TextLabel::new("Display").table_align(true).widget_instance(), + TextLabel::new("Display").min_width(60).max_width(60).table_align(true).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), ]; color_widgets.extend([ @@ -287,7 +287,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { widgets.push(LayoutGroup::row(color_widgets)); widgets.push(LayoutGroup::row(vec![ - TextLabel::new("Origin").table_align(true).widget_instance(), + TextLabel::new("Origin").min_width(60).max_width(60).table_align(true).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), NumberInput::new(Some(grid.origin.x)) .label("X") @@ -306,7 +306,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { match grid.grid_type { GridType::Rectangular { spacing } => widgets.push(LayoutGroup::row(vec![ - TextLabel::new("Spacing").table_align(true).widget_instance(), + TextLabel::new("Spacing").min_width(60).max_width(60).table_align(true).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), NumberInput::new(Some(spacing.x)) .label("X") @@ -326,7 +326,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { ])), GridType::Isometric { y_axis_spacing, angle_a, angle_b } => { widgets.push(LayoutGroup::row(vec![ - TextLabel::new("Y Spacing").table_align(true).widget_instance(), + TextLabel::new("Y Spacing").min_width(60).max_width(60).table_align(true).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), NumberInput::new(Some(y_axis_spacing)) .unit(" px") @@ -336,7 +336,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { .widget_instance(), ])); widgets.push(LayoutGroup::row(vec![ - TextLabel::new("Angles").table_align(true).widget_instance(), + TextLabel::new("Angles").min_width(60).max_width(60).table_align(true).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), NumberInput::new(Some(angle_a)) .unit("°") diff --git a/editor/src/messages/tool/common_functionality/color_selector.rs b/editor/src/messages/tool/common_functionality/color_selector.rs index 4845c45ad5..3f61d4a508 100644 --- a/editor/src/messages/tool/common_functionality/color_selector.rs +++ b/editor/src/messages/tool/common_functionality/color_selector.rs @@ -6,7 +6,7 @@ use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::utility_types::DocumentToolData; use graphene_std::Color; -use graphene_std::vector::style::FillChoice; +use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; /// Color selector widgets seen in [`LayoutTarget::ToolOptions`] bar. pub struct ToolColorOptions { @@ -109,13 +109,25 @@ impl ToolColorOptions { } /// Shared per-tool state for drawing tools that produce a stroked-and-filled shape (Shape, Pen, Freehand, Spline). -/// Bundles the weight, color, and selection-sync fields that would otherwise be duplicated across each tool's options struct. -/// The displayed fill/stroke colors track the global working colors. pub struct DrawingToolState { /// The current stroke weight. `None` = mixed across selected layers. pub line_weight: Option, /// Persistent default weight, updated when the user edits the weight while no layer is selected. pub default_line_weight: f64, + /// Stroke alignment from the selection. `None` = mixed. + pub stroke_align: Option, + /// Stroke cap from the selection. `None` = mixed. + pub stroke_cap: Option, + /// Stroke join from the selection. `None` = mixed. + pub stroke_join: Option, + /// Stroke miter limit from the selection. `None` = mixed. + pub miter_limit: Option, + /// Paint order from the selection. `None` = mixed. + pub paint_order: Option, + /// Dash lengths from the selection. `None` = mixed. + pub dash_lengths: Option>, + /// Dash offset from the selection. `None` = mixed. + pub dash_offset: Option, /// Set of layers we last synced from, used to detect real selection changes vs. internal node toggles. pub last_synced_selection: Vec, /// The fill swatch's color, checkbox, and mixed state. @@ -132,6 +144,13 @@ impl DrawingToolState { Self { line_weight: Some(DEFAULT_STROKE_WIDTH), default_line_weight: DEFAULT_STROKE_WIDTH, + stroke_align: Some(StrokeAlign::default()), + stroke_cap: Some(StrokeCap::default()), + stroke_join: Some(StrokeJoin::default()), + miter_limit: Some(4.), + paint_order: Some(PaintOrder::default()), + dash_lengths: Some(Vec::new()), + dash_offset: Some(0.), last_synced_selection: Vec::new(), fill: if fill_enabled { ToolColorOptions::new_enabled() } else { ToolColorOptions::new_disabled() }, stroke: ToolColorOptions::new_enabled(), @@ -143,6 +162,33 @@ impl DrawingToolState { pub fn effective_line_weight(&self) -> f64 { self.line_weight.unwrap_or(self.default_line_weight) } + + /// Dash lengths to apply, falling back to empty when [`Self::dash_lengths`] is `None` (mixed). + pub fn effective_dash_lengths(&self) -> Vec { + self.dash_lengths.clone().unwrap_or_default() + } + + /// Applies a stroke to a freshly created `layer` using the tool's currently selected color, weight, and stroke options (align, cap, join, etc.). + /// Used by the drawing tools at shape-creation time so new shapes inherit the popover's options instead of defaulting to the `Stroke` struct's defaults. + pub fn apply_stroke_to_new_layer(&self, layer: LayerNodeIdentifier, responses: &mut VecDeque) { + if !self.stroke.is_active() { + return; + } + let Some(FillChoice::Solid(color)) = &self.stroke.fill_choice else { return }; + let stroke = graphene_std::vector::style::Stroke { + color: Some(*color), + weight: self.effective_line_weight(), + align: self.stroke_align.unwrap_or_default(), + cap: self.stroke_cap.unwrap_or_default(), + join: self.stroke_join.unwrap_or_default(), + join_miter_limit: self.miter_limit.unwrap_or(4.), + paint_order: self.paint_order.unwrap_or_default(), + dash_lengths: self.effective_dash_lengths(), + dash_offset: self.dash_offset.unwrap_or(0.), + transform: glam::DAffine2::IDENTITY, + }; + responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); + } } /// Builds a `FillChoice::Solid` from a linear-space color, applying gamma conversion to display sRGB. @@ -250,9 +296,72 @@ pub fn sync_drawing_state(drawing: &mut DrawingToolState, natural_fill_enabled: needs_refresh = true; } + needs_refresh |= sync_stroke_options(drawing, document); + needs_refresh } +/// Reads the stroke proto-node inputs (align, cap, join, miter limit, paint order, dash lengths, dash offset) across the selection and updates +/// the matching fields on `drawing`. Each field becomes `None` (mixed) when selected strokes disagree. With no selection, fields are left as-is. +fn sync_stroke_options(drawing: &mut DrawingToolState, document: &DocumentMessageHandler) -> bool { + let strokes: Vec<_> = document + .network_interface + .selected_nodes() + .selected_layers_except_artboards(&document.network_interface) + .filter_map(|layer| graph_modification_utils::get_stroke_options(layer, &document.network_interface)) + .collect(); + if strokes.is_empty() { + return false; + } + + fn unanimous(values: impl IntoIterator) -> Option { + let mut iter = values.into_iter(); + let first = iter.next()?; + iter.all(|v| v == first).then_some(first) + } + + let new_align = unanimous(strokes.iter().map(|s| s.align)); + let new_cap = unanimous(strokes.iter().map(|s| s.cap)); + let new_join = unanimous(strokes.iter().map(|s| s.join)); + let new_miter = unanimous(strokes.iter().map(|s| s.miter_limit)); + let new_paint_order = unanimous(strokes.iter().map(|s| s.paint_order)); + let new_dash_lengths = unanimous(strokes.iter().map(|s| &s.dash_lengths)).cloned(); + let new_dash_offset = unanimous(strokes.iter().map(|s| s.dash_offset)); + + let mut changed = false; + + if drawing.stroke_align != new_align { + drawing.stroke_align = new_align; + changed = true; + } + if drawing.stroke_cap != new_cap { + drawing.stroke_cap = new_cap; + changed = true; + } + if drawing.stroke_join != new_join { + drawing.stroke_join = new_join; + changed = true; + } + if drawing.miter_limit != new_miter { + drawing.miter_limit = new_miter; + changed = true; + } + if drawing.paint_order != new_paint_order { + drawing.paint_order = new_paint_order; + changed = true; + } + if drawing.dash_lengths != new_dash_lengths { + drawing.dash_lengths = new_dash_lengths; + changed = true; + } + if drawing.dash_offset != new_dash_offset { + drawing.dash_offset = new_dash_offset; + changed = true; + } + + changed +} + /// Same as [`sync_color_options`] but for tools that only have a fill option (e.g., text). The fill follows the given working color when nothing is selected. pub fn sync_fill_only(fill: &mut ToolColorOptions, natural_fill_enabled: bool, fill_color: Color, document: &DocumentMessageHandler, selection_changed: bool) -> bool { let fill_fallback = solid_gamma(fill_color); diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index f440e31f1a..11eba120b5 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -15,7 +15,7 @@ use graphene_std::raster_types::{CPU, GPU, Image, Raster}; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; -use graphene_std::vector::style::{Fill, FillChoice, Gradient}; +use graphene_std::vector::style::{Fill, FillChoice, Gradient, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{GradientStops, PointId, SegmentId, VectorModificationType}; use std::collections::VecDeque; @@ -493,6 +493,66 @@ pub fn get_stroke_width(layer: LayerNodeIdentifier, network_interface: &NodeNetw } } +/// Subset of Stroke node inputs read for the control bar's stroke options popover. +#[derive(Debug, Clone, PartialEq)] +pub struct StrokeOptionsState { + pub align: StrokeAlign, + pub cap: StrokeCap, + pub join: StrokeJoin, + pub miter_limit: f64, + pub paint_order: PaintOrder, + pub dash_lengths: Vec, + pub dash_offset: f64, +} + +/// Reads the non-color stroke option inputs from a layer's Stroke proto node. Returns `None` when the layer has no Stroke node. +/// Inputs that aren't a static value (e.g. wired to another node) fall back to per-field defaults so the layer still participates in the sync. +pub fn get_stroke_options(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + let stroke = &DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER); + let layer_view = NodeGraphLayer::new(layer, network_interface); + layer_view.upstream_node_id_from_name(stroke)?; + let read = |index: usize| layer_view.find_input(stroke, index); + + let align = match read(graphene_std::vector::stroke::AlignInput::INDEX) { + Some(TaggedValue::StrokeAlign(value)) => *value, + _ => StrokeAlign::default(), + }; + let cap = match read(graphene_std::vector::stroke::CapInput::INDEX) { + Some(TaggedValue::StrokeCap(value)) => *value, + _ => StrokeCap::default(), + }; + let join = match read(graphene_std::vector::stroke::JoinInput::INDEX) { + Some(TaggedValue::StrokeJoin(value)) => *value, + _ => StrokeJoin::default(), + }; + let miter_limit = match read(graphene_std::vector::stroke::MiterLimitInput::INDEX) { + Some(TaggedValue::F64(value)) => *value, + _ => 4., + }; + let paint_order = match read(graphene_std::vector::stroke::PaintOrderInput::INDEX) { + Some(TaggedValue::PaintOrder(value)) => *value, + _ => PaintOrder::default(), + }; + let dash_lengths = match read(graphene_std::vector::stroke::DashLengthsInput::>::INDEX) { + Some(TaggedValue::F64Array(value)) => value.clone(), + _ => Vec::new(), + }; + let dash_offset = match read(graphene_std::vector::stroke::DashOffsetInput::INDEX) { + Some(TaggedValue::F64(value)) => *value, + _ => 0., + }; + + Some(StrokeOptionsState { + align, + cap, + join, + miter_limit, + paint_order, + dash_lengths, + dash_offset, + }) +} + /// Returns the node ID of a layer's upstream Stroke proto node, if one exists. pub fn get_stroke_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER)) @@ -759,9 +819,10 @@ impl<'a> NodeGraphLayer<'a> { self.network_interface.upstream_flow_back_from_nodes(vec![self.layer_node], &[], FlowType::HorizontalFlow) } - /// Node id of a node if it exists in the layer's primary flow + /// Node id of a node if it exists in this specific layer's primary flow, stopping at the next layer upstream so a group doesn't incorrectly match its children's nodes. pub fn upstream_node_id_from_name(&self, identifier: &DefinitionIdentifier) -> Option { self.horizontal_layer_flow() + .take_while(|&node_id| node_id == self.layer_node || !self.network_interface.is_layer(&node_id, &[])) .find(|node_id| self.network_interface.reference(node_id, &[]).is_some_and(|reference| reference == *identifier)) } diff --git a/editor/src/messages/tool/common_functionality/mod.rs b/editor/src/messages/tool/common_functionality/mod.rs index acd0df472a..7fbdd0dca9 100644 --- a/editor/src/messages/tool/common_functionality/mod.rs +++ b/editor/src/messages/tool/common_functionality/mod.rs @@ -10,5 +10,6 @@ pub mod resize; pub mod shape_editor; pub mod shapes; pub mod snapping; +pub mod stroke_options; pub mod transformation_cage; pub mod utility_functions; diff --git a/editor/src/messages/tool/common_functionality/stroke_options.rs b/editor/src/messages/tool/common_functionality/stroke_options.rs new file mode 100644 index 0000000000..a5dd9ef21e --- /dev/null +++ b/editor/src/messages/tool/common_functionality/stroke_options.rs @@ -0,0 +1,228 @@ +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; +use crate::messages::tool::common_functionality::color_selector::{DrawingToolState, apply_line_weight}; +use crate::messages::tool::common_functionality::graph_modification_utils; +use graph_craft::document::value::TaggedValue; +use graphene_std::NodeInputDecleration; +use graphene_std::choice_type::ChoiceTypeStatic; +use graphene_std::list::List; +use graphene_std::vector::style::{PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; + +/// All non-color stroke-related options surfaced in the control bar popover. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum StrokeOptionsUpdate { + LineWeight(f64), + Align(StrokeAlign), + Cap(StrokeCap), + Join(StrokeJoin), + MiterLimit(f64), + PaintOrder(PaintOrder), + DashLengths(Vec), + DashOffset(f64), +} + +/// Builds the control-bar popover button that opens the stroke options panel (weight, align, caps, joins, miter limit, paint order, dash). +/// `to_message` adapts a [`StrokeOptionsUpdate`] into the calling tool's `UpdateOptions` message. +pub fn create_stroke_options_popover_widget(drawing: &DrawingToolState, disabled: bool, to_message: F) -> WidgetInstance +where + F: Fn(StrokeOptionsUpdate) -> Message + 'static + Send + Sync + Clone, +{ + PopoverButton::new() + .popover_layout(Layout(build_popover_rows(drawing, to_message))) + .disabled(disabled) + .tooltip_label("Stroke Options") + .widget_instance() +} + +/// Dispatches a [`StrokeOptionsUpdate`] to the matching apply helper and updates `drawing` in lockstep. +pub fn apply_stroke_option(drawing: &mut DrawingToolState, update: StrokeOptionsUpdate, document: &DocumentMessageHandler, responses: &mut VecDeque) { + match update { + StrokeOptionsUpdate::LineWeight(weight) => apply_line_weight(drawing, weight, document, responses), + StrokeOptionsUpdate::Align(align) => apply_stroke_align(drawing, align, document, responses), + StrokeOptionsUpdate::Cap(cap) => apply_stroke_cap(drawing, cap, document, responses), + StrokeOptionsUpdate::Join(join) => apply_stroke_join(drawing, join, document, responses), + StrokeOptionsUpdate::MiterLimit(limit) => apply_miter_limit(drawing, limit, document, responses), + StrokeOptionsUpdate::PaintOrder(order) => apply_paint_order(drawing, order, document, responses), + StrokeOptionsUpdate::DashLengths(lengths) => apply_dash_lengths(drawing, lengths, document, responses), + StrokeOptionsUpdate::DashOffset(offset) => apply_dash_offset(drawing, offset, document, responses), + } +} + +fn build_popover_rows(drawing: &DrawingToolState, to_message: F) -> Vec +where + F: Fn(StrokeOptionsUpdate) -> Message + 'static + Send + Sync + Clone, +{ + // Miter limit only matters when the join is `Miter`; mixed (`None`) keeps the row visible so the user can still edit the value. + let show_miter_limit = drawing.stroke_join != Some(StrokeJoin::Bevel) && drawing.stroke_join != Some(StrokeJoin::Round); + // Mixed dash patterns (`None`) keep the offset row visible so the user can still edit the offset when at least some selected layers have dashes. + let has_dash = drawing.dash_lengths.as_deref().is_none_or(|lengths| !lengths.is_empty()); + + let mut rows = vec![ + LayoutGroup::row(vec![TextLabel::new("Stroke").bold(true).widget_instance()]), + LayoutGroup::row(weight_row(drawing.line_weight, to_message.clone())), + LayoutGroup::row(dash_lengths_row(drawing.dash_lengths.as_deref(), to_message.clone())), + ]; + if has_dash { + rows.push(LayoutGroup::row(dash_offset_row(drawing.dash_offset, to_message.clone()))); + } + rows.push(LayoutGroup::row(enum_radio_row::("Order", drawing.paint_order, false, { + let to_message = to_message.clone(); + move |value| to_message(StrokeOptionsUpdate::PaintOrder(value)) + }))); + rows.push(LayoutGroup::row(enum_radio_row::("Align", drawing.stroke_align, false, { + let to_message = to_message.clone(); + move |value| to_message(StrokeOptionsUpdate::Align(value)) + }))); + rows.push(LayoutGroup::row(enum_radio_row::("Cap", drawing.stroke_cap, false, { + let to_message = to_message.clone(); + move |value| to_message(StrokeOptionsUpdate::Cap(value)) + }))); + rows.push(LayoutGroup::row(enum_radio_row::("Join", drawing.stroke_join, false, { + let to_message = to_message.clone(); + move |value| to_message(StrokeOptionsUpdate::Join(value)) + }))); + if show_miter_limit { + rows.push(LayoutGroup::row(miter_limit_row(drawing.miter_limit, to_message))); + } + rows +} + +fn weight_row(weight: Option, to_message: F) -> Vec +where + F: Fn(StrokeOptionsUpdate) -> Message + 'static + Send + Sync, +{ + vec![ + TextLabel::new("Weight").table_align(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + NumberInput::new(weight) + .unit(" px") + .min(0.) + .max((1_u64 << f64::MANTISSA_DIGITS) as f64) + .on_update(move |number: &NumberInput| number.value.map_or(Message::NoOp, |value| to_message(StrokeOptionsUpdate::LineWeight(value)))) + .on_commit(|_| DocumentMessage::StartTransaction.into()) + .widget_instance(), + ] +} + +fn miter_limit_row(limit: Option, to_message: F) -> Vec +where + F: Fn(StrokeOptionsUpdate) -> Message + 'static + Send + Sync, +{ + vec![ + TextLabel::new("Limit").table_align(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + NumberInput::new(limit) + .min(0.) + .on_update(move |number: &NumberInput| number.value.map_or(Message::NoOp, |value| to_message(StrokeOptionsUpdate::MiterLimit(value)))) + .on_commit(|_| DocumentMessage::StartTransaction.into()) + .widget_instance(), + ] +} + +fn enum_radio_row(label_text: &str, current: Option, disabled: bool, to_message: F) -> Vec +where + E: ChoiceTypeStatic + 'static, + F: Fn(E) -> Message + 'static + Send + Sync + Clone, +{ + let entries = E::list() + .iter() + .flat_map(|section| section.iter()) + .map(|(value, meta)| { + let to_message = to_message.clone(); + let value = *value; + let entry = RadioEntryData::new(meta.name) + .tooltip_label(meta.label) + .tooltip_description(meta.description.unwrap_or_default()) + .on_update(move |_| to_message(value)) + .on_commit(|_| DocumentMessage::StartTransaction.into()); + if let Some(icon) = meta.icon { entry.icon(icon) } else { entry.label(meta.label) } + }) + .collect(); + vec![ + TextLabel::new(label_text).table_align(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + RadioInput::new(entries).selected_index(current.map(|c| c.as_u32())).disabled(disabled).widget_instance(), + ] +} + +fn dash_lengths_row(current: Option<&[f64]>, to_message: F) -> Vec +where + F: Fn(StrokeOptionsUpdate) -> Message + 'static + Send + Sync, +{ + let text = current + .map(|values| values.iter().map(|v| v.to_string()).collect::>().join(", ")) + .unwrap_or_else(|| "-".to_string()); + vec![ + TextLabel::new("Dash").table_align(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextInput::new(text) + .centered(true) + .tooltip_label("Dash Pattern") + .tooltip_description("Comma-separated dash and gap lengths.") + .on_update(move |input: &TextInput| { + let parsed = input.value.split(&[',', ' ']).filter(|piece| !piece.is_empty()).map(str::parse::).collect::, _>>(); + parsed.map_or(Message::NoOp, |lengths| to_message(StrokeOptionsUpdate::DashLengths(lengths))) + }) + .on_commit(|_| DocumentMessage::StartTransaction.into()) + .widget_instance(), + ] +} + +fn dash_offset_row(offset: Option, to_message: F) -> Vec +where + F: Fn(StrokeOptionsUpdate) -> Message + 'static + Send + Sync, +{ + vec![ + TextLabel::new("Offset").table_align(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + NumberInput::new(offset) + .unit(" px") + .on_update(move |number: &NumberInput| number.value.map_or(Message::NoOp, |value| to_message(StrokeOptionsUpdate::DashOffset(value)))) + .on_commit(|_| DocumentMessage::StartTransaction.into()) + .widget_instance(), + ] +} + +// ============= +// APPLY HELPERS +// ============= + +pub fn apply_stroke_align(drawing: &mut DrawingToolState, align: StrokeAlign, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.stroke_align = Some(align); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::AlignInput::INDEX, TaggedValue::StrokeAlign(align), responses); +} + +pub fn apply_stroke_cap(drawing: &mut DrawingToolState, cap: StrokeCap, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.stroke_cap = Some(cap); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::CapInput::INDEX, TaggedValue::StrokeCap(cap), responses); +} + +pub fn apply_stroke_join(drawing: &mut DrawingToolState, join: StrokeJoin, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.stroke_join = Some(join); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::JoinInput::INDEX, TaggedValue::StrokeJoin(join), responses); +} + +pub fn apply_miter_limit(drawing: &mut DrawingToolState, limit: f64, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.miter_limit = Some(limit); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::MiterLimitInput::INDEX, TaggedValue::F64(limit), responses); +} + +pub fn apply_paint_order(drawing: &mut DrawingToolState, order: PaintOrder, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.paint_order = Some(order); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::PaintOrderInput::INDEX, TaggedValue::PaintOrder(order), responses); +} + +pub fn apply_dash_lengths(drawing: &mut DrawingToolState, lengths: Vec, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.dash_lengths = Some(lengths.clone()); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::DashLengthsInput::>::INDEX, TaggedValue::F64Array(lengths), responses); +} + +pub fn apply_dash_offset(drawing: &mut DrawingToolState, offset: f64, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.dash_offset = Some(offset); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::DashOffsetInput::INDEX, TaggedValue::F64(offset), responses); +} + +fn set_stroke_input_for_selected(document: &DocumentMessageHandler, input_index: usize, value: TaggedValue, responses: &mut VecDeque) { + graph_modification_utils::set_proto_node_input_for_selected_layers(document, graphene_std::vector::stroke::IDENTIFIER, input_index, value, responses); +} diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index c8e1eb8225..052ce9e4b0 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -5,10 +5,11 @@ use crate::messages::portfolio::document::overlays::utility_functions::path_endp use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::color_selector::{ - DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_line_weight, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, - swap_fill_and_stroke, sync_drawing_state, + DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, swap_fill_and_stroke, + sync_drawing_state, }; use crate::messages::tool::common_functionality::graph_modification_utils; +use crate::messages::tool::common_functionality::stroke_options::{StrokeOptionsUpdate, apply_stroke_option, create_stroke_options_popover_widget}; use crate::messages::tool::common_functionality::utility_functions::should_extend; use glam::DVec2; use graph_craft::document::NodeId; @@ -58,7 +59,7 @@ pub enum FreehandToolMessage { pub enum FreehandOptionsUpdate { FillColor(FillChoice), FillEnabled(bool), - LineWeight(f64), + StrokeOption(StrokeOptionsUpdate), StrokeColor(Option), StrokeEnabled(bool), SwapFillAndStroke, @@ -84,29 +85,6 @@ impl ToolMetadata for FreehandTool { } } -fn create_weight_widget(line_weight: Option, disabled: bool) -> WidgetInstance { - NumberInput::new(line_weight) - .unit(" px") - .label("Weight") - .min(1.) - .max((1_u64 << f64::MANTISSA_DIGITS) as f64) - .min_width(100) - .narrow(true) - .disabled(disabled) - .on_update(|number_input: &NumberInput| { - if let Some(value) = number_input.value { - FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::LineWeight(value), - } - .into() - } else { - Message::NoOp - } - }) - .on_commit(|_| DocumentMessage::StartTransaction.into()) - .widget_instance() -} - impl LayoutHolder for FreehandTool { fn layout(&self) -> Layout { let mut widgets = self.options.drawing.fill.create_widgets( @@ -154,9 +132,13 @@ impl LayoutHolder for FreehandTool { .into() }, )); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); let weight_disabled = self.options.drawing.stroke.enabled == Some(false); - widgets.push(create_weight_widget(self.options.drawing.line_weight, weight_disabled)); + widgets.push(create_stroke_options_popover_widget(&self.options.drawing, weight_disabled, |update| { + FreehandToolMessage::UpdateOptions { + options: FreehandOptionsUpdate::StrokeOption(update), + } + .into() + })); Layout(vec![LayoutGroup::row(widgets)]) } @@ -193,8 +175,8 @@ impl<'a> MessageHandler> for Free FreehandOptionsUpdate::FillEnabled(enabled) => { apply_fill_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); } - FreehandOptionsUpdate::LineWeight(line_weight) => { - apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); + FreehandOptionsUpdate::StrokeOption(update) => { + apply_stroke_option(&mut self.options.drawing, update, context.document, responses); } FreehandOptionsUpdate::StrokeColor(color) => { apply_stroke_color_pick(&mut self.options.drawing, color, context.document, responses); @@ -244,7 +226,6 @@ impl ToolTransition for FreehandTool { struct FreehandToolData { end_point: Option<(DVec2, PointId)>, dragged: bool, - weight: f64, layer: Option, /// Viewport-space start position for newly created layers, used to compute local-space /// positions before the deferred TransformSet has been reflected in metadata. @@ -283,7 +264,6 @@ impl Fsm for FreehandToolFsmState { tool_data.dragged = false; tool_data.end_point = None; - tool_data.weight = tool_options.drawing.effective_line_weight(); tool_data.new_layer_viewport_start = None; // Extend an endpoint of the selected path @@ -322,7 +302,7 @@ impl Fsm for FreehandToolFsmState { let nodes = vec![(NodeId(0), node)]; let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); - tool_options.drawing.stroke.apply_stroke(tool_data.weight, layer, responses); + tool_options.drawing.apply_stroke_to_new_layer(layer, responses); tool_options.drawing.fill.apply_fill(layer, responses); tool_data.layer = Some(layer); tool_data.new_layer_viewport_start = Some(input.mouse.position); @@ -452,6 +432,7 @@ mod test_freehand { use crate::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, MouseKeys, ScrollDelta}; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_stroke_width}; + use crate::messages::tool::common_functionality::stroke_options::StrokeOptionsUpdate; use crate::messages::tool::tool_messages::freehand_tool::FreehandOptionsUpdate; use crate::test_utils::test_prelude::*; use glam::{DAffine2, DVec2}; @@ -776,7 +757,7 @@ mod test_freehand { let custom_line_weight = 5.; editor .handle_message(ToolMessage::Freehand(FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::LineWeight(custom_line_weight), + options: FreehandOptionsUpdate::StrokeOption(StrokeOptionsUpdate::LineWeight(custom_line_weight)), })) .await; diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index 1bed01863f..4c6c50c63a 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -8,12 +8,13 @@ use crate::messages::portfolio::document::overlays::utility_types::{DrawHandles, use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::color_selector::{ - DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_line_weight, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, - swap_fill_and_stroke, sync_drawing_state, + DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, swap_fill_and_stroke, + sync_drawing_state, }; use crate::messages::tool::common_functionality::graph_modification_utils::{self, merge_layers}; use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; +use crate::messages::tool::common_functionality::stroke_options::{StrokeOptionsUpdate, apply_stroke_option, create_stroke_options_popover_widget}; use crate::messages::tool::common_functionality::utility_functions::{calculate_segment_angle, closest_point, should_extend}; use graph_craft::document::NodeId; use graphene_std::Color; @@ -121,7 +122,7 @@ pub enum PenOverlayMode { pub enum PenOptionsUpdate { FillColor(FillChoice), FillEnabled(bool), - LineWeight(f64), + StrokeOption(StrokeOptionsUpdate), StrokeColor(Option), StrokeEnabled(bool), SwapFillAndStroke, @@ -141,29 +142,6 @@ impl ToolMetadata for PenTool { } } -fn create_weight_widget(line_weight: Option, disabled: bool) -> WidgetInstance { - NumberInput::new(line_weight) - .unit(" px") - .label("Weight") - .min(0.) - .max((1_u64 << f64::MANTISSA_DIGITS) as f64) - .min_width(100) - .narrow(true) - .disabled(disabled) - .on_update(|number_input: &NumberInput| { - if let Some(value) = number_input.value { - PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::LineWeight(value), - } - .into() - } else { - Message::NoOp - } - }) - .on_commit(|_| DocumentMessage::StartTransaction.into()) - .widget_instance() -} - impl LayoutHolder for PenTool { fn layout(&self) -> Layout { let mut widgets = self.options.drawing.fill.create_widgets( @@ -212,10 +190,13 @@ impl LayoutHolder for PenTool { }, )); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - let weight_disabled = self.options.drawing.stroke.enabled == Some(false); - widgets.push(create_weight_widget(self.options.drawing.line_weight, weight_disabled)); + widgets.push(create_stroke_options_popover_widget(&self.options.drawing, weight_disabled, |update| { + PenToolMessage::UpdateOptions { + options: PenOptionsUpdate::StrokeOption(update), + } + .into() + })); widgets.push(Separator::new(SeparatorStyle::Section).widget_instance()); @@ -277,8 +258,8 @@ impl<'a> MessageHandler> for PenT self.options.pen_overlay_mode = overlay_mode_type; responses.add(OverlaysMessage::Draw); } - PenOptionsUpdate::LineWeight(line_weight) => { - apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); + PenOptionsUpdate::StrokeOption(update) => { + apply_stroke_option(&mut self.options.drawing, update, context.document, responses); } PenOptionsUpdate::FillColor(fill_choice) => { apply_fill_color_pick(&mut self.options.drawing, fill_choice, context.document, responses); @@ -1316,7 +1297,7 @@ impl PenToolData { let parent = document.new_layer_bounding_artboard(input, viewport); let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); self.current_layer = Some(layer); - tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, responses); + tool_options.drawing.apply_stroke_to_new_layer(layer, responses); tool_options.drawing.fill.apply_fill(layer, responses); self.prior_segment = None; self.prior_segments = None; diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 0169bbdc62..0a8773539b 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -11,27 +11,44 @@ use crate::messages::portfolio::document::utility_types::network_interface::{Flo use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes; use crate::messages::preferences::SelectionMode; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; +use crate::messages::tool::common_functionality::color_selector::{ + DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, swap_fill_and_stroke, sync_drawing_state, +}; use crate::messages::tool::common_functionality::compass_rose::{Axis, CompassRose}; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::measure; use crate::messages::tool::common_functionality::pivot::{PivotGizmo, PivotGizmoType, PivotToolSource, pin_pivot_widget, pivot_gizmo_type_widget, pivot_reference_point_widget}; use crate::messages::tool::common_functionality::shape_editor::SelectionShapeType; use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData, SnapManager}; +use crate::messages::tool::common_functionality::stroke_options::{StrokeOptionsUpdate, apply_stroke_option, create_stroke_options_popover_widget}; use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::common_functionality::utility_functions::{resize_bounds, rotate_bounds, skew_bounds, text_bounding_box, transforming_transform_cage}; use glam::DMat2; use graph_craft::document::NodeId; +use graphene_std::Color; use graphene_std::renderer::Quad; use graphene_std::renderer::Rect; use graphene_std::subpath::Subpath; use graphene_std::transform::ReferencePoint; use graphene_std::vector::misc::BooleanOperation; +use graphene_std::vector::style::FillChoice; use std::fmt; -#[derive(Default, ExtractField)] +#[derive(ExtractField)] pub struct SelectTool { fsm_state: SelectToolFsmState, tool_data: SelectToolData, + drawing: DrawingToolState, +} + +impl Default for SelectTool { + fn default() -> Self { + Self { + fsm_state: SelectToolFsmState::default(), + tool_data: SelectToolData::default(), + drawing: DrawingToolState::new(true), + } + } } #[allow(dead_code)] @@ -41,12 +58,19 @@ pub struct SelectOptions { } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum SelectOptionsUpdate { NestedSelectionBehavior(NestedSelectionBehavior), PivotGizmoType(PivotGizmoType), SetPivotGizmoEnabled(bool), TogglePivotPinned, + FillColor(FillChoice), + FillEnabled(bool), + StrokeColor(Option), + StrokeEnabled(bool), + SwapFillAndStroke, + StrokeOption(StrokeOptionsUpdate), + WorkingColorsChanged, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -81,6 +105,8 @@ pub struct SelectToolPointerKeys { pub enum SelectToolMessage { // Standard messages Abort, + SelectionChanged, + WorkingColorChanged, Overlays { context: OverlayContext, }, @@ -190,18 +216,6 @@ impl SelectTool { }) } - fn turn_widgets(&self, disabled: bool) -> impl Iterator + use<> { - [(-90., "TurnNegative90", "Turn -90°"), (90., "TurnPositive90", "Turn 90°")] - .into_iter() - .map(move |(degrees, icon, label)| { - IconButton::new(icon, 24) - .tooltip_label(label) - .on_update(move |_| DocumentMessage::RotateSelectedLayers { degrees }.into()) - .disabled(disabled) - .widget_instance() - }) - } - fn boolean_widgets(&self, selected_count: usize) -> impl Iterator + use<> { let list = ::list(); list.iter().flat_map(|i| i.iter()).map(move |(operation, info)| { @@ -222,6 +236,65 @@ impl LayoutHolder for SelectTool { fn layout(&self) -> Layout { let mut widgets = Vec::new(); + // Fill/Stroke widget set (only shown when there's a selection to apply edits to) + if self.tool_data.selected_layers_count > 0 { + widgets.append(&mut self.drawing.fill.create_widgets( + "Fill:", + |checkbox: &CheckboxInput| { + SelectToolMessage::SelectOptions { + options: SelectOptionsUpdate::FillEnabled(checkbox.checked), + } + .into() + }, + |color: &ColorInput| { + SelectToolMessage::SelectOptions { + options: SelectOptionsUpdate::FillColor(color.value.clone()), + } + .into() + }, + )); + + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push( + IconButton::new("SwapHorizontal", 16) + .tooltip_label("Swap Fill/Stroke Colors") + .on_update(|_| { + SelectToolMessage::SelectOptions { + options: SelectOptionsUpdate::SwapFillAndStroke, + } + .into() + }) + .widget_instance(), + ); + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + + widgets.append(&mut self.drawing.stroke.create_widgets( + "Stroke:", + |checkbox: &CheckboxInput| { + SelectToolMessage::SelectOptions { + options: SelectOptionsUpdate::StrokeEnabled(checkbox.checked), + } + .into() + }, + |color: &ColorInput| { + SelectToolMessage::SelectOptions { + options: SelectOptionsUpdate::StrokeColor(color.value.as_solid()), + } + .into() + }, + )); + + let weight_disabled = self.drawing.stroke.enabled == Some(false); + widgets.push(create_stroke_options_popover_widget(&self.drawing, weight_disabled, |update| { + SelectToolMessage::SelectOptions { + options: SelectOptionsUpdate::StrokeOption(update), + } + .into() + })); + + widgets.push(Separator::new(SeparatorStyle::Section).widget_instance()); + } + // Select mode (Deep/Shallow) widgets.push(self.deep_selection_widget()); @@ -259,10 +332,6 @@ impl LayoutHolder for SelectTool { widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); widgets.extend(self.flip_widgets(disabled)); - // Turn - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets.extend(self.turn_widgets(disabled)); - // Boolean widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); widgets.extend(self.boolean_widgets(self.tool_data.selected_layers_count)); @@ -275,16 +344,27 @@ impl LayoutHolder for SelectTool { impl<'a> MessageHandler> for SelectTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { let mut redraw_reference_pivot = false; + let mut drawing_options_changed = false; - if let ToolMessage::Select(SelectToolMessage::SelectOptions { options: ref option_update }) = message { - match *option_update { + if matches!(&message, ToolMessage::Select(SelectToolMessage::SelectionChanged)) && sync_drawing_state(&mut self.drawing, true, true, context.global_tool_data, context.document) { + self.send_layout(responses, LayoutTarget::ToolOptions); + } + + if matches!(&message, ToolMessage::Select(SelectToolMessage::WorkingColorChanged)) { + responses.add(SelectToolMessage::SelectOptions { + options: SelectOptionsUpdate::WorkingColorsChanged, + }); + } + + if let ToolMessage::Select(SelectToolMessage::SelectOptions { options: option_update }) = &message { + match option_update { SelectOptionsUpdate::NestedSelectionBehavior(nested_selection_behavior) => { - self.tool_data.nested_selection_behavior = nested_selection_behavior; + self.tool_data.nested_selection_behavior = *nested_selection_behavior; responses.add(ToolMessage::UpdateHints); } SelectOptionsUpdate::PivotGizmoType(gizmo_type) => { if self.tool_data.pivot_gizmo.state.enabled { - self.tool_data.pivot_gizmo.state.gizmo_type = gizmo_type; + self.tool_data.pivot_gizmo.state.gizmo_type = *gizmo_type; responses.add(ToolMessage::UpdateHints); let pivot_gizmo = self.tool_data.pivot_gizmo(); responses.add(TransformLayerMessage::SetPivotGizmo { pivot_gizmo }); @@ -293,24 +373,51 @@ impl<'a> MessageHandler> for Sele } } SelectOptionsUpdate::SetPivotGizmoEnabled(enabled) => { - self.tool_data.pivot_gizmo.state.enabled = enabled; + self.tool_data.pivot_gizmo.state.enabled = *enabled; responses.add(ToolMessage::UpdateHints); responses.add(NodeGraphMessage::RunDocumentGraph); redraw_reference_pivot = true; } - SelectOptionsUpdate::TogglePivotPinned => { self.tool_data.pivot_gizmo.pivot.pinned = !self.tool_data.pivot_gizmo.pivot.pinned; responses.add(ToolMessage::UpdateHints); responses.add(NodeGraphMessage::RunDocumentGraph); redraw_reference_pivot = true; } + SelectOptionsUpdate::FillColor(fill_choice) => { + apply_fill_color_pick(&mut self.drawing, fill_choice.clone(), context.document, responses); + drawing_options_changed = true; + } + SelectOptionsUpdate::FillEnabled(enabled) => { + apply_fill_enabled(&mut self.drawing, *enabled, context.global_tool_data, context.document, responses); + drawing_options_changed = true; + } + SelectOptionsUpdate::StrokeColor(color) => { + apply_stroke_color_pick(&mut self.drawing, *color, context.document, responses); + drawing_options_changed = true; + } + SelectOptionsUpdate::StrokeEnabled(enabled) => { + apply_stroke_enabled(&mut self.drawing, *enabled, context.global_tool_data, context.document, responses); + drawing_options_changed = true; + } + SelectOptionsUpdate::SwapFillAndStroke => { + swap_fill_and_stroke(&mut self.drawing, context.document, responses); + drawing_options_changed = true; + } + SelectOptionsUpdate::StrokeOption(update) => { + apply_stroke_option(&mut self.drawing, update.clone(), context.document, responses); + drawing_options_changed = true; + } + SelectOptionsUpdate::WorkingColorsChanged => { + apply_working_colors(&mut self.drawing, context.global_tool_data, context.document); + drawing_options_changed = true; + } } } self.fsm_state.process_event(message, &mut self.tool_data, context, &(), responses, false); - if self.tool_data.pivot_gizmo.pivot.should_refresh_pivot_position() || self.tool_data.selected_layers_changed || redraw_reference_pivot { + if self.tool_data.pivot_gizmo.pivot.should_refresh_pivot_position() || self.tool_data.selected_layers_changed || redraw_reference_pivot || drawing_options_changed { // Send the layout containing the updated pivot position (a bit ugly to do it here not in the fsm but that doesn't have SelectTool) self.send_layout(responses, LayoutTarget::ToolOptions); self.tool_data.selected_layers_changed = false; @@ -340,6 +447,8 @@ impl ToolTransition for SelectTool { fn event_to_message_map(&self) -> EventToMessageMap { EventToMessageMap { tool_abort: Some(SelectToolMessage::Abort.into()), + selection_changed: Some(SelectToolMessage::SelectionChanged.into()), + working_color_changed: Some(SelectToolMessage::WorkingColorChanged.into()), overlay_provider: Some(|context| SelectToolMessage::Overlays { context }.into()), ..Default::default() } diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index 4a680e6393..ebe6f869d8 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -6,7 +6,7 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContex use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::color_selector::{ - DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_line_weight, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, has_selection, reset_colors_on_deactivation, + DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, has_selection, reset_colors_on_deactivation, swap_fill_and_stroke, sync_color_options, sync_drawing_state, }; use crate::messages::tool::common_functionality::gizmos::gizmo_manager::GizmoManager; @@ -23,6 +23,7 @@ use crate::messages::tool::common_functionality::shapes::spiral_shape::Spiral; use crate::messages::tool::common_functionality::shapes::star_shape::Star; use crate::messages::tool::common_functionality::shapes::{Ellipse, Line, Rectangle}; use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData, SnapTypeConfiguration}; +use crate::messages::tool::common_functionality::stroke_options::{StrokeOptionsUpdate, apply_stroke_option, create_stroke_options_popover_widget}; use crate::messages::tool::common_functionality::transformation_cage::{BoundingBoxManager, EdgeBool}; use crate::messages::tool::common_functionality::utility_functions::{closest_point, resize_bounds, rotate_bounds, skew_bounds, transforming_transform_cage}; use crate::messages::tool::utility_types::DocumentToolData; @@ -93,7 +94,7 @@ impl Default for ShapeToolOptions { pub enum ShapeOptionsUpdate { FillColor(FillChoice), FillEnabled(bool), - LineWeight(f64), + StrokeOption(StrokeOptionsUpdate), StrokeColor(Option), StrokeEnabled(bool), SwapFillAndStroke, @@ -239,29 +240,6 @@ fn create_arc_type_widget(arc_type: ArcType) -> WidgetInstance { RadioInput::new(entries).selected_index(Some(arc_type as u32)).widget_instance() } -fn create_weight_widget(line_weight: Option, disabled: bool) -> WidgetInstance { - NumberInput::new(line_weight) - .unit(" px") - .label("Weight") - .min(0.) - .max((1_u64 << f64::MANTISSA_DIGITS) as f64) - .min_width(100) - .narrow(true) - .disabled(disabled) - .on_update(|number_input: &NumberInput| { - if let Some(value) = number_input.value { - ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::LineWeight(value), - } - .into() - } else { - Message::NoOp - } - }) - .on_commit(|_| DocumentMessage::StartTransaction.into()) - .widget_instance() -} - fn create_arrow_shaft_width_widget(shaft_width: f64) -> WidgetInstance { NumberInput::new(Some(shaft_width)) .unit(" px") @@ -525,9 +503,13 @@ impl LayoutHolder for ShapeTool { .into() }, )); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); let weight_disabled = self.options.drawing.stroke.enabled == Some(false); - widgets.push(create_weight_widget(self.options.drawing.line_weight, weight_disabled)); + widgets.push(create_stroke_options_popover_widget(&self.options.drawing, weight_disabled, |update| { + ShapeToolMessage::UpdateOptions { + options: ShapeOptionsUpdate::StrokeOption(update), + } + .into() + })); // Shape-mode dropdown and per-shape parameters if !self.tool_data.hide_shape_option_widget { @@ -629,8 +611,8 @@ impl<'a> MessageHandler> for Shap } apply_fill_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); } - ShapeOptionsUpdate::LineWeight(line_weight) => { - apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); + ShapeOptionsUpdate::StrokeOption(update) => { + apply_stroke_option(&mut self.options.drawing, update, context.document, responses); } ShapeOptionsUpdate::StrokeColor(color) => { apply_stroke_color_pick(&mut self.options.drawing, color, context.document, responses); @@ -1176,7 +1158,7 @@ impl Fsm for ShapeToolFsmState { skip_rerender: false, }); - tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, defered_responses); + tool_options.drawing.apply_stroke_to_new_layer(layer, defered_responses); tool_options.drawing.fill.apply_fill(layer, defered_responses); } ShapeType::Arrow => { @@ -1190,7 +1172,7 @@ impl Fsm for ShapeToolFsmState { tool_data.line_data.weight = tool_options.drawing.effective_line_weight(); tool_data.line_data.editing_layer = Some(layer); - tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, defered_responses); + tool_options.drawing.apply_stroke_to_new_layer(layer, defered_responses); tool_options.drawing.fill.apply_fill(layer, defered_responses); } ShapeType::Line => { @@ -1204,7 +1186,7 @@ impl Fsm for ShapeToolFsmState { tool_data.line_data.weight = tool_options.drawing.effective_line_weight(); tool_data.line_data.editing_layer = Some(layer); - tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, defered_responses); + tool_options.drawing.apply_stroke_to_new_layer(layer, defered_responses); } } diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index a8cf783793..7973a428c3 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -8,11 +8,12 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContex use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::color_selector::{ - DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_line_weight, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, - swap_fill_and_stroke, sync_drawing_state, + DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, swap_fill_and_stroke, + sync_drawing_state, }; use crate::messages::tool::common_functionality::graph_modification_utils::{self, find_spline, merge_layers, merge_points}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration, SnappedPoint}; +use crate::messages::tool::common_functionality::stroke_options::{StrokeOptionsUpdate, apply_stroke_option, create_stroke_options_popover_widget}; use crate::messages::tool::common_functionality::utility_functions::{closest_point, should_extend}; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Color; @@ -73,7 +74,7 @@ enum SplineToolFsmState { pub enum SplineOptionsUpdate { FillColor(FillChoice), FillEnabled(bool), - LineWeight(f64), + StrokeOption(StrokeOptionsUpdate), StrokeColor(Option), StrokeEnabled(bool), SwapFillAndStroke, @@ -92,29 +93,6 @@ impl ToolMetadata for SplineTool { } } -fn create_weight_widget(line_weight: Option, disabled: bool) -> WidgetInstance { - NumberInput::new(line_weight) - .unit(" px") - .label("Weight") - .min(0.) - .max((1_u64 << f64::MANTISSA_DIGITS) as f64) - .min_width(100) - .narrow(true) - .disabled(disabled) - .on_update(|number_input: &NumberInput| { - if let Some(value) = number_input.value { - SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::LineWeight(value), - } - .into() - } else { - Message::NoOp - } - }) - .on_commit(|_| DocumentMessage::StartTransaction.into()) - .widget_instance() -} - impl LayoutHolder for SplineTool { fn layout(&self) -> Layout { let mut widgets = self.options.drawing.fill.create_widgets( @@ -162,9 +140,13 @@ impl LayoutHolder for SplineTool { .into() }, )); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); let weight_disabled = self.options.drawing.stroke.enabled == Some(false); - widgets.push(create_weight_widget(self.options.drawing.line_weight, weight_disabled)); + widgets.push(create_stroke_options_popover_widget(&self.options.drawing, weight_disabled, |update| { + SplineToolMessage::UpdateOptions { + options: SplineOptionsUpdate::StrokeOption(update), + } + .into() + })); Layout(vec![LayoutGroup::row(widgets)]) } @@ -195,8 +177,8 @@ impl<'a> MessageHandler> for Spli return; }; match options { - SplineOptionsUpdate::LineWeight(line_weight) => { - apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); + SplineOptionsUpdate::StrokeOption(update) => { + apply_stroke_option(&mut self.options.drawing, update, context.document, responses); } SplineOptionsUpdate::FillColor(fill_choice) => { apply_fill_color_pick(&mut self.options.drawing, fill_choice, context.document, responses); @@ -425,7 +407,7 @@ impl Fsm for SplineToolFsmState { let nodes = vec![(NodeId(1), path_node), (NodeId(0), spline_node)]; let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); - tool_options.drawing.stroke.apply_stroke(tool_data.weight, layer, responses); + tool_options.drawing.apply_stroke_to_new_layer(layer, responses); tool_options.drawing.fill.apply_fill(layer, responses); tool_data.current_layer = Some(layer); tool_data.new_layer_viewport_start = Some(viewport_vec); diff --git a/frontend/src/components/widgets/labels/TextLabel.svelte b/frontend/src/components/widgets/labels/TextLabel.svelte index ad9995e671..36da1795a8 100644 --- a/frontend/src/components/widgets/labels/TextLabel.svelte +++ b/frontend/src/components/widgets/labels/TextLabel.svelte @@ -23,6 +23,7 @@ export let tableAlign = false; // Sizing export let minWidth = 0; + export let maxWidth = 0; export let minWidthCharacters = 0; // Tooltips export let tooltipLabel: string | undefined = undefined; @@ -77,7 +78,8 @@ class:multiline class:center-align={centerAlign} class:table-align={tableAlign} - style:min-width={minWidthCharacters ? `${minWidthCharacters}ch` : minWidth || undefined} + style:min-width={minWidthCharacters ? `${minWidthCharacters}ch` : minWidth > 0 ? `${minWidth}px` : undefined} + style:max-width={maxWidth > 0 ? `${maxWidth}px` : undefined} style={`${styleName} ${extraStyles}`.trim() || undefined} data-tooltip-label={tooltipLabel} data-tooltip-description={tooltipDescription} diff --git a/node-graph/libraries/vector-types/src/vector/click_target.rs b/node-graph/libraries/vector-types/src/vector/click_target.rs index 2fffcfb852..020054423b 100644 --- a/node-graph/libraries/vector-types/src/vector/click_target.rs +++ b/node-graph/libraries/vector-types/src/vector/click_target.rs @@ -294,9 +294,10 @@ impl ClickTarget { return true; } - // Selection point inside compound fill (non-zero rule) - let combined: BezPath = subpaths.iter().flat_map(|subpath| subpath.to_bezpath()).collect(); - if bezier_iter().next().is_some_and(|bezier| combined.contains(bezier.start())) { + // Selection point inside compound fill (non-zero rule). + // Only closed subpaths contribute to the fill region; open segments would otherwise produce spurious winding on one side of the segment. + let combined: BezPath = subpaths.iter().filter(|subpath| subpath.closed()).flat_map(|subpath| subpath.to_bezpath()).collect(); + if !combined.is_empty() && bezier_iter().next().is_some_and(|bezier| combined.contains(bezier.start())) { return true; }