Can set stroke and fill on text and shapes (#551)

* Can set stroke and fill on text and shapes

* resend layout on failed update

* text input properly resets on bad input

* support modifying gradients

* can modify gradients in the properties panel

* updated labels

* remove heap allocation in favor of RC

* removed redundent line

* oops
This commit is contained in:
mfish33 2022-02-15 09:04:11 -08:00 committed by Keavon Chambers
parent 93dffb8741
commit a73d9b5811
17 changed files with 286 additions and 33 deletions

View File

@ -54,5 +54,5 @@ pub const FILE_EXPORT_SUFFIX: &str = ".svg";
pub const COLOR_ACCENT: Color = Color::from_unsafe(0x00 as f32 / 255., 0xA8 as f32 / 255., 0xFF as f32 / 255.);
// Document
pub const GRAPHITE_DOCUMENT_VERSION: &str = "0.0.3";
pub const GRAPHITE_DOCUMENT_VERSION: &str = "0.0.4";
pub const VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR: f32 = 1.05;

View File

@ -480,7 +480,7 @@ impl PropertyHolder for DocumentMessageHandler {
checked: self.snapping_enabled,
icon: "Snapping".into(),
tooltip: "Snapping".into(),
on_update: WidgetCallback::new(|updated_optional_input| DocumentMessage::SetSnapping { snap: updated_optional_input.checked }.into()),
on_update: WidgetCallback::new(|optional_input: &OptionalInput| DocumentMessage::SetSnapping { snap: optional_input.checked }.into()),
})),
WidgetHolder::new(Widget::PopoverButton(PopoverButton {
title: "Snapping".into(),
@ -508,12 +508,7 @@ impl PropertyHolder for DocumentMessageHandler {
checked: self.overlays_visible,
icon: "Overlays".into(),
tooltip: "Overlays".into(),
on_update: WidgetCallback::new(|updated_optional_input| {
DocumentMessage::SetOverlaysVisibility {
visible: updated_optional_input.checked,
}
.into()
}),
on_update: WidgetCallback::new(|optional_input: &OptionalInput| DocumentMessage::SetOverlaysVisibility { visible: optional_input.checked }.into()),
})),
WidgetHolder::new(Widget::PopoverButton(PopoverButton {
title: "Overlays".into(),
@ -561,7 +556,7 @@ impl PropertyHolder for DocumentMessageHandler {
unit: "°".into(),
value: self.movement_handler.tilt / (std::f64::consts::PI / 180.),
increment_factor: 15.,
on_update: WidgetCallback::new(|number_input| {
on_update: WidgetCallback::new(|number_input: &NumberInput| {
MovementMessage::SetCanvasRotation {
angle_radians: number_input.value * (std::f64::consts::PI / 180.),
}
@ -603,7 +598,7 @@ impl PropertyHolder for DocumentMessageHandler {
value: self.movement_handler.zoom * 100.,
min: Some(0.000001),
max: Some(1000000.),
on_update: WidgetCallback::new(|number_input| {
on_update: WidgetCallback::new(|number_input: &NumberInput| {
MovementMessage::SetCanvasZoom {
zoom_factor: number_input.value / 100.,
}

View File

@ -1,5 +1,6 @@
use crate::message_prelude::*;
use graphene::layers::style::Fill;
use serde::{Deserialize, Serialize};
#[remain::sorted]
@ -9,8 +10,11 @@ pub enum PropertiesPanelMessage {
CheckSelectedWasDeleted { path: Vec<LayerId> },
CheckSelectedWasUpdated { path: Vec<LayerId> },
ClearSelection,
ModifyFill { fill: Fill },
ModifyName { name: String },
ModifyStroke { color: String, weight: f64 },
ModifyTransform { value: f64, transform_op: TransformOp },
ResendActiveProperties,
SetActiveLayers { paths: Vec<Vec<LayerId>> },
}

View File

@ -6,13 +6,16 @@ use crate::layout::widgets::{
};
use crate::message_prelude::*;
use graphene::color::Color;
use graphene::document::Document as GrapheneDocument;
use graphene::layers::layer_info::{Layer, LayerDataType};
use graphene::layers::style::{Fill, Stroke};
use graphene::{LayerId, Operation};
use glam::{DAffine2, DVec2};
use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
use std::rc::Rc;
trait DAffine2Utils {
fn width(&self) -> f64;
@ -152,6 +155,21 @@ impl MessageHandler<PropertiesPanelMessage, &GrapheneDocument> for PropertiesPan
let path = self.active_path.clone().expect("Received update for properties panel with no active layer");
responses.push_back(DocumentMessage::SetLayerName { layer_path: path, name }.into())
}
ModifyFill { fill } => {
let path = self.active_path.clone().expect("Received update for properties panel with no active layer");
responses.push_back(Operation::SetLayerFill { path, fill }.into());
}
ModifyStroke { color, weight } => {
let path = self.active_path.clone().expect("Received update for properties panel with no active layer");
let layer = graphene_document.layer(&path).unwrap();
if let Some(color) = Color::from_rgba_str(&color).or(Color::from_rgb_str(&color)) {
let stroke = Stroke::new(color, weight as f32);
responses.push_back(Operation::SetLayerStroke { path, stroke }.into())
} else {
// Failed to update, Show user unchanged state
register_layer_properties(layer, responses)
}
}
CheckSelectedWasUpdated { path } => {
if self.matches_selected(&path) {
let layer = graphene_document.layer(&path).unwrap();
@ -177,6 +195,11 @@ impl MessageHandler<PropertiesPanelMessage, &GrapheneDocument> for PropertiesPan
);
}
}
ResendActiveProperties => {
let path = self.active_path.clone().expect("Received update for properties panel with no active layer");
let layer = graphene_document.layer(&path).unwrap();
register_layer_properties(layer, responses)
}
}
}
@ -217,7 +240,7 @@ fn register_layer_properties(layer: &Layer, responses: &mut VecDeque<Message>) {
})),
WidgetHolder::new(Widget::TextInput(TextInput {
value: layer.name.clone().unwrap_or_else(|| "Untitled".to_string()),
on_update: WidgetCallback::new(|text_input| PropertiesPanelMessage::ModifyName { name: text_input.value.clone() }.into()),
on_update: WidgetCallback::new(|text_input: &TextInput| PropertiesPanelMessage::ModifyName { name: text_input.value.clone() }.into()),
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,
@ -232,13 +255,21 @@ fn register_layer_properties(layer: &Layer, responses: &mut VecDeque<Message>) {
let properties_body = match &layer.data {
LayerDataType::Folder(_) => {
vec![node_section_transform(layer)]
vec![]
}
LayerDataType::Shape(_) => {
vec![node_section_transform(layer)]
LayerDataType::Shape(shape) => {
vec![
node_section_transform(layer),
node_section_fill(&shape.style.fill()),
node_section_stroke(&shape.style.stroke().unwrap_or_default()),
]
}
LayerDataType::Text(_) => {
vec![node_section_transform(layer)]
LayerDataType::Text(text) => {
vec![
node_section_transform(layer),
node_section_fill(&text.style.fill()),
node_section_stroke(&text.style.stroke().unwrap_or_default()),
]
}
};
@ -277,7 +308,7 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
value: layer.transform.x(),
label: "X".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input| {
on_update: WidgetCallback::new(|number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform {
value: number_input.value,
transform_op: TransformOp::X,
@ -294,7 +325,7 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
value: layer.transform.y(),
label: "Y".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input| {
on_update: WidgetCallback::new(|number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform {
value: number_input.value,
transform_op: TransformOp::Y,
@ -320,7 +351,7 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
value: layer.transform.width(),
label: "W".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input| {
on_update: WidgetCallback::new(|number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform {
value: number_input.value,
transform_op: TransformOp::Width,
@ -337,7 +368,7 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
value: layer.transform.height(),
label: "H".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input| {
on_update: WidgetCallback::new(|number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform {
value: number_input.value,
transform_op: TransformOp::Height,
@ -363,7 +394,7 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
value: layer.transform.rotation() * 180. / PI,
label: "R".into(),
unit: "°".into(),
on_update: WidgetCallback::new(|number_input| {
on_update: WidgetCallback::new(|number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform {
value: number_input.value / 180. * PI,
transform_op: TransformOp::Rotation,
@ -377,3 +408,161 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
],
}
}
fn node_section_fill(fill: &Fill) -> LayoutRow {
match fill {
Fill::Solid(color) => LayoutRow::Section {
name: "Fill".into(),
layout: vec![LayoutRow::Row {
name: "".into(),
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Color".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextInput(TextInput {
value: color.rgba_hex(),
on_update: WidgetCallback::new(|text_input: &TextInput| {
if let Some(color) = Color::from_rgba_str(&text_input.value).or(Color::from_rgb_str(&text_input.value)) {
let new_fill = Fill::Solid(color);
PropertiesPanelMessage::ModifyFill { fill: new_fill }.into()
} else {
PropertiesPanelMessage::ResendActiveProperties.into()
}
}),
})),
],
}],
},
Fill::LinearGradient(gradient) => {
let gradient_1 = Rc::new(gradient.clone());
let gradient_2 = gradient_1.clone();
LayoutRow::Section {
name: "Fill".into(),
layout: vec![
LayoutRow::Row {
name: "".into(),
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Gradient: 0%".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextInput(TextInput {
value: gradient_1.positions[0].1.rgba_hex(),
on_update: WidgetCallback::new(move |text_input: &TextInput| {
if let Some(color) = Color::from_rgba_str(&text_input.value).or(Color::from_rgb_str(&text_input.value)) {
let mut new_gradient = (*gradient_1).clone();
new_gradient.positions[0].1 = color;
PropertiesPanelMessage::ModifyFill {
fill: Fill::LinearGradient(new_gradient),
}
.into()
} else {
PropertiesPanelMessage::ResendActiveProperties.into()
}
}),
})),
],
},
LayoutRow::Row {
name: "".into(),
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Gradient: 100%".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextInput(TextInput {
value: gradient_2.positions[1].1.rgba_hex(),
on_update: WidgetCallback::new(move |text_input: &TextInput| {
if let Some(color) = Color::from_rgba_str(&text_input.value).or(Color::from_rgb_str(&text_input.value)) {
let mut new_gradient = (*gradient_2).clone();
new_gradient.positions[1].1 = color;
PropertiesPanelMessage::ModifyFill {
fill: Fill::LinearGradient(new_gradient),
}
.into()
} else {
PropertiesPanelMessage::ResendActiveProperties.into()
}
}),
})),
],
},
],
}
}
Fill::None => panic!("`node_section_fill` called on a shape that does not have a fill"),
}
}
fn node_section_stroke(stroke: &Stroke) -> LayoutRow {
let color = stroke.color();
let weight = stroke.width();
LayoutRow::Section {
name: "Stroke".into(),
layout: vec![
LayoutRow::Row {
name: "".into(),
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Color".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextInput(TextInput {
value: stroke.color().rgba_hex(),
on_update: WidgetCallback::new(move |text_input: &TextInput| {
PropertiesPanelMessage::ModifyStroke {
color: text_input.value.clone(),
weight: weight as f64,
}
.into()
}),
})),
],
},
LayoutRow::Row {
name: "".into(),
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Weight".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: stroke.width() as f64,
is_integer: true,
min: Some(0.),
unit: " px".into(),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
PropertiesPanelMessage::ModifyStroke {
color: color.rgba_hex(),
weight: number_input.value,
}
.into()
}),
..NumberInput::default()
})),
],
},
],
}
}

View File

@ -1,3 +1,5 @@
use std::rc::Rc;
use super::layout_message::LayoutTarget;
use crate::message_prelude::*;
@ -130,18 +132,18 @@ impl WidgetHolder {
#[derive(Clone)]
pub struct WidgetCallback<T> {
pub callback: fn(&T) -> Message,
pub callback: Rc<dyn Fn(&T) -> Message + 'static>,
}
impl<T> WidgetCallback<T> {
pub fn new(callback: fn(&T) -> Message) -> Self {
Self { callback }
pub fn new(callback: impl Fn(&T) -> Message + 'static) -> Self {
Self { callback: Rc::new(callback) }
}
}
impl<T> Default for WidgetCallback<T> {
fn default() -> Self {
Self { callback: |_| Message::NoOp }
Self::new(|_| Message::NoOp)
}
}

View File

@ -67,7 +67,7 @@ impl PropertyHolder for FreehandTool {
value: self.options.line_weight as f64,
is_integer: true,
min: Some(1.),
on_update: WidgetCallback::new(|number_input| FreehandToolMessage::UpdateOptions(FreehandToolMessageOptionsUpdate::LineWeight(number_input.value as u32)).into()),
on_update: WidgetCallback::new(|number_input: &NumberInput| FreehandToolMessage::UpdateOptions(FreehandToolMessageOptionsUpdate::LineWeight(number_input.value as u32)).into()),
..NumberInput::default()
}))],
}])

View File

@ -68,7 +68,7 @@ impl PropertyHolder for LineTool {
value: self.options.line_weight as f64,
is_integer: true,
min: Some(0.),
on_update: WidgetCallback::new(|number_input| LineToolMessage::UpdateOptions(LineOptionsUpdate::LineWeight(number_input.value as u32)).into()),
on_update: WidgetCallback::new(|number_input: &NumberInput| LineToolMessage::UpdateOptions(LineOptionsUpdate::LineWeight(number_input.value as u32)).into()),
..NumberInput::default()
}))],
}])

View File

@ -75,7 +75,7 @@ impl PropertyHolder for PenTool {
value: self.options.line_weight as f64,
is_integer: true,
min: Some(0.),
on_update: WidgetCallback::new(|number_input| PenToolMessage::UpdateOptions(PenOptionsUpdate::LineWeight(number_input.value as u32)).into()),
on_update: WidgetCallback::new(|number_input: &NumberInput| PenToolMessage::UpdateOptions(PenOptionsUpdate::LineWeight(number_input.value as u32)).into()),
..NumberInput::default()
}))],
}])

View File

@ -66,7 +66,7 @@ impl PropertyHolder for ShapeTool {
is_integer: true,
min: Some(3.),
max: Some(256.),
on_update: WidgetCallback::new(|number_input| ShapeToolMessage::UpdateOptions(ShapeOptionsUpdate::Vertices(number_input.value as u8)).into()),
on_update: WidgetCallback::new(|number_input: &NumberInput| ShapeToolMessage::UpdateOptions(ShapeOptionsUpdate::Vertices(number_input.value as u8)).into()),
..NumberInput::default()
}))],
}])

View File

@ -71,7 +71,7 @@ impl PropertyHolder for SplineTool {
value: self.options.line_weight as f64,
is_integer: true,
min: Some(0.),
on_update: WidgetCallback::new(|number_input| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::LineWeight(number_input.value as u32)).into()),
on_update: WidgetCallback::new(|number_input: &NumberInput| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::LineWeight(number_input.value as u32)).into()),
..NumberInput::default()
}))],
}])

View File

@ -71,7 +71,7 @@ impl PropertyHolder for TextTool {
value: self.options.font_size as f64,
is_integer: true,
min: Some(1.),
on_update: WidgetCallback::new(|number_input| TextMessage::UpdateOptions(TextOptionsUpdate::FontSize(number_input.value as u32)).into()),
on_update: WidgetCallback::new(|number_input: &NumberInput| TextMessage::UpdateOptions(TextOptionsUpdate::FontSize(number_input.value as u32)).into()),
..NumberInput::default()
}))],
}])

View File

@ -14,6 +14,8 @@
<style lang="scss">
.widget-section {
flex: 0 0 auto;
.header {
flex: 0 0 24px;
background: var(--color-4-dimgray);

View File

@ -67,6 +67,9 @@ export default defineComponent({
// TODO: Find a less hacky way to do this
const inputElement = (this.$refs.fieldInput as typeof FieldInput).$refs.input as HTMLInputElement;
this.$emit("commitText", inputElement.value);
// Required if value is not changed by the parent component upon update:value event
inputElement.value = this.value;
},
onCancelTextChange() {
this.editing = false;

View File

@ -162,4 +162,41 @@ impl Color {
pub fn rgb_hex(&self) -> String {
format!("{:02X?}{:02X?}{:02X?}", (self.r() * 255.) as u8, (self.g() * 255.) as u8, (self.b() * 255.) as u8,)
}
/// Creates a color from a 8-character RGBA hex string (without a # prefix).
///
/// # Examples
/// ```
/// use graphite_graphene::color::Color;
/// let color = Color::from_rgba_str("7C67FA61").unwrap();
/// assert!("7C67FA61" == color.rgba_hex())
/// ```
pub fn from_rgba_str(color_str: &str) -> Option<Color> {
if color_str.len() != 8 {
return None;
}
let r = u8::from_str_radix(&color_str[0..2], 16).ok()?;
let g = u8::from_str_radix(&color_str[2..4], 16).ok()?;
let b = u8::from_str_radix(&color_str[4..6], 16).ok()?;
let a = u8::from_str_radix(&color_str[6..8], 16).ok()?;
Some(Color::from_rgba8(r, g, b, a))
}
/// Creates a color from a 6-character RGB hex string (without a # prefix).
/// ```
/// use graphite_graphene::color::Color;
/// let color = Color::from_rgb_str("7C67FA").unwrap();
/// assert!("7C67FA" == color.rgb_hex())
/// ```
pub fn from_rgb_str(color_str: &str) -> Option<Color> {
if color_str.len() != 6 {
return None;
}
let r = u8::from_str_radix(&color_str[0..2], 16).ok()?;
let g = u8::from_str_radix(&color_str[2..4], 16).ok()?;
let b = u8::from_str_radix(&color_str[4..6], 16).ok()?;
Some(Color::from_rgb8(r, g, b))
}
}

View File

@ -775,11 +775,18 @@ impl Document {
let layer = self.layer_mut(&path)?;
match &mut layer.data {
LayerDataType::Shape(s) => s.style = style,
LayerDataType::Text(text) => text.style = style,
_ => return Err(DocumentError::NotAShape),
}
self.mark_as_dirty(&path)?;
Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(&path)].concat())
}
Operation::SetLayerStroke { path, stroke } => {
let layer = self.layer_mut(&path)?;
layer.style_mut()?.set_stroke(stroke);
self.mark_as_dirty(&path)?;
Some([vec![DocumentChanged], update_thumbnails_upstream(&path)].concat())
}
Operation::SetLayerFill { path, fill } => {
let layer = self.layer_mut(&path)?;
layer.style_mut()?.set_fill(fill);

View File

@ -133,7 +133,7 @@ impl Fill {
}
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Stroke {
color: Color,
width: f32,
@ -157,6 +157,16 @@ impl Stroke {
}
}
// Having an alpha of 1 to start with leads to a better experience with the properties panel
impl Default for Stroke {
fn default() -> Self {
Self {
width: 0.,
color: Color::from_rgba8(0, 0, 0, 255),
}
}
}
#[repr(C)]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PathStyle {

View File

@ -1,7 +1,7 @@
use crate::boolean_ops::BooleanOperation as BooleanOperationType;
use crate::layers::blend_mode::BlendMode;
use crate::layers::layer_info::Layer;
use crate::layers::style;
use crate::layers::style::{self, Stroke};
use crate::LayerId;
use serde::{Deserialize, Serialize};
@ -180,6 +180,10 @@ pub enum Operation {
path: Vec<LayerId>,
fill: style::Fill,
},
SetLayerStroke {
path: Vec<LayerId>,
stroke: Stroke,
},
}
impl Operation {