diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs index c9799a0e57..6054ae368e 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -102,6 +102,8 @@ impl MessageHandler for ColorPickerMessageHandler { self.gradient = None; self.active_marker_index = None; self.active_marker_is_midpoint = false; + + responses.add(DocumentMessage::EndTransaction); } ColorPickerMessage::VisualUpdate { update } => { self.hue = update.hue; diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index 18f30de47c..eaabac500d 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -202,6 +202,13 @@ pub struct ColorInput { #[serde(rename = "menuDirection")] pub menu_direction: Option, pub disabled: bool, + pub mixed: bool, + + // Sizing + #[serde(rename = "minWidth")] + pub min_width: u32, + #[serde(rename = "maxWidth")] + pub max_width: u32, // Styling pub narrow: bool, diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 90bf0fa0bb..1d664917a6 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -20,6 +20,7 @@ pub struct CheckboxInput { #[serde(rename = "forLabel")] pub for_label: CheckboxId, pub disabled: bool, + pub mixed: bool, // Tooltips #[serde(rename = "tooltipLabel")] diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 1aaa771fa7..0169440706 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -323,7 +323,6 @@ impl MessageHandler> for let layer = modify_inputs.create_layer(id); modify_inputs.insert_text(text, font, typesetting, layer); network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); - responses.add(GraphOperationMessage::StrokeSet { layer, stroke: Stroke::default() }); responses.add(NodeGraphMessage::RunDocumentGraph); } GraphOperationMessage::ResizeArtboard { layer, location, dimensions } => { diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index cb7f9618a1..5ea5829208 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -267,9 +267,6 @@ impl<'a> ModifyInputsContext<'a> { let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER) .expect("Transform node does not exist") .default_node_template(); - let stroke = resolve_proto_node_type(graphene_std::vector_nodes::stroke::IDENTIFIER) - .expect("Stroke node does not exist") - .default_node_template(); let fill = resolve_proto_node_type(graphene_std::vector_nodes::fill::IDENTIFIER) .expect("Fill node does not exist") .default_node_template(); @@ -282,10 +279,6 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.insert_node(transform_id, transform, &[]); self.network_interface.move_node_to_chain_start(&transform_id, layer, &[], self.import); - let stroke_id = NodeId::new(); - self.network_interface.insert_node(stroke_id, stroke, &[]); - self.network_interface.move_node_to_chain_start(&stroke_id, layer, &[], self.import); - let fill_id = NodeId::new(); self.network_interface.insert_node(fill_id, fill, &[]); self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import); diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index e498e6ac27..32d4cc5911 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -355,19 +355,21 @@ impl<'a> MessageHandler> for NodeG responses.add(NodeGraphMessage::DeleteSelectedNodes { delete_children: true }); } NodeGraphMessage::DeleteNodes { node_ids, delete_children } => { - // Detect stroke proto nodes among the doomed nodes before they're gone, so the stroke-using tools' - // Weight widgets can re-read the layer (they'll now read 0 px since the stroke node is missing). - let any_stroke_deleted = node_ids.iter().any(|node_id| { + // Detect stroke/fill proto nodes among the doomed nodes before they're gone so the tool control bars can re-sync + let stroke = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER); + let fill = DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER); + let any_fill_or_stroke_deleted = node_ids.iter().any(|node_id| { network_interface .reference(node_id, selection_network_path) - .is_some_and(|reference| reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER)) + .is_some_and(|reference| reference == stroke || reference == fill) }); network_interface.delete_nodes(node_ids, delete_children, selection_network_path); - if any_stroke_deleted { + if any_fill_or_stroke_deleted { responses.add(PenToolMessage::SelectionChanged); responses.add(FreehandToolMessage::SelectionChanged); responses.add(SplineToolMessage::SelectionChanged); responses.add(ShapeToolMessage::SelectionChanged); + responses.add(TextToolMessage::SelectionChanged); } } // Deletes selected_nodes. If `reconnect` is true, then all children nodes (secondary input) of the selected nodes are deleted and the siblings (primary input/output) are reconnected. @@ -1740,21 +1742,17 @@ impl<'a> MessageHandler> for NodeG } } NodeGraphMessage::SetInputValue { node_id, input_index, value } => { + use graphene_std::vector::generator_nodes::*; + let is_fill = matches!(value, TaggedValue::Fill(_)); let reference = network_interface.reference(&node_id, selection_network_path); let is_text_node = reference.as_ref().is_some_and(|r| *r == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)); let is_stroke_node = reference.as_ref().is_some_and(|r| *r == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER)); + let is_fill_node = reference.as_ref().is_some_and(|r| *r == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER)); let is_shape_generator_node = reference.as_ref().is_some_and(|r| { - [ - graphene_std::vector::generator_nodes::regular_polygon::IDENTIFIER, - graphene_std::vector::generator_nodes::star::IDENTIFIER, - graphene_std::vector::generator_nodes::arc::IDENTIFIER, - graphene_std::vector::generator_nodes::spiral::IDENTIFIER, - graphene_std::vector::generator_nodes::grid::IDENTIFIER, - graphene_std::vector::generator_nodes::arrow::IDENTIFIER, - ] - .into_iter() - .any(|id| *r == DefinitionIdentifier::ProtoNode(id)) + [regular_polygon::IDENTIFIER, star::IDENTIFIER, arc::IDENTIFIER, spiral::IDENTIFIER, grid::IDENTIFIER, arrow::IDENTIFIER] + .into_iter() + .any(|id| *r == DefinitionIdentifier::ProtoNode(id)) }); let input = NodeInput::value(value, false); @@ -1766,15 +1764,12 @@ impl<'a> MessageHandler> for NodeG if is_fill { responses.add(OverlaysMessage::Draw); } - if is_text_node { - responses.add(TextToolMessage::SelectionChanged); - } - if is_stroke_node || is_shape_generator_node { - // The dispatcher delivers each only to its tool when active, so this just covers all four stroke-using tools. + if is_stroke_node || is_fill_node || is_shape_generator_node || is_text_node { responses.add(PenToolMessage::SelectionChanged); responses.add(FreehandToolMessage::SelectionChanged); responses.add(SplineToolMessage::SelectionChanged); responses.add(ShapeToolMessage::SelectionChanged); + responses.add(TextToolMessage::SelectionChanged); } if network_interface.connected_to_output(&node_id, selection_network_path) { responses.add(NodeGraphMessage::RunDocumentGraph); diff --git a/editor/src/messages/tool/common_functionality/color_selector.rs b/editor/src/messages/tool/common_functionality/color_selector.rs index c9ac746768..4845c45ad5 100644 --- a/editor/src/messages/tool/common_functionality/color_selector.rs +++ b/editor/src/messages/tool/common_functionality/color_selector.rs @@ -1,74 +1,77 @@ +use crate::consts::DEFAULT_STROKE_WIDTH; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::TransactionStatus; 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; -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum ToolColorType { - Primary, - Secondary, - Custom, -} - /// Color selector widgets seen in [`LayoutTarget::ToolOptions`] bar. pub struct ToolColorOptions { - pub custom_color: Option, - pub primary_working_color: Option, - pub secondary_working_color: Option, - pub color_type: ToolColorType, + /// The fill/stroke value shown in the swatch. `None` = mixed across selected layers. + pub fill_choice: Option, + /// The checkbox state. `None` = mixed across selected layers. + pub enabled: Option, + /// When set, `fill_choice` is a working-color fallback (vs. a layer-derived saved color) and is refreshed live by `WorkingColorChanged`. + pub tracks_working_color: bool, } impl Default for ToolColorOptions { fn default() -> Self { Self { - color_type: ToolColorType::Primary, - custom_color: Some(Color::BLACK), - primary_working_color: Some(Color::BLACK), - secondary_working_color: Some(Color::WHITE), + fill_choice: Some(FillChoice::Solid(Color::BLACK)), + enabled: Some(true), + tracks_working_color: true, } } } impl ToolColorOptions { - pub fn new_primary() -> Self { + pub fn new_enabled() -> Self { Self::default() } - pub fn new_secondary() -> Self { + pub fn new_disabled() -> Self { Self { - color_type: ToolColorType::Secondary, - ..Default::default() + fill_choice: Some(FillChoice::None), + enabled: Some(false), + tracks_working_color: true, } } - pub fn new_none() -> Self { - Self { - color_type: ToolColorType::Custom, - custom_color: None, - ..Default::default() - } + /// True when the slot is actively applied, i.e. `enabled` is `Some(true)`. + /// `None` (mixed) and `Some(false)` both count as not actively applied. + pub fn is_active(&self) -> bool { + self.enabled == Some(true) } + /// The active solid color in linear sRGB, suitable for storing in a working color or downstream rendering input. + /// `fill_choice` is stored in gamma space (per [`FillChoice`]'s contract), so this method converts to linear before returning. pub fn active_color(&self) -> Option { - match self.color_type { - ToolColorType::Custom => self.custom_color, - ToolColorType::Primary => self.primary_working_color, - ToolColorType::Secondary => self.secondary_working_color, + if !self.is_active() { + return None; } + Some(self.fill_choice.as_ref()?.as_solid()?.to_linear_srgb()) } pub fn apply_fill(&self, layer: LayerNodeIdentifier, responses: &mut VecDeque) { - if let Some(color) = self.active_color() { - let fill = graphene_std::vector::style::Fill::solid(color.to_gamma_srgb()); + if !self.is_active() { + return; + } + if let Some(FillChoice::Solid(color)) = &self.fill_choice { + let fill = graphene_std::vector::style::Fill::Solid(*color); responses.add(GraphOperationMessage::FillSet { layer, fill }); } } pub fn apply_stroke(&self, weight: f64, layer: LayerNodeIdentifier, responses: &mut VecDeque) { - if let Some(color) = self.active_color() { - let stroke = graphene_std::vector::style::Stroke::new(Some(color.to_gamma_srgb()), weight); + if !self.is_active() { + return; + } + if let Some(FillChoice::Solid(color)) = &self.fill_choice { + let stroke = graphene_std::vector::style::Stroke::new(Some(*color), weight); responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); } } @@ -76,49 +79,419 @@ impl ToolColorOptions { pub fn create_widgets( &self, label_text: impl Into, - color_allow_none: bool, - reset_callback: impl Fn(&IconButton) -> Message + 'static + Send + Sync, - radio_callback: fn(ToolColorType) -> WidgetCallback<()>, + checkbox_callback: impl Fn(&CheckboxInput) -> Message + 'static + Send + Sync, color_callback: impl Fn(&ColorInput) -> Message + 'static + Send + Sync, ) -> Vec { - let mut widgets = vec![TextLabel::new(label_text).widget_instance()]; - - if !color_allow_none { - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - } else { - let reset = IconButton::new("CloseX", 12) - .disabled(self.custom_color.is_none() && self.color_type == ToolColorType::Custom) - .tooltip_label("Clear Color") - .on_update(reset_callback); - - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - widgets.push(reset.widget_instance()); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - }; + let checkbox_id = CheckboxId::new(); + // In the mixed state (`fill_choice` is `None`) the dash overlay covers the swatch, so the underlying widget value just drives the picker's initial position. + // `FillChoice::None` gives it a neutral starting point. + let mixed_color = self.fill_choice.is_none(); + let widget_value = self.fill_choice.clone().unwrap_or(FillChoice::None); + let mixed_enabled = self.enabled.is_none(); + // In the mixed-enabled state the underlying `checked` value is hidden behind the indeterminate dash. + // The frontend's click handler sends `true` when the user resolves the mixed state by clicking. + let checked = self.enabled.unwrap_or(false); - let entries = vec![ - ("WorkingColorsPrimary", "Primary Working Color", ToolColorType::Primary), - ("WorkingColorsSecondary", "Secondary Working Color", ToolColorType::Secondary), - ("CustomColor", "Custom Color", ToolColorType::Custom), + vec![ + CheckboxInput::new(checked).mixed(mixed_enabled).on_update(checkbox_callback).for_label(checkbox_id).widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + TextLabel::new(label_text).for_checkbox(checkbox_id).widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + ColorInput::new(widget_value) + .mixed(mixed_color) + .min_width(48) + .max_width(48) + .narrow(true) + .on_update(color_callback) + .widget_instance(), ] - .into_iter() - .map(|(icon, label, color_type)| { - let mut entry = RadioEntryData::new(format!("{color_type:?}")).tooltip_label(label).icon(icon); - entry.on_update = radio_callback(color_type); - entry - }) - .collect(); - let radio = RadioInput::new(entries).selected_index(Some(self.color_type.clone() as u32)).widget_instance(); - widgets.push(radio); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - - let fill_choice = match self.active_color() { - Some(color) => FillChoice::Solid(color.to_gamma_srgb()), - None => FillChoice::None, + } +} + +/// 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, + /// 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. + pub fill: ToolColorOptions, + /// The stroke swatch's color, checkbox, and mixed state. + pub stroke: ToolColorOptions, + /// When false (default), fill follows the secondary working color and stroke follows the primary; when true, the routing is reversed. + /// Persisted per-tool. The Shape tool additionally persists it for each shape mode via its options. + pub colors_swapped: bool, +} + +impl DrawingToolState { + pub fn new(fill_enabled: bool) -> Self { + Self { + line_weight: Some(DEFAULT_STROKE_WIDTH), + default_line_weight: DEFAULT_STROKE_WIDTH, + last_synced_selection: Vec::new(), + fill: if fill_enabled { ToolColorOptions::new_enabled() } else { ToolColorOptions::new_disabled() }, + stroke: ToolColorOptions::new_enabled(), + colors_swapped: false, + } + } + + /// The line weight to apply, falling back to the persistent default when [`Self::line_weight`] is `None` (mixed). + pub fn effective_line_weight(&self) -> f64 { + self.line_weight.unwrap_or(self.default_line_weight) + } +} + +/// Builds a `FillChoice::Solid` from a linear-space color, applying gamma conversion to display sRGB. +/// Common helper used throughout the color-syncing code where working colors (linear) flow into swatches that store gamma-encoded colors. +pub fn solid_gamma(color: Color) -> FillChoice { + FillChoice::Solid(color.to_gamma_srgb()) +} + +/// The fill working color (the source for the fill swatch when nothing is selected). +/// Defaults to secondary, swapped to primary when the per-tool [`DrawingToolState::colors_swapped`] flag is set. +pub fn fill_working_color(global: &DocumentToolData, colors_swapped: bool) -> Color { + if colors_swapped { global.primary_color } else { global.secondary_color } +} + +/// The stroke working color (the source for the stroke swatch when nothing is selected). +/// Defaults to primary, swapped to secondary when the per-tool [`DrawingToolState::colors_swapped`] flag is set. +pub fn stroke_working_color(global: &DocumentToolData, colors_swapped: bool) -> Color { + if colors_swapped { global.secondary_color } else { global.primary_color } +} + +/// Syncs fill and stroke from the selection (or working colors when empty). With `selection_changed = false`, preserves display values +/// for inactive states instead of resetting them. Returns `true` if anything changed. +pub fn sync_color_options( + drawing: &mut DrawingToolState, + natural_fill_enabled: bool, + natural_stroke_enabled: bool, + global: &DocumentToolData, + document: &DocumentMessageHandler, + selection_changed: bool, +) -> bool { + let fill_fallback = solid_gamma(fill_working_color(global, drawing.colors_swapped)); + let stroke_fallback = solid_gamma(stroke_working_color(global, drawing.colors_swapped)); + + let mut changed = false; + + // FILL + + let new_fill = if let Some(state) = graph_modification_utils::selected_fill_state(document) { + let active = state.enabled == Some(true); + let (display_choice, tracks_working) = match &state.fill_choice { + Some(choice) if active => (Some(choice.clone()), false), + Some(_) if selection_changed => (Some(fill_fallback.clone()), true), + Some(_) => (drawing.fill.fill_choice.clone(), drawing.fill.tracks_working_color), + None => (None, true), + }; + (state.enabled, display_choice, tracks_working) + } else { + // On a real deselect, revert to the working color; otherwise preserve the displayed value. + let display_choice = if selection_changed { Some(fill_fallback) } else { drawing.fill.fill_choice.clone() }; + let tracks_working = if selection_changed { true } else { drawing.fill.tracks_working_color }; + (Some(natural_fill_enabled), display_choice, tracks_working) + }; + if drawing.fill.enabled != new_fill.0 || drawing.fill.fill_choice != new_fill.1 || drawing.fill.tracks_working_color != new_fill.2 { + drawing.fill.enabled = new_fill.0; + drawing.fill.fill_choice = new_fill.1; + drawing.fill.tracks_working_color = new_fill.2; + changed = true; + } + + // STROKE + + let new_stroke = if let Some(state) = graph_modification_utils::selected_stroke_state(document) { + let active = state.enabled == Some(true); + let (display_choice, tracks_working) = match state.optional_color { + Some(color) if active => (Some(color.map_or(FillChoice::None, FillChoice::Solid)), false), + Some(_) if selection_changed => (Some(stroke_fallback.clone()), true), + Some(_) => (drawing.stroke.fill_choice.clone(), drawing.stroke.tracks_working_color), + None => (None, true), + }; + (state.enabled, display_choice, tracks_working) + } else { + let display_choice = if selection_changed { Some(stroke_fallback) } else { drawing.stroke.fill_choice.clone() }; + let tracks_working = if selection_changed { true } else { drawing.stroke.tracks_working_color }; + (Some(natural_stroke_enabled), display_choice, tracks_working) + }; + if drawing.stroke.enabled != new_stroke.0 || drawing.stroke.fill_choice != new_stroke.1 || drawing.stroke.tracks_working_color != new_stroke.2 { + drawing.stroke.enabled = new_stroke.0; + drawing.stroke.fill_choice = new_stroke.1; + drawing.stroke.tracks_working_color = new_stroke.2; + changed = true; + } + + changed +} + +/// Full SelectionChanged update for a drawing tool: syncs fill/stroke colors and the stroke weight. Returns `true` if the layout needs refreshing. +pub fn sync_drawing_state(drawing: &mut DrawingToolState, natural_fill_enabled: bool, natural_stroke_enabled: bool, global: &DocumentToolData, document: &DocumentMessageHandler) -> bool { + let selection_changed = selection_changed_since_last_sync(&mut drawing.last_synced_selection, document); + let mut needs_refresh = sync_color_options(drawing, natural_fill_enabled, natural_stroke_enabled, global, document, selection_changed); + + let new_line_weight = match compute_weight_sync(document) { + WeightSyncOutcome::Set(weight) => Some(weight), + WeightSyncOutcome::Mixed => None, + // On a real selection change, revert to the default; otherwise preserve. + WeightSyncOutcome::NoStrokes | WeightSyncOutcome::NoSelection => { + if selection_changed { + Some(drawing.default_line_weight) + } else { + drawing.line_weight + } + } + }; + if drawing.line_weight != new_line_weight { + drawing.line_weight = new_line_weight; + needs_refresh = true; + } + + needs_refresh +} + +/// 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); + + let new_fill = if let Some(state) = graph_modification_utils::selected_fill_state(document) { + let active = state.enabled == Some(true); + let (display_choice, tracks_working_color) = match &state.fill_choice { + Some(choice) if active => (Some(choice.clone()), false), + Some(_) if selection_changed => (Some(fill_fallback.clone()), true), + Some(_) => (fill.fill_choice.clone(), fill.tracks_working_color), + None => (None, true), }; - let color_button = ColorInput::new(fill_choice).allow_none(color_allow_none).on_update(color_callback); - widgets.push(color_button.widget_instance()); + (state.enabled, display_choice, tracks_working_color) + } else { + let display_choice = if selection_changed { Some(fill_fallback) } else { fill.fill_choice.clone() }; + let tracks_working = if selection_changed { true } else { fill.tracks_working_color }; + (Some(natural_fill_enabled), display_choice, tracks_working) + }; + + if fill.enabled != new_fill.0 || fill.fill_choice != new_fill.1 || fill.tracks_working_color != new_fill.2 { + fill.enabled = new_fill.0; + fill.fill_choice = new_fill.1; + fill.tracks_working_color = new_fill.2; + true + } else { + false + } +} + +/// True if at least one (non-artboard) layer is currently selected. +pub fn has_selection(document: &DocumentMessageHandler) -> bool { + document + .network_interface + .selected_nodes() + .selected_layers_except_artboards(&document.network_interface) + .next() + .is_some() +} + +/// Applies a user-picked fill (gradient or solid). With a selection, writes to the layers; with none, pushes a solid to the swap-routed working color slot. +pub fn apply_fill_color_pick(drawing: &mut DrawingToolState, fill_choice: FillChoice, document: &DocumentMessageHandler, responses: &mut VecDeque) { + apply_fill_only_color_pick(&mut drawing.fill, fill_choice, drawing.colors_swapped, document, responses); +} + +/// Single-slot variant of [`apply_fill_color_pick`] (e.g. for text). `slot_is_primary` says which working color this slot binds to. +pub fn apply_fill_only_color_pick(fill: &mut ToolColorOptions, fill_choice: FillChoice, slot_is_primary: bool, document: &DocumentMessageHandler, responses: &mut VecDeque) { + fill.fill_choice = Some(fill_choice.clone()); + fill.enabled = Some(true); + fill.tracks_working_color = false; + if has_selection(document) { + if document.network_interface.transaction_status() == TransactionStatus::Finished { + responses.add(DocumentMessage::StartTransaction); + } + graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses); + } else if let FillChoice::Solid(color) = fill_choice { + // Swatch is gamma; working colors are linear. + responses.add(ToolMessage::SelectWorkingColor { + color: color.to_linear_srgb(), + primary: slot_is_primary, + }); + } +} + +/// Applies a user-picked stroke color. With a selection, writes to the layers; with none, pushes to the swap-routed working color slot. +pub fn apply_stroke_color_pick(drawing: &mut DrawingToolState, color: Option, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.stroke.fill_choice = Some(color.map_or(FillChoice::None, FillChoice::Solid)); + drawing.stroke.enabled = Some(true); + drawing.stroke.tracks_working_color = false; + if has_selection(document) { + if document.network_interface.transaction_status() == TransactionStatus::Finished { + responses.add(DocumentMessage::StartTransaction); + } + graph_modification_utils::set_stroke_color_for_selected_layers(color, drawing.effective_line_weight(), document, responses); + } else if let Some(color) = color { + // Swatch is gamma; working colors are linear. + responses.add(ToolMessage::SelectWorkingColor { + color: color.to_linear_srgb(), + primary: !drawing.colors_swapped, + }); + } +} + +/// Toggles the fill checkbox: re-applies the preserved color when enabled, removes the fill node when disabled. +pub fn apply_fill_enabled(drawing: &mut DrawingToolState, enabled: bool, global: &DocumentToolData, document: &DocumentMessageHandler, responses: &mut VecDeque) { + apply_fill_only_enabled(&mut drawing.fill, enabled, fill_working_color(global, drawing.colors_swapped), document, responses); +} + +/// Single-slot variant of [`apply_fill_enabled`]. `working_color` is the fallback used when re-ticking or unticking from a mixed state. +pub fn apply_fill_only_enabled(fill: &mut ToolColorOptions, enabled: bool, working_color: Color, document: &DocumentMessageHandler, responses: &mut VecDeque) { + fill.enabled = Some(enabled); + if has_selection(document) { + responses.add(DocumentMessage::AddTransaction); + } + if enabled { + // Mixed re-tick has no per-layer color to restore; fall back to the working color and keep tracking it. + let fill_choice = fill.fill_choice.clone().unwrap_or_else(|| { + fill.tracks_working_color = true; + solid_gamma(working_color) + }); + fill.fill_choice = Some(fill_choice.clone()); + graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses); + } else { + // Unticking from mixed: capture the working color as the saved value so the swatch keeps following the link. + if fill.fill_choice.is_none() { + fill.fill_choice = Some(solid_gamma(working_color)); + fill.tracks_working_color = true; + } + graph_modification_utils::remove_fill_for_selected_layers(document, responses); + } +} + +/// Toggles the stroke checkbox: mirrors [`apply_fill_enabled`]. +pub fn apply_stroke_enabled(drawing: &mut DrawingToolState, enabled: bool, global: &DocumentToolData, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.stroke.enabled = Some(enabled); + if has_selection(document) { + responses.add(DocumentMessage::AddTransaction); + } + if enabled { + let stroke_choice = drawing.stroke.fill_choice.clone().unwrap_or_else(|| { + drawing.stroke.tracks_working_color = true; + solid_gamma(stroke_working_color(global, drawing.colors_swapped)) + }); + drawing.stroke.fill_choice = Some(stroke_choice.clone()); + graph_modification_utils::set_stroke_color_for_selected_layers(stroke_choice.as_solid(), drawing.effective_line_weight(), document, responses); + } else { + if drawing.stroke.fill_choice.is_none() { + drawing.stroke.fill_choice = Some(solid_gamma(stroke_working_color(global, drawing.colors_swapped))); + drawing.stroke.tracks_working_color = true; + } + graph_modification_utils::remove_stroke_for_selected_layers(document, responses); + } +} + +/// Applies a user-edited stroke weight to the selection, also persisting it as the no-selection default. +pub fn apply_line_weight(drawing: &mut DrawingToolState, line_weight: f64, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.line_weight = Some(line_weight); + if !has_selection(document) { + drawing.default_line_weight = line_weight; + } + graph_modification_utils::set_stroke_weight_for_selected_layers(line_weight, document, responses); +} + +/// Propagates working colors to the tool's swatches. With no selection both slots refresh; with a selection, only slots tracking the working color. +pub fn apply_working_colors(drawing: &mut DrawingToolState, global: &DocumentToolData, document: &DocumentMessageHandler) { + refresh_slot_working_color(&mut drawing.fill, fill_working_color(global, drawing.colors_swapped), document); + refresh_slot_working_color(&mut drawing.stroke, stroke_working_color(global, drawing.colors_swapped), document); +} + +/// Refreshes a single swatch from the given working color, subject to the rules in [`apply_working_colors`]. +pub fn refresh_slot_working_color(slot: &mut ToolColorOptions, working_color: Color, document: &DocumentMessageHandler) { + if slot.fill_choice.is_some() && (!has_selection(document) || slot.tracks_working_color) { + slot.fill_choice = Some(solid_gamma(working_color)); + } +} + +/// Resets the tool's swatches to the working colors. Called on tool deactivation and shape-mode changes. +pub fn reset_colors_on_deactivation(drawing: &mut DrawingToolState, global: &DocumentToolData) { + drawing.fill.fill_choice = Some(solid_gamma(fill_working_color(global, drawing.colors_swapped))); + drawing.stroke.fill_choice = Some(solid_gamma(stroke_working_color(global, drawing.colors_swapped))); + drawing.fill.tracks_working_color = true; + drawing.stroke.tracks_working_color = true; +} + +/// Handles the "Swap Fill/Stroke" button. Stroke can only hold a solid color, so a gradient fill collapses to `None` when moved. +pub fn swap_fill_and_stroke(drawing: &mut DrawingToolState, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.colors_swapped = !drawing.colors_swapped; + + if has_selection(document) { + responses.add(DocumentMessage::AddTransaction); + } + + // The new fill takes the old stroke's value as-is; the new stroke takes the old fill (with any gradient collapsed to `None`, + // since stroke can only hold a solid color). `None` (mixed) on either side propagates as `None` to the other. + let new_fill = drawing.stroke.fill_choice.clone(); + let new_stroke = drawing.fill.fill_choice.as_ref().map(|c| c.as_solid().map_or(FillChoice::None, FillChoice::Solid)); + let (new_fill_tracks, new_stroke_tracks) = (drawing.stroke.tracks_working_color, drawing.fill.tracks_working_color); + + drawing.fill.fill_choice = new_fill.clone(); + drawing.stroke.fill_choice = new_stroke.clone(); + drawing.fill.tracks_working_color = new_fill_tracks; + drawing.stroke.tracks_working_color = new_stroke_tracks; + + if has_selection(document) { + // Apply to layers only when we have a concrete value (`None` means mixed, no single value to broadcast). + if drawing.fill.is_active() + && let Some(choice) = new_fill + { + graph_modification_utils::set_fill_for_selected_layers(choice, document, responses); + } + if drawing.stroke.is_active() + && let Some(choice) = new_stroke + { + graph_modification_utils::set_stroke_color_for_selected_layers(choice.as_solid(), drawing.effective_line_weight(), document, responses); + } + } +} - widgets +/// Updates the cache and returns `true` if the current selection differs from the last-synced one. Cache stays sorted to keep comparisons cheap. +pub fn selection_changed_since_last_sync(last_synced: &mut Vec, document: &DocumentMessageHandler) -> bool { + let mut current: Vec = document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface).collect(); + + current.sort(); + + let changed = current != *last_synced; + *last_synced = current; + changed +} + +/// How the weight widget should update from inspecting selected layers' strokes. +pub enum WeightSyncOutcome { + /// All strokes share this weight. + Set(f64), + /// Stroke weights differ (or some layers lack a stroke): show the mixed dash. + Mixed, + /// Selection has no strokes: reset to the tool's default on a real selection change, otherwise preserve. + NoStrokes, + /// No selection: preserve the current value. + NoSelection, +} + +/// Inspects the selection and returns how the weight widget should update. +pub fn compute_weight_sync(document: &DocumentMessageHandler) -> WeightSyncOutcome { + let layers: Vec<_> = document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface).collect(); + + if layers.is_empty() { + return WeightSyncOutcome::NoSelection; } + + let stroke_weights: Vec = layers.iter().filter_map(|l| graph_modification_utils::get_stroke_width(*l, &document.network_interface)).collect(); + + if stroke_weights.is_empty() { + return WeightSyncOutcome::NoStrokes; + } + + if stroke_weights.len() != layers.len() { + return WeightSyncOutcome::Mixed; + } + + let first = stroke_weights[0]; + let all_same = stroke_weights.iter().all(|&w| (w - first).abs() < f64::EPSILON * 100.); + if all_same { WeightSyncOutcome::Set(first) } else { WeightSyncOutcome::Mixed } } 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 d21f32d460..f440e31f1a 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, Gradient}; +use graphene_std::vector::style::{Fill, FillChoice, Gradient}; use graphene_std::vector::{GradientStops, PointId, SegmentId, VectorModificationType}; use std::collections::VecDeque; @@ -526,6 +526,176 @@ pub fn set_stroke_weight_for_selected_layers(weight: f64, document: &DocumentMes } } +/// Returns the `Fill` value from a layer's upstream Fill node. +pub fn get_fill_value(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + let fill_index = graphene_std::vector::fill::FillInput::::INDEX; + let tagged = NodeGraphLayer::new(layer, network_interface).find_input(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER), fill_index)?; + if let TaggedValue::Fill(fill) = tagged { Some(fill.clone()) } else { None } +} + +/// Returns the stroke color from a layer's upstream Stroke node. +pub fn get_stroke_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option> { + let color_index = graphene_std::vector::stroke::ColorInput::INDEX; + let tagged = NodeGraphLayer::new(layer, network_interface).find_input(&DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER), color_index)?; + if let TaggedValue::Color(color) = tagged { Some(*color) } else { None } +} + +/// Aggregated fill state across all selected non-artboard layers. +pub struct SelectedFillState { + /// `None` means mixed values between selected layers. + pub enabled: Option, + /// `None` means mixed values between selected layers. + pub fill_choice: Option, +} + +/// Aggregated stroke state across all selected non-artboard layers. +pub struct SelectedStrokeState { + /// `None` means mixed values between selected layers. + pub enabled: Option, + /// `None` means mixed values between selected layers. + pub optional_color: Option>, +} + +/// Reads the fill state across all selected non-artboard layers, including whether their enabled states or colors differ. +/// "Enabled" tracks node attachment: a layer counts as enabled whenever a Fill node is attached, even when that fill's value is [`FillChoice::None`]. +/// Unticked means there is no Fill node. Returns `None` only when no layer is selected. +pub fn selected_fill_state(document: &DocumentMessageHandler) -> Option { + let selected_nodes = document.network_interface.selected_nodes(); + let mut per_layer = selected_nodes.selected_layers_except_artboards(&document.network_interface).map(|layer| { + if get_fill_id(layer, &document.network_interface).is_none() { + return (false, FillChoice::None); + } + let fill_choice = get_fill_value(layer, &document.network_interface).map_or(FillChoice::None, FillChoice::from); + (true, fill_choice) + }); + + let (initial_enabled, initial_choice) = per_layer.next()?; + let mut enabled_mixed = false; + let mut color_mixed = false; + let mut comparison_enabled = initial_enabled; + let mut comparison_choice = initial_choice; + for (enabled, fill_choice) in per_layer { + if enabled != initial_enabled { + enabled_mixed = true; + } + if enabled { + if comparison_enabled { + if fill_choice != comparison_choice { + color_mixed = true; + } + } else { + comparison_enabled = true; + comparison_choice = fill_choice; + } + } + } + + Some(SelectedFillState { + enabled: (!enabled_mixed).then_some(initial_enabled), + fill_choice: (!color_mixed).then_some(comparison_choice), + }) +} + +/// Reads the stroke state across all selected non-artboard layers, including whether their enabled states or colors differ. +/// "Enabled" tracks node attachment: a layer counts as enabled whenever a Stroke node is attached, even when that stroke's color is `None`. +/// Unticked means there is no Stroke node. Returns `None` only when no layer is selected. +pub fn selected_stroke_state(document: &DocumentMessageHandler) -> Option { + let selected_nodes = document.network_interface.selected_nodes(); + let mut per_layer = selected_nodes.selected_layers_except_artboards(&document.network_interface).map(|layer| { + if get_stroke_id(layer, &document.network_interface).is_none() { + return (false, None); + } + let color = get_stroke_color(layer, &document.network_interface).flatten(); + (true, color) + }); + + let (initial_enabled, initial_color) = per_layer.next()?; + let mut enabled_mixed = false; + let mut color_mixed = false; + let mut comparison_enabled = initial_enabled; + let mut comparison_color = initial_color; + for (enabled, color) in per_layer { + if enabled != initial_enabled { + enabled_mixed = true; + } + if enabled { + if comparison_enabled { + if color != comparison_color { + color_mixed = true; + } + } else { + comparison_enabled = true; + comparison_color = color; + } + } + } + + Some(SelectedStrokeState { + enabled: (!enabled_mixed).then_some(initial_enabled), + optional_color: (!color_mixed).then_some(comparison_color), + }) +} + +/// Sets the fill on all selected non-artboard layers, preserving gradient transform data when the layer already has a gradient fill. +pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &DocumentMessageHandler, responses: &mut VecDeque) { + let layers: Vec<_> = document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface).collect(); + for layer in layers { + let existing_gradient = get_fill_value(layer, &document.network_interface).and_then(|f| match f { + Fill::Gradient(g) => Some(g), + _ => None, + }); + let fill = fill_choice.clone().to_fill(existing_gradient.as_ref()); + responses.add(GraphOperationMessage::FillSet { layer, fill }); + } +} + +/// Sets the stroke color on all selected non-artboard layers. Layers without an existing Stroke node get one created using +/// the provided `weight`, so picking any color (including `None`) from an unticked stroke control bar entry both attaches +/// the Stroke node and applies the chosen color. +pub fn set_stroke_color_for_selected_layers(color: Option, weight: f64, document: &DocumentMessageHandler, responses: &mut VecDeque) { + let layers: Vec<_> = document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface).collect(); + for layer in layers { + if let Some(node_id) = get_stroke_id(layer, &document.network_interface) { + let input_index = graphene_std::vector::stroke::ColorInput::INDEX; + let value = TaggedValue::Color(color); + responses.add(NodeGraphMessage::SetInputValue { node_id, input_index, value }); + } else { + let stroke = graphene_std::vector::style::Stroke::new(color, weight); + responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); + } + } +} + +/// Removes the Fill node from all selected non-artboard layers. +pub fn remove_fill_for_selected_layers(document: &DocumentMessageHandler, responses: &mut VecDeque) { + let layers: Vec<_> = document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface).collect(); + for layer in layers { + if let Some(node_id) = get_fill_id(layer, &document.network_interface) { + responses.add(NodeGraphMessage::DeleteNodes { + node_ids: vec![node_id], + delete_children: true, + }); + } + } + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(NodeGraphMessage::SendGraph); +} + +/// Removes the Stroke node from all selected non-artboard layers. +pub fn remove_stroke_for_selected_layers(document: &DocumentMessageHandler, responses: &mut VecDeque) { + let layers: Vec<_> = document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface).collect(); + for layer in layers { + if let Some(node_id) = get_stroke_id(layer, &document.network_interface) { + responses.add(NodeGraphMessage::DeleteNodes { + node_ids: vec![node_id], + delete_children: true, + }); + } + } + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(NodeGraphMessage::SendGraph); +} + /// Reads a specific input from the matching proto node on the first selected non-artboard layer that has one. /// Used by tool control bars to mirror per-shape parameters (sides, arc type, turns, etc.) from the selection /// into the control bar's input widget state without each call site re-implementing the layer iteration. diff --git a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs index 96dbdd5165..5f0d6da016 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -25,7 +25,7 @@ use std::collections::VecDeque; use std::f64::consts::{PI, TAU}; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default, serde::Serialize, serde::Deserialize)] pub enum ShapeType { #[default] Polygon = 0, @@ -41,6 +41,26 @@ pub enum ShapeType { } impl ShapeType { + /// Every shape mode, in dropdown order. Used to seed per-mode default maps. + pub const ALL: &[ShapeType] = &[ + ShapeType::Polygon, + ShapeType::Star, + ShapeType::Circle, + ShapeType::Arc, + ShapeType::Spiral, + ShapeType::Grid, + ShapeType::Arrow, + ShapeType::Line, // KEEP THIS AT THE END + ShapeType::Rectangle, // KEEP THIS AT THE END + ShapeType::Ellipse, // KEEP THIS AT THE END + ]; + + /// True if this shape mode's fill checkbox is ticked by default when nothing is selected. + /// Spiral/Grid/Line are open paths and default to fill-off, the closed shapes default to fill-on. + pub fn defaults_to_fill(&self) -> bool { + matches!(self, Self::Polygon | Self::Star | Self::Circle | Self::Arc | Self::Rectangle | Self::Ellipse | Self::Arrow) + } + pub fn name(&self) -> String { (match self { Self::Polygon => "Polygon", @@ -50,9 +70,9 @@ impl ShapeType { Self::Spiral => "Spiral", Self::Grid => "Grid", Self::Arrow => "Arrow", - Self::Line => "Line", - Self::Rectangle => "Rectangle", - Self::Ellipse => "Ellipse", + Self::Line => "Line", // KEEP THIS AT THE END + Self::Rectangle => "Rectangle", // KEEP THIS AT THE END + Self::Ellipse => "Ellipse", // KEEP THIS AT THE END }) .into() } diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs index eaa97a2c28..e97d4573cc 100644 --- a/editor/src/messages/tool/tool_messages/brush_tool.rs +++ b/editor/src/messages/tool/tool_messages/brush_tool.rs @@ -4,12 +4,13 @@ use crate::messages::portfolio::document::graph_operation::transform_utils::get_ use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_proto_node_type}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::FlowType; -use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType}; +use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid_gamma}; use graph_craft::document::NodeId; use graph_craft::document::value::TaggedValue; use graphene_std::Color; use graphene_std::brush::brush_stroke::{BrushInputSample, BrushStroke, BrushStyle}; use graphene_std::raster::BlendMode; +use graphene_std::vector::style::FillChoice; const BRUSH_MAX_SIZE: f64 = 5000.; @@ -73,13 +74,12 @@ pub enum BrushToolMessageOptionsUpdate { BlendMode(BlendMode), ChangeDiameter(f64), Color(Option), - ColorType(ToolColorType), Diameter(f64), DrawMode(DrawMode), Flow(f64), Hardness(f64), Spacing(f64), - WorkingColors(Option, Option), + WorkingColorsChanged, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -104,6 +104,18 @@ impl ToolMetadata for BrushTool { impl LayoutHolder for BrushTool { fn layout(&self) -> Layout { let mut widgets = vec![ + ColorInput::new(self.options.color.fill_choice.clone().unwrap_or(FillChoice::None)) + .mixed(self.options.color.fill_choice.is_none()) + .narrow(true) + .on_update(|color: &ColorInput| { + BrushToolMessage::UpdateOptions { + // The picker emits gamma-space colors; working colors are stored in linear sRGB. + options: BrushToolMessageOptionsUpdate::Color(color.value.as_solid().map(|c| c.to_linear_srgb())), + } + .into() + }) + .widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), NumberInput::new(Some(self.options.diameter)) .label("Diameter") .min(1.) @@ -170,33 +182,6 @@ impl LayoutHolder for BrushTool { .collect(); widgets.push(RadioInput::new(draw_mode_entries).selected_index(Some(self.options.draw_mode as u32)).widget_instance()); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - widgets.append(&mut self.options.color.create_widgets( - "Color", - false, - |_| { - BrushToolMessage::UpdateOptions { - options: BrushToolMessageOptionsUpdate::Color(None), - } - .into() - }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { - BrushToolMessage::UpdateOptions { - options: BrushToolMessageOptionsUpdate::ColorType(color_type.clone()), - } - .into() - }) - }, - |color: &ColorInput| { - BrushToolMessage::UpdateOptions { - options: BrushToolMessageOptionsUpdate::Color(color.value.as_solid().map(|color| color.to_linear_srgb())), - } - .into() - }, - )); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); let blend_mode_entries: Vec> = BlendMode::list() @@ -254,13 +239,13 @@ impl<'a> MessageHandler> for Brus BrushToolMessageOptionsUpdate::Flow(flow) => self.options.flow = flow, BrushToolMessageOptionsUpdate::Spacing(spacing) => self.options.spacing = spacing, BrushToolMessageOptionsUpdate::Color(color) => { - self.options.color.custom_color = color; - self.options.color.color_type = ToolColorType::Custom; + // User picked a color: push to the global primary working color (no tool-local customization). + if let Some(color) = color { + responses.add(ToolMessage::SelectWorkingColor { color, primary: true }); + } } - BrushToolMessageOptionsUpdate::ColorType(color_type) => self.options.color.color_type = color_type, - BrushToolMessageOptionsUpdate::WorkingColors(primary, secondary) => { - self.options.color.primary_working_color = primary; - self.options.color.secondary_working_color = secondary; + BrushToolMessageOptionsUpdate::WorkingColorsChanged => { + self.options.color.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); } } @@ -355,9 +340,7 @@ impl Fsm for BrushToolFsmState { tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { - let ToolActionMessageContext { - document, global_tool_data, input, .. - } = tool_action_data; + let ToolActionMessageContext { document, input, .. } = tool_action_data; let ToolMessage::Brush(event) = event else { return self }; match (self, event) { @@ -450,7 +433,7 @@ impl Fsm for BrushToolFsmState { } (_, BrushToolMessage::WorkingColorChanged) => { responses.add(BrushToolMessage::UpdateOptions { - options: BrushToolMessageOptionsUpdate::WorkingColors(Some(global_tool_data.primary_color), Some(global_tool_data.secondary_color)), + options: BrushToolMessageOptionsUpdate::WorkingColorsChanged, }); self } diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index c1a2a8fe75..15d145e6bc 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -1,16 +1,19 @@ use super::tool_prelude::*; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::tool::common_functionality::color_selector::solid_gamma; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use graphene_std::raster::color::Color; use graphene_std::vector::style::Fill; #[derive(Default, ExtractField)] pub struct FillTool { fsm_state: FillToolFsmState, + primary_color: Color, } #[impl_message(Message, ToolMessage, Fill)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(PartialEq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum FillToolMessage { // Standard messages Abort, @@ -22,6 +25,7 @@ pub enum FillToolMessage { PointerUp, FillPrimaryColor, FillSecondaryColor, + SetColor { color: Option }, } impl ToolMetadata for FillTool { @@ -38,13 +42,39 @@ impl ToolMetadata for FillTool { impl LayoutHolder for FillTool { fn layout(&self) -> Layout { - Layout::default() + let widgets = vec![ + ColorInput::new(solid_gamma(self.primary_color)) + .narrow(true) + .on_update(|color: &ColorInput| { + FillToolMessage::SetColor { + color: color.value.as_solid().map(|c| c.to_linear_srgb()), + } + .into() + }) + .widget_instance(), + ]; + Layout(vec![LayoutGroup::row(widgets)]) } } #[message_handler_data] impl<'a> MessageHandler> for FillTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + // User picked a color in the control bar: push it to the global primary working color (no tool-local customization) + if let ToolMessage::Fill(FillToolMessage::SetColor { color: Some(color) }) = &message { + responses.add(ToolMessage::SelectWorkingColor { color: *color, primary: true }); + return; + } + + // Mirror the global primary working color into the control bar's color swatch + if matches!(message, ToolMessage::Fill(FillToolMessage::WorkingColorChanged)) { + let new_color = context.global_tool_data.primary_color; + if self.primary_color != new_color { + self.primary_color = new_color; + self.send_layout(responses, LayoutTarget::ToolOptions); + } + } + self.fsm_state.process_event(message, &mut (), context, &(), responses, true); } fn actions(&self) -> ActionList { @@ -105,7 +135,7 @@ impl Fsm for FillToolFsmState { let ToolMessage::Fill(event) = event else { return self }; match (self, event) { (_, FillToolMessage::Overlays { context: mut overlay_context }) => { - // Choose the working color to preview + // Choose the color to preview let use_secondary = input.keyboard.get(Key::Shift as usize); let preview_color = if use_secondary { global_tool_data.secondary_color } else { global_tool_data.primary_color }; diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index e96f3ba961..c8e1eb8225 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -1,17 +1,20 @@ use super::tool_prelude::*; -use crate::consts::DEFAULT_STROKE_WIDTH; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_network_node_type; use crate::messages::portfolio::document::overlays::utility_functions::path_endpoint_overlays; 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::{ToolColorOptions, ToolColorType}; +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, +}; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::utility_functions::should_extend; use glam::DVec2; use graph_craft::document::NodeId; use graphene_std::Color; use graphene_std::vector::VectorModificationType; +use graphene_std::vector::style::FillChoice; use graphene_std::vector::{PointId, SegmentId}; #[derive(Default, ExtractField)] @@ -22,17 +25,13 @@ pub struct FreehandTool { } pub struct FreehandOptions { - line_weight: f64, - fill: ToolColorOptions, - stroke: ToolColorOptions, + drawing: DrawingToolState, } impl Default for FreehandOptions { fn default() -> Self { Self { - line_weight: DEFAULT_STROKE_WIDTH, - fill: ToolColorOptions::new_none(), - stroke: ToolColorOptions::new_primary(), + drawing: DrawingToolState::new(false), } } } @@ -57,12 +56,13 @@ pub enum FreehandToolMessage { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum FreehandOptionsUpdate { - FillColor(Option), - FillColorType(ToolColorType), + FillColor(FillChoice), + FillEnabled(bool), LineWeight(f64), StrokeColor(Option), - StrokeColorType(ToolColorType), - WorkingColors(Option, Option), + StrokeEnabled(bool), + SwapFillAndStroke, + WorkingColorsChanged, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -84,76 +84,79 @@ impl ToolMetadata for FreehandTool { } } -fn create_weight_widget(line_weight: f64) -> WidgetInstance { - NumberInput::new(Some(line_weight)) +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| { - FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::LineWeight(number_input.value.unwrap()), + if let Some(value) = number_input.value { + FreehandToolMessage::UpdateOptions { + options: FreehandOptionsUpdate::LineWeight(value), + } + .into() + } else { + Message::NoOp } - .into() }) + .on_commit(|_| DocumentMessage::StartTransaction.into()) .widget_instance() } impl LayoutHolder for FreehandTool { fn layout(&self) -> Layout { - let mut widgets = self.options.fill.create_widgets( - "Fill", - true, - |_| { + let mut widgets = self.options.drawing.fill.create_widgets( + "Fill:", + |checkbox: &CheckboxInput| { FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::FillColor(None), + options: FreehandOptionsUpdate::FillEnabled(checkbox.checked), } .into() }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { - FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::FillColorType(color_type.clone()), - } - .into() - }) - }, |color: &ColorInput| { FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::FillColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: FreehandOptionsUpdate::FillColor(color.value.clone()), } .into() }, ); widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - widgets.append(&mut self.options.stroke.create_widgets( - "Stroke", - true, - |_| { - FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::StrokeColor(None), - } - .into() - }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { + widgets.push( + IconButton::new("SwapHorizontal", 16) + .tooltip_label("Swap Fill/Stroke Colors") + .on_update(|_| { FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::StrokeColorType(color_type.clone()), + options: FreehandOptionsUpdate::SwapFillAndStroke, } .into() }) + .widget_instance(), + ); + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + + widgets.append(&mut self.options.drawing.stroke.create_widgets( + "Stroke:", + |checkbox: &CheckboxInput| { + FreehandToolMessage::UpdateOptions { + options: FreehandOptionsUpdate::StrokeEnabled(checkbox.checked), + } + .into() }, |color: &ColorInput| { FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::StrokeColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: FreehandOptionsUpdate::StrokeColor(color.value.as_solid()), } .into() }, )); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets.push(create_weight_widget(self.options.line_weight)); + 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)); Layout(vec![LayoutGroup::row(widgets)]) } @@ -162,12 +165,18 @@ impl LayoutHolder for FreehandTool { #[message_handler_data] impl<'a> MessageHandler> for FreehandTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + // On tool deactivation (Abort fires from the dispatcher's tool transition), reset the displayed fill/stroke colors so + // the next activation starts fresh from the current working colors. The global swap state persists across tool switches. + // Guarded on `Ready` so Esc-mid-drawing (which also fires Abort) doesn't wipe the user's customized fill/stroke options. + if matches!(&message, ToolMessage::Freehand(FreehandToolMessage::Abort)) && self.fsm_state == FreehandToolFsmState::Ready { + reset_colors_on_deactivation(&mut self.options.drawing, context.global_tool_data); + } + if matches!(&message, ToolMessage::Freehand(FreehandToolMessage::SelectionChanged)) { - if self.fsm_state == FreehandToolFsmState::Ready - && let Some(weight) = graph_modification_utils::first_selected_stroke_weight(context.document) - && self.options.line_weight != weight - { - self.options.line_weight = weight; + if self.fsm_state != FreehandToolFsmState::Ready { + return; + } + if sync_drawing_state(&mut self.options.drawing, false, true, context.global_tool_data, context.document) { self.send_layout(responses, LayoutTarget::ToolOptions); } return; @@ -178,25 +187,26 @@ impl<'a> MessageHandler> for Free return; }; match options { - FreehandOptionsUpdate::FillColor(color) => { - self.options.fill.custom_color = color; - self.options.fill.color_type = ToolColorType::Custom; + FreehandOptionsUpdate::FillColor(fill_choice) => { + apply_fill_color_pick(&mut self.options.drawing, fill_choice, context.document, responses); + } + FreehandOptionsUpdate::FillEnabled(enabled) => { + apply_fill_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); } - FreehandOptionsUpdate::FillColorType(color_type) => self.options.fill.color_type = color_type, FreehandOptionsUpdate::LineWeight(line_weight) => { - self.options.line_weight = line_weight; - graph_modification_utils::set_stroke_weight_for_selected_layers(line_weight, context.document, responses); + apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); } FreehandOptionsUpdate::StrokeColor(color) => { - self.options.stroke.custom_color = color; - self.options.stroke.color_type = ToolColorType::Custom; + apply_stroke_color_pick(&mut self.options.drawing, color, context.document, responses); + } + FreehandOptionsUpdate::StrokeEnabled(enabled) => { + apply_stroke_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); + } + FreehandOptionsUpdate::SwapFillAndStroke => { + swap_fill_and_stroke(&mut self.options.drawing, context.document, responses); } - FreehandOptionsUpdate::StrokeColorType(color_type) => self.options.stroke.color_type = color_type, - FreehandOptionsUpdate::WorkingColors(primary, secondary) => { - self.options.stroke.primary_working_color = primary; - self.options.stroke.secondary_working_color = secondary; - self.options.fill.primary_working_color = primary; - self.options.fill.secondary_working_color = secondary; + FreehandOptionsUpdate::WorkingColorsChanged => { + apply_working_colors(&mut self.options.drawing, context.global_tool_data, context.document); } } @@ -255,7 +265,6 @@ impl Fsm for FreehandToolFsmState { ) -> Self { let ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -274,7 +283,7 @@ impl Fsm for FreehandToolFsmState { tool_data.dragged = false; tool_data.end_point = None; - tool_data.weight = tool_options.line_weight; + tool_data.weight = tool_options.drawing.effective_line_weight(); tool_data.new_layer_viewport_start = None; // Extend an endpoint of the selected path @@ -313,8 +322,8 @@ impl Fsm for FreehandToolFsmState { let nodes = vec![(NodeId(0), node)]; let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); - tool_options.stroke.apply_stroke(tool_data.weight, layer, responses); - tool_options.fill.apply_fill(layer, responses); + tool_options.drawing.stroke.apply_stroke(tool_data.weight, 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); @@ -381,7 +390,7 @@ impl Fsm for FreehandToolFsmState { } (_, FreehandToolMessage::WorkingColorChanged) => { responses.add(FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::WorkingColors(Some(global_tool_data.primary_color), Some(global_tool_data.secondary_color)), + options: FreehandOptionsUpdate::WorkingColorsChanged, }); self } diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 3c7570e50f..1105a99f3d 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -222,23 +222,30 @@ impl LayoutHolder for GradientTool { .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) .widget_instance(); - let stops_value = self.data.current_gradient_stops.clone().map(FillChoice::Gradient).unwrap_or_else(|| { - FillChoice::Gradient(GradientStops::new([ - GradientStop { - position: 0., - midpoint: 0.5, - color: self.data.primary_color, - }, - GradientStop { - position: 1., - midpoint: 0.5, - color: self.data.secondary_color, - }, - ])) - }); + // Display priority: the selected layer's stops, then any user-customized tool default, then the working colors + let stops_value = self + .data + .current_gradient_stops + .clone() + .or_else(|| self.data.default_gradient_stops.clone()) + .map(FillChoice::Gradient) + .unwrap_or_else(|| { + FillChoice::Gradient(GradientStops::new([ + GradientStop { + position: 0., + midpoint: 0.5, + color: self.data.primary_color, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: self.data.secondary_color, + }, + ])) + }); let stops_widget = ColorInput::new(stops_value) .allow_none(false) - .disabled(!self.data.has_selected_gradient) + .narrow(true) .tooltip_label("Gradient Stops") .tooltip_description("Edit the gradient's color stops.") .on_update(|input: &ColorInput| { @@ -786,9 +793,13 @@ struct GradientToolData { auto_pan_shift: DVec2, gradient_angle: f64, has_selected_gradient: bool, - /// Cached stops of the currently selected layer's gradient, mirrored into the control-bar widget. Independent of any - /// in-progress drag (which uses `selected_gradient`) so it stays current after selection changes too. + /// Cached stops of the currently selected layer's gradient, mirrored into the control-bar widget. + /// Independent of any in-progress drag (which uses `selected_gradient`) so it stays current after selection changes too. current_gradient_stops: Option, + /// User-customized default gradient stop colors: used when nothing that has a gradient is selected. + /// `None` means to follow the working colors. + /// Cleared on tool deactivation so each fresh activation starts from the working colors again. + default_gradient_stops: Option, /// Cached viewport-space orientation (true = predominantly rightward) of the selected gradient line. /// Used to refresh the control bar's "Reverse Direction" icon only when the line's apparent direction flips. gradient_orientation_rightward: bool, @@ -1545,6 +1556,8 @@ impl Fsm for GradientToolFsmState { } (_, GradientToolMessage::Abort) => { dismiss_color_stop_color_picker(tool_data, responses); + // Clear the tool-default gradient override so re-activating the tool starts fresh from the working colors + tool_data.default_gradient_stops = None; GradientToolFsmState::Ready { hovering: GradientHoverTarget::None, @@ -1785,6 +1798,7 @@ fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessa .selected_visible_layers(&context.document.network_interface) .collect(); + let mut updated_any_layer = false; for layer in selected_layers { if NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) { continue; @@ -1792,20 +1806,30 @@ fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessa if get_gradient_stops(layer, &context.document.network_interface).is_some() { responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: stops.clone() }); + updated_any_layer = true; } else if let Some(mut gradient) = get_gradient(layer, &context.document.network_interface) { gradient.stops = stops.clone(); responses.add(GraphOperationMessage::FillSet { layer, fill: Fill::Gradient(gradient), }); + updated_any_layer = true; } } if let Some(selected_gradient) = &mut data.selected_gradient { - selected_gradient.gradient.stops = stops; + selected_gradient.gradient.stops = stops.clone(); + } + + // When no selected layer had a gradient to update, the user is editing the tool's default gradient instead. + // Save those stops so the widget keeps showing them until the tool is deactivated. + if !updated_any_layer { + data.default_gradient_stops = Some(stops); } responses.add(PropertiesPanelMessage::Refresh); + // Refresh the tool options so the swatch's `chosen_gradient` (precomputed CSS string) updates live as the user edits stops in the picker. + responses.add(ToolMessage::RefreshToolOptions); } /// Find the first selected visible layer that has a gradient and return both the layer ID and its resolved gradient. diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 713ea258f9..3cd2a3b133 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -214,7 +214,7 @@ impl LayoutHolder for PathTool { let x_location = NumberInput::new(x) .unit(" px") .label("X") - .min_width(120) + .min_width(80) .disabled(x.is_none()) .min(-((1_u64 << f64::MANTISSA_DIGITS) as f64)) .max((1_u64 << f64::MANTISSA_DIGITS) as f64) @@ -230,7 +230,7 @@ impl LayoutHolder for PathTool { let y_location = NumberInput::new(y) .unit(" px") .label("Y") - .min_width(120) + .min_width(80) .disabled(y.is_none()) .min(-((1_u64 << f64::MANTISSA_DIGITS) as f64)) .max((1_u64 << f64::MANTISSA_DIGITS) as f64) diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index e61aa7c468..1bed01863f 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -1,5 +1,5 @@ use super::tool_prelude::*; -use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_05, DEFAULT_STROKE_WIDTH, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE, SEGMENT_OVERLAY_SIZE}; +use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_05, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE, SEGMENT_OVERLAY_SIZE}; use crate::messages::input_mapper::utility_types::input_mouse::MouseKeys; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_network_node_type; @@ -7,7 +7,10 @@ use crate::messages::portfolio::document::overlays::utility_functions::path_over use crate::messages::portfolio::document::overlays::utility_types::{DrawHandles, OverlayContext}; 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::{ToolColorOptions, ToolColorType}; +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, +}; 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}; @@ -16,6 +19,7 @@ use graph_craft::document::NodeId; use graphene_std::Color; use graphene_std::subpath::pathseg_points; use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point}; +use graphene_std::vector::style::FillChoice; use graphene_std::vector::{NoHashBuilder, PointId, SegmentId, StrokeId, Vector, VectorModificationType}; use kurbo::{CubicBez, PathSeg}; @@ -27,18 +31,14 @@ pub struct PenTool { } pub struct PenOptions { - line_weight: f64, - fill: ToolColorOptions, - stroke: ToolColorOptions, + drawing: DrawingToolState, pen_overlay_mode: PenOverlayMode, } impl Default for PenOptions { fn default() -> Self { Self { - line_weight: DEFAULT_STROKE_WIDTH, - fill: ToolColorOptions::new_secondary(), - stroke: ToolColorOptions::new_primary(), + drawing: DrawingToolState::new(true), pen_overlay_mode: PenOverlayMode::FrontierHandles, } } @@ -119,12 +119,13 @@ pub enum PenOverlayMode { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum PenOptionsUpdate { - FillColor(Option), - FillColorType(ToolColorType), + FillColor(FillChoice), + FillEnabled(bool), LineWeight(f64), StrokeColor(Option), - StrokeColorType(ToolColorType), - WorkingColors(Option, Option), + StrokeEnabled(bool), + SwapFillAndStroke, + WorkingColorsChanged, OverlayModeType(PenOverlayMode), } @@ -140,80 +141,83 @@ impl ToolMetadata for PenTool { } } -fn create_weight_widget(line_weight: f64) -> WidgetInstance { - NumberInput::new(Some(line_weight)) +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| { - PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::LineWeight(number_input.value.unwrap()), + if let Some(value) = number_input.value { + PenToolMessage::UpdateOptions { + options: PenOptionsUpdate::LineWeight(value), + } + .into() + } else { + Message::NoOp } - .into() }) + .on_commit(|_| DocumentMessage::StartTransaction.into()) .widget_instance() } impl LayoutHolder for PenTool { fn layout(&self) -> Layout { - let mut widgets = self.options.fill.create_widgets( - "Fill", - true, - |_| { + let mut widgets = self.options.drawing.fill.create_widgets( + "Fill:", + |checkbox: &CheckboxInput| { PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::FillColor(None), + options: PenOptionsUpdate::FillEnabled(checkbox.checked), } .into() }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { - PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::FillColorType(color_type.clone()), - } - .into() - }) - }, |color: &ColorInput| { PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::FillColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: PenOptionsUpdate::FillColor(color.value.clone()), } .into() }, ); widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - widgets.append(&mut self.options.stroke.create_widgets( - "Stroke", - true, - |_| { - PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::StrokeColor(None), - } - .into() - }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { + widgets.push( + IconButton::new("SwapHorizontal", 16) + .tooltip_label("Swap Fill/Stroke Colors") + .on_update(|_| { PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::StrokeColorType(color_type.clone()), + options: PenOptionsUpdate::SwapFillAndStroke, } .into() }) + .widget_instance(), + ); + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + + widgets.append(&mut self.options.drawing.stroke.create_widgets( + "Stroke:", + |checkbox: &CheckboxInput| { + PenToolMessage::UpdateOptions { + options: PenOptionsUpdate::StrokeEnabled(checkbox.checked), + } + .into() }, |color: &ColorInput| { PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::StrokeColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: PenOptionsUpdate::StrokeColor(color.value.as_solid()), } .into() }, )); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - widgets.push(create_weight_widget(self.options.line_weight)); + let weight_disabled = self.options.drawing.stroke.enabled == Some(false); + widgets.push(create_weight_widget(self.options.drawing.line_weight, weight_disabled)); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push(Separator::new(SeparatorStyle::Section).widget_instance()); widgets.push( RadioInput::new(vec![ @@ -249,12 +253,17 @@ impl LayoutHolder for PenTool { #[message_handler_data] impl<'a> MessageHandler> for PenTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + // On tool deactivation (Abort fires from the dispatcher's tool transition), reset the displayed fill/stroke colors so + // the next activation starts fresh from the current working colors. The global swap state persists across tool switches. + // Guarded on `Ready` so Esc-mid-drawing (which also fires Abort) doesn't wipe the user's customized fill/stroke options. + if matches!(&message, ToolMessage::Pen(PenToolMessage::Abort)) && self.fsm_state == PenToolFsmState::Ready { + reset_colors_on_deactivation(&mut self.options.drawing, context.global_tool_data); + } + if matches!(&message, ToolMessage::Pen(PenToolMessage::SelectionChanged)) && self.fsm_state == PenToolFsmState::Ready - && let Some(weight) = graph_modification_utils::first_selected_stroke_weight(context.document) - && self.options.line_weight != weight + && sync_drawing_state(&mut self.options.drawing, true, true, context.global_tool_data, context.document) { - self.options.line_weight = weight; self.send_layout(responses, LayoutTarget::ToolOptions); } @@ -269,24 +278,25 @@ impl<'a> MessageHandler> for PenT responses.add(OverlaysMessage::Draw); } PenOptionsUpdate::LineWeight(line_weight) => { - self.options.line_weight = line_weight; - graph_modification_utils::set_stroke_weight_for_selected_layers(line_weight, context.document, responses); + apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); } - PenOptionsUpdate::FillColor(color) => { - self.options.fill.custom_color = color; - self.options.fill.color_type = ToolColorType::Custom; + PenOptionsUpdate::FillColor(fill_choice) => { + apply_fill_color_pick(&mut self.options.drawing, fill_choice, context.document, responses); + } + PenOptionsUpdate::FillEnabled(enabled) => { + apply_fill_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); } - PenOptionsUpdate::FillColorType(color_type) => self.options.fill.color_type = color_type, PenOptionsUpdate::StrokeColor(color) => { - self.options.stroke.custom_color = color; - self.options.stroke.color_type = ToolColorType::Custom; + apply_stroke_color_pick(&mut self.options.drawing, color, context.document, responses); + } + PenOptionsUpdate::StrokeEnabled(enabled) => { + apply_stroke_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); + } + PenOptionsUpdate::SwapFillAndStroke => { + swap_fill_and_stroke(&mut self.options.drawing, context.document, responses); } - PenOptionsUpdate::StrokeColorType(color_type) => self.options.stroke.color_type = color_type, - PenOptionsUpdate::WorkingColors(primary, secondary) => { - self.options.stroke.primary_working_color = primary; - self.options.stroke.secondary_working_color = secondary; - self.options.fill.primary_working_color = primary; - self.options.fill.secondary_working_color = secondary; + PenOptionsUpdate::WorkingColorsChanged => { + apply_working_colors(&mut self.options.drawing, context.global_tool_data, context.document); } } @@ -1306,8 +1316,8 @@ 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.stroke.apply_stroke(tool_options.line_weight, layer, responses); - tool_options.fill.apply_fill(layer, responses); + tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, responses); + tool_options.drawing.fill.apply_fill(layer, responses); self.prior_segment = None; self.prior_segments = None; responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }); @@ -1490,7 +1500,6 @@ impl Fsm for PenToolFsmState { ) -> Self { let ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -1839,7 +1848,7 @@ impl Fsm for PenToolFsmState { } (_, PenToolMessage::WorkingColorChanged) => { responses.add(PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::WorkingColors(Some(global_tool_data.primary_color), Some(global_tool_data.secondary_color)), + options: PenOptionsUpdate::WorkingColorsChanged, }); self } diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index cf34ec276d..4a680e6393 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -1,11 +1,14 @@ use super::tool_prelude::*; -use crate::consts::{BOUNDS_SELECT_THRESHOLD, DEFAULT_STROKE_WIDTH, SNAP_POINT_TOLERANCE}; +use crate::consts::{BOUNDS_SELECT_THRESHOLD, SNAP_POINT_TOLERANCE}; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; 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::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType}; +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, + swap_fill_and_stroke, sync_color_options, sync_drawing_state, +}; use crate::messages::tool::common_functionality::gizmos::gizmo_manager::GizmoManager; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::resize::Resize; @@ -22,10 +25,12 @@ use crate::messages::tool::common_functionality::shapes::{Ellipse, Line, Rectang use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData, SnapTypeConfiguration}; 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; use graph_craft::document::NodeId; use graph_craft::document::value::TaggedValue; use graphene_std::renderer::Quad; use graphene_std::vector::misc::{ArcType, GridType, SpiralType}; +use graphene_std::vector::style::FillChoice; use graphene_std::{Color, NodeInputDecleration}; use std::vec; @@ -37,9 +42,17 @@ pub struct ShapeTool { } pub struct ShapeToolOptions { - line_weight: f64, - fill: ToolColorOptions, - stroke: ToolColorOptions, + drawing: DrawingToolState, + /// Per-shape-mode default for whether the fill checkbox is ticked when no layer is selected. Initialized from + /// [`ShapeType::defaults_to_fill`] and updated when the user toggles the fill checkbox while nothing is selected, + /// so the preference for each mode persists across mode switches and selection changes. + shape_fill_defaults: std::collections::HashMap, + /// Per-shape-mode default for whether the stroke checkbox is ticked when no layer is selected. + /// Initialized to `true` for every mode and updated when the user toggles the stroke checkbox while nothing is selected. + shape_stroke_defaults: std::collections::HashMap, + /// Per-shape-mode value of the fill/stroke swap flag (mirrors `drawing.colors_swapped` for the current shape mode). + /// Updated whenever the user toggles swap; read back when changing shape modes so each alias remembers its own routing. + shape_colors_swapped: std::collections::HashMap, vertices: u32, shape_type: ShapeType, arc_type: ArcType, @@ -53,10 +66,15 @@ pub struct ShapeToolOptions { impl Default for ShapeToolOptions { fn default() -> Self { + let shape_fill_defaults = ShapeType::ALL.iter().map(|&shape| (shape, shape.defaults_to_fill())).collect(); + let shape_stroke_defaults = ShapeType::ALL.iter().map(|&shape| (shape, true)).collect(); + let shape_colors_swapped = ShapeType::ALL.iter().map(|&shape| (shape, false)).collect(); + Self { - line_weight: DEFAULT_STROKE_WIDTH, - fill: ToolColorOptions::new_secondary(), - stroke: ToolColorOptions::new_primary(), + drawing: DrawingToolState::new(true), + shape_fill_defaults, + shape_stroke_defaults, + shape_colors_swapped, vertices: 5, shape_type: ShapeType::Polygon, arc_type: ArcType::Open, @@ -73,12 +91,13 @@ impl Default for ShapeToolOptions { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum ShapeOptionsUpdate { - FillColor(Option), - FillColorType(ToolColorType), + FillColor(FillChoice), + FillEnabled(bool), LineWeight(f64), StrokeColor(Option), - StrokeColorType(ToolColorType), - WorkingColors(Option, Option), + StrokeEnabled(bool), + SwapFillAndStroke, + WorkingColorsChanged, Vertices(u32), ShapeType(ShapeType), ArcType(ArcType), @@ -220,18 +239,26 @@ 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: f64) -> WidgetInstance { - NumberInput::new(Some(line_weight)) +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| { - ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::LineWeight(number_input.value.unwrap()), + if let Some(value) = number_input.value { + ShapeToolMessage::UpdateOptions { + options: ShapeOptionsUpdate::LineWeight(value), + } + .into() + } else { + Message::NoOp } - .into() }) + .on_commit(|_| DocumentMessage::StartTransaction.into()) .widget_instance() } @@ -431,102 +458,113 @@ fn sync_shape_options_from_selection(options: &mut ShapeToolOptions, tool_data: changed } +/// Shared logic for handling a shape-mode change from either the `SetShape` alias (FSM-driven) or the `ShapeType` dropdown. +/// Loads the new mode's persistent swap flag, resets the displayed fill/stroke colors back to the (now routed) working colors, +/// and re-syncs from the selection using the new mode's natural fill/stroke defaults. The caller is responsible for updating +/// `options.shape_type` and `tool_data.current_shape` beforehand if needed. +fn handle_shape_mode_change(options: &mut ShapeToolOptions, new_shape: ShapeType, prev_shape: ShapeType, global: &DocumentToolData, document: &DocumentMessageHandler) { + if new_shape != prev_shape { + options.drawing.colors_swapped = *options.shape_colors_swapped.get(&new_shape).unwrap_or(&false); + reset_colors_on_deactivation(&mut options.drawing, global); + } + let natural_fill_enabled = *options.shape_fill_defaults.get(&new_shape).unwrap_or(&new_shape.defaults_to_fill()); + let natural_stroke_enabled = *options.shape_stroke_defaults.get(&new_shape).unwrap_or(&true); + // Treat the shape change as a real selection change so the new mode's natural defaults apply when nothing matches on the selection. + sync_color_options(&mut options.drawing, natural_fill_enabled, natural_stroke_enabled, global, document, true); +} + impl LayoutHolder for ShapeTool { fn layout(&self) -> Layout { let mut widgets = vec![]; - if !self.tool_data.hide_shape_option_widget { - widgets.push(create_shape_option_widget(self.options.shape_type)); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - if self.options.shape_type == ShapeType::Polygon || self.options.shape_type == ShapeType::Star { - widgets.push(create_sides_widget(self.options.vertices)); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - } - - if self.options.shape_type == ShapeType::Arc { - widgets.push(create_arc_type_widget(self.options.arc_type)); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - } - } - - if self.options.shape_type == ShapeType::Spiral { - widgets.push(create_spiral_type_widget(self.options.spiral_type)); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - - widgets.push(create_turns_widget(self.options.turns)); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - } - - if self.options.shape_type == ShapeType::Grid { - widgets.push(create_grid_type_widget(self.options.grid_type)); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - } - - if self.options.shape_type == ShapeType::Arrow { - widgets.push(create_arrow_shaft_width_widget(self.options.arrow_shaft_width)); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - widgets.push(create_arrow_head_width_widget(self.options.arrow_head_width)); - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - widgets.push(create_arrow_head_length_widget(self.options.arrow_head_length)); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - } - + // Fill / Stroke / Weight (Shared across all shape modes. Line shows no Fill) if self.options.shape_type != ShapeType::Line { - widgets.append(&mut self.options.fill.create_widgets( - "Fill", - true, - |_| { + widgets.append(&mut self.options.drawing.fill.create_widgets( + "Fill:", + |checkbox: &CheckboxInput| { ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::FillColor(None), + options: ShapeOptionsUpdate::FillEnabled(checkbox.checked), } .into() }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { - ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::FillColorType(color_type.clone()), - } - .into() - }) - }, |color: &ColorInput| { ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::FillColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: ShapeOptionsUpdate::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(|_| { + ShapeToolMessage::UpdateOptions { + options: ShapeOptionsUpdate::SwapFillAndStroke, + } + .into() + }) + .widget_instance(), + ); + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); } - widgets.append(&mut self.options.stroke.create_widgets( - "Stroke", - true, - |_| { + widgets.append(&mut self.options.drawing.stroke.create_widgets( + "Stroke:", + |checkbox: &CheckboxInput| { ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::StrokeColor(None), + options: ShapeOptionsUpdate::StrokeEnabled(checkbox.checked), } .into() }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { - ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::StrokeColorType(color_type.clone()), - } - .into() - }) - }, |color: &ColorInput| { ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::StrokeColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: ShapeOptionsUpdate::StrokeColor(color.value.as_solid()), } .into() }, )); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets.push(create_weight_widget(self.options.line_weight)); + 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)); + + // Shape-mode dropdown and per-shape parameters + if !self.tool_data.hide_shape_option_widget { + widgets.push(Separator::new(SeparatorStyle::Section).widget_instance()); + widgets.push(create_shape_option_widget(self.options.shape_type)); + + if self.options.shape_type == ShapeType::Polygon || self.options.shape_type == ShapeType::Star { + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push(create_sides_widget(self.options.vertices)); + } + + if self.options.shape_type == ShapeType::Arc { + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push(create_arc_type_widget(self.options.arc_type)); + } + + if self.options.shape_type == ShapeType::Spiral { + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push(create_spiral_type_widget(self.options.spiral_type)); + widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); + widgets.push(create_turns_widget(self.options.turns)); + } + + if self.options.shape_type == ShapeType::Grid { + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push(create_grid_type_widget(self.options.grid_type)); + } + + if self.options.shape_type == ShapeType::Arrow { + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push(create_arrow_shaft_width_widget(self.options.arrow_shaft_width)); + widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); + widgets.push(create_arrow_head_width_widget(self.options.arrow_head_width)); + widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); + widgets.push(create_arrow_head_length_widget(self.options.arrow_head_length)); + } + } Layout(vec![LayoutGroup::row(widgets)]) } @@ -537,20 +575,23 @@ impl<'a> MessageHandler> for Shap fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { use graphene_std::vector::generator_nodes::*; + // On tool deactivation (Abort fires from the dispatcher's tool transition), reset the displayed fill/stroke colors so + // the next activation starts fresh from the current working colors. The global swap state persists across tool switches. + // Guarded on `Ready(_)` so Esc-mid-drawing (which also fires Abort) doesn't wipe the user's customized fill/stroke options. + if matches!(&message, ToolMessage::Shape(ShapeToolMessage::Abort)) && matches!(self.fsm_state, ShapeToolFsmState::Ready(_)) { + reset_colors_on_deactivation(&mut self.options.drawing, context.global_tool_data); + } + if matches!(&message, ToolMessage::Shape(ShapeToolMessage::SelectionChanged)) { if !matches!(self.fsm_state, ShapeToolFsmState::Ready(_)) { return; } - let mut needs_refresh = false; - - // Stroke weight is shape-agnostic. Sync it regardless of which (if any) shape proto node the layer has. - if let Some(weight) = graph_modification_utils::first_selected_stroke_weight(context.document) - && self.options.line_weight != weight - { - self.options.line_weight = weight; - needs_refresh = true; - } + // The natural fill/stroke defaults depend on the shape type (Spiral/Grid/Line have no fill by default). + let current_shape = self.tool_data.current_shape; + let natural_fill_enabled = *self.options.shape_fill_defaults.get(¤t_shape).unwrap_or(¤t_shape.defaults_to_fill()); + let natural_stroke_enabled = *self.options.shape_stroke_defaults.get(¤t_shape).unwrap_or(&true); + let mut needs_refresh = sync_drawing_state(&mut self.options.drawing, natural_fill_enabled, natural_stroke_enabled, context.global_tool_data, context.document); // Detect which shape the first selected layer is by checking for each generator's proto node, then mirror // the control bar's `shape_type` into that and pull the shape's parameters into the matching control bar fields. @@ -562,38 +603,57 @@ impl<'a> MessageHandler> for Shap return; } + // SetShape changes the active shape mode, which can change the natural fill default (e.g. Line/Spiral/Grid have no fill). + // Trigger a re-sync afterward so the controls reflect either the current selection or the new natural default. + // Note: the `UpdateOptions { ShapeType(_) }` variant matches the `let else` below and is handled by the `ShapeType` arm, + // so it can't reach the `else` block where this flag is read — only `SetShape` (the FSM-routed alias) can. + let is_set_shape = matches!(&message, ToolMessage::Shape(ShapeToolMessage::SetShape { .. })); + let shape_before = self.tool_data.current_shape; + let ToolMessage::Shape(ShapeToolMessage::UpdateOptions { options }) = message else { self.fsm_state.process_event(message, &mut self.tool_data, context, &self.options, responses, true); + if is_set_shape { + handle_shape_mode_change(&mut self.options, self.tool_data.current_shape, shape_before, context.global_tool_data, context.document); + self.send_layout(responses, LayoutTarget::ToolOptions); + } return; }; match options { - ShapeOptionsUpdate::FillColor(color) => { - self.options.fill.custom_color = color; - self.options.fill.color_type = ToolColorType::Custom; + ShapeOptionsUpdate::FillColor(fill_choice) => { + apply_fill_color_pick(&mut self.options.drawing, fill_choice, context.document, responses); } - ShapeOptionsUpdate::FillColorType(color_type) => { - self.options.fill.color_type = color_type; + ShapeOptionsUpdate::FillEnabled(enabled) => { + // When toggled with no selection, persist the new state as the current shape mode's default + if !has_selection(context.document) { + self.options.shape_fill_defaults.insert(self.tool_data.current_shape, enabled); + } + apply_fill_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); } ShapeOptionsUpdate::LineWeight(line_weight) => { - self.options.line_weight = line_weight; - graph_modification_utils::set_stroke_weight_for_selected_layers(line_weight, context.document, responses); + apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); } ShapeOptionsUpdate::StrokeColor(color) => { - self.options.stroke.custom_color = color; - self.options.stroke.color_type = ToolColorType::Custom; + apply_stroke_color_pick(&mut self.options.drawing, color, context.document, responses); + } + ShapeOptionsUpdate::StrokeEnabled(enabled) => { + // When toggled with no selection, persist the new state as the current shape mode's default + if !has_selection(context.document) { + self.options.shape_stroke_defaults.insert(self.tool_data.current_shape, enabled); + } + apply_stroke_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); } - ShapeOptionsUpdate::StrokeColorType(color_type) => { - self.options.stroke.color_type = color_type; + ShapeOptionsUpdate::SwapFillAndStroke => { + swap_fill_and_stroke(&mut self.options.drawing, context.document, responses); + // Persist the new swap state as the current shape mode's default + self.options.shape_colors_swapped.insert(self.tool_data.current_shape, self.options.drawing.colors_swapped); } - ShapeOptionsUpdate::WorkingColors(primary, secondary) => { - self.options.stroke.primary_working_color = primary; - self.options.stroke.secondary_working_color = secondary; - self.options.fill.primary_working_color = primary; - self.options.fill.secondary_working_color = secondary; + ShapeOptionsUpdate::WorkingColorsChanged => { + apply_working_colors(&mut self.options.drawing, context.global_tool_data, context.document); } ShapeOptionsUpdate::ShapeType(shape) => { self.options.shape_type = shape; self.tool_data.current_shape = shape; + handle_shape_mode_change(&mut self.options, shape, shape_before, context.global_tool_data, context.document); } ShapeOptionsUpdate::Vertices(vertices) => { self.options.vertices = vertices; @@ -806,7 +866,6 @@ impl Fsm for ShapeToolFsmState { tool_data: &mut Self::ToolData, ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -1117,8 +1176,8 @@ impl Fsm for ShapeToolFsmState { skip_rerender: false, }); - tool_options.stroke.apply_stroke(tool_options.line_weight, layer, defered_responses); - tool_options.fill.apply_fill(layer, defered_responses); + tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, defered_responses); + tool_options.drawing.fill.apply_fill(layer, defered_responses); } ShapeType::Arrow => { let viewport_drag_start = tool_data.data.viewport_drag_start(document); @@ -1129,10 +1188,10 @@ impl Fsm for ShapeToolFsmState { skip_rerender: false, }); - tool_data.line_data.weight = tool_options.line_weight; + tool_data.line_data.weight = tool_options.drawing.effective_line_weight(); tool_data.line_data.editing_layer = Some(layer); - tool_options.stroke.apply_stroke(tool_options.line_weight, layer, defered_responses); - tool_options.fill.apply_fill(layer, defered_responses); + tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, defered_responses); + tool_options.drawing.fill.apply_fill(layer, defered_responses); } ShapeType::Line => { let viewport_drag_start = tool_data.data.viewport_drag_start(document); @@ -1143,9 +1202,9 @@ impl Fsm for ShapeToolFsmState { skip_rerender: false, }); - tool_data.line_data.weight = tool_options.line_weight; + tool_data.line_data.weight = tool_options.drawing.effective_line_weight(); tool_data.line_data.editing_layer = Some(layer); - tool_options.stroke.apply_stroke(tool_options.line_weight, layer, defered_responses); + tool_options.drawing.stroke.apply_stroke(tool_options.drawing.effective_line_weight(), layer, defered_responses); } } @@ -1351,7 +1410,7 @@ impl Fsm for ShapeToolFsmState { } (_, ShapeToolMessage::WorkingColorChanged) => { responses.add(ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::WorkingColors(Some(global_tool_data.primary_color), Some(global_tool_data.secondary_color)), + options: ShapeOptionsUpdate::WorkingColorsChanged, }); self } diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index 3c3ad221a8..a8cf783793 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -1,5 +1,5 @@ use super::tool_prelude::*; -use crate::consts::{DEFAULT_STROKE_WIDTH, DRAG_THRESHOLD, PATH_JOIN_THRESHOLD, SNAP_POINT_TOLERANCE}; +use crate::consts::{DRAG_THRESHOLD, PATH_JOIN_THRESHOLD, SNAP_POINT_TOLERANCE}; use crate::messages::input_mapper::utility_types::input_mouse::MouseKeys; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::{resolve_network_node_type, resolve_proto_node_type}; @@ -7,12 +7,16 @@ 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::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType}; +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, +}; 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::utility_functions::{closest_point, should_extend}; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Color; +use graphene_std::vector::style::FillChoice; use graphene_std::vector::{PointId, SegmentId, VectorModificationType}; #[derive(Default, ExtractField)] @@ -23,17 +27,13 @@ pub struct SplineTool { } pub struct SplineOptions { - line_weight: f64, - fill: ToolColorOptions, - stroke: ToolColorOptions, + drawing: DrawingToolState, } impl Default for SplineOptions { fn default() -> Self { Self { - line_weight: DEFAULT_STROKE_WIDTH, - fill: ToolColorOptions::new_none(), - stroke: ToolColorOptions::new_primary(), + drawing: DrawingToolState::new(false), } } } @@ -71,12 +71,13 @@ enum SplineToolFsmState { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum SplineOptionsUpdate { - FillColor(Option), - FillColorType(ToolColorType), + FillColor(FillChoice), + FillEnabled(bool), LineWeight(f64), StrokeColor(Option), - StrokeColorType(ToolColorType), - WorkingColors(Option, Option), + StrokeEnabled(bool), + SwapFillAndStroke, + WorkingColorsChanged, } impl ToolMetadata for SplineTool { @@ -91,76 +92,79 @@ impl ToolMetadata for SplineTool { } } -fn create_weight_widget(line_weight: f64) -> WidgetInstance { - NumberInput::new(Some(line_weight)) +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| { - SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::LineWeight(number_input.value.unwrap()), + if let Some(value) = number_input.value { + SplineToolMessage::UpdateOptions { + options: SplineOptionsUpdate::LineWeight(value), + } + .into() + } else { + Message::NoOp } - .into() }) + .on_commit(|_| DocumentMessage::StartTransaction.into()) .widget_instance() } impl LayoutHolder for SplineTool { fn layout(&self) -> Layout { - let mut widgets = self.options.fill.create_widgets( - "Fill", - true, - |_| { + let mut widgets = self.options.drawing.fill.create_widgets( + "Fill:", + |checkbox: &CheckboxInput| { SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::FillColor(None), + options: SplineOptionsUpdate::FillEnabled(checkbox.checked), } .into() }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { - SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::FillColorType(color_type.clone()), - } - .into() - }) - }, |color: &ColorInput| { SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::FillColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: SplineOptionsUpdate::FillColor(color.value.clone()), } .into() }, ); widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - widgets.append(&mut self.options.stroke.create_widgets( - "Stroke", - true, - |_| { - SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::StrokeColor(None), - } - .into() - }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { + widgets.push( + IconButton::new("SwapHorizontal", 16) + .tooltip_label("Swap Fill/Stroke Colors") + .on_update(|_| { SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::StrokeColorType(color_type.clone()), + options: SplineOptionsUpdate::SwapFillAndStroke, } .into() }) + .widget_instance(), + ); + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + + widgets.append(&mut self.options.drawing.stroke.create_widgets( + "Stroke:", + |checkbox: &CheckboxInput| { + SplineToolMessage::UpdateOptions { + options: SplineOptionsUpdate::StrokeEnabled(checkbox.checked), + } + .into() }, |color: &ColorInput| { SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::StrokeColor(color.value.as_solid().map(|color| color.to_linear_srgb())), + options: SplineOptionsUpdate::StrokeColor(color.value.as_solid()), } .into() }, )); - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets.push(create_weight_widget(self.options.line_weight)); + 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)); Layout(vec![LayoutGroup::row(widgets)]) } @@ -169,12 +173,18 @@ impl LayoutHolder for SplineTool { #[message_handler_data] impl<'a> MessageHandler> for SplineTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + // On tool deactivation (Abort fires from the dispatcher's tool transition), reset the displayed fill/stroke colors so + // the next activation starts fresh from the current working colors. The global swap state persists across tool switches. + // Guarded on `Ready` so Esc-mid-drawing (which also fires Abort) doesn't wipe the user's customized fill/stroke options. + if matches!(&message, ToolMessage::Spline(SplineToolMessage::Abort)) && self.fsm_state == SplineToolFsmState::Ready { + reset_colors_on_deactivation(&mut self.options.drawing, context.global_tool_data); + } + if matches!(&message, ToolMessage::Spline(SplineToolMessage::SelectionChanged)) { - if self.fsm_state == SplineToolFsmState::Ready - && let Some(weight) = graph_modification_utils::first_selected_stroke_weight(context.document) - && self.options.line_weight != weight - { - self.options.line_weight = weight; + if self.fsm_state != SplineToolFsmState::Ready { + return; + } + if sync_drawing_state(&mut self.options.drawing, false, true, context.global_tool_data, context.document) { self.send_layout(responses, LayoutTarget::ToolOptions); } return; @@ -186,24 +196,25 @@ impl<'a> MessageHandler> for Spli }; match options { SplineOptionsUpdate::LineWeight(line_weight) => { - self.options.line_weight = line_weight; - graph_modification_utils::set_stroke_weight_for_selected_layers(line_weight, context.document, responses); + apply_line_weight(&mut self.options.drawing, line_weight, context.document, responses); } - SplineOptionsUpdate::FillColor(color) => { - self.options.fill.custom_color = color; - self.options.fill.color_type = ToolColorType::Custom; + SplineOptionsUpdate::FillColor(fill_choice) => { + apply_fill_color_pick(&mut self.options.drawing, fill_choice, context.document, responses); + } + SplineOptionsUpdate::FillEnabled(enabled) => { + apply_fill_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); } - SplineOptionsUpdate::FillColorType(color_type) => self.options.fill.color_type = color_type, SplineOptionsUpdate::StrokeColor(color) => { - self.options.stroke.custom_color = color; - self.options.stroke.color_type = ToolColorType::Custom; + apply_stroke_color_pick(&mut self.options.drawing, color, context.document, responses); + } + SplineOptionsUpdate::StrokeEnabled(enabled) => { + apply_stroke_enabled(&mut self.options.drawing, enabled, context.global_tool_data, context.document, responses); + } + SplineOptionsUpdate::SwapFillAndStroke => { + swap_fill_and_stroke(&mut self.options.drawing, context.document, responses); } - SplineOptionsUpdate::StrokeColorType(color_type) => self.options.stroke.color_type = color_type, - SplineOptionsUpdate::WorkingColors(primary, secondary) => { - self.options.stroke.primary_working_color = primary; - self.options.stroke.secondary_working_color = secondary; - self.options.fill.primary_working_color = primary; - self.options.fill.secondary_working_color = secondary; + SplineOptionsUpdate::WorkingColorsChanged => { + apply_working_colors(&mut self.options.drawing, context.global_tool_data, context.document); } } @@ -311,7 +322,6 @@ impl Fsm for SplineToolFsmState { ) -> Self { let ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -359,7 +369,7 @@ impl Fsm for SplineToolFsmState { tool_data.snap_manager.cleanup(responses); tool_data.cleanup(); - tool_data.weight = tool_options.line_weight; + tool_data.weight = tool_options.drawing.effective_line_weight(); let point = SnapCandidatePoint::handle(document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position)); let snapped = tool_data.snap_manager.free_snap(&SnapData::new(document, input, viewport), &point, SnapTypeConfiguration::default()); @@ -415,8 +425,8 @@ 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.stroke.apply_stroke(tool_data.weight, layer, responses); - tool_options.fill.apply_fill(layer, responses); + tool_options.drawing.stroke.apply_stroke(tool_data.weight, 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); @@ -545,7 +555,7 @@ impl Fsm for SplineToolFsmState { } (_, SplineToolMessage::WorkingColorChanged) => { responses.add(SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::WorkingColors(Some(global_tool_data.primary_color), Some(global_tool_data.secondary_color)), + options: SplineOptionsUpdate::WorkingColorsChanged, }); self } diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index f8f28fad0f..870d73cbf0 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -8,7 +8,9 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::portfolio::utility_types::{CachedData, FontCatalog, FontCatalogStyle}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType}; +use crate::messages::tool::common_functionality::color_selector::{ + ToolColorOptions, apply_fill_only_color_pick, apply_fill_only_enabled, refresh_slot_working_color, selection_changed_since_last_sync, solid_gamma, sync_fill_only, +}; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::resize::Resize; use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData}; @@ -20,7 +22,7 @@ use graph_craft::document::{NodeId, NodeInput}; use graphene_std::choice_type::ChoiceTypeStatic; use graphene_std::renderer::Quad; use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping}; -use graphene_std::vector::style::Fill; +use graphene_std::vector::style::{Fill, FillChoice}; use graphene_std::{Color, NodeInputDecleration}; #[derive(Default, ExtractField)] @@ -37,6 +39,8 @@ pub struct TextOptions { fill: ToolColorOptions, tilt: f64, align: TextAlign, + /// Set of layers we last synced from, used to detect real selection changes vs. internal node toggles. + last_synced_selection: Vec, } impl Default for TextOptions { @@ -45,9 +49,10 @@ impl Default for TextOptions { font_size: 24., character_spacing: 0., font: Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()), - fill: ToolColorOptions::new_primary(), + fill: ToolColorOptions::new_enabled(), tilt: 0., align: TextAlign::default(), + last_synced_selection: Vec::new(), } } } @@ -78,12 +83,12 @@ pub enum TextToolMessage { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum TextOptionsUpdate { - FillColor(Option), - FillColorType(ToolColorType), + FillColor(FillChoice), + FillEnabled(bool), Font { font: Font }, FontSize(f64), Align(TextAlign), - WorkingColors(Option, Option), + WorkingColorsChanged, } impl ToolMetadata for TextTool { @@ -263,34 +268,21 @@ impl TextTool { } fn layout(&self, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Layout { - let mut widgets = create_text_widgets(self, font_catalog, document); - - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - widgets.append(&mut self.options.fill.create_widgets( - "Fill", - true, - |_| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::FillColor(None), - } - .into() - }, - |color_type: ToolColorType| { - WidgetCallback::new(move |_| { + let mut widgets = vec![ + ColorInput::new(self.options.fill.fill_choice.clone().unwrap_or(graphene_std::vector::style::FillChoice::None)) + .mixed(self.options.fill.fill_choice.is_none()) + .narrow(true) + .on_update(|color: &ColorInput| { TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::FillColorType(color_type.clone()), + options: TextOptionsUpdate::FillColor(color.value.clone()), } .into() }) - }, - |color: &ColorInput| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::FillColor(color.value.as_solid().map(|color| color.to_linear_srgb())), - } - .into() - }, - )); + .widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + ]; + + widgets.extend(create_text_widgets(self, font_catalog, document)); Layout(vec![LayoutGroup::row(widgets)]) } @@ -299,6 +291,13 @@ impl TextTool { #[message_handler_data] impl<'a> MessageHandler> for TextTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + // On tool deactivation (Abort fires from the dispatcher's tool transition), + // reset the displayed fill color so the next activation starts fresh from the current working color. + // Guarded on `Ready` so Esc-mid-editing (which also fires Abort) doesn't wipe the user's customized fill option. + if matches!(&message, ToolMessage::Text(TextToolMessage::Abort)) && self.fsm_state == TextToolFsmState::Ready { + self.options.fill.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); + } + let options = match message { ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options, ToolMessage::Text(TextToolMessage::SelectionChanged) => { @@ -314,6 +313,18 @@ impl<'a> MessageHandler> for Text editing_text.font = font.clone(); } } + + // Only sync from a text selection; reading a non-text layer's fill would pollute the swatch + let selection_changed = selection_changed_since_last_sync(&mut self.options.last_synced_selection, context.document); + if can_edit_selected(context.document).is_some() { + sync_fill_only(&mut self.options.fill, true, context.global_tool_data.primary_color, context.document, selection_changed); + } else if selection_changed { + self.options.fill.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); + self.options.fill.tracks_working_color = true; + } + // Text tool has no fill checkbox; keep enabled so new text never starts with `None` + self.options.fill.enabled = Some(true); + self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog, context.document); return; } @@ -361,14 +372,15 @@ impl<'a> MessageHandler> for Text }); } } - TextOptionsUpdate::FillColor(color) => { - self.options.fill.custom_color = color; - self.options.fill.color_type = ToolColorType::Custom; + TextOptionsUpdate::FillColor(fill_choice) => { + // Text fill is bound to the primary working color (no swap concept). + apply_fill_only_color_pick(&mut self.options.fill, fill_choice, true, context.document, responses); } - TextOptionsUpdate::FillColorType(color_type) => self.options.fill.color_type = color_type, - TextOptionsUpdate::WorkingColors(primary, secondary) => { - self.options.fill.primary_working_color = primary; - self.options.fill.secondary_working_color = secondary; + TextOptionsUpdate::FillEnabled(enabled) => { + apply_fill_only_enabled(&mut self.options.fill, enabled, context.global_tool_data.primary_color, context.document, responses); + } + TextOptionsUpdate::WorkingColorsChanged => { + refresh_slot_working_color(&mut self.options.fill, context.global_tool_data.primary_color, context.document); } } @@ -618,9 +630,10 @@ fn can_edit_selected(document: &DocumentMessageHandler) -> Option Self { let ToolActionMessageContext { document, - global_tool_data, input, cached_data, viewport, @@ -1036,7 +1048,7 @@ impl Fsm for TextToolFsmState { } (_, TextToolMessage::WorkingColorChanged) => { responses.add(TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::WorkingColors(Some(global_tool_data.primary_color), Some(global_tool_data.secondary_color)), + options: TextOptionsUpdate::WorkingColorsChanged, }); self } diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index af9d28c07e..61c3cbc587 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -328,7 +328,7 @@ .icon-button, .text-button, .popover-button, - .color-button > button, + .color-input > button, .color-picker .preset-color, .working-colors-input .swatch > button, .radio-input button, diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 54308906cb..354b5cf67b 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -215,6 +215,7 @@ $$events: { value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, true), startHistoryTransaction: () => widgetValueCommit(index, props.value), + commitHistoryTransaction: () => editor.endTransaction(), }, }), }, diff --git a/frontend/src/components/widgets/inputs/CheckboxInput.svelte b/frontend/src/components/widgets/inputs/CheckboxInput.svelte index 3ddc7fc245..e550c245de 100644 --- a/frontend/src/components/widgets/inputs/CheckboxInput.svelte +++ b/frontend/src/components/widgets/inputs/CheckboxInput.svelte @@ -13,6 +13,7 @@ export let icon: IconName | undefined = undefined; export let forLabel: bigint | undefined = undefined; export let disabled = false; + export let mixed = false; // Tooltips export let tooltipLabel: string | undefined = undefined; export let tooltipDescription: string | undefined = undefined; @@ -22,6 +23,7 @@ $: id = forLabel !== undefined ? String(forLabel) : backupId; $: displayIcon = !checked && (!icon || icon === "Checkmark") ? "Empty12px" : icon || "Checkmark"; + $: if (inputElement) inputElement.indeterminate = mixed; export function isChecked() { return checked; @@ -43,7 +45,14 @@ type="checkbox" id={`checkbox-input-${id}`} bind:checked - on:change={(_) => dispatch("checked", inputElement?.checked || false)} + on:change={(_) => { + // Clicking a mixed-state checkbox always transitions to ticked rather than following HTML's default toggle from the previous `checked` value + if (mixed && inputElement && !inputElement.checked) { + inputElement.checked = true; + checked = true; + } + dispatch("checked", inputElement?.checked || false); + }} {disabled} tabindex={disabled ? -1 : 0} bind:this={inputElement} @@ -51,6 +60,7 @@