From 964da8dbbe5399e883eef2eeef8d8a08b3b366b3 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 11 May 2026 03:47:24 -0700 Subject: [PATCH 1/3] Revamp how the control bar handles fill and stroke colors --- .../utility_types/widgets/button_widgets.rs | 7 + .../utility_types/widgets/input_widgets.rs | 1 + .../node_graph/node_graph_message_handler.rs | 35 +- .../common_functionality/color_selector.rs | 531 +++++++++++++++--- .../graph_modification_utils.rs | 155 ++++- .../shapes/shape_utility.rs | 28 +- .../messages/tool/tool_messages/brush_tool.rs | 61 +- .../messages/tool/tool_messages/fill_tool.rs | 36 +- .../tool/tool_messages/freehand_tool.rs | 149 ++--- .../tool/tool_messages/gradient_tool.rs | 60 +- .../messages/tool/tool_messages/path_tool.rs | 4 +- .../messages/tool/tool_messages/pen_tool.rs | 145 ++--- .../messages/tool/tool_messages/shape_tool.rs | 293 ++++++---- .../tool/tool_messages/spline_tool.rs | 150 ++--- .../messages/tool/tool_messages/text_tool.rs | 80 +-- frontend/src/components/Editor.svelte | 2 +- .../widgets/inputs/CheckboxInput.svelte | 35 +- .../widgets/inputs/ColorInput.svelte | 42 +- 18 files changed, 1281 insertions(+), 533 deletions(-) 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/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..8c74753a9d 100644 --- a/editor/src/messages/tool/common_functionality/color_selector.rs +++ b/editor/src/messages/tool/common_functionality/color_selector.rs @@ -1,74 +1,74 @@ +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::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) } 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; } + self.fill_choice.as_ref()?.as_solid() } 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 +76,436 @@ 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); + 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(), + ] + } +} + +/// 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 options from the current selection, or to the (swap-routed) working colors when nothing is selected. +/// `selection_changed` is `true` when the document selection set differs from the last sync; when `false` (e.g., the same +/// selection just had a fill/stroke node toggled), display values for inactive states are preserved instead of being reset. +/// Returns `true` if anything changed (and the caller should refresh the layout). +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) { + // `display_choice` is the value stored in `fill_choice`. `None` means mixed (swatch renders a dash overlay). + // A single-color selection is layer-derived (`tracks_working = false`). Mixed and fallback states track the working color live (`tracks_working = true`). + 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 { + // No selection: on a real selection change (deselect), revert to the working color. + // When already empty (e.g., "deselect all" with nothing selected), preserve the user's currently-displayed color. + 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; + } - let entries = vec![ - ("WorkingColorsPrimary", "Primary Working Color", ToolColorType::Primary), - ("WorkingColorsSecondary", "Secondary Working Color", ToolColorType::Secondary), - ("CustomColor", "Custom Color", ToolColorType::Custom), - ] - .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, + // 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 +} + +/// Drives a drawing tool's full SelectionChanged update in one call: detects whether the selection changed, syncs fill/stroke +/// colors via [`sync_color_options`], then updates the stroke weight widget based on [`compute_weight_sync`]'s outcome. +/// Returns `true` if anything changed and the caller should refresh the layout. +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 tool's default weight; otherwise preserve the current value. + 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) + }; - widgets + 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 color/gradient: updates the tool's displayed fill (re-enabling the checkbox), then either writes +/// it to selected layers or (when nothing is selected) pushes a solid pick to the global working color slot that drives the +/// fill swatch (secondary by default, primary when the tool's [`DrawingToolState::colors_swapped`] is set). Gradient and `None` +/// picks with no selection don't have a working-color destination, so they aren't propagated and revert on the next sync. +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); +} + +/// Bare [`ToolColorOptions`] counterpart of [`apply_fill_color_pick`] for tools that only carry a single fill slot (e.g. text). +/// `slot_is_primary` is the working-color slot the swatch is bound to: `true` when the fill routes to primary (text), +/// or for a [`DrawingToolState`]-backed tool pass `drawing.colors_swapped`. +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); + // The user picked a specific value: no longer a working-color fallback. + fill.tracks_working_color = false; + if has_selection(document) { + graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses); + } else if let FillChoice::Solid(color) = fill_choice { + responses.add(ToolMessage::SelectWorkingColor { color, primary: slot_is_primary }); + } +} + +/// Applies a user-picked stroke color: updates the tool's displayed stroke (re-enabling the checkbox), then either writes it +/// to selected layers or (when nothing is selected) pushes the pick to the global working color slot that drives the stroke +/// swatch (primary by default, secondary when the tool's [`DrawingToolState::colors_swapped`] is set). +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); + // The user picked a specific value: no longer a working-color fallback. + drawing.stroke.tracks_working_color = false; + if has_selection(document) { + graph_modification_utils::set_stroke_color_for_selected_layers(color, drawing.effective_line_weight(), document, responses); + } else if let Some(color) = color { + // Stroke maps to primary by default, or secondary when the link is swapped. + responses.add(ToolMessage::SelectWorkingColor { + color, + primary: !drawing.colors_swapped, + }); + } +} + +/// Toggles the fill checkbox: when enabled, re-applies the preserved fill choice; when disabled, removes the fill node. +/// When unticking from a mixed selection, the saved fill_choice is replaced with the current working color and marked as a +/// working-color fallback, so the swatch follows the link while unticked rather than freezing on a per-layer color. +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); +} + +/// Bare [`ToolColorOptions`] counterpart of [`apply_fill_enabled`] for tools that only carry a single fill slot (e.g. text). +/// `working_color` is the linear-space working color this slot tracks, used to fill in a fallback 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 enabled { + // Re-applying from a mixed state has no specific layer color to restore, so use the current working color and mark the + // slot as a working-color fallback so it keeps tracking the link going forward. + 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 a mixed state: no specific layer color to remember. Capture the current working color as the saved + // value and mark it as a fallback, so the swatch follows the link while unticked and re-tick uses the live value. + 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: when enabled, re-applies the preserved stroke color; when disabled, removes the stroke node. +/// When unticking from a mixed selection, the saved stroke is replaced with the current working color and marked as a +/// working-color fallback (mirroring [`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 enabled { + // Re-applying from a mixed state has no specific layer color to restore, so use the current working color and mark the + // slot as a working-color fallback so it keeps tracking the link going forward. + 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: updates the tool's line weight, persists it as the no-selection default when nothing +/// is selected (so it survives selection cycles), and writes it to any selected layers. +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 the current (swap-routed) working colors to the tool's fill/stroke swatches. With no selection both slots always update. +/// With a selection, only slots marked as a working-color fallback (`tracks_working_color`) refresh, so the +/// saved/re-tick color follows the linked working color rather than going stale, while layer-derived colors are preserved. +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 fill/stroke swatch's stored color from the given working color, subject to the same rules as [`apply_working_colors`]: +/// with no selection always refresh, with a selection only refresh if the slot is tracking the working color. +/// Skips mixed (`fill_choice = None`) slots, there's no stored value to refresh. +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 displayed fill/stroke colors back to the (swap-routed) working colors. +/// Called on tool deactivation (Abort) and on shape-mode changes so the next activation starts fresh. +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: toggles the tool's [`DrawingToolState::colors_swapped`] flag, swaps the displayed +/// fill and stroke locally so the next layout refresh shows the change, and applies the same swap to any selected layers. +/// Stroke can only hold a solid color, so a fill that was a gradient becomes `None` when it moves to stroke. +pub fn swap_fill_and_stroke(drawing: &mut DrawingToolState, document: &DocumentMessageHandler, responses: &mut VecDeque) { + drawing.colors_swapped = !drawing.colors_swapped; + + // 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); + } + } +} + +/// Computes whether the current selection differs from the last-synced one, and updates the cache. +/// Returns `true` when the selection has actually changed (a different set of layers, or empty <-> non-empty). +pub fn selection_changed_since_last_sync(last_synced: &mut Vec, document: &DocumentMessageHandler) -> bool { + let current: Vec = document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface).collect(); + + let mut sorted_current = current.clone(); + sorted_current.sort(); + let mut sorted_last = last_synced.clone(); + sorted_last.sort(); + + let changed = sorted_current != sorted_last; + *last_synced = current; + changed +} + +/// Outcome of inspecting selected layers' stroke weights, used by tool control bars to decide between displaying a number, +/// rendering the "mixed" dash, or preserving the previous value. +pub enum WeightSyncOutcome { + /// All selected layers (with strokes) share this weight: assign it to `line_weight`. + Set(f64), + /// Selected layers have differing stroke weights (or some lack a stroke): render the mixed dash. + Mixed, + /// All selected layers lack a stroke: on a real selection change, reset to the tool's default, otherwise preserve. + NoStrokes, + /// No layers are selected: preserve 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..4475c6e6c1 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,159 @@ 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 (first_enabled, first_choice) = per_layer.next()?; + let mut enabled_mixed = false; + let mut color_mixed = false; + for (enabled, fill_choice) in per_layer { + if enabled != first_enabled { + enabled_mixed = true; + } + // Colors are only "mixed" when both layers are enabled but their fill values differ + if enabled && first_enabled && fill_choice != first_choice { + color_mixed = true; + } + } + + Some(SelectedFillState { + enabled: (!enabled_mixed).then_some(first_enabled), + fill_choice: (!color_mixed).then_some(first_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 (first_enabled, first_color) = per_layer.next()?; + let mut enabled_mixed = false; + let mut color_mixed = false; + for (enabled, color) in per_layer { + if enabled != first_enabled { + enabled_mixed = true; + } + if enabled && first_enabled && color != first_color { + color_mixed = true; + } + } + + Some(SelectedStrokeState { + enabled: (!enabled_mixed).then_some(first_enabled), + optional_color: (!color_mixed).then_some(first_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..74afee14e7 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,16 @@ 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)) + .narrow(true) + .on_update(|color: &ColorInput| { + BrushToolMessage::UpdateOptions { + options: BrushToolMessageOptionsUpdate::Color(color.value.as_solid()), + } + .into() + }) + .widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), NumberInput::new(Some(self.options.diameter)) .label("Diameter") .min(1.) @@ -170,33 +180,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 +237,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 +338,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 +431,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..6fa5fe8cd5 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,78 @@ 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() }) .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 +164,17 @@ 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. + if matches!(&message, ToolMessage::Freehand(FreehandToolMessage::Abort)) { + 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 +185,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 +263,6 @@ impl Fsm for FreehandToolFsmState { ) -> Self { let ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -274,7 +281,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 +320,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 +388,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..522a0aa587 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,82 @@ 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() }) .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 +252,16 @@ 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. + if matches!(&message, ToolMessage::Pen(PenToolMessage::Abort)) { + 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 +276,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 +1314,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 +1498,6 @@ impl Fsm for PenToolFsmState { ) -> Self { let ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -1839,7 +1846,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..456660825e 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,17 +239,24 @@ 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() }) .widget_instance() } @@ -431,102 +457,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 +574,22 @@ 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. + if matches!(&message, ToolMessage::Shape(ShapeToolMessage::Abort)) { + 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 +601,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 +864,6 @@ impl Fsm for ShapeToolFsmState { tool_data: &mut Self::ToolData, ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -1117,8 +1174,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 +1186,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 +1200,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 +1408,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..a4e9a4b45b 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,78 @@ 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() }) .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 +172,17 @@ 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. + if matches!(&message, ToolMessage::Spline(SplineToolMessage::Abort)) { + 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 +194,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 +320,6 @@ impl Fsm for SplineToolFsmState { ) -> Self { let ToolActionMessageContext { document, - global_tool_data, input, shape_editor, viewport, @@ -359,7 +367,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 +423,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 +553,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..19d459eacc 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,20 @@ 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)) + .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 +290,12 @@ 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. + if matches!(&message, ToolMessage::Text(TextToolMessage::Abort)) { + 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 +311,11 @@ impl<'a> MessageHandler> for Text editing_text.font = font.clone(); } } + + // Sync fill from the selected layer, falling back to the natural default (fill enabled, primary working color) when nothing is selected. + let selection_changed = selection_changed_since_last_sync(&mut self.options.last_synced_selection, context.document); + sync_fill_only(&mut self.options.fill, true, context.global_tool_data.primary_color, context.document, selection_changed); + self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog, context.document); return; } @@ -361,14 +363,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::FillEnabled(enabled) => { + apply_fill_only_enabled(&mut self.options.fill, enabled, context.global_tool_data.primary_color, 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::WorkingColorsChanged => { + refresh_slot_working_color(&mut self.options.fill, context.global_tool_data.primary_color, context.document); } } @@ -639,7 +642,6 @@ impl Fsm for TextToolFsmState { ) -> Self { let ToolActionMessageContext { document, - global_tool_data, input, cached_data, viewport, @@ -1036,7 +1038,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/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 @@