diff --git a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs index 50600e00..d27f0f05 100644 --- a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs +++ b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs @@ -1,4 +1,5 @@ use super::utility_types::TransformOp; +use crate::application::generate_uuid; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; use crate::messages::layout::utility_types::misc::LayoutTarget; use crate::messages::layout::utility_types::widgets::assist_widgets::PivotAssist; @@ -10,9 +11,10 @@ use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, use crate::messages::portfolio::utility_types::{ImaginateServerStatus, PersistentData}; use crate::messages::prelude::*; +use graphene::color::Color; use graphene::document::pick_layer_safe_imaginate_resolution; use graphene::layers::imaginate_layer::{ImaginateLayer, ImaginateSamplingMethod, ImaginateStatus}; -use graphene::layers::layer_info::{Layer, LayerDataType, LayerDataTypeDiscriminant}; +use graphene::layers::layer_info::{Layer, LayerData, LayerDataType, LayerDataTypeDiscriminant}; use graphene::layers::style::{Fill, Gradient, GradientType, LineCap, LineJoin, Stroke}; use graphene::layers::text_layer::{FontCache, TextLayer}; @@ -1137,7 +1139,7 @@ fn node_gradient_type(gradient: &Gradient) -> LayoutGroup { RadioEntryData { value: "linear".into(), label: "Linear".into(), - tooltip: "Linear Gradient".into(), + tooltip: "Linear gradient changes colors from one side to the other along a line".into(), on_update: WidgetCallback::new(move |_| { PropertiesPanelMessage::ModifyFill { fill: Fill::Gradient(cloned_gradient_linear.clone()), @@ -1149,7 +1151,7 @@ fn node_gradient_type(gradient: &Gradient) -> LayoutGroup { RadioEntryData { value: "radial".into(), label: "Radial".into(), - tooltip: "Radial Gradient".into(), + tooltip: "Radial gradient changes colors from the inside to the outside of a circular area".into(), on_update: WidgetCallback::new(move |_| { PropertiesPanelMessage::ModifyFill { fill: Fill::Gradient(cloned_gradient_radial.clone()), @@ -1165,60 +1167,210 @@ fn node_gradient_type(gradient: &Gradient) -> LayoutGroup { } } -fn node_gradient_color(gradient: &Gradient, percent_label: &'static str, position: usize) -> LayoutGroup { +fn node_gradient_color(gradient: &Gradient, position: usize) -> LayoutGroup { let gradient_clone = Rc::new(gradient.clone()); + let gradient_2 = gradient_clone.clone(); + let gradient_3 = gradient_clone.clone(); let send_fill_message = move |new_gradient: Gradient| PropertiesPanelMessage::ModifyFill { fill: Fill::Gradient(new_gradient) }.into(); - LayoutGroup::Row { - widgets: vec![ - WidgetHolder::new(Widget::TextLabel(TextLabel { - value: format!("Gradient: {}", percent_label), - ..TextLabel::default() - })), - WidgetHolder::new(Widget::Separator(Separator { - separator_type: SeparatorType::Unrelated, - direction: SeparatorDirection::Horizontal, - })), - WidgetHolder::new(Widget::ColorInput(ColorInput { - value: gradient_clone.positions[position].1, - on_update: WidgetCallback::new(move |text_input: &ColorInput| { - let mut new_gradient = (*gradient_clone).clone(); - new_gradient.positions[position].1 = text_input.value; - send_fill_message(new_gradient) - }), - ..ColorInput::default() - })), - ], + + let value = format!("Gradient: {:.0}%", gradient_clone.positions[position].0 * 100.); + let mut widgets = vec![WidgetHolder::new(Widget::TextLabel(TextLabel { + value, + tooltip: "Adjustable by dragging the gradient stops in the viewport with the Gradient tool active".into(), + ..TextLabel::default() + }))]; + widgets.push(WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + }))); + widgets.push(WidgetHolder::new(Widget::ColorInput(ColorInput { + value: gradient_clone.positions[position].1, + on_update: WidgetCallback::new(move |text_input: &ColorInput| { + let mut new_gradient = (*gradient_clone).clone(); + new_gradient.positions[position].1 = text_input.value; + send_fill_message(new_gradient) + }), + ..ColorInput::default() + }))); + + let mut skip_separator = false; + // Remove button + if gradient.positions.len() != position + 1 && position != 0 { + let on_update = WidgetCallback::new(move |_| { + let mut new_gradient = (*gradient_3).clone(); + new_gradient.positions.remove(position); + send_fill_message(new_gradient) + }); + + skip_separator = true; + widgets.push(WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + }))); + widgets.push(WidgetHolder::new(Widget::IconButton(IconButton { + icon: "Remove".to_string(), + tooltip: "Remove this gradient stop".to_string(), + size: 16, + on_update, + ..Default::default() + }))); } + // Add button + if gradient.positions.len() != position + 1 { + let on_update = WidgetCallback::new(move |_| { + let mut gradient = (*gradient_2).clone(); + + let get_color = |index: usize| match (gradient.positions[index].1, gradient.positions.get(index + 1).and_then(|x| x.1)) { + (Some(a), Some(b)) => Color::from_rgbaf32((a.r() + b.r()) / 2., (a.g() + b.g()) / 2., (a.b() + b.b()) / 2., ((a.a() + b.a()) / 2.).clamp(0., 1.)), + (Some(v), _) | (_, Some(v)) => Some(v), + _ => Some(Color::WHITE), + }; + let get_pos = |index: usize| (gradient.positions[index].0 + gradient.positions.get(index + 1).map(|v| v.0).unwrap_or(1.)) / 2.; + + gradient.positions.push((get_pos(position), get_color(position))); + + gradient.positions.sort_unstable_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + send_fill_message(gradient) + }); + + if !skip_separator { + widgets.push(WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + }))); + } + widgets.push(WidgetHolder::new(Widget::IconButton(IconButton { + icon: "Add".to_string(), + tooltip: "Add a gradient stop after this".to_string(), + size: 16, + on_update, + ..Default::default() + }))); + } + LayoutGroup::Row { widgets } } fn node_section_fill(fill: &Fill) -> Option { + let initial_color = if let Fill::Solid(color) = fill { *color } else { Color::BLACK }; + match fill { Fill::Solid(_) | Fill::None => Some(LayoutGroup::Section { name: "Fill".into(), - layout: vec![LayoutGroup::Row { - widgets: vec![ - WidgetHolder::new(Widget::TextLabel(TextLabel { - value: "Color".into(), - ..TextLabel::default() - })), - WidgetHolder::new(Widget::Separator(Separator { - separator_type: SeparatorType::Unrelated, - direction: SeparatorDirection::Horizontal, - })), - WidgetHolder::new(Widget::ColorInput(ColorInput { - value: if let Fill::Solid(color) = fill { Some(*color) } else { None }, - on_update: WidgetCallback::new(|text_input: &ColorInput| { - let fill = if let Some(value) = text_input.value { Fill::Solid(value) } else { Fill::None }; - PropertiesPanelMessage::ModifyFill { fill }.into() - }), - ..ColorInput::default() - })), - ], - }], + layout: vec![ + LayoutGroup::Row { + widgets: vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Color".into(), + ..TextLabel::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::ColorInput(ColorInput { + value: if let Fill::Solid(color) = fill { Some(*color) } else { None }, + on_update: WidgetCallback::new(|text_input: &ColorInput| { + let fill = if let Some(value) = text_input.value { Fill::Solid(value) } else { Fill::None }; + PropertiesPanelMessage::ModifyFill { fill }.into() + }), + ..ColorInput::default() + })), + ], + }, + LayoutGroup::Row { + widgets: vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "".into(), + ..TextLabel::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextButton(TextButton { + label: "Use Gradient".into(), + tooltip: "Change this fill from a solid color to a gradient".into(), + on_update: WidgetCallback::new(move |_: &TextButton| { + let (r, g, b, _) = initial_color.components(); + let opposite_color = Color::from_rgbaf32(1. - r, 1. - g, 1. - b, 1.).unwrap(); + + PropertiesPanelMessage::ModifyFill { + fill: Fill::Gradient(Gradient::new( + DVec2::new(0., 0.5), + initial_color, + DVec2::new(1., 0.5), + opposite_color, + DAffine2::IDENTITY, + generate_uuid(), + GradientType::Linear, + )), + } + .into() + }), + ..TextButton::default() + })), + ], + }, + ], }), Fill::Gradient(gradient) => Some(LayoutGroup::Section { name: "Fill".into(), - layout: vec![node_gradient_type(gradient), node_gradient_color(gradient, "0%", 0), node_gradient_color(gradient, "100%", 1)], + layout: { + let cloned_gradient = gradient.clone(); + let first_color = gradient.positions.get(0).unwrap_or(&(0., None)).1; + + let mut layout = vec![node_gradient_type(gradient)]; + layout.extend((0..gradient.positions.len()).map(|pos| node_gradient_color(gradient, pos))); + layout.push(LayoutGroup::Row { + widgets: vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "".into(), + ..TextLabel::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextButton(TextButton { + label: "Invert".into(), + icon: Some("Swap".into()), + tooltip: "Reverse the order of each color stop".into(), + on_update: WidgetCallback::new(move |_: &TextButton| { + let mut new_gradient = cloned_gradient.clone(); + new_gradient.positions = new_gradient.positions.iter().map(|(distance, color)| (1. - distance, *color)).collect(); + new_gradient.positions.reverse(); + PropertiesPanelMessage::ModifyFill { fill: Fill::Gradient(new_gradient) }.into() + }), + ..TextButton::default() + })), + ], + }); + layout.push(LayoutGroup::Row { + widgets: vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "".into(), + ..TextLabel::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextButton(TextButton { + label: "Use Solid Color".into(), + tooltip: "Change this fill from a gradient to a solid color, keeping the 0% stop color".into(), + on_update: WidgetCallback::new(move |_: &TextButton| { + PropertiesPanelMessage::ModifyFill { + fill: Fill::Solid(first_color.unwrap_or_default()), + } + .into() + }), + ..TextButton::default() + })), + ], + }); + layout + }, }), } } diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 2f4e7afa..35bb3ee1 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -162,6 +162,7 @@ fn gradient_space_transform(path: &[LayerId], layer: &Layer, document: &Document pub struct GradientOverlay { pub handles: [Vec; 2], pub line: Vec, + pub steps: Vec>, path: Vec, transform: DAffine2, gradient: Gradient, @@ -205,22 +206,35 @@ impl GradientOverlay { path } - pub fn new(fill: &Gradient, dragging_start: Option, path: &[LayerId], layer: &Layer, document: &DocumentMessageHandler, responses: &mut VecDeque, font_cache: &FontCache) -> Self { + pub fn new( + fill: &Gradient, + dragging: Option, + path: &[LayerId], + layer: &Layer, + document: &DocumentMessageHandler, + responses: &mut VecDeque, + font_cache: &FontCache, + ) -> Self { let transform = gradient_space_transform(path, layer, document, font_cache); - let Gradient { start, end, .. } = fill; + let Gradient { start, end, positions, .. } = fill; let [start, end] = [transform.transform_point2(*start), transform.transform_point2(*end)]; let line = Self::generate_overlay_line(start, end, responses); let handles = [ - Self::generate_overlay_handle(start, responses, dragging_start == Some(true)), - Self::generate_overlay_handle(end, responses, dragging_start == Some(false)), + Self::generate_overlay_handle(start, responses, dragging == Some(GradientDragTarget::Start)), + Self::generate_overlay_handle(end, responses, dragging == Some(GradientDragTarget::End)), ]; + let not_at_end = |(_, x): &(_, f64)| x.abs() > f64::EPSILON * 1000. && (1. - x).abs() > f64::EPSILON * 1000.; + let create_step = |(index, pos)| Self::generate_overlay_handle(start.lerp(end, pos), responses, dragging == Some(GradientDragTarget::Step(index))); + let steps = positions.iter().map(|(pos, _)| *pos).enumerate().filter(not_at_end).map(create_step).collect(); + let path = path.to_vec(); let gradient = fill.clone(); Self { handles, + steps, line, path, transform, @@ -233,6 +247,9 @@ impl GradientOverlay { let [start, end] = self.handles; responses.push_back(DocumentMessage::Overlays(Operation::DeleteLayer { path: start }.into()).into()); responses.push_back(DocumentMessage::Overlays(Operation::DeleteLayer { path: end }.into()).into()); + for step in self.steps { + responses.push_back(DocumentMessage::Overlays(Operation::DeleteLayer { path: step }.into()).into()); + } } pub fn evaluate_gradient_start(&self) -> DVec2 { @@ -244,13 +261,21 @@ impl GradientOverlay { } } +#[derive(PartialEq, Eq, Clone, Copy, Debug, Default)] +pub enum GradientDragTarget { + Start, + #[default] + End, + Step(usize), +} + /// Contains information about the selected gradient handle #[derive(Clone, Debug, Default)] struct SelectedGradient { path: Vec, transform: DAffine2, gradient: Gradient, - dragging_start: bool, + dragging: GradientDragTarget, } impl SelectedGradient { @@ -260,7 +285,7 @@ impl SelectedGradient { path: path.to_vec(), transform, gradient, - dragging_start: false, + dragging: GradientDragTarget::End, } } @@ -272,8 +297,8 @@ impl SelectedGradient { pub fn update_gradient(&mut self, mut mouse: DVec2, responses: &mut VecDeque, snap_rotate: bool, gradient_type: GradientType) { self.gradient.gradient_type = gradient_type; - if snap_rotate { - let point = if self.dragging_start { + if snap_rotate && matches!(self.dragging, GradientDragTarget::End | GradientDragTarget::Start) { + let point = if self.dragging == GradientDragTarget::Start { self.transform.transform_point2(self.gradient.end) } else { self.transform.transform_point2(self.gradient.start) @@ -293,10 +318,22 @@ impl SelectedGradient { mouse = self.transform.inverse().transform_point2(mouse); - if self.dragging_start { - self.gradient.start = mouse; - } else { - self.gradient.end = mouse; + match self.dragging { + GradientDragTarget::Start => self.gradient.start = mouse, + GradientDragTarget::End => self.gradient.end = mouse, + GradientDragTarget::Step(s) => { + // Calculate the new position by finding the closest point on the line + let new_pos = ((self.gradient.end - self.gradient.start).angle_between(mouse - self.gradient.start)).cos() * self.gradient.start.distance(mouse) + / self.gradient.start.distance(self.gradient.end); + + // Should not go off end but can swap (like inscape) + let clamped = new_pos.clamp(0., 1.); + self.gradient.positions[s].0 = clamped; + let new_pos = self.gradient.positions[s]; + + self.gradient.positions.sort_unstable_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + self.dragging = GradientDragTarget::Step(self.gradient.positions.iter().position(|x| *x == new_pos).unwrap()); + } } self.gradient.transform = self.transform; @@ -348,16 +385,17 @@ impl Fsm for GradientToolFsmState { } for path in document.selected_visible_layers() { + if !document.graphene_document.multiply_transforms(path).unwrap().inverse().is_finite() { + continue; + } let layer = document.graphene_document.layer(path).unwrap(); if let Ok(Fill::Gradient(gradient)) = layer.style().map(|style| style.fill()) { - let dragging_start = tool_data + let dragging = tool_data .selected_gradient .as_ref() - .and_then(|selected| if selected.path == path { Some(selected.dragging_start) } else { None }); - tool_data - .gradient_overlays - .push(GradientOverlay::new(gradient, dragging_start, path, layer, document, responses, font_cache)) + .and_then(|selected| if selected.path == path { Some(selected.dragging) } else { None }); + tool_data.gradient_overlays.push(GradientOverlay::new(gradient, dragging, path, layer, document, responses, font_cache)) } } @@ -371,25 +409,35 @@ impl Fsm for GradientToolFsmState { let mut dragging = false; for overlay in &tool_data.gradient_overlays { - if overlay.evaluate_gradient_start().distance_squared(mouse) < tolerance { - dragging = true; - start_snap(&mut tool_data.snap_manager, document, font_cache); - tool_data.selected_gradient = Some(SelectedGradient { - path: overlay.path.clone(), - transform: overlay.transform, - gradient: overlay.gradient.clone(), - dragging_start: true, - }) + // Check for dragging step + for (index, (pos, _)) in overlay.gradient.positions.iter().enumerate() { + let pos = overlay.transform.transform_point2(overlay.gradient.start.lerp(overlay.gradient.end, *pos)); + if pos.distance_squared(mouse) < tolerance { + dragging = true; + tool_data.selected_gradient = Some(SelectedGradient { + path: overlay.path.clone(), + transform: overlay.transform, + gradient: overlay.gradient.clone(), + dragging: GradientDragTarget::Step(index), + }) + } } - if overlay.evaluate_gradient_end().distance_squared(mouse) < tolerance { - dragging = true; - start_snap(&mut tool_data.snap_manager, document, font_cache); - tool_data.selected_gradient = Some(SelectedGradient { - path: overlay.path.clone(), - transform: overlay.transform, - gradient: overlay.gradient.clone(), - dragging_start: false, - }) + + // Check dragging start or end handle + for (pos, dragging_target) in [ + (overlay.evaluate_gradient_start(), GradientDragTarget::Start), + (overlay.evaluate_gradient_end(), GradientDragTarget::End), + ] { + if pos.distance_squared(mouse) < tolerance { + dragging = true; + start_snap(&mut tool_data.snap_manager, document, font_cache); + tool_data.selected_gradient = Some(SelectedGradient { + path: overlay.path.clone(), + transform: overlay.transform, + gradient: overlay.gradient.clone(), + dragging: dragging_target, + }) + } } } if dragging { diff --git a/frontend/assets/icon-12px-solid/add.svg b/frontend/assets/icon-12px-solid/add.svg new file mode 100644 index 00000000..4fb9442e --- /dev/null +++ b/frontend/assets/icon-12px-solid/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/icon-12px-solid/remove.svg b/frontend/assets/icon-12px-solid/remove.svg new file mode 100644 index 00000000..08da921f --- /dev/null +++ b/frontend/assets/icon-12px-solid/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/utility-functions/icons.ts b/frontend/src/utility-functions/icons.ts index ae76c007..b5a0aaf2 100644 --- a/frontend/src/utility-functions/icons.ts +++ b/frontend/src/utility-functions/icons.ts @@ -8,6 +8,7 @@ const GRAPHICS = { } as const; // 12px Solid +import Add from "@/../assets/icon-12px-solid/add.svg"; import Checkmark from "@/../assets/icon-12px-solid/checkmark.svg"; import CloseX from "@/../assets/icon-12px-solid/close-x.svg"; import DropdownArrow from "@/../assets/icon-12px-solid/dropdown-arrow.svg"; @@ -30,6 +31,7 @@ import KeyboardSpace from "@/../assets/icon-12px-solid/keyboard-space.svg"; import KeyboardTab from "@/../assets/icon-12px-solid/keyboard-tab.svg"; import Link from "@/../assets/icon-12px-solid/link.svg"; import Overlays from "@/../assets/icon-12px-solid/overlays.svg"; +import Remove from "@/../assets/icon-12px-solid/remove.svg"; import ResetColors from "@/../assets/icon-12px-solid/reset-colors.svg"; import Snapping from "@/../assets/icon-12px-solid/snapping.svg"; import Swap from "@/../assets/icon-12px-solid/swap.svg"; @@ -41,6 +43,7 @@ import WindowButtonWinMinimize from "@/../assets/icon-12px-solid/window-button-w import WindowButtonWinRestoreDown from "@/../assets/icon-12px-solid/window-button-win-restore-down.svg"; const SOLID_12PX = { + Add: { component: Add, size: 12 }, Checkmark: { component: Checkmark, size: 12 }, CloseX: { component: CloseX, size: 12 }, DropdownArrow: { component: DropdownArrow, size: 12 }, @@ -63,6 +66,7 @@ const SOLID_12PX = { KeyboardTab: { component: KeyboardTab, size: 12 }, Link: { component: Link, size: 12 }, Overlays: { component: Overlays, size: 12 }, + Remove: { component: Remove, size: 12 }, ResetColors: { component: ResetColors, size: 12 }, Snapping: { component: Snapping, size: 12 }, Swap: { component: Swap, size: 12 },