Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions editor/src/messages/layout/utility_types/layout_widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ impl Diffable for WidgetInstance {
&& button1.tooltip_description == button2.tooltip_description
&& button1.tooltip_shortcut == button2.tooltip_shortcut
&& button1.popover_min_width == button2.popover_min_width
&& button1.popover_layout.0.len() == button2.popover_layout.0.len()
{
// Only the popover layout differs, diff it recursively
for (i, (a, b)) in button1.popover_layout.0.iter_mut().zip(button2.popover_layout.0.iter()).enumerate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ pub struct TextLabel {
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
#[serde(rename = "minWidthCharacters")]
pub min_width_characters: u32,

Expand Down
12 changes: 6 additions & 6 deletions editor/src/messages/portfolio/document/overlays/grid_overlays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
widgets.push(LayoutGroup::row(vec![TextLabel::new("Grid").bold(true).widget_instance()]));

widgets.push(LayoutGroup::row(vec![
TextLabel::new("Type").table_align(true).widget_instance(),
TextLabel::new("Type").min_width(60).max_width(60).table_align(true).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
RadioInput::new(vec![
RadioEntryData::new("rectangular").label("Rectangular").on_update(update_val(grid, |grid, _| {
Expand Down Expand Up @@ -262,7 +262,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
]));

let mut color_widgets = vec![
TextLabel::new("Display").table_align(true).widget_instance(),
TextLabel::new("Display").min_width(60).max_width(60).table_align(true).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
];
color_widgets.extend([
Expand All @@ -287,7 +287,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
widgets.push(LayoutGroup::row(color_widgets));

widgets.push(LayoutGroup::row(vec![
TextLabel::new("Origin").table_align(true).widget_instance(),
TextLabel::new("Origin").min_width(60).max_width(60).table_align(true).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(grid.origin.x))
.label("X")
Expand All @@ -306,7 +306,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {

match grid.grid_type {
GridType::Rectangular { spacing } => widgets.push(LayoutGroup::row(vec![
TextLabel::new("Spacing").table_align(true).widget_instance(),
TextLabel::new("Spacing").min_width(60).max_width(60).table_align(true).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(spacing.x))
.label("X")
Expand All @@ -326,7 +326,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
])),
GridType::Isometric { y_axis_spacing, angle_a, angle_b } => {
widgets.push(LayoutGroup::row(vec![
TextLabel::new("Y Spacing").table_align(true).widget_instance(),
TextLabel::new("Y Spacing").min_width(60).max_width(60).table_align(true).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(y_axis_spacing))
.unit(" px")
Expand All @@ -336,7 +336,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
.widget_instance(),
]));
widgets.push(LayoutGroup::row(vec![
TextLabel::new("Angles").table_align(true).widget_instance(),
TextLabel::new("Angles").min_width(60).max_width(60).table_align(true).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(angle_a))
.unit("°")
Expand Down
115 changes: 112 additions & 3 deletions editor/src/messages/tool/common_functionality/color_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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;
use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};

/// Color selector widgets seen in [`LayoutTarget::ToolOptions`] bar.
pub struct ToolColorOptions {
Expand Down Expand Up @@ -109,13 +109,25 @@ impl ToolColorOptions {
}

/// 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<f64>,
/// Persistent default weight, updated when the user edits the weight while no layer is selected.
pub default_line_weight: f64,
/// Stroke alignment from the selection. `None` = mixed.
pub stroke_align: Option<StrokeAlign>,
/// Stroke cap from the selection. `None` = mixed.
pub stroke_cap: Option<StrokeCap>,
/// Stroke join from the selection. `None` = mixed.
pub stroke_join: Option<StrokeJoin>,
/// Stroke miter limit from the selection. `None` = mixed.
pub miter_limit: Option<f64>,
/// Paint order from the selection. `None` = mixed.
pub paint_order: Option<PaintOrder>,
/// Dash lengths from the selection. `None` = mixed.
pub dash_lengths: Option<Vec<f64>>,
/// Dash offset from the selection. `None` = mixed.
pub dash_offset: Option<f64>,
/// Set of layers we last synced from, used to detect real selection changes vs. internal node toggles.
pub last_synced_selection: Vec<LayerNodeIdentifier>,
/// The fill swatch's color, checkbox, and mixed state.
Expand All @@ -132,6 +144,13 @@ impl DrawingToolState {
Self {
line_weight: Some(DEFAULT_STROKE_WIDTH),
default_line_weight: DEFAULT_STROKE_WIDTH,
stroke_align: Some(StrokeAlign::default()),
stroke_cap: Some(StrokeCap::default()),
stroke_join: Some(StrokeJoin::default()),
miter_limit: Some(4.),
paint_order: Some(PaintOrder::default()),
dash_lengths: Some(Vec::new()),
dash_offset: Some(0.),
last_synced_selection: Vec::new(),
fill: if fill_enabled { ToolColorOptions::new_enabled() } else { ToolColorOptions::new_disabled() },
stroke: ToolColorOptions::new_enabled(),
Expand All @@ -143,6 +162,33 @@ impl DrawingToolState {
pub fn effective_line_weight(&self) -> f64 {
self.line_weight.unwrap_or(self.default_line_weight)
}

/// Dash lengths to apply, falling back to empty when [`Self::dash_lengths`] is `None` (mixed).
pub fn effective_dash_lengths(&self) -> Vec<f64> {
self.dash_lengths.clone().unwrap_or_default()
}

/// Applies a stroke to a freshly created `layer` using the tool's currently selected color, weight, and stroke options (align, cap, join, etc.).
/// Used by the drawing tools at shape-creation time so new shapes inherit the popover's options instead of defaulting to the `Stroke` struct's defaults.
pub fn apply_stroke_to_new_layer(&self, layer: LayerNodeIdentifier, responses: &mut VecDeque<Message>) {
if !self.stroke.is_active() {
return;
}
let Some(FillChoice::Solid(color)) = &self.stroke.fill_choice else { return };
let stroke = graphene_std::vector::style::Stroke {
color: Some(*color),
weight: self.effective_line_weight(),
align: self.stroke_align.unwrap_or_default(),
cap: self.stroke_cap.unwrap_or_default(),
join: self.stroke_join.unwrap_or_default(),
join_miter_limit: self.miter_limit.unwrap_or(4.),
paint_order: self.paint_order.unwrap_or_default(),
dash_lengths: self.effective_dash_lengths(),
dash_offset: self.dash_offset.unwrap_or(0.),
transform: glam::DAffine2::IDENTITY,
};
responses.add(GraphOperationMessage::StrokeSet { layer, stroke });
}
}

/// Builds a `FillChoice::Solid` from a linear-space color, applying gamma conversion to display sRGB.
Expand Down Expand Up @@ -250,9 +296,72 @@ pub fn sync_drawing_state(drawing: &mut DrawingToolState, natural_fill_enabled:
needs_refresh = true;
}

needs_refresh |= sync_stroke_options(drawing, document);

needs_refresh
}

/// Reads the stroke proto-node inputs (align, cap, join, miter limit, paint order, dash lengths, dash offset) across the selection and updates
/// the matching fields on `drawing`. Each field becomes `None` (mixed) when selected strokes disagree. With no selection, fields are left as-is.
fn sync_stroke_options(drawing: &mut DrawingToolState, document: &DocumentMessageHandler) -> bool {
let strokes: Vec<_> = document
.network_interface
.selected_nodes()
.selected_layers_except_artboards(&document.network_interface)
.filter_map(|layer| graph_modification_utils::get_stroke_options(layer, &document.network_interface))
.collect();
if strokes.is_empty() {
Comment thread
Keavon marked this conversation as resolved.
return false;
}

fn unanimous<T: PartialEq + Clone>(values: impl IntoIterator<Item = T>) -> Option<T> {
let mut iter = values.into_iter();
let first = iter.next()?;
iter.all(|v| v == first).then_some(first)
}

let new_align = unanimous(strokes.iter().map(|s| s.align));
let new_cap = unanimous(strokes.iter().map(|s| s.cap));
let new_join = unanimous(strokes.iter().map(|s| s.join));
let new_miter = unanimous(strokes.iter().map(|s| s.miter_limit));
let new_paint_order = unanimous(strokes.iter().map(|s| s.paint_order));
let new_dash_lengths = unanimous(strokes.iter().map(|s| &s.dash_lengths)).cloned();
let new_dash_offset = unanimous(strokes.iter().map(|s| s.dash_offset));

let mut changed = false;

if drawing.stroke_align != new_align {
drawing.stroke_align = new_align;
changed = true;
}
if drawing.stroke_cap != new_cap {
drawing.stroke_cap = new_cap;
changed = true;
}
if drawing.stroke_join != new_join {
drawing.stroke_join = new_join;
changed = true;
}
if drawing.miter_limit != new_miter {
drawing.miter_limit = new_miter;
changed = true;
}
if drawing.paint_order != new_paint_order {
drawing.paint_order = new_paint_order;
changed = true;
}
if drawing.dash_lengths != new_dash_lengths {
drawing.dash_lengths = new_dash_lengths;
changed = true;
}
if drawing.dash_offset != new_dash_offset {
drawing.dash_offset = new_dash_offset;
changed = true;
}

changed
}

/// 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, FillChoice, Gradient};
use graphene_std::vector::style::{Fill, FillChoice, Gradient, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::vector::{GradientStops, PointId, SegmentId, VectorModificationType};
use std::collections::VecDeque;

Expand Down Expand Up @@ -493,6 +493,66 @@ pub fn get_stroke_width(layer: LayerNodeIdentifier, network_interface: &NodeNetw
}
}

/// Subset of Stroke node inputs read for the control bar's stroke options popover.
#[derive(Debug, Clone, PartialEq)]
pub struct StrokeOptionsState {
pub align: StrokeAlign,
pub cap: StrokeCap,
pub join: StrokeJoin,
pub miter_limit: f64,
pub paint_order: PaintOrder,
pub dash_lengths: Vec<f64>,
pub dash_offset: f64,
}

/// Reads the non-color stroke option inputs from a layer's Stroke proto node. Returns `None` when the layer has no Stroke node.
/// Inputs that aren't a static value (e.g. wired to another node) fall back to per-field defaults so the layer still participates in the sync.
pub fn get_stroke_options(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<StrokeOptionsState> {
let stroke = &DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER);
let layer_view = NodeGraphLayer::new(layer, network_interface);
layer_view.upstream_node_id_from_name(stroke)?;
let read = |index: usize| layer_view.find_input(stroke, index);

let align = match read(graphene_std::vector::stroke::AlignInput::INDEX) {
Some(TaggedValue::StrokeAlign(value)) => *value,
_ => StrokeAlign::default(),
};
let cap = match read(graphene_std::vector::stroke::CapInput::INDEX) {
Some(TaggedValue::StrokeCap(value)) => *value,
_ => StrokeCap::default(),
};
let join = match read(graphene_std::vector::stroke::JoinInput::INDEX) {
Some(TaggedValue::StrokeJoin(value)) => *value,
_ => StrokeJoin::default(),
};
let miter_limit = match read(graphene_std::vector::stroke::MiterLimitInput::INDEX) {
Some(TaggedValue::F64(value)) => *value,
_ => 4.,
};
let paint_order = match read(graphene_std::vector::stroke::PaintOrderInput::INDEX) {
Some(TaggedValue::PaintOrder(value)) => *value,
_ => PaintOrder::default(),
};
let dash_lengths = match read(graphene_std::vector::stroke::DashLengthsInput::<List<f64>>::INDEX) {
Some(TaggedValue::F64Array(value)) => value.clone(),
_ => Vec::new(),
};
let dash_offset = match read(graphene_std::vector::stroke::DashOffsetInput::INDEX) {
Some(TaggedValue::F64(value)) => *value,
_ => 0.,
};

Some(StrokeOptionsState {
align,
cap,
join,
miter_limit,
paint_order,
dash_lengths,
dash_offset,
})
}

/// Returns the node ID of a layer's upstream Stroke proto node, if one exists.
pub fn get_stroke_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<NodeId> {
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER))
Expand Down Expand Up @@ -759,9 +819,10 @@ impl<'a> NodeGraphLayer<'a> {
self.network_interface.upstream_flow_back_from_nodes(vec![self.layer_node], &[], FlowType::HorizontalFlow)
}

/// Node id of a node if it exists in the layer's primary flow
/// Node id of a node if it exists in this specific layer's primary flow, stopping at the next layer upstream so a group doesn't incorrectly match its children's nodes.
pub fn upstream_node_id_from_name(&self, identifier: &DefinitionIdentifier) -> Option<NodeId> {
self.horizontal_layer_flow()
.take_while(|&node_id| node_id == self.layer_node || !self.network_interface.is_layer(&node_id, &[]))
.find(|node_id| self.network_interface.reference(node_id, &[]).is_some_and(|reference| reference == *identifier))
}

Expand Down
1 change: 1 addition & 0 deletions editor/src/messages/tool/common_functionality/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ pub mod resize;
pub mod shape_editor;
pub mod shapes;
pub mod snapping;
pub mod stroke_options;
pub mod transformation_cage;
pub mod utility_functions;
Loading
Loading