Add control over gradient stops (#834)

* Add gradient stops

* Better step adding

* Steps can be dragged past each other

* Swapping and switching gradient/fill

* Fix convert to gradient

* Skip non finite transforms for overlays

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube 2022-11-05 13:30:48 +00:00 committed by Keavon Chambers
parent a1e061fa14
commit 1462d2b662
5 changed files with 291 additions and 80 deletions

View File

@ -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<LayoutGroup> {
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
},
}),
}
}

View File

@ -162,6 +162,7 @@ fn gradient_space_transform(path: &[LayerId], layer: &Layer, document: &Document
pub struct GradientOverlay {
pub handles: [Vec<LayerId>; 2],
pub line: Vec<LayerId>,
pub steps: Vec<Vec<LayerId>>,
path: Vec<LayerId>,
transform: DAffine2,
gradient: Gradient,
@ -205,22 +206,35 @@ impl GradientOverlay {
path
}
pub fn new(fill: &Gradient, dragging_start: Option<bool>, path: &[LayerId], layer: &Layer, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>, font_cache: &FontCache) -> Self {
pub fn new(
fill: &Gradient,
dragging: Option<GradientDragTarget>,
path: &[LayerId],
layer: &Layer,
document: &DocumentMessageHandler,
responses: &mut VecDeque<Message>,
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<LayerId>,
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<Message>, 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 {

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<rect width="8" height="2" x="2" y="5" />
<rect width="2" height="8" x="5" y="2" />
</svg>

After

Width:  |  Height:  |  Size: 154 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<rect width="8" height="2" x="2" y="5" />
</svg>

After

Width:  |  Height:  |  Size: 111 B

View File

@ -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 },