Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.clone()));
Comment thread
Keavon marked this conversation as resolved.
Outdated
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,64 @@ 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.
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);
let read = |index: usize| layer_view.find_input(stroke, index);

let align = match read(graphene_std::vector::stroke::AlignInput::INDEX)? {
TaggedValue::StrokeAlign(value) => *value,
_ => StrokeAlign::default(),
};
let cap = match read(graphene_std::vector::stroke::CapInput::INDEX)? {
TaggedValue::StrokeCap(value) => *value,
_ => StrokeCap::default(),
};
let join = match read(graphene_std::vector::stroke::JoinInput::INDEX)? {
TaggedValue::StrokeJoin(value) => *value,
_ => StrokeJoin::default(),
};
let miter_limit = match read(graphene_std::vector::stroke::MiterLimitInput::INDEX)? {
TaggedValue::F64(value) => *value,
_ => 4.,
};
let paint_order = match read(graphene_std::vector::stroke::PaintOrderInput::INDEX)? {
TaggedValue::PaintOrder(value) => *value,
_ => PaintOrder::default(),
};
let dash_lengths = match read(graphene_std::vector::stroke::DashLengthsInput::<List<f64>>::INDEX)? {
TaggedValue::F64Array(value) => value.clone(),
_ => Vec::new(),
};
let dash_offset = match read(graphene_std::vector::stroke::DashOffsetInput::INDEX)? {
TaggedValue::F64(value) => *value,
_ => 0.,
};
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

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 +817,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