Make the data model use Item and List types universally, with nodes authored as rank-polymorphic kernels (#4335)

* Add rank polymorphism node audit classifying all 271 nodes

* Implement StaticType for Item<T>

* Generate Item and mapped List wire variants for nodes declaring an Item<T> primary input

* Migrate nine nodes to Item element-wise kernels, dissolving the blending trait boilerplate

* Document the Item kernel implementation and staging plan

* Route Item<Vector> through TaggedValue::TypeDefault

* Add executor integration tests covering the Item and List wire variants

* Collapse element-wise Item/List wire pairs to the List form for conversion insertion

* Migrate sixteen vector modifier nodes to Item element-wise kernels

* Migrate Sample Image, Extend Image to Bounds, and Dehaze to Item element-wise kernels

* Fix bevel_with_transform test to actually exercise the transform attribute

* Implement From<T> for Item<T>

* Register PromoteNode rank adapters wrapping bare values into Item wires

* Insert PromoteNode adapters for Item/List wire pair fields in the preprocessor

* Define a real promote node backing the PromoteNode registry identifiers

* Zip ranked Item connectors by frame slot in the mapped element-wise variant

* Register ItemToListNode singleton raise adapters

* Resolve Item wires against List connectors by inserting promotion adapters at construction

* Rank the Offset Points distance connector and prove mixed-rank resolution end-to-end

* Implement Clampable for Item and List wires with per-variant clamp bounds

* Rank the Round Corners radius connector, exercising hard bounds on a ranked wire

* Implement ApplyTransform for Item

* Add Item wire implementations to the Transform node, keeping rank-0 chains rank 0

* Detect element-wise nodes by lazy primary connectors declaring Output = Item

* Convert Transform to an Item kernel with ranked parameters, delivering the broadcast milestone

* Rename Apply Transform to Bake Transform, baking item transforms on Vector, DAffine2, and DVec2

* Promote bare wires onto Item connectors at resolution via WrapItemNode adapters

* Rank the numeric, vector, and boolean parameters across the migrated element-wise nodes

* Rank the enum, integer, and seed parameters, registering their rank adapters via a consolidated macro

* Amend the audit with the DashPattern value type resolution

* Migrate the string family to Item element-wise kernels

* Unwrap Item wires into bare legacy connectors at resolution via UnwrapItemNode adapters

* Shadow owned node parameters in bodies instead of mut in signatures

* Migrate the math family and string measure nodes to Item element-wise kernels

* Convert the comparison and clamp nodes to Item kernels, dropping unreachable &str rows

* Flat-map expander kernels returning List under the mapped variant's frame

* Migrate the expander nodes to Item kernels flat-mapping under the frame

* Remove the unused peel_list helper

* Rank the raster adjustment and blending kernels, recontextualizing shader nodes onto an Item stand-in

Migrate the 16 adjustment nodes, Mix, Color Overlay, and Gradient Map from whole-List kernels to rank-0 Item kernels, letting the macro derive the List-mapped (zip) variants. Move the Adjust and Blend per-element seams off List onto the element types (add the Raster<CPU> impls, drop the now-dead List impls).

Shader nodes keep their bodies verbatim: PerPixelAdjust re-emits the identical kernel against a transparent no_std Item stand-in, so every Item<T> connector and .element() call resolves to a zero-cost identity on the GPU while the uniform buffer stays bare repr(C). The macro peels Item off ranked uniform params, wraps the fetched texel and uniforms at the entry point, and unwraps the result. This drops the shader_node/Item incompatibility guard. Register rank adapters for the adjustment enums.

* Update the rank polymorphism roadmap for the landed shader-node and adjustments chunk

* Rename the GPU Item stand-in to ShaderItem, aliased as Item at its shader-node import sites

* Flip the vector shape generators to emit rank-0 Item<Vector>

The shape generators (Rectangle, Circle, Ellipse, Arc, Spiral, Polygon, Star, Arrow, Line, Grid, QR Code) each produced exactly one shape wrapped in a singleton List<Vector>. Emit Item<Vector> directly so they connect to the rank-0 content connector of the migrated Transform node. Downstream List consumers receive the value through the existing Item to List promotion.

Relax the element-wise validation so a `()` (generator) primary may return Item<T> without being element-wise. Adapt the Repeat on Points test, which still takes a List content connector, by raising the generator's Item output through a singleton wrapper node.

* Parse ranked Item<T> parameter defaults against the bare element type

A ranked `Item<T>` parameter's default value is a bare, unranked `T` (promoted to the wire at resolution), but the preprocessor was handed the wrapped `Item<T>` type and could not parse the literal, flooding the console with warnings and dropping the defaults. Key the field's default_type metadata off the peeled element type for concrete ranked parameters, leaving generic `Item<T>` primaries and skip_impl nodes untouched.

* Parse an element-wise primary's scalar default against the bare element type

An element-wise node's primary reports its default_type as the List wire form so an unconnected primary defaults to an empty list. But when the primary carries a scalar `#[default]` (such as Root's radicand), that literal must parse as a bare element, not a List. Key the primary's default_type off the bare element type when it has a Default value source, keeping the List form otherwise.

* Add the DashPattern value type for stroke dash sequences

Introduce a rank-0 DashPattern value type (a Vec<f64> of alternating dash and gap lengths) so a stroke's dash pattern is a single frameable value rather than a rank-1 List<f64>. Register it as an auto-generated TaggedValue variant, parse its default from a comma or space separated string, and register its rank adapters. Not yet wired into the Stroke node.

* Rank the Fill and Stroke nodes element-wise and give Stroke a DashPattern connector

Migrate Fill and Stroke to element-wise Item<V> primaries (over Vector and Graphic element types) via a new element-level VectorItemMut trait, so styling one shape yields one shape and rank is preserved instead of promoting the input to a singleton List and emitting a List. The macro derives the List-mapped variant for genuine collections.

Wire the Stroke dash sequence to the new rank-0 DashPattern value type, collapsing the old content x paint x dash cartesian and dropping the IntoF64Vec trait. Update the stroke properties dash widget, the drawing tool, and graph-operation plumbing to read and write DashPattern, and migrate legacy F64Array, F64, and String dash inputs on document open.

Assign Colors stays a whole-collection node: each element's gradient position depends on its index among all siblings, which the element frame does not expose, so it keeps its List primary and the VectorListIterMut trait.

* Register rank adapters for the ranked Stroke enum parameters

The element-wise Stroke node ranks its align, cap, and paint order parameters as Item<StrokeAlign>, Item<StrokeCap>, and Item<PaintOrder>, but those enums lacked promotion adapters, so a bare default enum value could not be promoted to its Item wire and no Stroke variant resolved ("No construct found for node"). Register their rank adapters alongside StrokeJoin.

* Display Item wires in the Data panel without a List's ID column

Add a TableItemLayout impl for Item<T> and recognize Item wire types when introspecting graph data. An Item holds a single element, so it renders as a one-row table of the element plus its attributes with no leading index column, and it labels as its element type T rather than a List's T[]. Add ItemAttributeValues::get_any for the attribute widget dispatch.

* Register MonitorNode for Item wire types so the Data panel introspects them directly

Graph introspection wraps the inspected output in a generic MonitorNode typed to the wire. Without Item<T> monitor registrations, an Item<Vector> output could only be monitored after an Item to List promotion, so the Data panel captured and displayed a List<Vector> despite the connector being Item<Vector>. Register monitors for the Item types the element-wise nodes emit, and add the matching Data panel downcast entries.

* Color and double Item/List wires and cleave layer-stack connectors in the node graph

* Route wire color and rank through hidden nodes and refresh them on type changes

* Rework the DashPattern connector conversions with element-wise promotion and an explicit reducer node

* Rank the remaining value, context, aggregation, and transform nodes onto Item<T> wires

* Back DashPattern with a List<f64> so the Data panel can introspect its lengths

* Carry a single Item<T> through varargs so the Read context nodes emit Item<T> not List<T>

* Relax rank validation for aggregation shapes, add element adapters, and match variants by fewest promotions

* Rank the remaining bare and unnecessarily-List connectors across the node catalog

* Add Graphic::None and the FillChoice paint value, making colors and gradients plain values

* Rename GradientStops to Gradient and the legacy Gradient/Fill structs to LegacyGradient/LegacyFill

* Restore generator frame-from-params ranking to the roadmap as a planned stage

* Rename the ranked-field adapter identifier from PromoteNode to FieldAdapterNode to reflect its full contract

* Unload only the wires whose displayed style changed when types update

* Peel wire rank in the editor's semantic type checks so rank-0 layers are recognized

* Restore the whole-List Transform variant so rank-1 content wires resolve again

* Register the Item wire forms for the Memoize and Context Modification infrastructure nodes

* Give every ranked connector a field adapter and add numeric cast variants for legacy wires

* Key a ranked param's type default off its Item wire form when no literal default exists

* Inherit the layer's content value when splicing a node into an empty chain

* Migrate stale List-form TypeDefault inputs to the definition's current default

* Generate the mapped wire variant only when the element-wise node has a frame source

* Let a bare wire feed a List connector via a wrap-raise adapter, costed as two rank steps

* Add a zip companion to the whole-List Transform so ranked List parameters pair per slot

* Add the Sum, Average, Minimum, Maximum, Any, and All list reducers

* Convert the measure family to element-wise Item kernels per the audit classification

* Prefer the bare element value over the Item type default so ranked params keep their widgets

* Rename GradientStopsUI to GradientUI

* Split Fill's optional transform into a _has_transform bool and a ranked _transform matrix

* Rename the migration-only OptionalDAffine2 TaggedValue to LegacyOptionalDAffine2

* Flow byte buffers as Item<Resource> instead of List<u8> across the byte nodes

* Macro-generate the list-content wire variant, retiring the hand-written Transform-zip, Area, and Centroid companions

* Let ()-primary generators take ranked params and frame over them via the mapped variant, ranking Circle's radius

* Rank the vector shape generators' params to Item, adding a rank-aware input grab to the introspection harness

* Rank the value, color, and text generator params to Item

* Rank the raster, web-request, and context-reader generator params to Item

* Fix the repeat and brush test wirings left behind by the param-ranking sweeps

* Delete the vestigial Some, Unwrap Option, and Size Of debug nodes

* Delete the Attach Attribute node, folding its role into Write Attribute

* Add the Filter and Sort list companion nodes

* Guard the removed-definition migration swap target with a test

* Add the Box Corners value type in place of the rectangle corner radius list

* Split Text to Vector's per-glyph mode into a Text to Vector Glyphs node

* Rank the Combine Channels node's channel connectors to Item

* Make Map Points an element-wise node

* Delete the deprecated Upload Texture node

* Update the implementation roadmap to reflect the landed stages

* Let monitor introspection read rank-0 wires, locking in the layer coercion promotion path

* Prefer the rank-0 default when disconnecting a rank-capable input

* Make Path Modify an element-wise node

* Wrap node paths in a NodeIdPath newtype so they flow as a single Item

* Give Item<Raster<CPU>> a default so an unconnected Brush background resolves

* Stop the Brush node from setting layer attributes its paint operation doesn't produce

* Present-gate Flatten Path's adopted layer path like its fill and stroke

* Gate carried layer attributes on static column presence, not runtime values

* Give the remaining graphic Item<T> types a default so unconnected primaries resolve

* Dispatch a ranked param's Properties widget from its rank-0 element type

* Make Extract Transform an element-wise node, restoring the Origins to Polyline body

* Rename Flatten Path to Combine Paths

* Stamp Legacy Layer Extend's adopted layer path as a readable NodeIdPath

* Drop the dead List<u8> and List<NodeId> wire rows

* Rank Flatten Graphic's Fully Flatten toggle to Item

* Update the implementation roadmap with the endgame scope

* Make Combine Paths a reducer that collapses the whole frame into one path

* Stop type-converter nodes from carrying the source's unrelated attributes

* Format the Origins to Polyline regression test

* Wrap the Brush node's trace in a BrushTrace newtype so it flows as one value

* Make Switch a framed element-wise select, bundling whole collections

* Widen and align element-type coverage across the list and graphic nodes

* Register the compiler's cache chain pair for every ranked enum and newtype wire

* Fix wire colors for Passthrough outputs, bundled lists, and bools, and widen list wires

* Represent List wire types structurally with Type::List, replacing name-parsed rank promotion

* Treat scope and data fields as environment, rank scope wires as Item, and feed the render boundary through a context vararg

* Delete the vestigial Clone debug node

* Reinstate Upload Texture as an element-wise node and fix the GPU variants' scope executor and rank adapters

* Rename Combine Paths back to Flatten Path, deferring that rename to its own PR

* Deduplicate the promotion adapter registrations into the field adapter macro

* Rank Write Attribute's value connector to Item<AttributeValueDyn>, retiring the UnwrapItem bridge

* Vertical wire styling

* Store the editor layer path attribute as a bare NodeIdPath, not an Item<NodeIdPath>

* Rank Context Modification's features connector to Item<ContextFeatures>, dropping the dead memoize row

* Rank Path Modify's modification parameter to Item<Box<VectorModification>>

* Rename the field adapter node family to input adapter

* Drop the dead bare scalar rows from Context Modification's implementations list

* Move the dynamic executor's test module into its own file

* Drop the registry's unreachable bare rows for Memoize, the cache chain, and ConvertNode

* Materialize stored TaggedValues as ranked Item wires at the source

* Remove the bare-wire promotion and adapter machinery made dead by ranked value materialization

* Plant the input adapter for List-only inputs, composing position conversion from standard rows

* Consolidate Into/Convert conversions into the input adapter umbrella and rename the rank adapter identifiers

* Fix grouped layers gaining a phantom None stack element from the FillChoice default hijacking every List<Graphic> disconnect

* Enforce ranked node inputs in the macro, rejecting bare wire declarations

* Remove the unit Context => () machinery rows, leaving () purely as the no-primary sentinel

* Add a --signatures rank-audit mode to node-docs for the ranked-wire migration

* Remove the node-docs --signatures rank-audit mode now that ranked wires are enforced

* Migrate legacy no-color values on the Black & White, Color Overlay, and Empty Image color inputs

* Rewrite the element-wise accessor wire type at the primary input, not raw index 0

* Register the cache chain for Resource wires, replacing the lone hand-written Monitor row

* Gate the remaining Raster<GPU> registry rows behind the gpu feature

* Let List<DVec2> wires erase to ListDyn for the attribute reader and element counter

* Rename Extract Element to Item at Index, Count Elements to List Length, and Omit Element to Remove at Index

* Store paint picks as plain color/gradient values, removing the FillChoice value type

* Code review restructuring

* Sort by the consumed sort_key attribute or natural element order, adding the Sort Key node

* Remove the new list-combinator and reducer nodes to defer them to a follow-up PR

* Parse Fill and Stroke color defaults through the paint wire's Graphic element

* Emit ranked implementation-row default types structurally so their element TypeIds survive to default-literal parsing

* Exempt the deliberate no-paint choice from the stale List-form TypeDefault migration

* Migrate the legacy 4-input Fill directly to the split has-transform shape

* Upgrade the demo artwork

* Fix the valid AI review findings: Item eq/hash contract, table-era no-paint migration, quantize List rows, and other smaller issues

* Remove the rank polymorphism working documents

* Hash Item attribute values directly instead of debug-formatting them, speeding up cached evaluation

* Replace the data panel's dead bare-wire downcast arms with full coverage of the ranked monitor row types

* Derive PartialEq for Item now that attributes participate in equality

* Extend the data panel's attribute dispatchers with the newly supported scalar and choice enum types

* Add List monitor rows for the framed numeric conversion outputs so inspecting them resolves, with matching data panel arms
This commit is contained in:
Keavon Chambers
2026-09-08 16:03:01 +00:00
committed by Timon
parent 296185b7fc
commit a708a54492
3257 changed files with 766342 additions and 1829 deletions
@@ -0,0 +1,124 @@
use crate::dispatcher::Dispatcher;
use crate::messages::prelude::*;
use graph_craft::application_io::PlatformApplicationIo;
use graph_craft::application_io::resource::ResourceStorage;
pub use graphene_std::uuid::*;
use std::sync::{Arc, OnceLock};
pub struct Editor {
pub dispatcher: Dispatcher,
}
impl Editor {
pub fn new(
environment: Environment,
uuid_random_seed: u64,
resource_storage: Arc<dyn ResourceStorage>,
working_copy_root: Option<std::path::PathBuf>,
mut application_io: PlatformApplicationIo,
wake: Wake,
) -> Self {
ENVIRONMENT.set(environment).expect("Editor shoud only be initialized once");
graphene_std::uuid::set_uuid_seed(uuid_random_seed);
let mut dispatcher = Dispatcher::new(resource_storage, working_copy_root);
dispatcher.message_handlers.future_message_handler.set_wake(wake);
application_io.inject_resource_proxy(dispatcher.message_handlers.resource_storage_message_handler.resources());
crate::node_graph_executor::replace_application_io(application_io);
Self { dispatcher }
}
#[cfg(test)]
pub(crate) fn new_local_executor() -> (Self, crate::node_graph_executor::NodeRuntime) {
let _ = ENVIRONMENT.set(*Editor::environment());
graphene_std::uuid::set_uuid_seed(0);
let (mut runtime, executor) = crate::node_graph_executor::NodeGraphExecutor::new_with_local_runtime();
let editor = Self {
dispatcher: Dispatcher::with_executor(executor),
};
let mut application_io = PlatformApplicationIo::default();
application_io.inject_resource_proxy(editor.dispatcher.message_handlers.resource_storage_message_handler.resources());
runtime.replace_application_io(application_io);
(editor, runtime)
}
pub fn handle_message<T: Into<Message>>(&mut self, message: T) -> Vec<FrontendMessage> {
self.dispatcher.handle_message(message, true);
std::mem::take(&mut self.dispatcher.responses)
}
pub fn poll_node_graph_evaluation(&mut self, responses: &mut VecDeque<Message>) -> Result<(), String> {
self.dispatcher.poll_node_graph_evaluation(responses)
}
}
static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
impl Editor {
#[cfg(not(test))]
pub fn environment() -> &'static Environment {
ENVIRONMENT.get().expect("Editor environment accessed before initialization")
}
#[cfg(test)]
pub fn environment() -> &'static Environment {
&Environment {
platform: Platform::Desktop,
host: Host::Linux,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Environment {
pub platform: Platform,
pub host: Host,
}
#[derive(Clone, Copy, Debug)]
pub enum Platform {
Desktop,
Web,
}
#[derive(Clone, Copy, Debug)]
pub enum Host {
Windows,
Mac,
Linux,
}
impl Environment {
pub fn is_desktop(&self) -> bool {
matches!(self.platform, Platform::Desktop)
}
pub fn is_web(&self) -> bool {
matches!(self.platform, Platform::Web)
}
pub fn is_windows(&self) -> bool {
matches!(self.host, Host::Windows)
}
pub fn is_mac(&self) -> bool {
matches!(self.host, Host::Mac)
}
pub fn is_linux(&self) -> bool {
matches!(self.host, Host::Linux)
}
}
pub const GRAPHITE_RELEASE_SERIES: &str = env!("GRAPHITE_RELEASE_SERIES");
pub const GRAPHITE_GIT_COMMIT_BRANCH: Option<&str> = option_env!("GRAPHITE_GIT_COMMIT_BRANCH");
pub const GRAPHITE_GIT_COMMIT_HASH: &str = env!("GRAPHITE_GIT_COMMIT_HASH");
pub const GRAPHITE_GIT_COMMIT_DATE: &str = env!("GRAPHITE_GIT_COMMIT_DATE");
pub fn commit_info_localized(localized_commit_date: &str) -> String {
let mut info = String::new();
info.push_str(&format!("Release Series: {GRAPHITE_RELEASE_SERIES}\n"));
if let Some(branch) = GRAPHITE_GIT_COMMIT_BRANCH {
info.push_str(&format!("Branch: {branch}\n"));
}
info.push_str(&format!("Commit: {}\n", GRAPHITE_GIT_COMMIT_HASH.get(..8).unwrap_or(GRAPHITE_GIT_COMMIT_HASH)));
info.push_str(localized_commit_date);
info
}
+195
View File
@@ -0,0 +1,195 @@
// GRAPH
pub const GRID_SIZE: u32 = 24;
pub const HALF_GRID_SIZE: u32 = GRID_SIZE / 2;
pub const EXPORTS_TO_TOP_EDGE_PIXEL_GAP: u32 = 72;
pub const EXPORTS_TO_RIGHT_EDGE_PIXEL_GAP: u32 = 120;
pub const IMPORTS_TO_TOP_EDGE_PIXEL_GAP: u32 = 72;
pub const IMPORTS_TO_LEFT_EDGE_PIXEL_GAP: u32 = 120;
/// Vertical grid distance between adjacent stack siblings, or between a parent layer and its first stack child.
pub const STACK_VERTICAL_GAP: i32 = 3;
/// Horizontal grid indentation of a child layer relative to its parent layer.
pub const LAYER_INDENT_OFFSET: i32 = 8;
/// Horizontal grid width of a non-layer node in a chain.
pub const NODE_CHAIN_WIDTH: i32 = 7;
// VIEWPORT
pub const VIEWPORT_ZOOM_WHEEL_RATE: f64 = (1. / 600.) * 3.;
pub const VIEWPORT_ZOOM_MOUSE_RATE: f64 = 1. / 400.;
pub const VIEWPORT_ZOOM_SCALE_MIN: f64 = 0.000_000_1;
pub const VIEWPORT_ZOOM_SCALE_MAX: f64 = 10_000.;
pub const VIEWPORT_ZOOM_MIN_FRACTION_COVER: f64 = 0.01;
pub const VIEWPORT_ZOOM_LEVELS: [f64; 74] = [
0.0001, 0.000125, 0.00016, 0.0002, 0.00025, 0.00032, 0.0004, 0.0005, 0.00064, 0.0008, 0.001, 0.0016, 0.002, 0.0025, 0.0032, 0.004, 0.005, 0.0064, 0.008, 0.01, 0.01125, 0.015, 0.02, 0.025, 0.03,
0.04, 0.05, 0.06, 0.08, 0.1, 0.125, 0.15, 0.2, 0.25, 0.33333333, 0.4, 0.5, 0.66666666, 0.8, 1., 1.25, 1.6, 2., 2.5, 3.2, 4., 5., 6.4, 8., 10., 12.5, 16., 20., 25., 32., 40., 50., 64., 80., 100.,
128., 160., 200., 256., 320., 400., 512., 640., 800., 1024., 1280., 1600., 2048., 2560.,
];
/// Higher values create a steeper curve (a faster zoom rate change)
pub const VIEWPORT_ZOOM_WHEEL_RATE_CHANGE: f64 = 3.;
/// Helps push values that end in approximately half, plus or minus some floating point imprecision, towards the same side of the round() function.
pub const VIEWPORT_GRID_ROUNDING_BIAS: f64 = 0.002;
pub const VIEWPORT_SCROLL_RATE: f64 = 0.6;
pub const VIEWPORT_ROTATE_SNAP_INTERVAL: f64 = 15.;
pub const VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR: f64 = 0.95;
pub const DRAG_BEYOND_VIEWPORT_MAX_OVEREXTENSION_PIXELS: f64 = 50.;
pub const DRAG_BEYOND_VIEWPORT_SPEED_FACTOR: f64 = 20.;
// SNAPPING POINT
pub const SNAP_POINT_TOLERANCE: f64 = 5.;
/// These are layers whose bounding boxes are used for alignment.
pub const MAX_ALIGNMENT_CANDIDATES: usize = 100;
/// These are layers that are used for the layer snapper.
pub const MAX_SNAP_CANDIDATES: usize = 10;
/// These are points (anchors and bounding box corners etc.) in the layer snapper.
pub const MAX_LAYER_SNAP_POINTS: usize = 100;
pub const DRAG_THRESHOLD: f64 = 1.;
// TRANSFORMING LAYER
pub const ROTATE_INCREMENT: f64 = 15.;
pub const SCALE_INCREMENT: f64 = 0.1;
pub const SLOWING_DIVISOR: f64 = 10.;
pub const NUDGE_AMOUNT: f64 = 1.;
pub const BIG_NUDGE_AMOUNT: f64 = 10.;
// TOOLS
pub const DEFAULT_STROKE_WIDTH: f64 = 2.;
// SELECT TOOL
pub const SELECTION_TOLERANCE: f64 = 5.;
pub const DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD: f64 = 15.;
pub const SELECTION_DRAG_ANGLE: f64 = 90.;
pub const LAYER_ORIGIN_CROSS_DIAMETER: f64 = 10.;
pub const LAYER_ORIGIN_CROSS_THICKNESS: f64 = 1.;
// PIVOT
pub const PIVOT_CROSSHAIR_THICKNESS: f64 = 1.;
pub const PIVOT_CROSSHAIR_LENGTH: f64 = 9.;
pub const PIVOT_DIAMETER: f64 = 5.;
pub const DOWEL_PIN_RADIUS: f64 = 4.;
// COMPASS ROSE
pub const COMPASS_ROSE_RING_INNER_DIAMETER: f64 = 13.;
pub const COMPASS_ROSE_MAIN_RING_DIAMETER: f64 = 15.;
pub const COMPASS_ROSE_HOVER_RING_DIAMETER: f64 = 23.;
pub const COMPASS_ROSE_ARROW_SIZE: f64 = 5.;
// Angle to either side of the compass arrows where they are targetted by the cursor (in degrees, must be less than 45°)
pub const COMPASS_ROSE_ARROW_CLICK_TARGET_ANGLE: f64 = 20.;
// TRANSFORM OVERLAY
pub const ANGLE_MEASURE_RADIUS_FACTOR: f64 = 0.04;
pub const ARC_MEASURE_RADIUS_FACTOR_RANGE: (f64, f64) = (0.05, 0.15);
// TRANSFORM CAGE
pub const RESIZE_HANDLE_SIZE: f64 = 6.;
pub const BOUNDS_SELECT_THRESHOLD: f64 = 10.;
pub const BOUNDS_ROTATE_THRESHOLD: f64 = 20.;
pub const MIN_LENGTH_FOR_MIDPOINT_VISIBILITY: f64 = 20.;
pub const MIN_LENGTH_FOR_CORNERS_VISIBILITY: f64 = 12.;
/// The width or height that the transform cage needs to be (at least) before the corner resize handle click targets take up their full surroundings. Otherwise, when less than this value, the interior edge resize handle takes precedence so the corner handles don't eat into the edge area, making it harder to resize the cage from its edges.
pub const MIN_LENGTH_FOR_EDGE_RESIZE_PRIORITY_OVER_CORNERS: f64 = 10.;
/// When the width or height of the transform cage is less than this value, only the exterior of the bounding box will act as a click target for resizing.
pub const MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR: f64 = 40.;
/// When dragging the edge of a cage with Alt, it centers around the pivot.
/// However if the pivot is on or near the same edge you are dragging, we should avoid scaling by a massive factor caused by the small denominator.
///
/// The motion of the user's cursor by an `x` pixel offset results in `x * scale_factor` pixels of offset on the other side.
pub const MAXIMUM_ALT_SCALE_FACTOR: f64 = 25.;
/// The width or height that the transform cage needs before it is considered to have no width or height.
pub const MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT: f64 = 1e-4;
// SKEW TRIANGLES
pub const SKEW_TRIANGLE_SIZE: f64 = 7.;
pub const SKEW_TRIANGLE_OFFSET: f64 = 4.;
pub const MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY: f64 = 48.;
// PATH TOOL
pub const MANIPULATOR_GROUP_MARKER_SIZE: f64 = 6.;
pub const SELECTION_THRESHOLD: f64 = 10.;
pub const DRILL_THROUGH_THRESHOLD: f64 = 10.;
pub const HIDE_HANDLE_DISTANCE: f64 = 3.;
pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.;
pub const SEGMENT_INSERTION_DISTANCE: f64 = 5.;
pub const SEGMENT_OVERLAY_SIZE: f64 = 10.;
pub const SEGMENT_SELECTED_THICKNESS: f64 = 3.;
pub const HANDLE_LENGTH_FACTOR: f64 = 0.5;
// GRADIENT TOOL
pub const GRADIENT_MIDPOINT_DIAMOND_RADIUS: f64 = 4.;
pub const GRADIENT_MIDPOINT_MIN: f64 = 0.01;
pub const GRADIENT_MIDPOINT_MAX: f64 = 0.99;
pub const GRADIENT_STOP_MIN_VIEWPORT_GAP: f64 = 10.;
// PEN TOOL
pub const CREATE_CURVE_THRESHOLD: f64 = 5.;
// SPLINE TOOL
pub const PATH_JOIN_THRESHOLD: f64 = 5.;
// LINE TOOL
pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.;
// BRUSH TOOL
pub const BRUSH_SIZE_CHANGE_KEYBOARD: f64 = 5.;
pub const DEFAULT_BRUSH_SIZE: f64 = 20.;
// EYEDROPPER TOOL
pub const EYEDROPPER_PREVIEW_AREA_RESOLUTION: u32 = 11;
// GIZMOS
pub const POINT_RADIUS_HANDLE_SNAP_THRESHOLD: f64 = 8.;
pub const POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD: f64 = 7.9;
pub const NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION: f64 = 1.2;
pub const NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH: f64 = 10.;
pub const ARC_SNAP_THRESHOLD: f64 = 5.;
pub const ARC_SWEEP_GIZMO_RADIUS: f64 = 14.;
pub const ARC_SWEEP_GIZMO_TEXT_HEIGHT: f64 = 12.;
pub const GIZMO_HIDE_THRESHOLD: f64 = 20.;
pub const GRID_ROW_COLUMN_GIZMO_OFFSET: f64 = 15.;
// SCROLLBARS
pub const SCROLLBAR_SPACING: f64 = 0.1;
pub const ASYMPTOTIC_EFFECT: f64 = 0.5;
pub const SCALE_EFFECT: f64 = 0.5;
// COLORS
pub const COLOR_OVERLAY_BLUE: &str = "#00a8ff";
pub const COLOR_OVERLAY_BLUE_50: &str = "#00a8ff80";
pub const COLOR_OVERLAY_BLUE_25: &str = "#00a8ff40";
pub const COLOR_OVERLAY_BLUE_05: &str = "#00a8ff0d";
pub const COLOR_OVERLAY_YELLOW: &str = "#ffc848";
pub const COLOR_OVERLAY_YELLOW_DULL: &str = "#d7ba8b";
pub const COLOR_OVERLAY_GREEN: &str = "#63ce63";
pub const COLOR_OVERLAY_GREEN_25: &str = "#63ce6340";
pub const COLOR_OVERLAY_RED: &str = "#ef5454";
pub const COLOR_OVERLAY_RED_25: &str = "#ef545440";
pub const COLOR_OVERLAY_GRAY: &str = "#cccccc";
pub const COLOR_OVERLAY_GRAY_25: &str = "#cccccc40";
pub const COLOR_OVERLAY_WHITE: &str = "#ffffff";
pub const COLOR_OVERLAY_WHITE_05: &str = "#ffffff0d";
pub const COLOR_OVERLAY_BLACK: &str = "#000000";
pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf";
// DOCUMENT
pub const FILE_EXTENSION: &str = "graphite";
/// New document container format. Save writes a `.gdd` with the legacy `.graphite` embedded as a fallback
/// during the dual-write soak; `.graphite` stays a supported open/import input.
pub const GDD_FILE_EXTENSION: &str = "gdd";
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences
pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1;
// INPUT
pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500;
// UI
pub const UI_SCALE_DEFAULT: f64 = 1.;
pub const UI_SCALE_MIN: f64 = 0.5;
pub const UI_SCALE_MAX: f64 = 3.;
// ACTIONS
pub const BLEND_COUNT_PER_LAYER: usize = 10;
+508
View File
@@ -0,0 +1,508 @@
use crate::messages::debug::utility_types::MessageLoggingVerbosity;
use crate::messages::defer::DeferMessageContext;
use crate::messages::dialog::DialogMessageContext;
use crate::messages::layout::layout_message_handler::LayoutMessageContext;
use crate::messages::portfolio::utility_types::PanelType;
use crate::messages::preferences::preferences_message_handler::PreferencesMessageContext;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed;
use graph_craft::application_io::resource::ResourceStorage;
use std::sync::Arc;
#[derive(Debug, Default)]
pub struct Dispatcher {
message_queues: Vec<VecDeque<Message>>,
pub responses: Vec<FrontendMessage>,
pub frontend_update_messages: Vec<Message>,
pub message_handlers: DispatcherMessageHandlers,
}
#[derive(Debug, Default)]
pub struct DispatcherMessageHandlers {
animation_message_handler: AnimationMessageHandler,
app_window_message_handler: AppWindowMessageHandler,
pub(crate) future_message_handler: FutureMessageHandler,
broadcast_message_handler: BroadcastMessageHandler,
clipboard_message_handler: ClipboardMessageHandler,
color_picker_message_handler: ColorPickerMessageHandler,
debug_message_handler: DebugMessageHandler,
defer_message_handler: DeferMessageHandler,
dialog_message_handler: DialogMessageHandler,
input_preprocessor_message_handler: InputPreprocessorMessageHandler,
key_mapping_message_handler: KeyMappingMessageHandler,
layout_message_handler: LayoutMessageHandler,
menu_bar_message_handler: MenuBarMessageHandler,
network_message_handler: NetworkMessageHandler,
pub(crate) portfolio_message_handler: PortfolioMessageHandler,
preferences_message_handler: PreferencesMessageHandler,
pub(crate) resource_storage_message_handler: ResourceStorageMessageHandler,
tool_message_handler: ToolMessageHandler,
viewport_message_handler: ViewportMessageHandler,
}
impl DispatcherMessageHandlers {
pub fn with_resource_storage(resource_storage: Arc<dyn ResourceStorage>) -> Self {
Self {
resource_storage_message_handler: ResourceStorageMessageHandler::new(resource_storage),
..Self::default()
}
}
pub fn with_executor(executor: crate::node_graph_executor::NodeGraphExecutor) -> Self {
Self {
portfolio_message_handler: PortfolioMessageHandler::with_executor(executor),
..Self::default()
}
}
}
/// For optimization, these are messages guaranteed to be redundant when repeated.
/// The last occurrence of the message in the message queue is sufficient to ensure correct behavior.
/// In addition, these messages do not change any state in the backend (aside from caches).
const SIDE_EFFECT_FREE_MESSAGES: &[MessageDiscriminant] = &[
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::DocumentStructureChanged)),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::NodeGraph(
NodeGraphMessageDiscriminant::RunDocumentGraph,
))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::SubmitActiveGraphRender),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::SubmitEyedropperPreviewRender),
MessageDiscriminant::Frontend(FrontendMessageDiscriminant::UpdateUIScale),
];
/// Since we don't need to update the frontend multiple times per frame,
/// we have a set of messages which we will buffer until the next frame is requested.
const FRONTEND_UPDATE_MESSAGES: &[MessageDiscriminant] = &[
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::PropertiesPanel(
PropertiesPanelMessageDiscriminant::Refresh,
))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::UpdateDocumentWidgets),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::Overlays(OverlaysMessageDiscriminant::Draw))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::RenderRulers)),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::RenderScrollbars)),
MessageDiscriminant::Frontend(FrontendMessageDiscriminant::UpdateDocumentLayerStructure),
];
const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[
MessageDiscriminant::Broadcast(BroadcastMessageDiscriminant::TriggerEvent(EventMessageDiscriminant::AnimationFrame)),
MessageDiscriminant::Animation(AnimationMessageDiscriminant::IncrementFrameCounter),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::AutoSaveAllDocuments),
];
// TODO: Find a way to combine these with the list above. We use strings for now since these are the standard variant names used by multiple messages. But having these also type-checked would be best.
const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"];
impl Dispatcher {
pub fn new(resource_storage: Arc<dyn ResourceStorage>, working_copy_root: Option<std::path::PathBuf>) -> Self {
let mut s = Self::default();
s.message_handlers.resource_storage_message_handler = ResourceStorageMessageHandler::new(resource_storage);
s.message_handlers.portfolio_message_handler.set_working_copy_root(working_copy_root);
s
}
pub fn with_executor(executor: crate::node_graph_executor::NodeGraphExecutor) -> Self {
let mut s = Self::default();
s.message_handlers.portfolio_message_handler = PortfolioMessageHandler::with_executor(executor);
s
}
// If the deepest queues (higher index in queues list) are now empty (after being popped from) then remove them
fn cleanup_queues(&mut self, leave_last: bool) {
while self.message_queues.last().filter(|queue| queue.is_empty()).is_some() {
if leave_last && self.message_queues.len() == 1 {
break;
}
self.message_queues.pop();
}
}
/// Add a message to a queue so that it can be executed.
/// If `process_after_all_current` is set, all currently queued messages (including children) will be processed first.
/// If not set, it (and its children) will be processed as soon as possible.
pub fn schedule_execution(message_queues: &mut Vec<VecDeque<Message>>, process_after_all_current: bool, messages: impl IntoIterator<Item = Message>) {
match message_queues.first_mut() {
// If there are currently messages being processed and we are processing after them, add to the end of the first queue
Some(queue) if process_after_all_current => queue.extend(messages),
// In all other cases, make a new inner queue and add our message there
_ => message_queues.push(VecDeque::from_iter(messages)),
}
}
pub fn handle_message<T: Into<Message>>(&mut self, message: T, process_after_all_current: bool) {
let message = message.into();
// Drain async results into the queue before processing the new message.
let mut async_results = VecDeque::new();
self.message_handlers.future_message_handler.drain_results(&mut async_results);
if !async_results.is_empty() {
Self::schedule_execution(&mut self.message_queues, true, async_results);
}
// If we are not maintaining the buffer, simply add to the current queue
Self::schedule_execution(&mut self.message_queues, process_after_all_current, [message]);
while let Some(message) = self.message_queues.last_mut().and_then(VecDeque::pop_front) {
// Skip processing of this message if it will be processed later (at the end of the shallowest level queue)
if FRONTEND_UPDATE_MESSAGES.contains(&message.to_discriminant()) {
let already_in_queue = self.message_queues.first().is_some_and(|queue| queue.contains(&message));
if already_in_queue {
self.cleanup_queues(false);
continue;
} else if self.message_queues.len() > 1 {
if !self.frontend_update_messages.contains(&message) {
self.frontend_update_messages.push(message);
}
self.cleanup_queues(false);
continue;
}
}
if SIDE_EFFECT_FREE_MESSAGES.contains(&message.to_discriminant()) {
let already_in_queue = self.message_queues.first().filter(|queue| queue.contains(&message)).is_some();
if already_in_queue {
self.log_deferred_message(&message, &self.message_queues, self.message_handlers.debug_message_handler.message_logging_verbosity);
self.cleanup_queues(false);
continue;
} else if self.message_queues.len() > 1 {
self.log_deferred_message(&message, &self.message_queues, self.message_handlers.debug_message_handler.message_logging_verbosity);
self.cleanup_queues(true);
self.message_queues[0].add(message);
continue;
}
}
// Print the message at a verbosity level of `info`
self.log_message(&message, &self.message_queues, self.message_handlers.debug_message_handler.message_logging_verbosity);
// Create a new queue for the child messages
let mut queue = VecDeque::new();
// Process the action by forwarding it to the relevant message handler, or saving the FrontendMessage to be sent to the frontend
match message {
Message::Animation(message) => {
if let AnimationMessage::IncrementFrameCounter = &message {
self.message_queues[0].extend(self.frontend_update_messages.drain(..));
}
self.message_handlers.animation_message_handler.process_message(message, &mut queue, ());
}
Message::AppWindow(message) => {
self.message_handlers.app_window_message_handler.process_message(message, &mut queue, ());
}
Message::Future(message) => {
self.message_handlers.future_message_handler.process_message(message, &mut queue, FutureMessageContext {});
}
Message::Broadcast(message) => self.message_handlers.broadcast_message_handler.process_message(message, &mut queue, ()),
Message::Clipboard(message) => {
let context = ClipboardMessageContext {
portfolio: &mut self.message_handlers.portfolio_message_handler,
current_tool: &self.message_handlers.tool_message_handler.tool_state.tool_data.active_tool_type,
resource_storage: &self.message_handlers.resource_storage_message_handler,
};
self.message_handlers.clipboard_message_handler.process_message(message, &mut queue, context);
}
Message::ColorPicker(message) => self.message_handlers.color_picker_message_handler.process_message(message, &mut queue, ()),
Message::Debug(message) => {
self.message_handlers.debug_message_handler.process_message(message, &mut queue, ());
}
Message::Defer(message) => {
let context = DeferMessageContext {
portfolio: &self.message_handlers.portfolio_message_handler,
};
self.message_handlers.defer_message_handler.process_message(message, &mut queue, context);
}
Message::Dialog(message) => {
let context = DialogMessageContext {
portfolio: &self.message_handlers.portfolio_message_handler,
preferences: &self.message_handlers.preferences_message_handler,
};
self.message_handlers.dialog_message_handler.process_message(message, &mut queue, context);
}
Message::Frontend(message) => {
self.responses.push(message);
}
Message::InputPreprocessor(message) => {
self.message_handlers.input_preprocessor_message_handler.process_message(
message,
&mut queue,
InputPreprocessorMessageContext {
viewport: &self.message_handlers.viewport_message_handler,
},
);
}
Message::KeyMapping(message) => {
let input = &self.message_handlers.input_preprocessor_message_handler;
let actions = self.collect_actions();
self.message_handlers
.key_mapping_message_handler
.process_message(message, &mut queue, KeyMappingMessageContext { input, actions });
}
Message::Layout(message) => {
let action_input_mapping = &|action_to_find: &MessageDiscriminant| self.message_handlers.key_mapping_message_handler.action_input_mapping(action_to_find);
let context = LayoutMessageContext { action_input_mapping };
self.message_handlers.layout_message_handler.process_message(message, &mut queue, context);
}
Message::Network(message) => {
self.message_handlers.network_message_handler.process_message(message, &mut queue, NetworkMessageContext {});
}
Message::ResourceStorage(message) => {
self.message_handlers
.resource_storage_message_handler
.process_message(message, &mut queue, ResourceStorageMessageContext {});
}
Message::Portfolio(message) => {
self.message_handlers.portfolio_message_handler.process_message(
message,
&mut queue,
PortfolioMessageContext {
ipp: &self.message_handlers.input_preprocessor_message_handler,
preferences: &self.message_handlers.preferences_message_handler,
current_tool: &self.message_handlers.tool_message_handler.tool_state.tool_data.active_tool_type,
reset_node_definitions_on_open: self.message_handlers.portfolio_message_handler.reset_node_definitions_on_open,
timing_information: self.message_handlers.animation_message_handler.timing_information(),
animation: &self.message_handlers.animation_message_handler,
viewport: &self.message_handlers.viewport_message_handler,
resource_storage: &self.message_handlers.resource_storage_message_handler,
},
);
}
Message::MenuBar(message) => {
let menu_bar_message_handler = &mut self.message_handlers.menu_bar_message_handler;
menu_bar_message_handler.focus_document = self.message_handlers.portfolio_message_handler.workspace_panel_layout.focus_document;
let layout = &self.message_handlers.portfolio_message_handler.workspace_panel_layout;
menu_bar_message_handler.data_panel_open = layout.is_panel_present(PanelType::Data);
menu_bar_message_handler.layers_panel_open = layout.is_panel_present(PanelType::Layers);
menu_bar_message_handler.properties_panel_open = layout.is_panel_present(PanelType::Properties);
menu_bar_message_handler.message_logging_verbosity = self.message_handlers.debug_message_handler.message_logging_verbosity;
menu_bar_message_handler.reset_node_definitions_on_open = self.message_handlers.portfolio_message_handler.reset_node_definitions_on_open;
menu_bar_message_handler.show_storage_preferences = self.message_handlers.preferences_message_handler.show_storage_preferences;
if let Some(document) = self
.message_handlers
.portfolio_message_handler
.active_document_id
.and_then(|document_id| self.message_handlers.portfolio_message_handler.documents.get_mut(&document_id))
{
let selected_nodes = document.network_interface.selected_nodes();
let metadata = &document.network_interface.document_network_metadata().persistent_metadata;
menu_bar_message_handler.has_active_document = true;
menu_bar_message_handler.canvas_tilted = document.document_ptz.tilt() != 0.;
menu_bar_message_handler.canvas_flipped = document.document_ptz.flip;
menu_bar_message_handler.rulers_visible = document.rulers_visible;
menu_bar_message_handler.node_graph_open = document.is_graph_overlay_open();
menu_bar_message_handler.has_selected_nodes = selected_nodes.selected_nodes().next().is_some();
menu_bar_message_handler.has_selected_layers = selected_nodes.selected_visible_layers(&document.network_interface).next().is_some();
menu_bar_message_handler.has_selection_history = (!metadata.selection_undo_history.is_empty(), !metadata.selection_redo_history.is_empty());
menu_bar_message_handler.make_path_editable_is_allowed = make_path_editable_is_allowed(&mut document.network_interface).is_some();
} else {
menu_bar_message_handler.has_active_document = false;
menu_bar_message_handler.canvas_tilted = false;
menu_bar_message_handler.canvas_flipped = false;
menu_bar_message_handler.rulers_visible = false;
menu_bar_message_handler.node_graph_open = false;
menu_bar_message_handler.has_selected_nodes = false;
menu_bar_message_handler.has_selected_layers = false;
menu_bar_message_handler.has_selection_history = (false, false);
menu_bar_message_handler.make_path_editable_is_allowed = false;
}
menu_bar_message_handler.process_message(message, &mut queue, ());
}
Message::Preferences(message) => {
let context = PreferencesMessageContext {
tool_message_handler: &self.message_handlers.tool_message_handler,
};
self.message_handlers.preferences_message_handler.process_message(message, &mut queue, context);
}
Message::Tool(message) => {
let Some(document_id) = self.message_handlers.portfolio_message_handler.active_document_id() else {
warn!("Called ToolMessage without an active document.\nGot {message:?}");
return;
};
let Some(document) = self.message_handlers.portfolio_message_handler.documents.get_mut(&document_id) else {
warn!("Called ToolMessage with an invalid active document.\nGot {message:?}");
return;
};
let context = ToolMessageContext {
document_id,
document,
input: &self.message_handlers.input_preprocessor_message_handler,
fonts: &self.message_handlers.portfolio_message_handler.fonts,
node_graph: &self.message_handlers.portfolio_message_handler.executor,
preferences: &self.message_handlers.preferences_message_handler,
viewport: &self.message_handlers.viewport_message_handler,
};
self.message_handlers.tool_message_handler.process_message(message, &mut queue, context);
}
Message::Viewport(message) => {
self.message_handlers.viewport_message_handler.process_message(message, &mut queue, ());
}
Message::NoOp => {}
Message::Batched { messages } => {
messages.into_iter().for_each(|message| self.handle_message(message, false));
}
}
// If there are child messages, append the queue to the list of queues
if !queue.is_empty() {
self.message_queues.push(queue);
}
self.cleanup_queues(false);
}
}
pub fn collect_actions(&self) -> ActionList {
// TODO: Reduce the number of heap allocations
let mut list = Vec::new();
list.extend(self.message_handlers.app_window_message_handler.actions());
list.extend(self.message_handlers.clipboard_message_handler.actions());
list.extend(self.message_handlers.dialog_message_handler.actions());
list.extend(self.message_handlers.animation_message_handler.actions());
list.extend(self.message_handlers.input_preprocessor_message_handler.actions());
list.extend(self.message_handlers.key_mapping_message_handler.actions());
list.extend(self.message_handlers.debug_message_handler.actions());
if let Some(document) = self.message_handlers.portfolio_message_handler.active_document()
&& !document.graph_view_overlay_open
{
list.extend(self.message_handlers.tool_message_handler.actions_with_preferences(&self.message_handlers.preferences_message_handler));
}
list.extend(self.message_handlers.portfolio_message_handler.actions());
list
}
pub fn poll_node_graph_evaluation(&mut self, responses: &mut VecDeque<Message>) -> Result<(), String> {
self.message_handlers.portfolio_message_handler.poll_node_graph_evaluation(responses)
}
/// Create the tree structure for logging the messages as a tree
fn create_indents(queues: &[VecDeque<Message>]) -> String {
String::from_iter(queues.iter().enumerate().skip(1).map(|(index, queue)| {
if index == queues.len() - 1 {
if queue.is_empty() { "└── " } else { "├── " }
} else if queue.is_empty() {
" "
} else {
"│ "
}
}))
}
/// Logs a message that is about to be executed, either as a tree
/// with a discriminant or the entire payload (depending on settings)
fn log_message(&self, message: &Message, queues: &[VecDeque<Message>], message_logging_verbosity: MessageLoggingVerbosity) {
let discriminant = MessageDiscriminant::from(message);
let is_blocked =
|discriminant| DEBUG_MESSAGE_BLOCK_LIST.contains(&discriminant) || DEBUG_MESSAGE_ENDING_BLOCK_LIST.iter().any(|blocked_name| discriminant.local_name().ends_with(blocked_name));
let is_batch_all_blocked = if let Message::Batched { messages } = message {
messages.iter().all(|message| is_blocked(MessageDiscriminant::from(message)))
} else {
false
};
if !is_blocked(discriminant) && !is_batch_all_blocked {
match message_logging_verbosity {
MessageLoggingVerbosity::Off => {}
MessageLoggingVerbosity::Names => {
info!("{}{:?}", Self::create_indents(queues), message.to_discriminant());
}
MessageLoggingVerbosity::Contents => {
if !(matches!(message, Message::InputPreprocessor(_))) {
info!("Message: {}{:?}", Self::create_indents(queues), message);
}
}
}
}
}
/// Logs into the tree that the message is in the side effect free messages and its execution will be deferred
fn log_deferred_message(&self, message: &Message, queues: &[VecDeque<Message>], message_logging_verbosity: MessageLoggingVerbosity) {
if let MessageLoggingVerbosity::Names = message_logging_verbosity {
info!("{}Deferred \"{:?}\" because it's a SIDE_EFFECT_FREE_MESSAGE", Self::create_indents(queues), message.to_discriminant());
}
}
}
#[cfg(test)]
mod test {
pub use crate::test_utils::test_prelude::*;
#[tokio::test]
/// This test will fail when you make changes to the underlying serialization format for a document.
async fn check_if_demo_art_opens() {
use crate::messages::layout::utility_types::widget_prelude::*;
let print_problem_to_terminal_on_failure = |value: &String| {
println!();
println!("-------------------------------------------------");
println!("Failed test due to receiving a DisplayDialogError while loading a Graphite demo file.");
println!();
println!("NOTE:");
println!("Document upgrading isn't performed in tests like when opening in the actual editor.");
println!("You may need to open and re-save a document in the editor to apply its migrations.");
println!();
println!("DisplayDialogError details:");
println!();
println!("Description:");
println!("{value}");
println!("-------------------------------------------------");
println!();
panic!()
};
let mut editor = EditorTestUtils::create();
// UNCOMMENT THIS FOR RUNNING UNDER MIRI
//
// let files = [
// include_str!("../../demo-artwork/changing-seasons.graphite"),
// include_str!("../../demo-artwork/isometric-fountain.graphite"),
// include_str!("../../demo-artwork/painted-dreams.graphite"),
// include_str!("../../demo-artwork/procedural-string-lights.graphite"),
// include_str!("../../demo-artwork/parametric-dunescape.graphite"),
// include_str!("../../demo-artwork/red-dress.graphite"),
// include_str!("../../demo-artwork/valley-of-spires.graphite"),
// ];
// for (id, document_serialized_content) in files.iter().enumerate() {
// let document_name = format!("document {id}");
for (document_name, _, file_name) in crate::messages::dialog::simple_dialogs::ARTWORK {
let document_serialized_content = std::fs::read_to_string(format!("../demo-artwork/{file_name}")).unwrap();
assert_eq!(
document_serialized_content.lines().count(),
1,
"Demo artwork '{document_name}' has more than 1 line (remember to open and re-save it in Graphite)",
);
let responses = editor.editor.handle_message(PortfolioMessage::OpenFile {
path: file_name.into(),
content: document_serialized_content.bytes().collect(),
});
// Check if the graph renders
if let Err(e) = editor.eval_graph().await {
print_problem_to_terminal_on_failure(&format!("Failed to evaluate the graph for document '{document_name}':\n{e}"));
}
for response in responses {
// Check for the existence of the file format incompatibility warning dialog after opening the test file
if let FrontendMessage::UpdateLayout {
layout_target: LayoutTarget::DialogColumn1,
diff,
} = response
{
if let DiffUpdate::Layout(sub_layout) = &diff[0].new_value {
if let LayoutGroup::Row(WidgetRow { widgets }) = &sub_layout.0[0] {
if let Widget::TextLabel(TextLabel { value, .. }) = &*widgets[0].widget {
print_problem_to_terminal_on_failure(value);
}
}
}
}
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
// Bumped past the default 128 because the deeply-generic message-passing types pull in wgpu/naga
// trait chains that overflow the trait resolver under `--tests`. Set to the same value the compiler suggests.
#![recursion_limit = "256"]
extern crate graphite_proc_macros;
// `macro_use` puts these macros into scope for all descendant code files
#[macro_use]
mod macros;
#[macro_use]
extern crate log;
pub mod application;
pub mod consts;
pub mod dispatcher;
pub mod messages;
pub mod node_graph_executor;
#[cfg(test)]
pub mod test_utils;
pub mod utility_traits;
pub mod utility_types;
+56
View File
@@ -0,0 +1,56 @@
/// Syntax sugar for initializing an `ActionList`
///
/// # Example
///
/// ```ignore
/// actions!(DocumentMessage::Undo, DocumentMessage::Redo);
/// ```
///
/// expands to:
/// ```ignore
/// vec![vec![DocumentMessage::Undo, DocumentMessage::Redo]];
/// ```
///
/// and
/// ```ignore
/// actions!(DocumentMessage;
/// Undo,
/// Redo,
/// );
/// ```
///
/// expands to:
/// ```ignore
/// vec![vec![DocumentMessage::Undo, DocumentMessage::Redo]];
/// ```
///
macro_rules! actions {
($($v:expr_2021),* $(,)?) => {{
vec![$(vec![$v.into()]),*]
}};
($name:ident; $($v:ident),* $(,)?) => {{
vec![vec![$(($name::$v).into()),*]]
}};
}
/// Does the same thing as the `actions!` macro but wraps everything in:
///
/// ```ignore
/// fn actions(&self) -> ActionList {
/// actions!(…)
/// }
/// ```
macro_rules! advertise_actions {
($($v:expr_2021),* $(,)?) => {
fn actions(&self) -> $crate::utility_traits::ActionList {
actions!($($v),*)
}
};
($name:ident; $($v:ident),* $(,)?) => {
fn actions(&self) -> $crate::utility_traits::ActionList {
actions!($name; $($v),*)
}
}
}
@@ -0,0 +1,16 @@
use super::animation_message_handler::AnimationTimeMode;
use crate::messages::prelude::*;
#[impl_message(Message, Animation)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum AnimationMessage {
ToggleLivePreview,
EnableLivePreview,
DisableLivePreview,
RestartAnimation,
SetFrameIndex { frame: f64 },
SetTime { time: f64 },
UpdateTime,
IncrementFrameCounter,
SetAnimationTimeMode { animation_time_mode: AnimationTimeMode },
}
@@ -0,0 +1,133 @@
use std::time::Duration;
use crate::messages::prelude::*;
use super::TimingInformation;
#[derive(PartialEq, Clone, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum AnimationTimeMode {
#[default]
TimeBased,
FrameBased,
}
#[derive(Default, Debug, Clone, PartialEq)]
enum AnimationState {
#[default]
Stopped,
Playing {
start: f64,
},
Paused {
start: f64,
pause_time: f64,
},
}
#[derive(Default, Debug, Clone, PartialEq, ExtractField)]
pub struct AnimationMessageHandler {
/// Used to re-send the UI on the next frame after playback starts
live_preview_recently_zero: bool,
timestamp: f64,
frame_index: f64,
animation_state: AnimationState,
fps: f64,
animation_time_mode: AnimationTimeMode,
}
impl AnimationMessageHandler {
pub(crate) fn timing_information(&self) -> TimingInformation {
let animation_time = self.timestamp - self.animation_start();
let animation_time = match self.animation_time_mode {
AnimationTimeMode::TimeBased => Duration::from_millis(animation_time as u64),
AnimationTimeMode::FrameBased => Duration::from_secs((self.frame_index / self.fps) as u64),
};
TimingInformation { time: self.timestamp, animation_time }
}
pub(crate) fn animation_start(&self) -> f64 {
match self.animation_state {
AnimationState::Stopped => self.timestamp,
AnimationState::Playing { start } => start,
AnimationState::Paused { start, pause_time } => start + self.timestamp - pause_time,
}
}
pub fn is_playing(&self) -> bool {
matches!(self.animation_state, AnimationState::Playing { .. })
}
}
#[message_handler_data]
impl MessageHandler<AnimationMessage, ()> for AnimationMessageHandler {
fn process_message(&mut self, message: AnimationMessage, responses: &mut VecDeque<Message>, _: ()) {
match message {
AnimationMessage::ToggleLivePreview => match self.animation_state {
AnimationState::Stopped => responses.add(AnimationMessage::EnableLivePreview),
AnimationState::Playing { .. } => responses.add(AnimationMessage::DisableLivePreview),
AnimationState::Paused { .. } => responses.add(AnimationMessage::EnableLivePreview),
},
AnimationMessage::EnableLivePreview => {
self.animation_state = AnimationState::Playing { start: self.animation_start() };
// Update the restart and pause/play buttons
responses.add(PortfolioMessage::UpdateDocumentWidgets);
}
AnimationMessage::DisableLivePreview => {
match self.animation_state {
AnimationState::Stopped => (),
AnimationState::Playing { start } => self.animation_state = AnimationState::Paused { start, pause_time: self.timestamp },
AnimationState::Paused { .. } => (),
}
// Update the restart and pause/play buttons
responses.add(PortfolioMessage::UpdateDocumentWidgets);
}
AnimationMessage::SetFrameIndex { frame } => {
self.frame_index = frame;
responses.add(PortfolioMessage::SubmitActiveGraphRender);
// Update the restart and pause/play buttons
responses.add(PortfolioMessage::UpdateDocumentWidgets);
}
AnimationMessage::SetTime { time } => {
self.timestamp = time;
responses.add(AnimationMessage::UpdateTime);
}
AnimationMessage::IncrementFrameCounter => {
if self.is_playing() {
self.frame_index += 1.;
responses.add(AnimationMessage::UpdateTime);
}
}
AnimationMessage::UpdateTime => {
if self.is_playing() {
responses.add(PortfolioMessage::SubmitActiveGraphRender);
if self.live_preview_recently_zero {
// Update the restart and pause/play buttons
responses.add(PortfolioMessage::UpdateDocumentWidgets);
self.live_preview_recently_zero = false;
}
}
}
AnimationMessage::RestartAnimation => {
self.frame_index = 0.;
self.animation_state = match self.animation_state {
AnimationState::Playing { .. } => AnimationState::Playing { start: self.timestamp },
_ => AnimationState::Stopped,
};
self.live_preview_recently_zero = true;
responses.add(PortfolioMessage::SubmitActiveGraphRender);
// Update the restart and pause/play buttons
responses.add(PortfolioMessage::UpdateDocumentWidgets);
}
AnimationMessage::SetAnimationTimeMode { animation_time_mode } => {
self.animation_time_mode = animation_time_mode;
}
}
}
advertise_actions!(AnimationMessageDiscriminant;
ToggleLivePreview,
SetFrameIndex,
RestartAnimation,
);
}
@@ -0,0 +1,9 @@
mod animation_message;
mod animation_message_handler;
#[doc(inline)]
pub use animation_message::{AnimationMessage, AnimationMessageDiscriminant};
#[doc(inline)]
pub use animation_message_handler::AnimationMessageHandler;
pub use graphene_std::application_io::TimingInformation;
@@ -0,0 +1,18 @@
use crate::messages::prelude::*;
#[impl_message(Message, AppWindow)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum AppWindowMessage {
PointerLock,
PointerLockMove { x: f64, y: f64 },
Restart,
Close,
Minimize,
Maximize,
Fullscreen,
Drag,
Focus,
Hide,
HideOthers,
ShowAll,
}
@@ -0,0 +1,92 @@
use crate::application::{Environment, Platform};
use crate::messages::prelude::*;
use crate::{application::Host, messages::app_window::AppWindowMessage};
use graphite_proc_macros::{ExtractField, message_handler_data};
#[derive(Debug, Clone, Default, ExtractField)]
pub struct AppWindowMessageHandler {}
#[message_handler_data]
impl MessageHandler<AppWindowMessage, ()> for AppWindowMessageHandler {
fn process_message(&mut self, message: AppWindowMessage, responses: &mut std::collections::VecDeque<Message>, _: ()) {
match message {
AppWindowMessage::PointerLock => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowPointerLock);
}
AppWindowMessage::PointerLockMove { x, y } => {
responses.add(FrontendMessage::WindowPointerLockMove { position: (x, y) });
}
AppWindowMessage::Close => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowClose);
}
AppWindowMessage::Minimize => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowMinimize);
}
AppWindowMessage::Maximize => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowMaximize);
}
AppWindowMessage::Fullscreen => {
responses.add(FrontendMessage::WindowFullscreen);
}
AppWindowMessage::Drag => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowDrag);
}
AppWindowMessage::Focus => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowFocus);
}
AppWindowMessage::Hide => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowHide);
}
AppWindowMessage::HideOthers => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowHideOthers);
}
AppWindowMessage::ShowAll => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowShowAll);
}
AppWindowMessage::Restart => {
responses.add(PortfolioMessage::AutoSaveAllDocuments);
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowRestart);
}
}
}
advertise_actions!(AppWindowMessageDiscriminant;
Close,
Minimize,
Maximize,
Fullscreen,
Drag,
Hide,
HideOthers,
);
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum AppWindowPlatform {
#[default]
Web,
Windows,
Mac,
Linux,
}
impl From<&Environment> for AppWindowPlatform {
fn from(environment: &Environment) -> Self {
match (environment.platform, environment.host) {
(Platform::Web, _) => AppWindowPlatform::Web,
(Platform::Desktop, Host::Linux) => AppWindowPlatform::Linux,
(Platform::Desktop, Host::Mac) => AppWindowPlatform::Mac,
(Platform::Desktop, Host::Windows) => AppWindowPlatform::Windows,
}
}
}
@@ -0,0 +1,7 @@
mod app_window_message;
pub mod app_window_message_handler;
#[doc(inline)]
pub use app_window_message::{AppWindowMessage, AppWindowMessageDiscriminant};
#[doc(inline)]
pub use app_window_message_handler::AppWindowMessageHandler;
@@ -0,0 +1,19 @@
use crate::messages::prelude::*;
#[impl_message(Message, Broadcast)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum BroadcastMessage {
// Sub-messages
#[child]
TriggerEvent(EventMessage),
// Messages
SubscribeEvent {
on: EventMessage,
send: Box<Message>,
},
UnsubscribeEvent {
on: EventMessage,
send: Box<Message>,
},
}
@@ -0,0 +1,25 @@
use crate::messages::prelude::*;
#[derive(Debug, Clone, Default, ExtractField)]
pub struct BroadcastMessageHandler {
event: EventMessageHandler,
listeners: HashMap<EventMessage, Vec<Message>>,
}
#[message_handler_data]
impl MessageHandler<BroadcastMessage, ()> for BroadcastMessageHandler {
fn process_message(&mut self, message: BroadcastMessage, responses: &mut VecDeque<Message>, _: ()) {
match message {
// Sub-messages
BroadcastMessage::TriggerEvent(message) => self.event.process_message(message, responses, EventMessageContext { listeners: &mut self.listeners }),
// Messages
BroadcastMessage::SubscribeEvent { on, send } => self.listeners.entry(on).or_default().push(*send),
BroadcastMessage::UnsubscribeEvent { on, send } => self.listeners.entry(on).or_default().retain(|msg| *msg != *send),
}
}
fn actions(&self) -> ActionList {
actions!(EventMessageDiscriminant;)
}
}
@@ -0,0 +1,12 @@
use crate::messages::prelude::*;
#[impl_message(Message, BroadcastMessage, TriggerEvent)]
#[derive(PartialEq, Eq, Clone, Debug, serde::Serialize, serde::Deserialize, Hash)]
pub enum EventMessage {
/// Triggered by requestAnimationFrame in JS
AnimationFrame,
CanvasTransformed,
ToolAbort,
SelectionChanged,
WorkingColorChanged,
}
@@ -0,0 +1,22 @@
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct EventMessageContext<'a> {
pub listeners: &'a mut HashMap<EventMessage, Vec<Message>>,
}
#[derive(Debug, Clone, Default, ExtractField)]
pub struct EventMessageHandler {}
#[message_handler_data]
impl MessageHandler<EventMessage, EventMessageContext<'_>> for EventMessageHandler {
fn process_message(&mut self, message: EventMessage, responses: &mut VecDeque<Message>, context: EventMessageContext) {
for message in context.listeners.entry(message).or_default() {
responses.add_front(message.clone())
}
}
fn actions(&self) -> ActionList {
actions!(EventMessageDiscriminant;)
}
}
@@ -0,0 +1,7 @@
mod event_message;
mod event_message_handler;
#[doc(inline)]
pub use event_message::{EventMessage, EventMessageDiscriminant};
#[doc(inline)]
pub use event_message_handler::{EventMessageContext, EventMessageHandler};
@@ -0,0 +1,9 @@
mod broadcast_message;
mod broadcast_message_handler;
pub mod event;
#[doc(inline)]
pub use broadcast_message::{BroadcastMessage, BroadcastMessageDiscriminant};
#[doc(inline)]
pub use broadcast_message_handler::BroadcastMessageHandler;
@@ -0,0 +1,20 @@
use crate::messages::clipboard::utility_types::{ClipboardContent, ClipboardContentRaw, ClipboardItem, ClipboardLayer, ClipboardVectorEntry};
use crate::messages::prelude::*;
#[impl_message(Message, Clipboard)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardMessage {
Cut,
Copy,
Paste,
ReadClipboard { content: ClipboardContentRaw },
ReadSelection { content: Option<String>, cut: bool },
Write { content: ClipboardContent },
CopyLayers,
CutLayers,
WriteItems { items: Vec<ClipboardItem> },
PasteItems { data: String },
PasteLayers { entries: Vec<ClipboardLayer> },
PasteVectors { paths: Vec<ClipboardVectorEntry> },
}
@@ -0,0 +1,665 @@
use crate::consts::DEFAULT_STROKE_WIDTH;
use crate::messages::clipboard::utility_types::{ClipboardContent, ClipboardContentRaw, ClipboardItem, ClipboardLayer, ClipboardResource, ResourceData};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_network_node_type;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface;
use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::utility_types::ToolType;
use graph_craft::application_io::resource::{DataSource, ResourceHash};
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::raster::Image;
use graphene_std::subpath::BezierHandles;
use graphene_std::vector::misc::HandleId;
use graphene_std::vector::{PointId, SegmentId, VectorModificationType};
use graphite_proc_macros::{ExtractField, message_handler_data};
use std::sync::Arc;
const CLIPBOARD_PREFIX: &str = "graphite: ";
#[derive(ExtractField)]
pub struct ClipboardMessageContext<'a> {
pub portfolio: &'a mut PortfolioMessageHandler,
pub current_tool: &'a ToolType,
pub resource_storage: &'a ResourceStorageMessageHandler,
}
#[derive(Debug, Clone, Default, ExtractField)]
pub struct ClipboardMessageHandler {}
#[message_handler_data]
impl MessageHandler<ClipboardMessage, ClipboardMessageContext<'_>> for ClipboardMessageHandler {
fn process_message(&mut self, message: ClipboardMessage, responses: &mut std::collections::VecDeque<Message>, context: ClipboardMessageContext) {
let ClipboardMessageContext {
portfolio,
current_tool,
resource_storage,
} = context;
match message {
ClipboardMessage::Cut => responses.add(FrontendMessage::TriggerSelectionRead { cut: true }),
ClipboardMessage::Copy => responses.add(FrontendMessage::TriggerSelectionRead { cut: false }),
ClipboardMessage::Paste => responses.add(FrontendMessage::TriggerClipboardRead),
ClipboardMessage::ReadClipboard { content } => match content {
ClipboardContentRaw::Text(text) => {
if let Some(graphite) = text.strip_prefix(CLIPBOARD_PREFIX) {
responses.add(ClipboardMessage::PasteItems { data: graphite.to_string() });
} else {
responses.add(FrontendMessage::TriggerSelectionWrite { content: text });
}
}
ClipboardContentRaw::Svg(svg) => {
responses.add(PortfolioMessage::InsertSvg {
svg,
name: None,
mouse: None,
parent_and_insert_index: None,
});
}
ClipboardContentRaw::Image { data, width, height } => {
responses.add(PortfolioMessage::InsertImage {
image: Image::from_image_data(&data, width, height),
name: None,
mouse: None,
parent_and_insert_index: None,
});
}
},
ClipboardMessage::ReadSelection { content, cut } => {
if let Some(text) = content {
responses.add(ClipboardMessage::Write {
content: ClipboardContent::Text(text),
});
} else if cut {
responses.add(ClipboardMessage::CutLayers);
} else {
responses.add(ClipboardMessage::CopyLayers);
}
}
ClipboardMessage::Write { content } => {
let text = match content {
ClipboardContent::Svg(_) => {
log::error!("SVG copying is not yet supported");
return;
}
ClipboardContent::Image { .. } => {
log::error!("Image copying is not yet supported");
return;
}
ClipboardContent::Graphite(graphite) => format!("{CLIPBOARD_PREFIX}{graphite}"),
ClipboardContent::Text(text) => text,
};
responses.add(FrontendMessage::TriggerClipboardWrite { content: text });
}
ClipboardMessage::CopyLayers => {
if current_tool == &ToolType::Path {
responses.add(PathToolMessage::Copy);
return;
}
let Some(active_document) = portfolio.active_document_id.and_then(|id| portfolio.documents.get_mut(&id)) else {
return;
};
if active_document.graph_view_overlay_open() {
responses.add(NodeGraphMessage::Copy);
return;
}
let mut buffer = Vec::new();
let mut ordered_last_elements = active_document.network_interface.shallowest_unique_layers(&[]).collect::<Vec<_>>();
ordered_last_elements.sort_by_key(|layer| {
let Some(parent) = layer.parent(active_document.metadata()) else { return usize::MAX };
DocumentMessageHandler::get_calculated_insert_index(active_document.metadata(), &SelectedNodes(vec![layer.to_node()]), parent)
});
for layer in ordered_last_elements.into_iter() {
let layer_node_id = layer.to_node();
let mut copy_ids = HashMap::new();
copy_ids.insert(layer_node_id, NodeId(0));
active_document
.network_interface
.upstream_flow_back_from_nodes(vec![layer_node_id], &[], network_interface::FlowType::LayerChildrenUpstreamFlow)
.enumerate()
.for_each(|(index, node_id)| {
copy_ids.insert(node_id, NodeId((index + 1) as u64));
});
// The layer panel keys collapse state on the top-down path of node IDs down to the layer
let mut tree_path = layer
.ancestors(active_document.metadata())
.filter(|ancestor| *ancestor != LayerNodeIdentifier::ROOT_PARENT)
.map(|ancestor| ancestor.to_node())
.collect::<Vec<_>>();
tree_path.reverse();
buffer.push(ClipboardLayer {
nodes: active_document.network_interface.copy_nodes(&copy_ids, &[]).collect(),
visible: active_document.network_interface.selected_nodes().layer_visible(layer, &active_document.network_interface),
locked: active_document.network_interface.selected_nodes().layer_locked(layer, &active_document.network_interface),
collapsed: active_document.collapsed.0.contains(&tree_path),
});
}
responses.add(ClipboardMessage::WriteItems {
items: buffer.into_iter().map(ClipboardItem::Layer).collect(),
});
}
ClipboardMessage::CutLayers => {
if current_tool == &ToolType::Path {
responses.add(PathToolMessage::Cut);
return;
}
if let Some(active_document) = portfolio.active_document()
&& active_document.graph_view_overlay_open()
{
responses.add(NodeGraphMessage::Cut);
return;
}
responses.add(ClipboardMessage::CopyLayers);
responses.add(DocumentMessage::DeleteSelectedLayers);
}
ClipboardMessage::WriteItems { items } => {
let has_content = items.iter().any(|item| match item {
ClipboardItem::Layer(entry) => !entry.nodes.is_empty(),
ClipboardItem::Nodes(nodes) => !nodes.is_empty(),
ClipboardItem::Vector(vector) => !vector.is_empty(),
ClipboardItem::Resource(_) => true,
});
if !has_content {
return;
}
let mut resource_ids = HashSet::new();
for item in &items {
match item {
ClipboardItem::Layer(entry) => entry
.nodes
.iter()
.for_each(|(_, template)| network_interface::collect_node_resources(&template.document_node, &mut resource_ids)),
ClipboardItem::Nodes(nodes) => nodes
.iter()
.for_each(|(_, template)| network_interface::collect_node_resources(&template.document_node, &mut resource_ids)),
ClipboardItem::Vector(_) | ClipboardItem::Resource(_) => {}
}
}
// Snapshot each resource's registry entry; embedded ones also need their bytes
let mut resources = Vec::new();
let mut bytes_to_load = Vec::new();
if let Some(document) = portfolio.active_document() {
for id in resource_ids {
let Some(info) = document.resources.registry.info(&id) else { continue };
if let Some(hash) = info.hash
&& info.sources.contains(&DataSource::Embedded)
{
bytes_to_load.push((resources.len(), *hash));
}
resources.push(ClipboardResource {
id,
hash: info.hash.copied(),
sources: info.sources.to_vec(),
data: None,
});
}
}
if bytes_to_load.is_empty() {
let mut items = items;
items.extend(resources.into_iter().map(ClipboardItem::Resource));
if let Some(content) = serialize_clipboard(&items) {
responses.add(ClipboardMessage::Write { content });
}
} else {
// Load the embedded bytes from the resource storage, then write
let load_handle = resource_storage.resources();
responses.add(async move {
let mut resources = resources;
for (index, hash) in bytes_to_load {
if let Some(resource) = load_handle.load(hash).await {
resources[index].data = Some(ResourceData(resource.as_ref().to_vec()));
}
}
let mut items = items;
items.extend(resources.into_iter().map(ClipboardItem::Resource));
match serialize_clipboard(&items) {
Some(content) => ClipboardMessage::Write { content }.into(),
None => Message::NoOp,
}
});
}
}
ClipboardMessage::PasteItems { data } => {
let items = match serde_json::from_str::<Vec<ClipboardItem>>(&data) {
Ok(items) => items,
Err(error) => {
log::error!("Failed to deserialize clipboard payload: {error}");
return;
}
};
let mut layers = Vec::new();
let mut node_groups = Vec::new();
let mut vectors = Vec::new();
let mut resources = Vec::new();
for item in items {
// Pasted templates skip document migration, so stored types normalize here
match item {
ClipboardItem::Layer(mut entry) => {
for (_, template) in &mut entry.nodes {
template.document_node.normalize_stored_types();
}
layers.push(entry);
}
ClipboardItem::Nodes(mut nodes) => {
for (_, template) in &mut nodes {
template.document_node.normalize_stored_types();
}
node_groups.push(nodes);
}
ClipboardItem::Vector(vector) => vectors.push(vector),
ClipboardItem::Resource(resource) => resources.push(resource),
}
}
// Re-register the carried resources and store their bytes
let mut needs_resolve = false;
if !resources.is_empty()
&& let Some(document) = portfolio.active_document_mut()
{
for resource in resources {
if document.resources.registry.contains(&resource.id) {
continue;
}
for source in resource.sources {
document.resources.registry.push_source_back(&resource.id, source);
}
match resource.data {
Some(data) => match resource.hash {
Some(hash) if ResourceHash::from(data.0.as_slice()) == hash => {
document.resources.registry.resolve(&resource.id, hash);
responses.add(ResourceStorageMessage::Store { data: Arc::from(data.0) });
}
_ => warn!("Discarding pasted resource {:?}: embedded bytes do not match its advertised hash", resource.id),
},
None => needs_resolve = true,
}
}
}
// Paste the content through the existing per-type handlers
if !layers.is_empty() {
responses.add(ClipboardMessage::PasteLayers { entries: layers });
}
for nodes in node_groups {
responses.add(NodeGraphMessage::InsertNodes { nodes });
}
for paths in vectors {
responses.add(ClipboardMessage::PasteVectors { paths });
}
// Re-resolve resources that carried no bytes (URL- or font-backed)
if needs_resolve && let Some(document_id) = portfolio.active_document_id {
responses.add(PortfolioMessage::ResolveDocumentResources { document_id });
}
}
ClipboardMessage::PasteLayers { entries } => {
if let Some(document) = portfolio.active_document() {
let parent = document.new_layer_parent(false);
// Top-down node ID path of the destination parent, used to derive each pasted layer's collapse path
let mut parent_tree_path = parent
.ancestors(document.metadata())
.filter(|ancestor| *ancestor != LayerNodeIdentifier::ROOT_PARENT)
.map(|ancestor| ancestor.to_node())
.collect::<Vec<_>>();
parent_tree_path.reverse();
let mut all_new_ids = Vec::new();
let mut layers = Vec::new();
let mut added_nodes = false;
for entry in entries.into_iter().rev() {
let new_ids: HashMap<_, _> = entry.nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect();
let Some(&root_id) = new_ids.get(&NodeId(0)) else {
warn!("Skipping pasted layer missing its root node");
continue;
};
let layer = LayerNodeIdentifier::new_unchecked(root_id);
if !added_nodes {
responses.add(DocumentMessage::DeselectAllLayers);
responses.add(DocumentMessage::AddTransaction);
added_nodes = true;
}
all_new_ids.extend(new_ids.values().copied());
responses.add(NodeGraphMessage::AddNodes { nodes: entry.nodes, new_ids });
responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index: 0 });
// Restore the copied layer's visibility, lock, and collapse state
if !entry.visible {
responses.add(NodeGraphMessage::SetVisibility {
node_id: root_id,
network_path: Vec::new(),
visible: false,
});
}
if entry.locked {
responses.add(NodeGraphMessage::SetLocked {
node_id: root_id,
network_path: Vec::new(),
locked: true,
});
}
if entry.collapsed {
let mut tree_path = parent_tree_path.clone();
tree_path.push(root_id);
responses.add(DocumentMessage::ToggleLayerExpansion { tree_path, recursive: false });
}
layers.push(layer);
}
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: all_new_ids });
responses.add(DeferMessage::AfterGraphRun {
messages: vec![PortfolioMessage::CenterLayers { layers }.into()],
});
}
}
ClipboardMessage::PasteVectors { paths } => {
// If using Path tool then send the operation to Path tool
// TODO: Consider if this is actually the correct place to put this logic
// TODO: Consider making paste in general go through the current tool, so that the tool can decide what to do
if *current_tool == ToolType::Path {
responses.add(PathToolMessage::Paste { paths });
return;
}
// If not using Path tool, create new layers and add paths into those
if let Some(document) = portfolio.active_document() {
let mut layers = Vec::new();
for (_, new_vector, transform) in paths {
let Some(node_type) = resolve_network_node_type("Path") else {
error!("Path node does not exist");
continue;
};
let nodes = vec![(NodeId(0), node_type.default_node_template())];
let parent = document.new_layer_parent(false);
let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses);
layers.push(layer);
// Adding the transform back into the layer
responses.add(GraphOperationMessage::TransformSet {
layer,
transform,
transform_in: TransformIn::Local,
skip_rerender: false,
});
// Add default fill and stroke to the layer
responses.add(GraphOperationMessage::FillColorSet { layer, color: Some(Color::WHITE) });
let color = Some(Color::BLACK);
let stroke = graphene_std::vector::style::Stroke::new(DEFAULT_STROKE_WIDTH);
responses.add(GraphOperationMessage::StrokeSet { layer, color, stroke });
// Create new point ids and add those into the existing Vector path
let mut points_map = HashMap::new();
for (point, position) in new_vector.point_domain.iter() {
let new_point_id = PointId::generate();
points_map.insert(point, new_point_id);
let modification_type = VectorModificationType::InsertPoint { id: new_point_id, position };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
// Create new segment ids and add the segments into the existing Vector path
let mut segments_map = HashMap::new();
for (segment_id, bezier, start, end) in new_vector.segment_bezier_iter() {
let (Some(&start_point), Some(&end_point)) = (points_map.get(&start), points_map.get(&end)) else {
warn!("Skipping pasted vector segment with an unknown endpoint");
continue;
};
let new_segment_id = SegmentId::generate();
segments_map.insert(segment_id, new_segment_id);
let handles = match bezier.handles {
BezierHandles::Linear => [None, None],
BezierHandles::Quadratic { handle } => [Some(handle - bezier.start), None],
BezierHandles::Cubic { handle_start, handle_end } => [Some(handle_start - bezier.start), Some(handle_end - bezier.end)],
};
let points = [start_point, end_point];
let modification_type = VectorModificationType::InsertSegment { id: new_segment_id, points, handles };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
// Set G1 continuity
for handles in new_vector.colinear_manipulators {
let to_new_handle = |handle: HandleId| -> Option<HandleId> {
Some(HandleId {
ty: handle.ty,
segment: *segments_map.get(&handle.segment)?,
})
};
let (Some(first), Some(second)) = (to_new_handle(handles[0]), to_new_handle(handles[1])) else {
continue;
};
let modification_type = VectorModificationType::SetG1Continuous {
handles: [first, second],
enabled: true,
};
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
}
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(Message::Defer(DeferMessage::AfterGraphRun {
messages: vec![PortfolioMessage::CenterLayers { layers }.into()],
}));
}
}
}
}
advertise_actions!(ClipboardMessageDiscriminant;
Cut,
Copy,
Paste,
);
}
/// Serialize the clipboard items, logging on failure.
fn serialize_clipboard(items: &[ClipboardItem]) -> Option<ClipboardContent> {
match serde_json::to_string(items) {
Ok(data) => Some(ClipboardContent::Graphite(data)),
Err(error) => {
log::error!("Failed to serialize clipboard payload: {error}");
None
}
}
}
#[cfg(test)]
mod test {
use crate::messages::clipboard::utility_types::{ClipboardItem, ClipboardResource, ResourceData};
use crate::test_utils::test_prelude::*;
use graph_craft::application_io::resource::{DataSource, ResourceHash, ResourceId};
/// Create an editor with three layers
/// 1. A red rectangle
/// 2. A blue shape
/// 3. A green ellipse
async fn create_editor_with_three_layers() -> EditorTestUtils {
let mut editor = EditorTestUtils::create();
editor.new_document().await;
editor.select_primary_color(Color::RED).await;
editor.draw_rect(100., 200., 300., 400.).await;
editor.select_primary_color(Color::BLUE).await;
editor.draw_polygon(10., 1200., 1300., 400.).await;
editor.select_primary_color(Color::GREEN).await;
editor.draw_ellipse(104., 1200., 1300., 400.).await;
editor
}
/// Copies the layer selection and returns the written clipboard payload.
async fn copy_layers_to_clipboard(editor: &mut EditorTestUtils) -> String {
editor
.handle_message(ClipboardMessage::CopyLayers)
.await
.into_iter()
.find_map(|message| match message {
FrontendMessage::TriggerClipboardWrite { content } => Some(content),
_ => None,
})
.expect("copying layers should write a payload to the clipboard")
}
/// Pastes a clipboard string as if read from the system clipboard.
async fn paste_from_clipboard(editor: &mut EditorTestUtils, clipboard: &str) {
editor
.handle_message(ClipboardMessage::ReadClipboard {
content: ClipboardContentRaw::Text(clipboard.to_string()),
})
.await;
}
/// - create rect, shape and ellipse
/// - copy
/// - paste
/// - assert that ellipse was copied
#[tokio::test]
async fn copy_paste_single_layer() {
let mut editor = create_editor_with_three_layers().await;
let layers_before_copy = editor.active_document().metadata().all_layers().collect::<Vec<_>>();
let clipboard = copy_layers_to_clipboard(&mut editor).await;
paste_from_clipboard(&mut editor, &clipboard).await;
let layers_after_copy = editor.active_document().metadata().all_layers().collect::<Vec<_>>();
assert_eq!(layers_before_copy.len(), 3);
assert_eq!(layers_after_copy.len(), 4);
// Existing layers are unaffected
for i in 0..=2 {
assert_eq!(layers_before_copy[i], layers_after_copy[i + 1]);
}
}
#[cfg_attr(miri, ignore)]
/// - create rect, shape and ellipse
/// - select shape
/// - copy
/// - paste
/// - assert that shape was copied
#[tokio::test]
async fn copy_paste_single_layer_from_middle() {
let mut editor = create_editor_with_three_layers().await;
let layers_before_copy = editor.active_document().metadata().all_layers().collect::<Vec<_>>();
let shape_id = editor.active_document().metadata().all_layers().nth(1).unwrap();
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![shape_id.to_node()] }).await;
let clipboard = copy_layers_to_clipboard(&mut editor).await;
paste_from_clipboard(&mut editor, &clipboard).await;
let layers_after_copy = editor.active_document().metadata().all_layers().collect::<Vec<_>>();
assert_eq!(layers_before_copy.len(), 3);
assert_eq!(layers_after_copy.len(), 4);
// Existing layers are unaffected
for i in 0..=2 {
assert_eq!(layers_before_copy[i], layers_after_copy[i + 1]);
}
}
#[cfg_attr(miri, ignore)]
/// - create rect, shape and ellipse
/// - select ellipse and rect
/// - copy
/// - delete
/// - create another rect
/// - paste
/// - paste
#[tokio::test]
async fn copy_paste_deleted_layers() {
let mut editor = create_editor_with_three_layers().await;
assert_eq!(editor.active_document().metadata().all_layers().count(), 3);
let layers_before_copy = editor.active_document().metadata().all_layers().collect::<Vec<_>>();
let rect_id = layers_before_copy[0];
let shape_id = layers_before_copy[1];
let ellipse_id = layers_before_copy[2];
editor
.handle_message(NodeGraphMessage::SelectedNodesSet {
nodes: vec![rect_id.to_node(), ellipse_id.to_node()],
})
.await;
let clipboard = copy_layers_to_clipboard(&mut editor).await;
editor.handle_message(NodeGraphMessage::DeleteSelectedNodes { delete_children: true }).await;
editor.draw_rect(0., 800., 12., 200.).await;
paste_from_clipboard(&mut editor, &clipboard).await;
paste_from_clipboard(&mut editor, &clipboard).await;
let layers_after_copy = editor.active_document().metadata().all_layers().collect::<Vec<_>>();
assert_eq!(layers_before_copy.len(), 3);
assert_eq!(layers_after_copy.len(), 6);
assert_eq!(layers_after_copy[5], shape_id);
}
/// A pasted `graphite:` payload re-registers the resources it carries into the active document.
#[tokio::test]
async fn paste_carries_embedded_resource() {
let mut editor = EditorTestUtils::create();
editor.new_document().await;
let id = ResourceId::new();
assert!(!editor.active_document().resources.registry.contains(&id));
// Carry the resource and its bytes through the clipboard
let bytes = b"a pretend png".to_vec();
let hash = ResourceHash::from(bytes.as_slice());
let items = vec![ClipboardItem::Resource(ClipboardResource {
id,
hash: Some(hash),
sources: vec![DataSource::Embedded],
data: Some(ResourceData(bytes)),
})];
let payload = format!("graphite: {}", serde_json::to_string(&items).unwrap());
editor
.handle_message(ClipboardMessage::ReadClipboard {
content: ClipboardContentRaw::Text(payload),
})
.await;
let registry = &editor.active_document().resources.registry;
assert!(registry.contains(&id), "the carried resource should be registered after paste");
assert_eq!(registry.hash(&id), Some(hash), "the carried resource should resolve to the carried bytes' hash");
}
}
@@ -0,0 +1,8 @@
mod clipboard_message;
pub mod clipboard_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use clipboard_message::{ClipboardMessage, ClipboardMessageDiscriminant};
#[doc(inline)]
pub use clipboard_message_handler::{ClipboardMessageContext, ClipboardMessageHandler};
@@ -0,0 +1,70 @@
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use glam::DAffine2;
use graph_craft::application_io::resource::{DataSource, ResourceHash, ResourceId};
use graph_craft::document::NodeId;
use graphene_std::vector::Vector;
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardContentRaw {
Text(String),
Svg(String),
Image { data: Vec<u8>, width: u32, height: u32 },
}
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardContent {
Graphite(String),
Text(String),
Svg(String),
Image { data: Vec<u8>, width: u32, height: u32 },
}
pub type ClipboardVectorEntry = (LayerNodeIdentifier, Vector, DAffine2);
/// An entry in the `graphite:` clipboard payload.
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardItem {
Layer(ClipboardLayer),
Nodes(Vec<(NodeId, NodeTemplate)>),
Vector(Vec<ClipboardVectorEntry>),
Resource(ClipboardResource),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ClipboardLayer {
pub nodes: Vec<(NodeId, NodeTemplate)>,
pub visible: bool,
pub locked: bool,
pub collapsed: bool,
}
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ClipboardResource {
pub id: ResourceId,
pub hash: Option<ResourceHash>,
pub sources: Vec<DataSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<ResourceData>,
}
#[derive(PartialEq, Clone)]
pub struct ResourceData(pub Vec<u8>);
impl std::fmt::Debug for ResourceData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResourceData").field("len", &self.0.len()).finish()
}
}
impl serde::Serialize for ResourceData {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&BASE64.encode(&self.0))
}
}
impl<'de> serde::Deserialize<'de> for ResourceData {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let encoded = String::deserialize(deserializer)?;
Ok(Self(BASE64.decode(&encoded).map_err(serde::de::Error::custom)?))
}
}
@@ -0,0 +1,55 @@
use crate::messages::layout::utility_types::widgets::input_widgets::{SpectrumInputUpdate, VisualColorPickersInputUpdate};
use crate::messages::prelude::*;
use graphene_std::vector::style::FillChoice;
/// Identifies which RGB channel a numeric input change targets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RgbChannel {
Red,
Green,
Blue,
}
/// Identifies which HSV channel a numeric input change targets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum HsvChannel {
Hue,
Saturation,
Value,
}
#[impl_message(Message, ColorPicker)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ColorPickerMessage {
/// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `<ColorPicker />` opens.
Open { initial_value: FillChoice, allow_none: bool, disabled: bool },
/// Clear the picker state. Called by the frontend when the popover closes.
Close,
/// Visual sat/val/hue/alpha drag updates from `VisualColorPickersInput`.
VisualUpdate { update: VisualColorPickersInputUpdate },
/// Numeric RGB channel update.
SetChannelRgb { channel: RgbChannel, value: Option<f64> },
/// Numeric HSV channel update.
SetChannelHsv { channel: HsvChannel, value: Option<f64> },
/// Alpha percentage update from the alpha slider numeric input.
SetAlphaPercent { value: Option<f64> },
/// CSS / hex color string from the hex `TextInput`.
SetHexCode { code: String },
/// Pick a preset (specific solid color or "None").
PickPreset { preset: FillChoice },
/// Color picked via the browser-native eyedropper. The string is the eyedropper's returned hex code.
EyedropperColorCode { code: String },
/// Swap the current "new" color with the captured "old" color.
SwapNewWithOld,
/// `SpectrumInput` change: marker move/insert/delete, midpoint move/reset, or active marker selection changed.
GradientUpdate { update: SpectrumInputUpdate },
/// Tell the frontend to start an undo transaction (forwarded as a `FrontendMessage` it bridges out to the picker's parent).
StartTransaction,
/// Tell the frontend to commit the in-flight undo transaction.
CommitTransaction,
}
@@ -0,0 +1,689 @@
use crate::messages::color_picker::color_picker_message::{HsvChannel, RgbChannel};
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate};
use crate::messages::prelude::*;
use graphene_std::Color;
use graphene_std::color::SRGBA8;
use graphene_std::core_types::misc::parse_css_color;
use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientUI};
/// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops).
const MIN_MIDPOINT: f64 = 0.01;
const MAX_MIDPOINT: f64 = 0.99;
#[derive(Debug, Clone, PartialEq, ExtractField)]
pub struct ColorPickerMessageHandler {
// HSV is the source of truth so the hue is preserved when the user desaturates the color (or drives the value to black) and back.
hue: f64,
saturation: f64,
value: f64,
alpha: f64,
is_none: bool,
// Snapshot of the color when the picker opened, used by the new/old comparison swatch and the swap button.
old_hue: f64,
old_saturation: f64,
old_value: f64,
old_alpha: f64,
old_is_none: bool,
// When set, the picker is editing a gradient: the visual pickers and inputs target the active stop's color.
gradient: Option<Gradient>,
active_marker_index: Option<u32>,
active_marker_is_midpoint: bool,
allow_none: bool,
disabled: bool,
}
impl Default for ColorPickerMessageHandler {
fn default() -> Self {
Self {
hue: 0.,
saturation: 0.,
value: 0.,
alpha: 1.,
is_none: true,
old_hue: 0.,
old_saturation: 0.,
old_value: 0.,
old_alpha: 1.,
old_is_none: true,
gradient: None,
active_marker_index: None,
active_marker_is_midpoint: false,
allow_none: true,
disabled: false,
}
}
}
#[message_handler_data]
impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
fn process_message(&mut self, message: ColorPickerMessage, responses: &mut VecDeque<Message>, _context: ()) {
match message {
ColorPickerMessage::Open { initial_value, allow_none, disabled } => {
self.allow_none = allow_none;
self.disabled = disabled;
match initial_value {
FillChoice::None => {
self.set_new_hsva(0., 0., 0., 1., true);
self.gradient = None;
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
}
FillChoice::Solid(color) => {
self.gradient = None;
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
self.adopt_color(color);
}
FillChoice::Gradient(stops) => {
self.active_marker_index = Some(0);
self.active_marker_is_midpoint = false;
let first_color = stops.color.first().copied().unwrap_or(Color::BLACK);
self.gradient = Some(stops);
self.adopt_color(first_color);
}
}
self.snapshot_old();
self.send_layouts(responses);
}
ColorPickerMessage::Close => {
responses.add(DocumentMessage::EndTransaction);
}
ColorPickerMessage::VisualUpdate { update } => {
self.hue = update.hue;
self.saturation = update.saturation;
self.value = update.value;
self.alpha = update.alpha;
self.is_none = false;
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetChannelRgb { channel, value } => {
let Some(strength) = value else { return };
let Some(current) = self.current_color() else { return };
// The RGB inputs are 0..255 sRGB display values; substitute the new channel into the gamma triple and lift back to linear for storage.
let new_gamma_channel = (strength / 255.) as f32;
let [cur_r, cur_g, cur_b, cur_a] = current.to_gamma_srgb_channels();
let updated = match channel {
RgbChannel::Red => Color::from_gamma_srgb_channels(new_gamma_channel, cur_g, cur_b, cur_a),
RgbChannel::Green => Color::from_gamma_srgb_channels(cur_r, new_gamma_channel, cur_b, cur_a),
RgbChannel::Blue => Color::from_gamma_srgb_channels(cur_r, cur_g, new_gamma_channel, cur_a),
};
self.adopt_color(updated);
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetChannelHsv { channel, value } => {
let Some(strength) = value else { return };
match channel {
HsvChannel::Hue => self.hue = strength / 360.,
HsvChannel::Saturation => self.saturation = strength / 100.,
HsvChannel::Value => self.value = strength / 100.,
}
self.is_none = false;
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetAlphaPercent { value } => {
let Some(strength) = value else { return };
self.alpha = strength / 100.;
self.is_none = false;
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetHexCode { code } => {
let Some(color) = parse_css_color(&code) else {
// Parse failed: re-send the layouts so the TextInput's displayed value reverts from the user's bad input
// back to the current color's hex string. The TextInput dispatch arm has already mutated the stored
// widget's `value` to the bad input, so the diff between (stored = bad) and (new = correct) sends an update.
self.send_layouts(responses);
return;
};
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
self.adopt_color(color);
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::PickPreset { preset } => {
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
match preset {
FillChoice::None => {
self.set_new_hsva(0., 0., 0., 1., true);
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None });
}
FillChoice::Solid(color) => {
self.adopt_color(color);
self.emit_color(responses);
}
FillChoice::Gradient(_) => {
// The presets row only emits solid colors or "None"; the gradient case is unreachable, so safely ignore.
}
}
self.send_layouts(responses);
}
ColorPickerMessage::EyedropperColorCode { code } => {
let Some(color) = parse_css_color(&code) else { return };
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
self.adopt_color(color);
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SwapNewWithOld => {
let temp = (self.hue, self.saturation, self.value, self.alpha, self.is_none);
self.set_new_hsva(self.old_hue, self.old_saturation, self.old_value, self.old_alpha, self.old_is_none);
self.set_old_hsva(temp.0, temp.1, temp.2, temp.3, temp.4);
if self.is_none {
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None });
} else {
self.emit_color(responses);
}
self.send_layouts(responses);
}
ColorPickerMessage::GradientUpdate { update } => self.apply_gradient_update(update, responses),
ColorPickerMessage::StartTransaction => {
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
}
ColorPickerMessage::CommitTransaction => {
responses.add(FrontendMessage::ColorPickerCommitHistoryTransaction);
}
}
}
fn actions(&self) -> ActionList {
actions!(ColorPickerMessageDiscriminant;)
}
}
impl ColorPickerMessageHandler {
// The picker's internal HSV state is HSV of sRGB display values
fn current_color(&self) -> Option<Color> {
if self.is_none {
None
} else {
Some(Color::from_hsva(self.hue as f32, self.saturation as f32, self.value as f32, self.alpha as f32))
}
}
fn old_color(&self) -> Option<Color> {
if self.old_is_none {
None
} else {
Some(Color::from_hsva(self.old_hue as f32, self.old_saturation as f32, self.old_value as f32, self.old_alpha as f32))
}
}
fn set_new_hsva(&mut self, h: f64, s: f64, v: f64, a: f64, is_none: bool) {
self.hue = h;
self.saturation = s;
self.value = v;
self.alpha = a;
self.is_none = is_none;
}
fn set_old_hsva(&mut self, h: f64, s: f64, v: f64, a: f64, is_none: bool) {
self.old_hue = h;
self.old_saturation = s;
self.old_value = v;
self.old_alpha = a;
self.old_is_none = is_none;
}
fn snapshot_old(&mut self) {
self.old_hue = self.hue;
self.old_saturation = self.saturation;
self.old_value = self.value;
self.old_alpha = self.alpha;
self.old_is_none = self.is_none;
}
/// Set HSV state from a Color, preserving hue and saturation in degenerate cases.
fn adopt_color(&mut self, color: Color) {
let [target_h, target_s, target_v, target_a] = color.to_hsva();
let (target_h, target_s, target_v, target_a) = (target_h as f64, target_s as f64, target_v as f64, target_a as f64);
// Preserve hue: avoid jumping from 360° (top) to 0° (bottom) and don't reset hue when the color is desaturated or fully dark.
if !(target_h == 0. && self.hue == 1.) && target_s > 0. && target_v > 0. {
self.hue = target_h;
}
// Preserve saturation when value is black (saturation is meaningless on the bottom edge of the saturation-value box).
if target_v != 0. {
self.saturation = target_s;
}
self.value = target_v;
self.alpha = target_a;
self.is_none = false;
}
/// Compute the FillChoice and forward it via `FrontendMessage::ColorPickerColorChanged`. In gradient mode, the active stop's color is updated in place.
fn emit_color(&mut self, responses: &mut VecDeque<Message>) {
let Some(color) = self.current_color() else { return };
if let Some(gradient) = &mut self.gradient
&& let Some(active_index) = self.active_marker_index
&& let Some(stop_color) = gradient.color.get_mut(active_index as usize)
{
*stop_color = color;
let stops = gradient.clone();
let fill_choice = FillChoice::Gradient(stops);
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoiceUI::from(&fill_choice),
});
} else {
let fill_choice = FillChoice::Solid(color);
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoiceUI::from(&fill_choice),
});
}
}
fn send_layouts(&self, responses: &mut VecDeque<Message>) {
responses.add(LayoutMessage::SendLayout {
layout: self.pickers_and_gradient_layout(),
layout_target: LayoutTarget::ColorPickerPickersAndGradient,
});
responses.add(LayoutMessage::SendLayout {
layout: self.details_layout(),
layout_target: LayoutTarget::ColorPickerDetails,
});
}
/// Apply an incoming `SpectrumInput` intent to the gradient state and broadcast the result.
fn apply_gradient_update(&mut self, update: SpectrumInputUpdate, responses: &mut VecDeque<Message>) {
// Active marker selection is the one update that doesn't mutate the gradient
if let SpectrumInputUpdate::ActiveMarker {
active_marker_index,
active_marker_is_midpoint,
} = update
{
self.active_marker_index = active_marker_index;
self.active_marker_is_midpoint = active_marker_is_midpoint;
if let Some(index) = active_marker_index
&& let Some(gradient) = &self.gradient
&& let Some(color) = gradient.color.get(index as usize).copied()
{
self.adopt_color(color);
self.snapshot_old();
}
self.send_layouts(responses);
return;
}
let Some(mut gradient) = self.gradient.clone() else { return };
match update {
SpectrumInputUpdate::MoveMarker { index, position } => {
let new_index = gradient.move_stop(index as usize, position);
if Some(index) == self.active_marker_index {
self.active_marker_index = Some(new_index as u32);
}
}
SpectrumInputUpdate::MoveMidpoint { index, position } => {
if let Some(midpoint) = gradient.midpoint.get_mut(index as usize) {
*midpoint = position.clamp(MIN_MIDPOINT, MAX_MIDPOINT);
} else {
return;
}
}
SpectrumInputUpdate::InsertMarker { position } => {
let new_index = gradient.insert_stop(position);
self.active_marker_index = Some(new_index as u32);
self.active_marker_is_midpoint = false;
if let Some(color) = gradient.color.get(new_index).copied() {
self.adopt_color(color);
self.snapshot_old();
}
}
SpectrumInputUpdate::InsertDuplicate { index, position } => {
let source = index as usize;
let Some(insert_index) = gradient.duplicate_stop(source, position) else { return };
// The dragged stop (the duplication source) stays active. Its index shifts up if the frozen copy landed at or before it.
let dragged_index = if insert_index <= source { source + 1 } else { source };
self.active_marker_index = Some(dragged_index as u32);
self.active_marker_is_midpoint = false;
}
SpectrumInputUpdate::RemoveDuplicate { index } => {
let anchor = index as usize;
if anchor >= gradient.position.len() || gradient.position.len() <= 2 {
return;
}
// Never remove the active (dragged) stop itself, this should only ever target the frozen copy.
if self.active_marker_index == Some(anchor as u32) {
return;
}
gradient.remove(anchor);
// Keep the dragged stop active. Its index shifts down if the removed copy came before it.
if let Some(active) = self.active_marker_index
&& (anchor as u32) < active
{
self.active_marker_index = Some(active - 1);
}
}
SpectrumInputUpdate::DeleteMarker { index } => {
// Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful.
if gradient.position.len() <= 2 || (index as usize) >= gradient.position.len() {
return;
}
gradient.remove(index as usize);
let new_active = (index as usize).min(gradient.position.len() - 1);
self.active_marker_index = Some(new_active as u32);
self.active_marker_is_midpoint = false;
if let Some(color) = gradient.color.get(new_active).copied() {
self.adopt_color(color);
self.snapshot_old();
}
}
SpectrumInputUpdate::ResetMidpoint { index } => {
gradient.reset_midpoint(index as usize);
}
SpectrumInputUpdate::ResetMarker { index } => {
let i = index as usize;
let count = gradient.position.len();
if i >= count {
return;
}
// Each stop's "natural" position is its evenly-spaced fraction along 0..1, e.g., for 5 stops: 0, 0.25, 0.5, 0.75, 1. Falls back to the midpoint between neighbors when the natural position would push the stop past another.
let left = if i == 0 { 0. } else { gradient.position[i - 1] };
let right = gradient.position.get(i + 1).copied().unwrap_or(1.);
let natural = if count <= 1 { 0. } else { i as f64 / (count - 1) as f64 };
let new_position = if (left..=right).contains(&natural) { natural } else { (left + right) / 2. };
let new_index = gradient.move_stop(i, new_position);
if Some(index) == self.active_marker_index {
self.active_marker_index = Some(new_index as u32);
}
}
SpectrumInputUpdate::ActiveMarker { .. } => unreachable!("handled above"),
}
self.gradient = Some(gradient.clone());
let fill_choice = FillChoice::Gradient(gradient);
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoiceUI::from(&fill_choice),
});
self.send_layouts(responses);
}
fn pickers_and_gradient_layout(&self) -> Layout {
let mut groups = Vec::new();
// Visual H/S/V/A pickers
groups.push(LayoutGroup::row(vec![
VisualColorPickersInput::default()
.hue(self.hue)
.saturation(self.saturation)
.value(self.value)
.alpha(self.alpha)
.is_none(self.is_none)
.disabled(self.disabled)
.on_update(|update: &VisualColorPickersInputUpdate| ColorPickerMessage::VisualUpdate { update: update.clone() }.into())
.on_commit(|_| ColorPickerMessage::CommitTransaction.into())
.widget_instance(),
]));
// Gradient editor (only present when the picker is in gradient mode)
if let Some(gradient) = &self.gradient {
// For gradient editing, the markers' handle colors mirror their gradient stop colors
let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect();
let mut row_widgets = vec![
SpectrumInput::new(GradientUI::from(gradient))
.markers(markers)
.active_marker_index(self.active_marker_index)
.active_marker_is_midpoint(self.active_marker_is_midpoint)
.show_midpoints(true)
.allow_insert(!self.disabled)
.allow_delete(!self.disabled)
.allow_reorder(true)
.disabled(self.disabled)
.on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into())
.widget_instance(),
];
if let Some(active) = self.active_marker_index {
let active_index = active as usize;
let position_value = if self.active_marker_is_midpoint {
gradient.midpoint.get(active_index).copied().unwrap_or(0.)
} else {
gradient.position.get(active_index).copied().unwrap_or(0.)
};
let is_midpoint = self.active_marker_is_midpoint;
let captured_index = active;
row_widgets.push(
NumberInput::new(Some(position_value * 100.))
.disabled(self.disabled)
.display_decimal_places(0)
.min(if is_midpoint { 1. } else { 0. })
.max(if is_midpoint { 99. } else { 100. })
.unit("%")
.on_update(move |widget: &NumberInput| {
let Some(new_value) = widget.value else {
return Message::NoOp;
};
let update = if is_midpoint {
SpectrumInputUpdate::MoveMidpoint {
index: captured_index,
position: new_value / 100.,
}
} else {
SpectrumInputUpdate::MoveMarker {
index: captured_index,
position: new_value / 100.,
}
};
ColorPickerMessage::GradientUpdate { update }.into()
})
.widget_instance(),
);
}
groups.push(LayoutGroup::row(row_widgets));
}
Layout(groups)
}
fn details_layout(&self) -> Layout {
let new_color = self.current_color();
let old_color = self.old_color();
let hex_value = new_color.map(|c| color_to_hex_optional_alpha(&c)).unwrap_or_else(|| "-".to_string());
// RGB readouts display sRGB byte values to the user, so we convert from linear-light to gamma here before quantizing.
let rgb_255 = new_color.map(|c| {
let [r, g, b, _] = c.to_gamma_srgb_channels();
(r as f64 * 255., g as f64 * 255., b as f64 * 255.)
});
// Epsilon comparison since the picker round-trips through HSV
let differs = match (new_color, old_color) {
(Some(a), Some(b)) => {
const EPSILON: f32 = 1e-6;
(a.r() - b.r()).abs() >= EPSILON || (a.g() - b.g()).abs() >= EPSILON || (a.b() - b.b()).abs() >= EPSILON || (a.a() - b.a()).abs() >= EPSILON
}
(None, None) => false,
_ => true,
};
let outline_amount = contrasting_outline_factor(new_color).max(contrasting_outline_factor(old_color));
let mut groups = Vec::new();
// New/old comparison swatch with swap button
groups.push(LayoutGroup::row(vec![
ColorComparisonInput::new(new_color.map(SRGBA8::from), old_color.map(SRGBA8::from))
.is_none(self.is_none)
.old_is_none(self.old_is_none)
.disabled(self.disabled)
.differs(differs)
.outline_amount(outline_amount)
.on_update(|_: &()| ColorPickerMessage::SwapNewWithOld.into())
.widget_instance(),
]));
// Hex
groups.push(LayoutGroup::row(vec![
TextLabel::new("Hex").tooltip_label("Hex Color Code").tooltip_description(HEX_DESCRIPTION).widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
TextInput::new(hex_value)
.centered(true)
.disabled(self.disabled)
.tooltip_label("Hex Color Code")
.tooltip_description(HEX_DESCRIPTION)
.on_update(|widget: &TextInput| ColorPickerMessage::SetHexCode { code: widget.value.clone() }.into())
.widget_instance(),
]));
// RGB
groups.push(LayoutGroup::row(vec![
TextLabel::new("RGB").tooltip_label("Red/Green/Blue").tooltip_description("Integers 0–255.").widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
rgb_input(RgbChannel::Red, rgb_255.map(|(r, _, _)| r), "Red Channel", self.disabled),
Separator::new(SeparatorStyle::Related).widget_instance(),
rgb_input(RgbChannel::Green, rgb_255.map(|(_, g, _)| g), "Green Channel", self.disabled),
Separator::new(SeparatorStyle::Related).widget_instance(),
rgb_input(RgbChannel::Blue, rgb_255.map(|(_, _, b)| b), "Blue Channel", self.disabled),
]));
// HSV
groups.push(LayoutGroup::row(vec![
TextLabel::new("HSV")
.tooltip_label("Hue/Saturation/Value")
.tooltip_description("Also known as Hue/Saturation/Brightness (HSB). Not to be confused with Hue/Saturation/Lightness (HSL), a different color model.")
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
hsv_input(
HsvChannel::Hue,
if self.is_none { None } else { Some(self.hue * 360.) },
360.,
"°",
"Hue Component",
HUE_DESCRIPTION,
self.disabled,
),
Separator::new(SeparatorStyle::Related).widget_instance(),
hsv_input(
HsvChannel::Saturation,
if self.is_none { None } else { Some(self.saturation * 100.) },
100.,
"%",
"Saturation Component",
SATURATION_DESCRIPTION,
self.disabled,
),
Separator::new(SeparatorStyle::Related).widget_instance(),
hsv_input(
HsvChannel::Value,
if self.is_none { None } else { Some(self.value * 100.) },
100.,
"%",
"Value Component",
VALUE_DESCRIPTION,
self.disabled,
),
]));
// Alpha
groups.push(LayoutGroup::row(vec![
TextLabel::new("Alpha").tooltip_label("Alpha").tooltip_description(ALPHA_DESCRIPTION).widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
NumberInput::new(if self.is_none { None } else { Some(self.alpha * 100.) })
.disabled(self.disabled)
.min(0.)
.max(100.)
.mode_range()
.unit("%")
.display_decimal_places(1)
.tooltip_label("Alpha")
.tooltip_description(ALPHA_DESCRIPTION)
.on_update(|widget: &NumberInput| ColorPickerMessage::SetAlphaPercent { value: widget.value }.into())
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
.widget_instance(),
]));
// Color presets (None / Black / White / pure colors / eyedropper)
groups.push(LayoutGroup::row(vec![
ColorPresetsInput::default()
.disabled(self.disabled)
.show_none_option(self.allow_none && self.gradient.is_none())
.on_update(|update: &ColorPresetsInputUpdate| match update {
ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset {
preset: FillChoice::from(fill_choice),
}
.into(),
ColorPresetsInputUpdate::EyedropperColorCode(code) => ColorPickerMessage::EyedropperColorCode { code: code.clone() }.into(),
})
.widget_instance(),
]));
Layout(groups)
}
}
fn rgb_input(channel: RgbChannel, value: Option<f64>, tooltip_label: &'static str, disabled: bool) -> WidgetInstance {
NumberInput::new(value)
.disabled(disabled)
.min(0.)
.max(255.)
.min_width(1)
.display_decimal_places(0)
.tooltip_label(tooltip_label)
.tooltip_description("Integers 0–255.")
.on_update(move |widget: &NumberInput| ColorPickerMessage::SetChannelRgb { channel, value: widget.value }.into())
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
.widget_instance()
}
fn hsv_input(channel: HsvChannel, value: Option<f64>, max: f64, unit: &'static str, tooltip_label: &'static str, tooltip_description: &'static str, disabled: bool) -> WidgetInstance {
NumberInput::new(value)
.disabled(disabled)
.min(0.)
.max(max)
.min_width(1)
.unit(unit)
.display_decimal_places(1)
.tooltip_label(tooltip_label)
.tooltip_description(tooltip_description)
.on_update(move |widget: &NumberInput| ColorPickerMessage::SetChannelHsv { channel, value: widget.value }.into())
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
.widget_instance()
}
const HEX_DESCRIPTION: &str = "Color code in hexadecimal format. 6 digits if opaque, 8 with alpha. Accepts input of CSS color values including named colors.";
const HUE_DESCRIPTION: &str = "The shade along the spectrum of the rainbow.";
const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color.";
const VALUE_DESCRIPTION: &str = "The brightness from black to full color.";
const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%).";
/// The popover's background color as sRGB gamma-encoded channels (the `--color-2-mildblack` design token, `#222`).
/// Used by the comparison swatch's outline computation to brighten the inset border for colors close to this background.
const POPOVER_BACKGROUND_GAMMA_CHANNELS: [f32; 4] = [0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.];
/// The luminance window within which a color is considered close enough to the popover background
/// to warrant an outline. Mirrors the `proximityRange` argument the legacy frontend passed to `contrastingOutlineFactor`.
const OUTLINE_PROXIMITY_RANGE: f64 = 0.01;
/// Returns a 0..1 outline strength for the comparison swatch, growing toward 1 as the color's luminance and saturation
/// both approach the popover background's luminance, when a color would otherwise visually blend into the popover.
fn contrasting_outline_factor(color: Option<Color>) -> f64 {
let Some(color) = color else { return 0. };
// WCAG-style relative luminance, with alpha composited over white in sRGB gamma space (matching the perceptual intent of `SRGBA8::contrasting_text_color`).
let luminance_from_gamma_channels = |[r, g, b, a]: [f32; 4]| -> f64 {
let inv_a = 1. - a;
Color::from_gamma_srgb_channels(inv_a + r * a, inv_a + g * a, inv_a + b * a, 1.).luminance_rec_709() as f64
};
let color_gamma_channels = color.to_gamma_srgb_channels();
let distance = (luminance_from_gamma_channels(POPOVER_BACKGROUND_GAMMA_CHANNELS) - luminance_from_gamma_channels(color_gamma_channels))
.abs()
.max(0.);
let proximity = 1. - (distance / OUTLINE_PROXIMITY_RANGE).min(1.);
let [_, saturation, _, _] = color.to_hsva();
proximity * (1. - saturation as f64)
}
/// Format a linear `Color` as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque.
fn color_to_hex_optional_alpha(color: &Color) -> String {
SRGBA8::from(*color).to_css_hex()
}
@@ -0,0 +1,7 @@
mod color_picker_message;
pub mod color_picker_message_handler;
#[doc(inline)]
pub use color_picker_message::{ColorPickerMessage, ColorPickerMessageDiscriminant};
#[doc(inline)]
pub use color_picker_message_handler::ColorPickerMessageHandler;
@@ -0,0 +1,10 @@
use crate::messages::prelude::*;
#[impl_message(Message, Debug)]
#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)]
pub enum DebugMessage {
ToggleTraceLogs,
MessageOff,
MessageNames,
MessageContents,
}
@@ -0,0 +1,50 @@
use super::utility_types::MessageLoggingVerbosity;
use crate::messages::prelude::*;
#[derive(Debug, Default, ExtractField)]
pub struct DebugMessageHandler {
pub message_logging_verbosity: MessageLoggingVerbosity,
}
#[message_handler_data]
impl MessageHandler<DebugMessage, ()> for DebugMessageHandler {
fn process_message(&mut self, message: DebugMessage, responses: &mut VecDeque<Message>, _: ()) {
match message {
DebugMessage::ToggleTraceLogs => {
if log::max_level() == log::LevelFilter::Debug {
log::set_max_level(log::LevelFilter::Trace);
} else {
log::set_max_level(log::LevelFilter::Debug);
}
// Refresh the checkmark beside the menu entry for this
responses.add(MenuBarMessage::SendLayout);
}
DebugMessage::MessageOff => {
self.message_logging_verbosity = MessageLoggingVerbosity::Off;
// Refresh the checkmark beside the menu entry for this
responses.add(MenuBarMessage::SendLayout);
}
DebugMessage::MessageNames => {
self.message_logging_verbosity = MessageLoggingVerbosity::Names;
// Refresh the checkmark beside the menu entry for this
responses.add(MenuBarMessage::SendLayout);
}
DebugMessage::MessageContents => {
self.message_logging_verbosity = MessageLoggingVerbosity::Contents;
// Refresh the checkmark beside the menu entry for this
responses.add(MenuBarMessage::SendLayout);
}
}
}
advertise_actions!(DebugMessageDiscriminant;
ToggleTraceLogs,
MessageOff,
MessageNames,
MessageContents,
);
}
@@ -0,0 +1,9 @@
mod debug_message;
mod debug_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use debug_message::{DebugMessage, DebugMessageDiscriminant};
#[doc(inline)]
pub use debug_message_handler::DebugMessageHandler;
@@ -0,0 +1,7 @@
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub enum MessageLoggingVerbosity {
#[default]
Off,
Names,
Contents,
}
@@ -0,0 +1,11 @@
use crate::messages::prelude::*;
#[impl_message(Message, Defer)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DeferMessage {
SetGraphSubmissionIndex { execution_id: u64 },
TriggerGraphRun { execution_id: u64, document_id: DocumentId },
AfterGraphRun { messages: Vec<Message> },
TriggerNavigationReady,
AfterNavigationReady { messages: Vec<Message> },
}
@@ -0,0 +1,57 @@
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct DeferMessageContext<'a> {
pub portfolio: &'a PortfolioMessageHandler,
}
#[derive(Debug, Default, ExtractField)]
pub struct DeferMessageHandler {
after_graph_run: HashMap<DocumentId, Vec<(u64, Message)>>,
after_viewport_resize: Vec<Message>,
current_graph_submission_id: u64,
}
#[message_handler_data]
impl MessageHandler<DeferMessage, DeferMessageContext<'_>> for DeferMessageHandler {
fn process_message(&mut self, message: DeferMessage, responses: &mut VecDeque<Message>, context: DeferMessageContext) {
match message {
DeferMessage::AfterGraphRun { mut messages } => {
let after_graph_run = self.after_graph_run.entry(context.portfolio.active_document_id.unwrap_or(DocumentId(0))).or_default();
after_graph_run.extend(messages.drain(..).map(|m| (self.current_graph_submission_id, m)));
responses.add(NodeGraphMessage::RunDocumentGraph);
}
DeferMessage::AfterNavigationReady { messages } => {
self.after_viewport_resize.extend_from_slice(&messages);
}
DeferMessage::SetGraphSubmissionIndex { execution_id } => {
self.current_graph_submission_id = execution_id + 1;
}
DeferMessage::TriggerGraphRun { execution_id, document_id } => {
let after_graph_run = self.after_graph_run.entry(document_id).or_default();
if after_graph_run.is_empty() {
return;
}
// Find the index of the last message we can process
let split = after_graph_run.partition_point(|&(id, _)| id <= execution_id);
let elements = after_graph_run.drain(..split);
for (_, message) in elements.rev() {
responses.add_front(message);
}
for (&document_id, messages) in self.after_graph_run.iter() {
if !messages.is_empty() {
responses.add(PortfolioMessage::SubmitGraphRender { document_id, ignore_hash: false });
}
}
}
DeferMessage::TriggerNavigationReady => {
for message in self.after_viewport_resize.drain(..).rev() {
responses.add_front(message);
}
}
}
}
advertise_actions!(DeferMessageDiscriminant;
);
}
@@ -0,0 +1,7 @@
mod defer_message;
mod defer_message_handler;
#[doc(inline)]
pub use defer_message::{DeferMessage, DeferMessageDiscriminant};
#[doc(inline)]
pub use defer_message_handler::{DeferMessageContext, DeferMessageHandler};
@@ -0,0 +1,43 @@
use crate::messages::prelude::*;
#[impl_message(Message, Dialog)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DialogMessage {
// Sub-messages
#[child]
ExportDialog(ExportDialogMessage),
#[child]
NewDocumentDialog(NewDocumentDialogMessage),
#[child]
PreferencesDialog(PreferencesDialogMessage),
// Messages
Dismiss,
Close,
CloseAndThen {
followups: Vec<Message>,
},
CloseAllDocumentsWithConfirmation,
DisplayDialogError {
title: String,
description: String,
},
RequestAboutGraphiteDialog,
RequestAboutGraphiteDialogWithLocalizedCommitDate {
localized_commit_date: String,
localized_commit_year: String,
},
RequestDemoArtworkDialog,
RequestExportDialog,
RequestLicensesDialogWithLocalizedCommitDate {
localized_commit_year: String,
},
RequestLicensesThirdPartyDialogWithLicenseText {
license_text: String,
},
RequestNewDocumentDialog,
RequestPreferencesDialog,
RequestConfirmRestartDialog {
preferences_requiring_restart: Vec<String>,
},
}
@@ -0,0 +1,153 @@
use super::simple_dialogs::{self, AboutGraphiteDialog, DemoArtworkDialog, LicensesDialog};
use crate::application::GRAPHITE_GIT_COMMIT_DATE;
use crate::messages::dialog::simple_dialogs::{ConfirmRestartDialog, LicensesThirdPartyDialog};
use crate::messages::frontend::utility_types::ExportBounds;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct DialogMessageContext<'a> {
pub portfolio: &'a PortfolioMessageHandler,
pub preferences: &'a PreferencesMessageHandler,
}
/// Stores the dialogs which require state. These are the ones that have their own message handlers, and are not the ones defined in `simple_dialogs`.
#[derive(Debug, Default, Clone, ExtractField)]
pub struct DialogMessageHandler {
on_dismiss: Option<Message>,
export_dialog: ExportDialogMessageHandler,
new_document_dialog: NewDocumentDialogMessageHandler,
preferences_dialog: PreferencesDialogMessageHandler,
}
#[message_handler_data]
impl MessageHandler<DialogMessage, DialogMessageContext<'_>> for DialogMessageHandler {
fn process_message(&mut self, message: DialogMessage, responses: &mut VecDeque<Message>, context: DialogMessageContext) {
let DialogMessageContext { portfolio, preferences } = context;
match message {
DialogMessage::ExportDialog(message) => self.export_dialog.process_message(message, responses, ExportDialogMessageContext { portfolio }),
DialogMessage::NewDocumentDialog(message) => self.new_document_dialog.process_message(message, responses, ()),
DialogMessage::PreferencesDialog(message) => self.preferences_dialog.process_message(message, responses, PreferencesDialogMessageContext { preferences }),
DialogMessage::Dismiss => {
if let Some(message) = self.on_dismiss.take() {
responses.add(message);
}
}
DialogMessage::Close => {
self.on_dismiss = None;
responses.add(FrontendMessage::DialogClose)
}
DialogMessage::CloseAndThen { followups } => {
for message in followups.into_iter() {
responses.add(message);
}
// This come after followups, so that the followups (which can cause the dialog to open) happen first, then we close it afterwards.
// If it comes before, the dialog reopens (and appears to not close at all).
responses.add(DialogMessage::Close);
}
DialogMessage::CloseAllDocumentsWithConfirmation => {
self.on_dismiss = Some(DialogMessage::Close.into());
let dialog = simple_dialogs::CloseAllDocumentsDialog {
unsaved_document_names: portfolio.unsaved_document_names(),
};
dialog.send_dialog_to_frontend(responses);
}
DialogMessage::DisplayDialogError { title, description } => {
self.on_dismiss = None;
let dialog = simple_dialogs::ErrorDialog { title, description };
dialog.send_dialog_to_frontend(responses);
}
DialogMessage::RequestAboutGraphiteDialog => {
self.on_dismiss = Some(DialogMessage::Close.into());
responses.add(FrontendMessage::TriggerAboutGraphiteLocalizedCommitDate {
commit_date: GRAPHITE_GIT_COMMIT_DATE.into(),
});
}
DialogMessage::RequestAboutGraphiteDialogWithLocalizedCommitDate {
localized_commit_date,
localized_commit_year,
} => {
self.on_dismiss = Some(DialogMessage::Close.into());
let dialog = AboutGraphiteDialog {
localized_commit_date,
localized_commit_year,
};
dialog.send_dialog_to_frontend(responses);
}
DialogMessage::RequestDemoArtworkDialog => {
self.on_dismiss = Some(DialogMessage::Close.into());
let dialog = DemoArtworkDialog;
dialog.send_dialog_to_frontend(responses);
}
DialogMessage::RequestExportDialog => {
self.on_dismiss = Some(DialogMessage::Close.into());
if let Some(document) = portfolio.active_document() {
let artboards = document
.metadata()
.all_layers()
.filter(|&layer| document.network_interface.is_artboard(&layer.to_node(), &[]))
.map(|layer| {
let name = document
.network_interface
.node_metadata(&layer.to_node(), &[])
.map(|node| node.persistent_metadata.display_name.clone())
.and_then(|name| if name.is_empty() { None } else { Some(name) })
.unwrap_or_else(|| "Artboard".to_string());
(layer, name)
})
.collect();
self.export_dialog.artboards = artboards;
if let ExportBounds::Artboard(layer) = self.export_dialog.bounds
&& !self.export_dialog.artboards.contains_key(&layer)
{
self.export_dialog.bounds = ExportBounds::AllArtwork;
}
self.export_dialog.has_selection = document.network_interface.selected_nodes().selected_layers(document.metadata()).next().is_some();
self.export_dialog.send_dialog_to_frontend(responses);
}
}
DialogMessage::RequestLicensesDialogWithLocalizedCommitDate { localized_commit_year } => {
self.on_dismiss = Some(DialogMessage::Close.into());
let dialog = LicensesDialog { localized_commit_year };
dialog.send_dialog_to_frontend(responses);
}
DialogMessage::RequestLicensesThirdPartyDialogWithLicenseText { license_text } => {
self.on_dismiss = Some(DialogMessage::Close.into());
let dialog = LicensesThirdPartyDialog { license_text };
dialog.send_dialog_to_frontend(responses);
}
DialogMessage::RequestNewDocumentDialog => {
self.on_dismiss = Some(DialogMessage::Close.into());
self.new_document_dialog = NewDocumentDialogMessageHandler {
name: portfolio.generate_new_document_name(None),
infinite: false,
dimensions: glam::UVec2::new(1920, 1080),
};
self.new_document_dialog.send_dialog_to_frontend(responses);
}
DialogMessage::RequestPreferencesDialog => {
self.on_dismiss = Some(PreferencesDialogMessage::Confirm.into());
self.preferences_dialog.send_dialog_to_frontend(responses, preferences);
}
DialogMessage::RequestConfirmRestartDialog { preferences_requiring_restart } => {
self.on_dismiss = Some(DialogMessage::Close.into());
let dialog = ConfirmRestartDialog { preferences_requiring_restart };
dialog.send_dialog_to_frontend(responses);
}
}
}
advertise_actions!(DialogMessageDiscriminant;
CloseAllDocumentsWithConfirmation,
RequestExportDialog,
RequestNewDocumentDialog,
RequestPreferencesDialog,
);
}
@@ -0,0 +1,12 @@
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
use crate::messages::prelude::*;
#[impl_message(Message, DialogMessage, ExportDialog)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ExportDialogMessage {
FileType { file_type: FileType },
ScaleFactor { factor: f64 },
ExportBounds { bounds: ExportBounds },
Submit,
}
@@ -0,0 +1,168 @@
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct ExportDialogMessageContext<'a> {
pub portfolio: &'a PortfolioMessageHandler,
}
/// A dialog to allow users to customize their file export.
#[derive(Debug, Clone, ExtractField)]
pub struct ExportDialogMessageHandler {
pub file_type: FileType,
pub scale_factor: f64,
pub bounds: ExportBounds,
pub artboards: HashMap<LayerNodeIdentifier, String>,
pub has_selection: bool,
}
impl Default for ExportDialogMessageHandler {
fn default() -> Self {
Self {
file_type: Default::default(),
scale_factor: 1.,
bounds: Default::default(),
artboards: Default::default(),
has_selection: false,
}
}
}
#[message_handler_data]
impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for ExportDialogMessageHandler {
fn process_message(&mut self, message: ExportDialogMessage, responses: &mut VecDeque<Message>, context: ExportDialogMessageContext) {
let ExportDialogMessageContext { portfolio } = context;
match message {
ExportDialogMessage::FileType { file_type } => self.file_type = file_type,
ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor,
ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds,
ExportDialogMessage::Submit => {
// Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected
let bounds = if !self.has_selection && self.bounds == ExportBounds::Selection {
ExportBounds::AllArtwork
} else {
self.bounds
};
let artboard_name = match bounds {
ExportBounds::Artboard(layer) => self.artboards.get(&layer).cloned(),
_ => None,
};
responses.add_front(PortfolioMessage::SubmitDocumentExport {
name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(),
file_type: self.file_type,
scale_factor: self.scale_factor,
bounds,
artboard_name,
artboard_count: self.artboards.len(),
})
}
}
self.send_dialog_to_frontend(responses);
}
advertise_actions!(ExportDialogUpdate;
);
}
impl DialogLayoutHolder for ExportDialogMessageHandler {
const ICON: &'static str = "File";
const TITLE: &'static str = "Export";
fn layout_buttons(&self) -> Layout {
let widgets = vec![
TextButton::new("Export")
.emphasized(true)
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![ExportDialogMessage::Submit.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Cancel").on_update(|_| FrontendMessage::DialogClose.into()).widget_instance(),
];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for ExportDialogMessageHandler {
fn layout(&self) -> Layout {
let entries = [(FileType::Png, "PNG"), (FileType::Jpg, "JPG"), (FileType::Svg, "SVG")]
.into_iter()
.map(|(file_type, name)| {
RadioEntryData::new(format!("{file_type:?}"))
.label(name)
.on_update(move |_| ExportDialogMessage::FileType { file_type }.into())
})
.collect();
let export_type = vec![
TextLabel::new("File Type").table_align(true).min_width(100).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
RadioInput::new(entries).selected_index(Some(self.file_type as u32)).widget_instance(),
];
let resolution = vec![
TextLabel::new("Scale Factor").table_align(true).min_width(100).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(self.scale_factor))
.unit("")
.min(0.)
.max((1_u64 << f64::MANTISSA_DIGITS) as f64)
.increment_step(0.5)
.disabled(self.file_type == FileType::Svg)
.on_update(|number_input: &NumberInput| ExportDialogMessage::ScaleFactor { factor: number_input.value.unwrap() }.into())
.min_width(200)
.widget_instance(),
];
let standard_bounds = vec![
(ExportBounds::AllArtwork, "All Artwork".to_string(), false),
(ExportBounds::Selection, "Selection".to_string(), !self.has_selection),
];
let artboards = self.artboards.iter().map(|(&layer, name)| (ExportBounds::Artboard(layer), name.to_string(), false)).collect();
let choices = [standard_bounds, artboards];
// Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected
let current_bounds = if !self.has_selection && self.bounds == ExportBounds::Selection {
ExportBounds::AllArtwork
} else {
self.bounds
};
let index = choices.iter().flatten().position(|(bounds, _, _)| *bounds == current_bounds).unwrap_or(0);
let mut entries = choices
.into_iter()
.map(|choice| {
choice
.into_iter()
.map(|(bounds, name, disabled)| {
MenuListEntry::new(format!("{bounds:?}"))
.label(name)
.on_commit(move |_| ExportDialogMessage::ExportBounds { bounds }.into())
.disabled(disabled)
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
if entries[1].is_empty() {
entries.remove(1);
}
let export_area = vec![
TextLabel::new("Bounds").table_align(true).min_width(100).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
DropdownInput::new(entries).selected_index(Some(index as u32)).widget_instance(),
];
Layout(vec![LayoutGroup::row(export_type), LayoutGroup::row(resolution), LayoutGroup::row(export_area)])
}
}
@@ -0,0 +1,7 @@
mod export_dialog_message;
mod export_dialog_message_handler;
#[doc(inline)]
pub use export_dialog_message::{ExportDialogMessage, ExportDialogMessageDiscriminant};
#[doc(inline)]
pub use export_dialog_message_handler::{ExportDialogMessageContext, ExportDialogMessageHandler};
@@ -0,0 +1,19 @@
//! Handles dialogs that appear as floating menus in the center of the editor window.
//!
//! Dialogs are represented as structs that implement the `DialogLayoutHolder` trait.
//!
//! To open a dialog, call the function `send_dialog_to_frontend()` on the dialog struct.
//! Then dialog can be opened by sending the `FrontendMessage::DisplayDialog` message;
mod dialog_message;
mod dialog_message_handler;
pub mod export_dialog;
pub mod new_document_dialog;
pub mod preferences_dialog;
pub mod simple_dialogs;
#[doc(inline)]
pub use dialog_message::{DialogMessage, DialogMessageDiscriminant};
#[doc(inline)]
pub use dialog_message_handler::{DialogMessageContext, DialogMessageHandler};
@@ -0,0 +1,7 @@
mod new_document_dialog_message;
mod new_document_dialog_message_handler;
#[doc(inline)]
pub use new_document_dialog_message::{NewDocumentDialogMessage, NewDocumentDialogMessageDiscriminant};
#[doc(inline)]
pub use new_document_dialog_message_handler::NewDocumentDialogMessageHandler;
@@ -0,0 +1,12 @@
use crate::messages::prelude::*;
#[impl_message(Message, DialogMessage, NewDocumentDialog)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum NewDocumentDialogMessage {
Name { name: String },
Infinite { infinite: bool },
DimensionsX { width: f64 },
DimensionsY { height: f64 },
Submit,
}
@@ -0,0 +1,149 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::prelude::*;
use glam::UVec2;
use graph_craft::document::NodeId;
use graphene_std::Color;
/// A dialog to allow users to set some initial options about a new document.
#[derive(Debug, Clone, Default, ExtractField)]
pub struct NewDocumentDialogMessageHandler {
pub name: String,
pub infinite: bool,
pub dimensions: UVec2,
}
#[message_handler_data]
impl MessageHandler<NewDocumentDialogMessage, ()> for NewDocumentDialogMessageHandler {
fn process_message(&mut self, message: NewDocumentDialogMessage, responses: &mut VecDeque<Message>, _: ()) {
match message {
NewDocumentDialogMessage::Name { name } => self.name = name,
NewDocumentDialogMessage::Infinite { infinite } => self.infinite = infinite,
NewDocumentDialogMessage::DimensionsX { width } => self.dimensions.x = width as u32,
NewDocumentDialogMessage::DimensionsY { height } => self.dimensions.y = height as u32,
NewDocumentDialogMessage::Submit => {
responses.add(PortfolioMessage::NewDocumentWithName { name: self.name.clone() });
if self.infinite {
// Infinite canvas: add a locked white background layer
let node_id = NodeId::new();
responses.add(GraphOperationMessage::NewColorFillLayer {
node_id,
color: Color::WHITE,
parent: LayerNodeIdentifier::ROOT_PARENT,
insert_index: 0,
});
responses.add(NodeGraphMessage::SetDisplayNameImpl {
node_id,
network_path: Vec::new(),
alias: "Background".to_string(),
});
responses.add(NodeGraphMessage::SetLocked {
node_id,
network_path: Vec::new(),
locked: true,
});
} else if self.dimensions.x > 0 && self.dimensions.y > 0 {
// Finite canvas: create an artboard with the specified dimensions
responses.add(GraphOperationMessage::NewArtboard {
id: NodeId::new(),
location: glam::DVec2::ZERO,
dimensions: self.dimensions.as_dvec2(),
background: Color::WHITE,
clip: true,
});
responses.add(NavigationMessage::CanvasPan { delta: self.dimensions.as_dvec2() });
}
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(ViewportMessage::RepropagateUpdate);
responses.add(DocumentMessage::DeselectAllLayers);
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into(), PortfolioMessage::AutoSaveActiveDocument.into()],
});
responses.add(DocumentMessage::MarkAsSaved);
}
}
self.send_dialog_to_frontend(responses);
}
advertise_actions!(NewDocumentDialogUpdate;
);
}
impl DialogLayoutHolder for NewDocumentDialogMessageHandler {
const ICON: &'static str = "File";
const TITLE: &'static str = "New Document";
fn layout_buttons(&self) -> Layout {
let widgets = vec![
TextButton::new("OK")
.emphasized(true)
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![NewDocumentDialogMessage::Submit.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Cancel").on_update(|_| FrontendMessage::DialogClose.into()).widget_instance(),
];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for NewDocumentDialogMessageHandler {
fn layout(&self) -> Layout {
let name = vec![
TextLabel::new("Name").table_align(true).min_width(90).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
TextInput::new(&self.name)
.on_update(|text_input: &TextInput| NewDocumentDialogMessage::Name { name: text_input.value.clone() }.into())
.min_width(204) // Matches the 100px of both NumberInputs below + the 4px of the Unrelated-type separator
.widget_instance(),
];
let checkbox_id = CheckboxId::new();
let infinite = vec![
TextLabel::new("Infinite Canvas").table_align(true).min_width(90).for_checkbox(checkbox_id).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(self.infinite)
.on_update(|checkbox_input: &CheckboxInput| NewDocumentDialogMessage::Infinite { infinite: checkbox_input.checked }.into())
.for_label(checkbox_id)
.widget_instance(),
];
let scale = vec![
TextLabel::new("Dimensions").table_align(true).min_width(90).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(self.dimensions.x as f64))
.label("W")
.unit(" px")
.min(0.)
.max((1_u64 << f64::MANTISSA_DIGITS) as f64)
.is_integer(true)
.disabled(self.infinite)
.min_width(100)
.on_update(|number_input: &NumberInput| NewDocumentDialogMessage::DimensionsX { width: number_input.value.unwrap() }.into())
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
NumberInput::new(Some(self.dimensions.y as f64))
.label("H")
.unit(" px")
.min(0.)
.max((1_u64 << f64::MANTISSA_DIGITS) as f64)
.is_integer(true)
.disabled(self.infinite)
.min_width(100)
.on_update(|number_input: &NumberInput| NewDocumentDialogMessage::DimensionsY { height: number_input.value.unwrap() }.into())
.widget_instance(),
];
Layout(vec![LayoutGroup::row(name), LayoutGroup::row(infinite), LayoutGroup::row(scale)])
}
}
@@ -0,0 +1,7 @@
mod preferences_dialog_message;
mod preferences_dialog_message_handler;
#[doc(inline)]
pub use preferences_dialog_message::{PreferencesDialogMessage, PreferencesDialogMessageDiscriminant};
#[doc(inline)]
pub use preferences_dialog_message_handler::{PreferencesDialogMessageContext, PreferencesDialogMessageHandler};
@@ -0,0 +1,9 @@
use crate::messages::prelude::*;
#[impl_message(Message, DialogMessage, PreferencesDialog)]
#[derive(Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum PreferencesDialogMessage {
MayRequireRestart,
Confirm,
Update,
}
@@ -0,0 +1,559 @@
use crate::consts::{VIEWPORT_ZOOM_WHEEL_RATE, VIEWPORT_ZOOM_WHEEL_RATE_CHANGE};
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::utility_types::wires::GraphWireStyle;
use crate::messages::preferences::SelectionMode;
use crate::messages::prelude::*;
use graph_craft::application_io::EditorPreferences;
#[derive(ExtractField)]
pub struct PreferencesDialogMessageContext<'a> {
pub preferences: &'a PreferencesMessageHandler,
}
/// A dialog to allow users to customize Graphite editor options
#[derive(Debug, Clone, Default, ExtractField)]
pub struct PreferencesDialogMessageHandler {
unmodified_preferences: Option<PreferencesMessageHandler>,
}
#[message_handler_data]
impl MessageHandler<PreferencesDialogMessage, PreferencesDialogMessageContext<'_>> for PreferencesDialogMessageHandler {
fn process_message(&mut self, message: PreferencesDialogMessage, responses: &mut VecDeque<Message>, context: PreferencesDialogMessageContext) {
let PreferencesDialogMessageContext { preferences } = context;
match message {
PreferencesDialogMessage::MayRequireRestart => {
if self.unmodified_preferences.is_none() {
self.unmodified_preferences = Some(preferences.clone());
}
}
PreferencesDialogMessage::Confirm => {
if let Some(unmodified_preferences) = &self.unmodified_preferences
&& let preferences_requiring_restart = unmodified_preferences.preferences_requiring_restart(preferences)
&& !preferences_requiring_restart.is_empty()
{
responses.add(DialogMessage::RequestConfirmRestartDialog { preferences_requiring_restart });
} else {
responses.add(DialogMessage::Close);
}
}
PreferencesDialogMessage::Update => {}
}
}
advertise_actions!(PreferencesDialogUpdate;
);
}
// This doesn't actually implement the `DialogLayoutHolder` trait like the other dialog message handlers.
// That's because we need to give `send_layout` the `preferences` argument, which is not part of the trait.
// However, it's important to keep the methods in sync with those from the trait for consistency.
impl PreferencesDialogMessageHandler {
const ICON: &'static str = "Settings";
const TITLE: &'static str = "Editor Preferences";
fn layout(&self, preferences: &PreferencesMessageHandler) -> Layout {
let mut rows = Vec::new();
// ==========
// NAVIGATION
// ==========
{
let header = vec![TextLabel::new("Navigation").italic(true).widget_instance()];
let zoom_rate_description = "
Adjust how fast zooming occurs when using the scroll wheel or pinch gesture.\n\
\n\
*Default: 50.*
"
.trim();
let zoom_rate_label = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
TextLabel::new("Zoom Rate").tooltip_label("Zoom Rate").tooltip_description(zoom_rate_description).widget_instance(),
];
let zoom_rate = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(map_zoom_rate_to_display(preferences.viewport_zoom_wheel_rate)))
.tooltip_label("Zoom Rate")
.tooltip_description(zoom_rate_description)
.mode_range()
.int()
.min(1.)
.max(100.)
.on_update(|number_input: &NumberInput| {
if let Some(display_value) = number_input.value {
let actual_rate = map_display_to_zoom_rate(display_value);
PreferencesMessage::ViewportZoomWheelRate { rate: actual_rate }.into()
} else {
PreferencesMessage::ViewportZoomWheelRate { rate: VIEWPORT_ZOOM_WHEEL_RATE }.into()
}
})
.widget_instance(),
];
let checkbox_id = CheckboxId::new();
let zoom_with_scroll_description = "
Use the scroll wheel for zooming instead of vertically panning (not recommended for trackpads).\n\
\n\
*Default: Off.*
"
.trim();
let zoom_with_scroll = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.zoom_with_scroll)
.tooltip_label("Zoom with Scroll")
.tooltip_description(zoom_with_scroll_description)
.on_update(|checkbox_input: &CheckboxInput| {
PreferencesMessage::ModifyLayout {
zoom_with_scroll: checkbox_input.checked,
}
.into()
})
.for_label(checkbox_id)
.widget_instance(),
TextLabel::new("Zoom with Scroll")
.tooltip_label("Zoom with Scroll")
.tooltip_description(zoom_with_scroll_description)
.for_checkbox(checkbox_id)
.widget_instance(),
];
rows.extend_from_slice(&[header, zoom_rate_label, zoom_rate, zoom_with_scroll]);
}
// =======
// EDITING
// =======
{
let header = vec![TextLabel::new("Editing").italic(true).widget_instance()];
let selection_label_description = "
Choose how targets are selected within dragged rectangular and lasso areas.\n\
\n\
*Default: Touched.*
"
.trim();
let selection_label = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
TextLabel::new("Selection")
.tooltip_label("Selection")
.tooltip_description(selection_label_description)
.widget_instance(),
];
let selection_mode = RadioInput::new(vec![
RadioEntryData::new(SelectionMode::Touched.to_string())
.label(SelectionMode::Touched.to_string())
.tooltip_label(SelectionMode::Touched.to_string())
.tooltip_description(SelectionMode::Touched.tooltip_description())
.on_update(move |_| {
PreferencesMessage::SelectionMode {
selection_mode: SelectionMode::Touched,
}
.into()
}),
RadioEntryData::new(SelectionMode::Enclosed.to_string())
.label(SelectionMode::Enclosed.to_string())
.tooltip_label(SelectionMode::Enclosed.to_string())
.tooltip_description(SelectionMode::Enclosed.tooltip_description())
.on_update(move |_| {
PreferencesMessage::SelectionMode {
selection_mode: SelectionMode::Enclosed,
}
.into()
}),
RadioEntryData::new(SelectionMode::Directional.to_string())
.label(SelectionMode::Directional.to_string())
.tooltip_label(SelectionMode::Directional.to_string())
.tooltip_description(SelectionMode::Directional.tooltip_description())
.on_update(move |_| {
PreferencesMessage::SelectionMode {
selection_mode: SelectionMode::Directional,
}
.into()
}),
])
.selected_index(Some(preferences.selection_mode as u32))
.widget_instance();
let selection_mode = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
selection_mode,
];
rows.extend_from_slice(&[header, selection_label, selection_mode]);
}
// =========
// INTERFACE
// =========
#[cfg(not(target_family = "wasm"))]
{
let header = vec![TextLabel::new("Interface").italic(true).widget_instance()];
let scale_description = "
Adjust the scale of the entire user interface.\n\
\n\
*Default: 100%.*
"
.trim();
let scale_label = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
TextLabel::new("Scale").tooltip_label("Scale").tooltip_description(scale_description).widget_instance(),
];
let scale = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(ui_scale_to_display(preferences.ui_scale)))
.tooltip_label("Scale")
.tooltip_description(scale_description)
.mode_range()
.int()
.min(ui_scale_to_display(crate::consts::UI_SCALE_MIN))
.max(ui_scale_to_display(crate::consts::UI_SCALE_MAX))
.unit("%")
.on_update(|number_input: &NumberInput| {
if let Some(display_value) = number_input.value {
let scale = map_display_to_ui_scale(display_value);
PreferencesMessage::UIScale { scale }.into()
} else {
PreferencesMessage::UIScale {
scale: crate::consts::UI_SCALE_DEFAULT,
}
.into()
}
})
.widget_instance(),
];
rows.extend_from_slice(&[header, scale_label, scale]);
}
// ============
// EXPERIMENTAL
// ============
{
let header = vec![TextLabel::new("Experimental").italic(true).widget_instance()];
let node_graph_section_description = "
Configure the appearance of the wires running between node connections in the graph.\n\
\n\
*Default: Direct.*
"
.trim();
let node_graph_wires_label = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
TextLabel::new("Node Graph Wires")
.tooltip_label("Node Graph Wires")
.tooltip_description(node_graph_section_description)
.widget_instance(),
];
let graph_wire_style = RadioInput::new(vec![
RadioEntryData::new(GraphWireStyle::Direct.to_string())
.label(GraphWireStyle::Direct.to_string())
.tooltip_label(GraphWireStyle::Direct.to_string())
.tooltip_description(GraphWireStyle::Direct.tooltip_description())
.on_update(move |_| PreferencesMessage::GraphWireStyle { style: GraphWireStyle::Direct }.into()),
RadioEntryData::new(GraphWireStyle::GridAligned.to_string())
.label(GraphWireStyle::GridAligned.to_string())
.tooltip_label(GraphWireStyle::GridAligned.to_string())
.tooltip_description(GraphWireStyle::GridAligned.tooltip_description())
.on_update(move |_| PreferencesMessage::GraphWireStyle { style: GraphWireStyle::GridAligned }.into()),
])
.selected_index(Some(preferences.graph_wire_style as u32))
.widget_instance();
let graph_wire_style = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
graph_wire_style,
];
let checkbox_id = CheckboxId::new();
let brush_tool_description = "
Enable the Brush tool to support basic raster-based layer painting.\n\
\n\
This legacy experimental tool has performance and quality limitations and is slated for replacement in future versions of Graphite that will have a renewed focus on raster graphics editing.\n\
\n\
Content created with the Brush tool may not be compatible with future versions of Graphite.\n\
\n\
*Default: Off.*
"
.trim();
let brush_tool = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.brush_tool)
.tooltip_label("Brush Tool")
.tooltip_description(brush_tool_description)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::BrushTool { enabled: checkbox_input.checked }.into())
.for_label(checkbox_id)
.widget_instance(),
TextLabel::new("Brush Tool")
.tooltip_label("Brush Tool")
.tooltip_description(brush_tool_description)
.for_checkbox(checkbox_id)
.widget_instance(),
];
rows.extend_from_slice(&[header, node_graph_wires_label, graph_wire_style, brush_tool]);
}
// =========
// DOCUMENTS
// =========
// Soak-only `.gdd` storage options, hidden unless enabled from the developer debug menu.
if preferences.show_storage_preferences {
let header = vec![TextLabel::new("Documents").italic(true).widget_instance()];
let save_as_gdd_description = "
Save documents in the new .gdd container format (with the legacy .graphite file embedded as a recovery fallback) instead of a plain .graphite file. The .gdd format is still being validated, so this is opt-in for now.\n\
\n\
*Default: Off.*
"
.trim();
let save_as_gdd_checkbox_id = CheckboxId::new();
let save_as_gdd = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.save_as_gdd)
.tooltip_label("Save as .gdd")
.tooltip_description(save_as_gdd_description)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::SaveAsGdd { enabled: checkbox_input.checked }.into())
.for_label(save_as_gdd_checkbox_id)
.widget_instance(),
TextLabel::new("Save as .gdd")
.tooltip_label("Save as .gdd")
.tooltip_description(save_as_gdd_description)
.for_checkbox(save_as_gdd_checkbox_id)
.widget_instance(),
];
let validate_description = "
Validate every document save, open, and undo by round-tripping it through the .gdd storage format and comparing against the legacy path, logging any mismatch. Useful for debugging the .gdd format during its soak, but the per-edit round-trip has a performance cost.\n\
\n\
*Default: Off.*
"
.trim();
let validate_checkbox_id = CheckboxId::new();
let validate_storage_round_trip = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.validate_storage_round_trip)
.tooltip_label("Validate Storage Round-Trip")
.tooltip_description(validate_description)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::ValidateStorageRoundTrip { enabled: checkbox_input.checked }.into())
.for_label(validate_checkbox_id)
.widget_instance(),
TextLabel::new("Validate Storage Round-Trip")
.tooltip_label("Validate Storage Round-Trip")
.tooltip_description(validate_description)
.for_checkbox(validate_checkbox_id)
.widget_instance(),
];
rows.extend_from_slice(&[header, save_as_gdd, validate_storage_round_trip]);
}
// =============
// COMPATIBILITY
// =============
{
let wgpu_available = graph_craft::application_io::wgpu_available().unwrap_or(false);
let is_desktop = cfg!(not(target_family = "wasm"));
if wgpu_available || is_desktop {
let header = vec![TextLabel::new("Compatibility").italic(true).widget_instance()];
rows.push(header);
}
if wgpu_available {
let render_tile_resolution_description = "
Maximum X or Y resolution per render tile. Larger tiles may improve performance but can cause flickering or missing content in complex artwork if set too high.\n\
\n\
*Default: 1280 px.*
"
.trim();
let render_tile_resolution_label = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
TextLabel::new("Render Tile Resolution")
.tooltip_label("Render Tile Resolution")
.tooltip_description(render_tile_resolution_description)
.widget_instance(),
];
let render_tile_resolution = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
NumberInput::new(Some(preferences.max_render_region_size as f64))
.tooltip_label("Render Tile Resolution")
.tooltip_description(render_tile_resolution_description)
.mode_range()
.int()
.min(256.)
.max(4096.)
.increment_step(256.)
.unit(" px")
.on_update(|number_input: &NumberInput| {
let size = number_input.value.unwrap_or(EditorPreferences::default().max_render_region_size as f64) as u32;
PreferencesMessage::MaxRenderRegionSize { size }.into()
})
.widget_instance(),
];
rows.extend_from_slice(&[render_tile_resolution_label, render_tile_resolution]);
}
if is_desktop {
let ui_acceleration_description = "
Use the CPU to draw the Graphite user interface (areas outside of the canvas) instead of the GPU. This does not affect the rendering of artwork in the canvas, which remains hardware accelerated.\n\
\n\
Disabling UI acceleration may slightly degrade performance, so this should be used as a workaround only if issues are observed with displaying the UI. This setting may become enabled automatically if Graphite launches, detects that it cannot draw the UI normally, and restarts in compatibility mode.\n\
\n\
*Default: Off.*
"
.trim();
let checkbox_id = CheckboxId::new();
let ui_acceleration = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.disable_ui_acceleration)
.tooltip_label("Disable UI Acceleration")
.tooltip_description(ui_acceleration_description)
.on_update(|number_input: &CheckboxInput| Message::Batched {
messages: Box::new([
PreferencesDialogMessage::MayRequireRestart.into(),
PreferencesMessage::DisableUIAcceleration {
disable_ui_acceleration: number_input.checked,
}
.into(),
]),
})
.for_label(checkbox_id)
.widget_instance(),
TextLabel::new("Disable UI Acceleration")
.tooltip_label("Disable UI Acceleration")
.tooltip_description(ui_acceleration_description)
.for_checkbox(checkbox_id)
.widget_instance(),
];
rows.push(ui_acceleration);
}
#[cfg(target_os = "macos")]
{
let vsync_description = "
Render frames with vertical synchronization (v-sync) to prevent visual tearing within Graphite and the operating system compositor. This introduces increased input latency which is more noticeable on lower refresh rate displays. Future versions of Graphite will aim to reduce the macOS-specific latency without tearing artifacts.\n\
\n\
The application will restart for this change to take effect.\n\
\n\
*Default: Off.*
"
.trim();
let checkbox_id = CheckboxId::new();
let vsync_checked = preferences.vsync;
let vsync = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(vsync_checked)
.tooltip_label("Enable V-Sync")
.tooltip_description(vsync_description)
.on_update(|checkbox_input: &CheckboxInput| Message::Batched {
messages: Box::new([PreferencesDialogMessage::MayRequireRestart.into(), PreferencesMessage::VSync { vsync: checkbox_input.checked }.into()]),
})
.for_label(checkbox_id)
.widget_instance(),
TextLabel::new("Enable V-Sync")
.tooltip_label("Enable V-Sync")
.tooltip_description(vsync_description)
.for_checkbox(checkbox_id)
.widget_instance(),
];
rows.push(vsync);
}
}
Layout(rows.into_iter().map(LayoutGroup::row).collect())
}
pub fn send_layout(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget, preferences: &PreferencesMessageHandler) {
responses.add(LayoutMessage::SendLayout {
layout: self.layout(preferences),
layout_target,
})
}
fn layout_column_2(&self) -> Layout {
Layout::default()
}
fn send_layout_column_2(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget) {
responses.add(LayoutMessage::SendLayout {
layout: self.layout_column_2(),
layout_target,
});
}
fn layout_buttons(&self) -> Layout {
let widgets = vec![
TextButton::new("OK").emphasized(true).on_update(|_| PreferencesDialogMessage::Confirm.into()).widget_instance(),
TextButton::new("Reset to Defaults").on_update(|_| PreferencesMessage::ResetToDefaults.into()).widget_instance(),
];
Layout(vec![LayoutGroup::row(widgets)])
}
fn send_layout_buttons(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget) {
responses.add(LayoutMessage::SendLayout {
layout: self.layout_buttons(),
layout_target,
});
}
pub fn send_dialog_to_frontend(&self, responses: &mut VecDeque<Message>, preferences: &PreferencesMessageHandler) {
self.send_layout(responses, LayoutTarget::DialogColumn1, preferences);
self.send_layout_column_2(responses, LayoutTarget::DialogColumn2);
self.send_layout_buttons(responses, LayoutTarget::DialogButtons);
responses.add(FrontendMessage::DisplayDialog {
icon: Self::ICON.into(),
title: Self::TITLE.into(),
});
}
}
/// Maps display values (1-100) to actual zoom rates.
fn map_display_to_zoom_rate(display: f64) -> f64 {
// Calculate the relative distance from the reference point (50)
let distance_from_reference = display - 50.;
let scaling_factor = (VIEWPORT_ZOOM_WHEEL_RATE_CHANGE * distance_from_reference / 50.).exp();
VIEWPORT_ZOOM_WHEEL_RATE * scaling_factor
}
/// Maps actual zoom rates back to display values (1-100).
fn map_zoom_rate_to_display(rate: f64) -> f64 {
// Calculate the scaling factor from the reference rate
let scaling_factor = rate / VIEWPORT_ZOOM_WHEEL_RATE;
let distance_from_reference = 50. * scaling_factor.ln() / VIEWPORT_ZOOM_WHEEL_RATE_CHANGE;
let display = 50. + distance_from_reference;
display.clamp(1., 100.).round()
}
/// Maps display values in percent to actual ui scale.
#[cfg(not(target_family = "wasm"))]
fn map_display_to_ui_scale(display: f64) -> f64 {
display / 100.
}
/// Maps actual ui scale back to display values in percent.
#[cfg(not(target_family = "wasm"))]
fn ui_scale_to_display(scale: f64) -> f64 {
scale * 100.
}
@@ -0,0 +1,66 @@
use crate::application::commit_info_localized;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
/// A dialog for displaying information on [BuildMetadata] viewable via *Help* > *About Graphite* in the menu bar.
pub struct AboutGraphiteDialog {
pub localized_commit_date: String,
pub localized_commit_year: String,
}
impl DialogLayoutHolder for AboutGraphiteDialog {
const ICON: &'static str = "GraphiteLogo";
const TITLE: &'static str = "About Graphite";
fn layout_buttons(&self) -> Layout {
let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()];
Layout(vec![LayoutGroup::row(widgets)])
}
fn layout_column_2(&self) -> Layout {
let links = [
("Heart", "Donate", "https://graphite.art/donate/"),
("GraphiteLogo", "Website", "https://graphite.art"),
("Volunteer", "Volunteer", "https://graphite.art/volunteer/"),
("Credits", "Credits", "https://github.com/GraphiteEditor/Graphite/graphs/contributors"),
];
let mut widgets = links
.into_iter()
.map(|(icon, label, url)| {
TextButton::new(label)
.icon(icon)
.flush(true)
.on_update(|_| FrontendMessage::TriggerVisitLink { url: url.into() }.into())
.widget_instance()
})
.collect::<Vec<_>>();
// Cloning here and below seems to be necessary to appease the borrow checker, as far as I can tell.
let localized_commit_year = self.localized_commit_year.clone();
widgets.push(
TextButton::new("Licenses")
.icon("License")
.flush(true)
.on_update(move |_| {
DialogMessage::RequestLicensesDialogWithLocalizedCommitDate {
localized_commit_year: localized_commit_year.clone(),
}
.into()
})
.widget_instance(),
);
Layout(vec![LayoutGroup::column(widgets)])
}
}
impl LayoutHolder for AboutGraphiteDialog {
fn layout(&self) -> Layout {
Layout(vec![
LayoutGroup::row(vec![TextLabel::new("About this release").bold(true).widget_instance()]),
LayoutGroup::row(vec![TextLabel::new(commit_info_localized(&self.localized_commit_date)).multiline(true).widget_instance()]),
LayoutGroup::row(vec![TextLabel::new(format!("Copyright © {} Graphite contributors", self.localized_commit_year)).widget_instance()]),
])
}
}
@@ -0,0 +1,40 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
/// A dialog for confirming the closing of all documents viewable via `File -> Close All` in the menu bar.
pub struct CloseAllDocumentsDialog {
pub unsaved_document_names: Vec<String>,
}
impl DialogLayoutHolder for CloseAllDocumentsDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Closing All Documents";
fn layout_buttons(&self) -> Layout {
let widgets = vec![
TextButton::new("Discard All")
.emphasized(true)
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![PortfolioMessage::CloseAllDocuments.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Cancel").on_update(|_| FrontendMessage::DialogClose.into()).widget_instance(),
];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for CloseAllDocumentsDialog {
fn layout(&self) -> Layout {
let unsaved_list = "• ".to_string() + &self.unsaved_document_names.join("\n• ");
Layout(vec![
LayoutGroup::row(vec![TextLabel::new("Save documents before closing them?").bold(true).multiline(true).widget_instance()]),
LayoutGroup::row(vec![TextLabel::new(format!("Documents with unsaved changes:\n{unsaved_list}")).multiline(true).widget_instance()]),
])
}
}
@@ -0,0 +1,59 @@
use crate::messages::broadcast::event::EventMessage;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
/// A dialog for confirming the closing a document with unsaved changes.
pub struct CloseDocumentDialog {
pub document_name: String,
pub document_id: DocumentId,
}
impl DialogLayoutHolder for CloseDocumentDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Closing Document";
fn layout_buttons(&self) -> Layout {
let document_id = self.document_id;
let widgets = vec![
TextButton::new("Save")
.emphasized(true)
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![DocumentMessage::SaveDocument.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Discard")
.on_update(move |_| {
DialogMessage::CloseAndThen {
followups: vec![EventMessage::ToolAbort.into(), PortfolioMessage::CloseDocument { document_id }.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Cancel").on_update(|_| FrontendMessage::DialogClose.into()).widget_instance(),
];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for CloseDocumentDialog {
fn layout(&self) -> Layout {
let max_length = 60;
let max_one_line_length = 40;
let mut name = self.document_name.clone();
name.truncate(max_length);
let ellipsis = if self.document_name.len() > max_length { "…" } else { "" };
let break_lines = if self.document_name.len() > max_one_line_length { '\n' } else { ' ' };
Layout(vec![
LayoutGroup::row(vec![TextLabel::new("Save document before closing it?").bold(true).widget_instance()]),
LayoutGroup::row(vec![TextLabel::new(format!("\"{name}{ellipsis}\"{break_lines}has unsaved changes")).multiline(true).widget_instance()]),
])
}
}
@@ -0,0 +1,55 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
/// A dialog for confirming the restart of the application when changing a preference that requires a restart to take effect.
pub struct ConfirmRestartDialog {
pub preferences_requiring_restart: Vec<String>,
}
impl DialogLayoutHolder for ConfirmRestartDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Restart Required";
fn layout_buttons(&self) -> Layout {
let widgets = vec![
TextButton::new("Restart Now")
.emphasized(true)
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![AppWindowMessage::Restart.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Later").on_update(|_| FrontendMessage::DialogClose.into()).widget_instance(),
];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for ConfirmRestartDialog {
fn layout(&self) -> Layout {
let changed_settings = "• ".to_string() + &self.preferences_requiring_restart.join("\n• ");
Layout(vec![
LayoutGroup::row(vec![TextLabel::new("Restart to apply changes?").bold(true).multiline(true).widget_instance()]),
LayoutGroup::row(vec![
TextLabel::new(
format!(
"
Settings that only take effect on next launch:\n\
{changed_settings}\n\
\n\
This only takes a few seconds. Open documents,\n\
even unsaved ones, will be automatically restored.
"
)
.trim(),
)
.multiline(true)
.widget_instance(),
]),
])
}
}
@@ -0,0 +1,64 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
/// A dialog to let the user browse a gallery of demo artwork that can be opened.
pub struct DemoArtworkDialog;
/// `(name, thumbnail, filename)`
pub const ARTWORK: [(&str, &str, &str); 7] = [
("Isometric Fountain", "ThumbnailIsometricFountain", "isometric-fountain.graphite"),
("Changing Seasons", "ThumbnailChangingSeasons", "changing-seasons.graphite"),
("Painted Dreams", "ThumbnailPaintedDreams", "painted-dreams.graphite"),
("Parametric Dunescape", "ThumbnailParametricDunescape", "parametric-dunescape.graphite"),
("Red Dress", "ThumbnailRedDress", "red-dress.graphite"),
("Procedural String Lights", "ThumbnailProceduralStringLights", "procedural-string-lights.graphite"),
("Valley of Spires", "ThumbnailValleyOfSpires", "valley-of-spires.graphite"),
];
impl DialogLayoutHolder for DemoArtworkDialog {
const ICON: &'static str = "Image";
const TITLE: &'static str = "Demo Artwork";
fn layout_buttons(&self) -> Layout {
let widgets = vec![TextButton::new("Close").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for DemoArtworkDialog {
fn layout(&self) -> Layout {
let mut rows_of_images_with_buttons: Vec<_> = ARTWORK
.chunks(4)
.flat_map(|chunk| {
fn make_dialog(name: &str, filename: &str) -> Message {
DialogMessage::CloseAndThen {
followups: vec![
FrontendMessage::TriggerFetchAndOpenDocument {
name: name.to_string(),
filename: filename.to_string(),
}
.into(),
],
}
.into()
}
let images = chunk
.iter()
.map(|(name, thumbnail, filename)| ImageButton::new(*thumbnail).width("256px").on_update(|_| make_dialog(name, filename)).widget_instance())
.collect();
let buttons = chunk
.iter()
.map(|(name, _, filename)| TextButton::new(*name).min_width(256).flush(true).on_update(|_| make_dialog(name, filename)).widget_instance())
.collect();
vec![LayoutGroup::row(images), LayoutGroup::row(buttons), LayoutGroup::row(vec![])]
})
.collect();
let _ = rows_of_images_with_buttons.pop();
Layout(rows_of_images_with_buttons)
}
}
@@ -0,0 +1,28 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
/// A dialog to notify users of a non-fatal error.
pub struct ErrorDialog {
pub title: String,
pub description: String,
}
impl DialogLayoutHolder for ErrorDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Error";
fn layout_buttons(&self) -> Layout {
let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for ErrorDialog {
fn layout(&self) -> Layout {
Layout(vec![
LayoutGroup::row(vec![TextLabel::new(&self.title).bold(true).widget_instance()]),
LayoutGroup::row(vec![TextLabel::new(&self.description).multiline(true).widget_instance()]),
])
}
}
@@ -0,0 +1,113 @@
// TODO: Eventually remove this document upgrade code
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
pub struct FailedToLoadDocumentsDialog {
pub failed_document_names: Vec<String>,
}
impl DialogLayoutHolder for FailedToLoadDocumentsDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Failed to Open Documents";
fn layout_buttons(&self) -> Layout {
let widgets = vec![
TextButton::new("Download")
.emphasized(true)
.tooltip_description("Save the raw document data to disk so it can be recovered later.")
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![PortfolioMessage::DownloadFailedToLoadDocuments.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Discard")
.tooltip_description("Permanently delete the autosaved data for these documents.")
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![PortfolioMessage::DiscardFailedToLoadDocuments.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Dismiss")
.tooltip_description("Close this dialog. The autosaved data is kept and this dialog will reappear on next launch.")
.on_update(|_| FrontendMessage::DialogClose.into())
.widget_instance(),
];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for FailedToLoadDocumentsDialog {
fn layout(&self) -> Layout {
let count = self.failed_document_names.len();
let header = format!("{count} document{} couldn't be reopened.", if count == 1 { "" } else { "s" });
let list = "• ".to_string() + &self.failed_document_names.join("\n• ");
let plural_s = if count == 1 { "" } else { "s" };
let plural_it_them = if count == 1 { "it" } else { "them" };
Layout(vec![
LayoutGroup::row(vec![TextLabel::new(header).bold(true).multiline(true).widget_instance()]),
LayoutGroup::row(vec![
TextLabel::new(format!(
"Sorry about that!\n\
This shouldn't happen, and we'd like to help.\n\
\n\
Click \"Download\" to save a copy of the affected file{plural_s},\n\
then please share {plural_it_them} with us so we can investigate:"
))
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Ask on Discord")
.icon("Volunteer")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://discord.graphite.art".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Report on GitHub")
.icon("Bug")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://github.com/GraphiteEditor/Graphite/issues/new".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextLabel::new(
"In the meantime, you can keep working in the\n\
previous version of Graphite:",
)
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Sept. 2025 Release")
.icon("GraphiteLogo")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://57130155.graphite.pages.dev/".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![TextLabel::new(format!("Affected document{plural_s}:\n{list}")).multiline(true).widget_instance()]),
])
}
}
@@ -0,0 +1,84 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
pub struct FailedToOpenDocumentDialog {
pub document_name: String,
}
impl DialogLayoutHolder for FailedToOpenDocumentDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Failed to Open Document";
fn layout_buttons(&self) -> Layout {
let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for FailedToOpenDocumentDialog {
fn layout(&self) -> Layout {
let header = if self.document_name.trim().is_empty() {
"The document couldn't be opened.".to_string()
} else {
format!("\"{}\" couldn't be opened.", self.document_name)
};
Layout(vec![
LayoutGroup::row(vec![TextLabel::new(header).bold(true).multiline(true).widget_instance()]),
LayoutGroup::row(vec![
TextLabel::new(
"Sorry about that!\n\
This shouldn't happen, and we'd like to help.\n\
\n\
Please share the file with us so we can investigate:",
)
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Ask on Discord")
.icon("Volunteer")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://discord.graphite.art".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Report on GitHub")
.icon("Bug")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://github.com/GraphiteEditor/Graphite/issues/new".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextLabel::new(
"In the meantime, you can keep working in the\n\
previous version of Graphite:",
)
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Sept. 2025 Release")
.icon("GraphiteLogo")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://57130155.graphite.pages.dev/".into(),
}
.into()
})
.widget_instance(),
]),
])
}
}
@@ -0,0 +1,63 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
pub struct LicensesDialog {
pub localized_commit_year: String,
}
impl DialogLayoutHolder for LicensesDialog {
const ICON: &'static str = "License12px";
const TITLE: &'static str = "Licenses";
fn layout_buttons(&self) -> Layout {
let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()];
Layout(vec![LayoutGroup::row(widgets)])
}
fn layout_column_2(&self) -> Layout {
#[allow(clippy::type_complexity)]
let button_definitions: &[(&str, &str, fn() -> Message)] = &[
("Code", "Source Code License", || {
FrontendMessage::TriggerVisitLink {
url: "https://graphite.art/license#source-code".into(),
}
.into()
}),
("GraphiteLogo", "Branding License", || {
FrontendMessage::TriggerVisitLink {
url: "https://graphite.art/license#branding".into(),
}
.into()
}),
("IconsGrid", "Dependency Licenses", || FrontendMessage::TriggerDisplayThirdPartyLicensesDialog.into()),
];
let widgets = button_definitions
.iter()
.map(|&(icon, label, message_factory)| TextButton::new(label).icon(icon).flush(true).on_update(move |_| message_factory()).widget_instance())
.collect();
Layout(vec![LayoutGroup::column(widgets)])
}
}
impl LayoutHolder for LicensesDialog {
fn layout(&self) -> Layout {
let year = &self.localized_commit_year;
let description = format!(
"
Graphite source code is copyright © {year} Graphite contrib-\nutors and is available under the Apache License 2.0. See\n\"Source Code License\" for details.\n\
\n\
The Graphite logo, icons, and visual identity are copyright ©\n{year} Graphite Labs, LLC. See \"Branding License\" for details.\n\
\n\
Graphite is distributed with third-party open source code\ndependencies. See \"Dependency Licenses\" for details.
"
);
let description = description.trim();
Layout(vec![
LayoutGroup::row(vec![TextLabel::new("Graphite is free, open source software").bold(true).widget_instance()]),
LayoutGroup::row(vec![TextLabel::new(description).multiline(true).widget_instance()]),
])
}
}
@@ -0,0 +1,42 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
pub struct LicensesThirdPartyDialog {
pub license_text: String,
}
impl DialogLayoutHolder for LicensesThirdPartyDialog {
const ICON: &'static str = "License12px";
const TITLE: &'static str = "Third-Party Software License Notices";
fn layout_buttons(&self) -> Layout {
let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()];
Layout(vec![LayoutGroup::row(widgets)])
}
}
impl LayoutHolder for LicensesThirdPartyDialog {
fn layout(&self) -> Layout {
// Remove the header and begin with the line containing the first license section (we otherwise keep the title for standalone viewing of the licenses text file)
let license_text = if let Some(first_underscore_line) = self.license_text.lines().position(|line| line.contains('_')) {
// Find the byte position where the line with underscore starts
let char_position = self.license_text.split('\n').take(first_underscore_line).map(|line| line.len() + '\n'.len_utf8()).sum();
self.license_text[char_position..].to_string()
} else {
// This shouldn't be encountered, but if no underscore line is found, we use the full text as a safety fallback
self.license_text.clone()
};
// Two characters (one before, one after) the sequence of underscore characters, plus one additional column to provide a space between the text and the scrollbar
let non_wrapping_column_width = license_text.split('\n').map(|line| line.chars().filter(|&c| c == '_').count() as u32).max().unwrap_or(0) + 2 + 1;
Layout(vec![LayoutGroup::row(vec![
TextLabel::new(license_text)
.monospace(true)
.multiline(true)
.min_width_characters(non_wrapping_column_width)
.widget_instance(),
])])
}
}
@@ -0,0 +1,22 @@
mod about_graphite_dialog;
mod close_all_documents_dialog;
mod close_document_dialog;
mod confirm_restart_dialog;
mod demo_artwork_dialog;
mod error_dialog;
mod failed_to_load_documents_dialog;
mod failed_to_open_document_dialog;
mod licenses_dialog;
mod licenses_third_party_dialog;
pub use about_graphite_dialog::AboutGraphiteDialog;
pub use close_all_documents_dialog::CloseAllDocumentsDialog;
pub use close_document_dialog::CloseDocumentDialog;
pub use confirm_restart_dialog::ConfirmRestartDialog;
pub use demo_artwork_dialog::ARTWORK;
pub use demo_artwork_dialog::DemoArtworkDialog;
pub use error_dialog::ErrorDialog;
pub use failed_to_load_documents_dialog::FailedToLoadDocumentsDialog;
pub use failed_to_open_document_dialog::FailedToOpenDocumentDialog;
pub use licenses_dialog::LicensesDialog;
pub use licenses_third_party_dialog::LicensesThirdPartyDialog;
@@ -0,0 +1,365 @@
use super::IconName;
use super::utility_types::{MouseCursorIcon, PersistedState};
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform;
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, RasterizedImage};
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::utility_types::{
BoxSelection, ContextMenuInformation, FrontendClickTargets, FrontendGraphInput, FrontendGraphOutput, FrontendNode, FrontendNodeType, NodeGraphErrorDiagnostic,
};
use crate::messages::portfolio::document::utility_types::nodes::{LayerPanelEntry, LayerStructureEntry};
use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate};
use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
use graph_craft::document::NodeId;
use graphene_std::color::SRGBA8;
use graphene_std::vector::style::FillChoiceUI;
use std::path::PathBuf;
#[cfg(not(target_family = "wasm"))]
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
#[impl_message(Message, Frontend)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(derivative::Derivative, Clone, serde::Serialize, serde::Deserialize)]
#[derivative(Debug, PartialEq)]
pub enum FrontendMessage {
// Display prefix: make the frontend show something, like a dialog
DisplayDialog {
title: String,
icon: IconName,
},
DialogClose,
DisplayDialogPanic {
#[serde(rename = "panicInfo")]
panic_info: String,
},
DisplayEditableTextbox {
text: String,
#[serde(rename = "lineHeightRatio")]
line_height_ratio: f64,
#[serde(rename = "fontSize")]
font_size: f64,
color: String,
#[serde(rename = "fontData")]
font_data: serde_bytes::ByteBuf,
transform: [f64; 6],
#[serde(rename = "maxWidth")]
max_width: Option<f64>,
#[serde(rename = "maxHeight")]
max_height: Option<f64>,
align: String,
#[serde(rename = "alignLast")]
align_last: String,
},
DisplayEditableTextboxUpdateFontData {
#[serde(rename = "fontData")]
font_data: serde_bytes::ByteBuf,
},
DisplayEditableTextboxTransform {
transform: [f64; 6],
},
DisplayRemoveEditableTextbox,
// Send prefix: Send global, static data to the frontend that is never updated
SendUIMetadata {
#[serde(rename = "nodeDescriptions")]
node_descriptions: Vec<(String, String)>,
#[serde(rename = "nodeTypes")]
node_types: Vec<FrontendNodeType>,
},
SendShortcutFullscreen {
shortcut: Option<ActionShortcut>,
#[serde(rename = "shortcutMac")]
shortcut_mac: Option<ActionShortcut>,
},
SendShortcutAltClick {
shortcut: Option<ActionShortcut>,
},
SendShortcutShiftClick {
shortcut: Option<ActionShortcut>,
},
// Trigger prefix: cause a frontend specific API to do something
TriggerAboutGraphiteLocalizedCommitDate {
#[serde(rename = "commitDate")]
commit_date: String,
},
TriggerDisplayThirdPartyLicensesDialog,
TriggerSaveDocument {
document_id: DocumentId,
name: String,
path: Option<PathBuf>,
folder: Option<PathBuf>,
content: serde_bytes::ByteBuf,
},
TriggerSaveFile {
name: String,
folder: Option<PathBuf>,
content: serde_bytes::ByteBuf,
},
TriggerExportImage {
svg: String,
name: String,
mime: String,
size: (f64, f64),
},
TriggerFetchAndOpenDocument {
name: String,
filename: String,
},
TriggerPersistenceReadState,
TriggerPersistenceReadDocument {
#[serde(rename = "documentId")]
document_id: DocumentId,
},
TriggerPersistenceWriteState {
state: PersistedState,
},
TriggerPersistenceDeleteDocument {
#[serde(rename = "documentId")]
document_id: DocumentId,
},
TriggerPersistenceWriteDocument {
#[serde(rename = "documentId")]
document_id: DocumentId,
document: String,
},
TriggerOpenLaunchDocuments,
TriggerLoadPreferences,
TriggerOpen,
TriggerImport,
TriggerSavePreferences {
#[cfg_attr(feature = "wasm", tsify(type = "unknown"))]
preferences: PreferencesMessageHandler,
},
TriggerTextCommit,
/// Asks the frontend to enter inline-rename mode for a layer in the graph view.
TriggerEditLayerNameInGraph {
#[serde(rename = "nodeId")]
node_id: NodeId,
},
TriggerVisitLink {
url: String,
},
TriggerClipboardRead,
TriggerClipboardWrite {
content: String,
},
TriggerSelectionRead {
cut: bool,
},
TriggerSelectionWrite {
content: String,
},
// Update prefix: give the frontend a new value or state for it to use
UpdateActiveDocument {
#[serde(rename = "documentId")]
document_id: DocumentId,
},
UpdateGradientStopColorPickerPosition {
color: SRGBA8, // TODO: Color (without `none`) -> Color (with `none`)
position: (f64, f64),
},
/// The Rust color picker handler picked a new color/gradient. The frontend `<ColorPicker />` forwards this as its `colorOrGradient` event.
ColorPickerColorChanged {
value: FillChoiceUI,
},
/// The Rust color picker handler is starting an undo transaction. The frontend `<ColorPicker />` forwards this as its `startHistoryTransaction` event.
ColorPickerStartHistoryTransaction,
/// The Rust color picker handler is committing the in-flight undo transaction. The frontend `<ColorPicker />` forwards this as its `commitHistoryTransaction` event.
ColorPickerCommitHistoryTransaction,
UpdateImportsExports {
/// If the primary import is not visible, then it is None.
imports: Vec<Option<FrontendGraphOutput>>,
/// If the primary export is not visible, then it is None.
exports: Vec<Option<FrontendGraphInput>>,
/// The primary import location.
#[serde(rename = "importPosition")]
import_position: (i32, i32),
/// The primary export location.
#[serde(rename = "exportPosition")]
export_position: (i32, i32),
/// The document network does not have an add import or export button.
#[serde(rename = "addImportExport")]
add_import_export: bool,
},
UpdateInSelectedNetwork {
#[serde(rename = "inSelectedNetwork")]
in_selected_network: bool,
},
UpdateBox {
#[serde(rename = "box")]
box_selection: Option<BoxSelection>,
},
UpdateContextMenuInformation {
#[serde(rename = "contextMenuInformation")]
context_menu_information: Option<ContextMenuInformation>,
},
UpdateClickTargets {
#[serde(rename = "clickTargets")]
click_targets: Option<FrontendClickTargets>,
},
UpdateGraphViewOverlay {
open: bool,
},
UpdateWorkspacePanelLayout {
#[serde(rename = "panelLayout")]
panel_layout: WorkspacePanelLayout,
},
UpdateLayout {
#[serde(rename = "layoutTarget")]
layout_target: LayoutTarget,
diff: Vec<WidgetDiff>, // TODO: Align this with what's generated
},
UpdateImportReorderIndex {
#[serde(rename = "importIndex")]
index: Option<usize>,
},
UpdateExportReorderIndex {
#[serde(rename = "exportIndex")]
index: Option<usize>,
},
UpdateLayerWidths {
#[serde(rename = "layerWidths")]
layer_widths: HashMap<NodeId, u32>,
#[serde(rename = "chainWidths")]
chain_widths: HashMap<NodeId, u32>,
#[serde(rename = "hasLeftInputWire")]
has_left_input_wire: HashMap<NodeId, bool>,
},
UpdateDocumentArtwork {
svg: String,
},
UpdateImageData {
image_data: Vec<RasterizedImage>,
},
UpdateDocumentLayerDetails {
data: LayerPanelEntry,
},
UpdateDocumentLayerStructure {
#[serde(rename = "layerStructure")]
layer_structure: Vec<LayerStructureEntry>,
},
UpdateDocumentRulers {
origin: (f64, f64),
spacing: f64,
interval: f64,
visible: bool,
tilt: f64,
flip: bool,
#[serde(rename = "selectionQuad")]
selection_quad: Option<[(f64, f64); 4]>,
},
UpdateDocumentScrollbars {
position: (f64, f64),
size: (f64, f64),
multiplier: (f64, f64),
},
UpdateEyedropperSamplingState {
image: Option<EyedropperPreviewImage>,
#[serde(rename = "mousePosition")]
mouse_position: Option<(f64, f64)>,
#[serde(rename = "primaryColor")]
primary_color: String,
#[serde(rename = "secondaryColor")]
secondary_color: String,
#[serde(rename = "setColorChoice")]
set_color_choice: Option<PrimarySecondary>,
},
UpdateGraphFadeArtwork {
percentage: f64,
},
UpdateMouseCursor {
cursor: MouseCursorIcon,
},
UpdateNodeGraphNodes {
nodes: Vec<FrontendNode>,
},
UpdateNodeGraphErrorDiagnostic {
error: Option<NodeGraphErrorDiagnostic>,
},
UpdateVisibleNodes {
nodes: Vec<NodeId>,
},
UpdateNodeGraphWires {
wires: Vec<WirePathUpdate>,
},
ClearAllNodeGraphWires,
UpdateNodeGraphSelection {
selected: Vec<NodeId>,
},
UpdateNodeGraphTransform {
translation: (f64, f64),
scale: f64,
},
UpdateNodeThumbnail {
id: NodeId,
value: String,
},
UpdateOpenDocumentsList {
#[serde(rename = "openDocuments")]
open_documents: Vec<DocumentInfo>,
},
UpdateWirePathInProgress {
#[serde(rename = "wirePath")]
wire_path: Option<WirePath>,
},
UpdatePlatform {
platform: AppWindowPlatform,
},
UpdateMaximized {
maximized: bool,
},
UpdateFullscreen {
fullscreen: bool,
},
UpdateViewportHolePunch {
active: bool,
},
#[cfg(not(target_family = "wasm"))]
UpdateViewportPhysicalBounds {
x: f64,
y: f64,
width: f64,
height: f64,
},
UpdateUIScale {
scale: f64,
},
#[cfg(not(target_family = "wasm"))]
RenderOverlays {
#[serde(skip, default = "OverlayContext::default")]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
context: OverlayContext,
},
// Window prefix: cause the application window to do something
#[cfg(not(target_family = "wasm"))]
WindowPointerLock,
WindowPointerLockMove {
position: (f64, f64),
},
#[cfg(not(target_family = "wasm"))]
WindowClose,
#[cfg(not(target_family = "wasm"))]
WindowMinimize,
#[cfg(not(target_family = "wasm"))]
WindowMaximize,
WindowFullscreen,
#[cfg(not(target_family = "wasm"))]
WindowDrag,
#[cfg(not(target_family = "wasm"))]
WindowHide,
#[cfg(not(target_family = "wasm"))]
WindowFocus,
#[cfg(not(target_family = "wasm"))]
WindowHideOthers,
#[cfg(not(target_family = "wasm"))]
WindowShowAll,
#[cfg(not(target_family = "wasm"))]
WindowRestart,
}
@@ -0,0 +1,10 @@
mod frontend_message;
pub mod utility_types;
#[doc(inline)]
pub use frontend_message::{FrontendMessage, FrontendMessageDiscriminant};
// TODO: Make this an enum with the actual icon names, somehow derived from or tied to the frontend icon set.
// TODO: Then remove `#[widget_builder(string)]` from all icon fields.
pub type IconName = String;
@@ -0,0 +1,93 @@
use std::path::PathBuf;
use graph_craft::application_io::resource::ResourceHash;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
use crate::messages::prelude::*;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DocumentInfo {
pub id: DocumentId,
pub name: String,
#[serde(default)]
#[cfg_attr(feature = "wasm", tsify(type = "unknown"))]
pub resources: Option<Box<[ResourceHash]>>,
#[serde(default)]
pub path: Option<PathBuf>,
pub is_saved: bool,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints, from_wasm_abi))]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PersistedState {
pub documents: Vec<DocumentInfo>,
pub current_document: Option<DocumentId>,
#[serde(default)]
pub workspace_layout: Option<WorkspacePanelLayout>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum MouseCursorIcon {
#[default]
Default,
None,
ZoomIn,
ZoomOut,
Grabbing,
Crosshair,
Text,
Move,
NSResize,
EWResize,
NESWResize,
NWSEResize,
Rotate,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum FileType {
#[default]
Png,
Jpg,
Svg,
}
impl FileType {
pub fn to_mime(self) -> &'static str {
match self {
FileType::Png => "image/png",
FileType::Jpg => "image/jpeg",
FileType::Svg => "image/svg+xml",
}
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ExportBounds {
#[default]
AllArtwork,
Selection,
Artboard(LayerNodeIdentifier),
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EyedropperPreviewImage {
pub data: serde_bytes::ByteBuf,
pub width: u32,
pub height: u32,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
pub struct RasterizedImage {
pub id: u64,
pub width: u32,
pub height: u32,
pub pixels: serde_bytes::ByteBuf,
}
@@ -0,0 +1,16 @@
use crate::messages::future::MessageFuture;
use crate::messages::prelude::*;
#[impl_message(Message, Future)]
#[derive(derivative::Derivative, Clone, serde::Serialize, serde::Deserialize)]
#[derivative(Debug, PartialEq)]
pub enum FutureMessage {
/// Spawn `future`; its resolved [`Message`] re-enters the dispatcher on the next tick.
Await {
#[serde(skip, default)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
future: MessageFuture,
},
/// Sent by a wake callback to nudge an idle event loop into a dispatch tick.
Wake,
}
@@ -0,0 +1,203 @@
use std::future::{Future, IntoFuture};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use dyn_any::WasmNotSend;
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use crate::messages::prelude::*;
#[cfg(not(target_family = "wasm"))]
type InnerMessageFuture = Pin<Box<dyn Future<Output = Message> + Send + 'static>>;
#[cfg(target_family = "wasm")]
type InnerMessageFuture = Pin<Box<dyn Future<Output = Message> + 'static>>;
/// Invoked by the spawner after a result is sent, to wake the platform event loop.
pub type Wake = Arc<dyn Fn() + Send + Sync>;
fn noop_wake() -> Wake {
Arc::new(|| {})
}
/// One-shot async work whose result re-enters the dispatcher as a [`Message`].
/// Resolves to [`Message::NoOp`] if polled after the inner future has already been taken.
#[derive(Clone, Default)]
pub struct MessageFuture {
inner: Arc<Mutex<Option<InnerMessageFuture>>>,
}
impl MessageFuture {
pub fn new(future: impl Future<Output = Message> + WasmNotSend + 'static) -> Self {
Self {
inner: Arc::new(Mutex::new(Some(Box::pin(future)))),
}
}
}
impl IntoFuture for MessageFuture {
type Output = Message;
type IntoFuture = InnerMessageFuture;
fn into_future(self) -> Self::IntoFuture {
let taken = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).take();
match taken {
Some(future) => future,
None => Box::pin(async { Message::NoOp }),
}
}
}
impl From<MessageFuture> for Message {
fn from(future: MessageFuture) -> Self {
FutureMessage::Await { future }.into()
}
}
impl<T> From<T> for Message
where
T: Future<Output = Message> + WasmNotSend + 'static,
{
fn from(future: T) -> Self {
MessageFuture::new(future).into()
}
}
/// Platform-specific async-task executor.
/// Runs `future`, sends the resolved message on `results`, then calls `wake`.
pub trait MessageSpawner: Send + Sync {
fn spawn(&self, future: InnerMessageFuture, results: UnboundedSender<Message>, wake: Wake);
}
#[derive(ExtractField)]
pub struct FutureMessageContext {}
#[derive(ExtractField)]
pub struct FutureMessageHandler {
#[cfg_attr(test, expect(dead_code))]
spawner: Arc<dyn MessageSpawner>,
wake: Wake,
#[cfg_attr(test, expect(dead_code))]
results_sender: UnboundedSender<Message>,
results_receiver: UnboundedReceiver<Message>,
}
impl FutureMessageHandler {
pub fn with_wake(wake: Wake) -> Self {
let (results_sender, results_receiver) = unbounded();
Self {
spawner: default_spawner(),
wake,
results_sender,
results_receiver,
}
}
pub fn set_wake(&mut self, wake: Wake) {
self.wake = wake;
}
/// Pull every resolved async result into `out`.
pub fn drain_results(&mut self, out: &mut VecDeque<Message>) {
while let Ok(Some(message)) = self.results_receiver.try_next() {
out.push_back(message);
}
}
}
impl Default for FutureMessageHandler {
fn default() -> Self {
Self::with_wake(noop_wake())
}
}
impl std::fmt::Debug for FutureMessageHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FutureMessageHandler").finish_non_exhaustive()
}
}
#[message_handler_data]
impl MessageHandler<FutureMessage, FutureMessageContext> for FutureMessageHandler {
fn process_message(&mut self, message: FutureMessage, _responses: &mut VecDeque<Message>, _context: FutureMessageContext) {
match message {
FutureMessage::Await { future } => {
#[cfg(not(test))]
self.spawner.spawn(future.into_future(), self.results_sender.clone(), self.wake.clone());
// For tests, block on the future to ensure the result is available when validating editor state afterwards.
#[cfg(test)]
{
let message = futures::executor::block_on(future.into_future());
_responses.push_back(message);
}
}
FutureMessage::Wake => {
// Tick-only message: the dispatcher's top-of-tick drain handles the real work.
}
}
}
advertise_actions!(FutureMessageDiscriminant;);
}
#[cfg(not(target_family = "wasm"))]
fn default_spawner() -> Arc<dyn MessageSpawner> {
Arc::new(TokioSpawner::default())
}
#[cfg(target_family = "wasm")]
fn default_spawner() -> Arc<dyn MessageSpawner> {
Arc::new(WasmSpawner)
}
#[cfg(not(target_family = "wasm"))]
struct TokioSpawner {
/// Built lazily on first spawn. `multi_thread(1)` lets Tokio manage its own driver.
runtime: std::sync::OnceLock<tokio::runtime::Runtime>,
}
#[cfg(not(target_family = "wasm"))]
impl Default for TokioSpawner {
fn default() -> Self {
Self { runtime: std::sync::OnceLock::new() }
}
}
#[cfg(not(target_family = "wasm"))]
impl TokioSpawner {
fn runtime(&self) -> &tokio::runtime::Runtime {
self.runtime.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.thread_name("graphite-async")
.enable_all()
.build()
.expect("failed to construct async-message tokio runtime")
})
}
}
#[cfg(not(target_family = "wasm"))]
impl MessageSpawner for TokioSpawner {
fn spawn(&self, future: InnerMessageFuture, results: UnboundedSender<Message>, wake: Wake) {
self.runtime().spawn(async move {
let message = future.await;
let _ = results.unbounded_send(message);
wake();
});
}
}
#[cfg(target_family = "wasm")]
struct WasmSpawner;
#[cfg(target_family = "wasm")]
impl MessageSpawner for WasmSpawner {
fn spawn(&self, future: InnerMessageFuture, results: UnboundedSender<Message>, wake: Wake) {
wasm_bindgen_futures::spawn_local(async move {
let message = future.await;
let _ = results.unbounded_send(message);
wake();
});
}
}
@@ -0,0 +1,7 @@
mod future_message;
mod future_message_handler;
#[doc(inline)]
pub use future_message::{FutureMessage, FutureMessageDiscriminant};
#[doc(inline)]
pub use future_message_handler::{FutureMessageContext, FutureMessageHandler, MessageFuture, MessageSpawner, Wake};
@@ -0,0 +1,24 @@
use crate::messages::input_mapper::utility_types::input_keyboard::Key;
use crate::messages::input_mapper::utility_types::input_mouse::MouseButton;
use crate::messages::prelude::*;
#[impl_message(Message, KeyMappingMessage, Lookup)]
#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)]
pub enum InputMapperMessage {
// Sub-messages
#[child]
KeyDown(Key),
#[child]
KeyUp(Key),
#[child]
KeyDownNoRepeat(Key),
#[child]
KeyUpNoRepeat(Key),
#[child]
DoubleClick(MouseButton),
// Messages
PointerMove,
PointerShake,
WheelScroll,
}
@@ -0,0 +1,107 @@
use super::utility_types::input_keyboard::KeysGroup;
use super::utility_types::misc::Mapping;
use crate::application::Editor;
use crate::messages::input_mapper::utility_types::input_keyboard::{self, Key};
use crate::messages::input_mapper::utility_types::misc::MappingEntry;
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct InputMapperMessageContext<'a> {
pub input: &'a InputPreprocessorMessageHandler,
pub actions: ActionList,
}
#[derive(Debug, Default, ExtractField)]
pub struct InputMapperMessageHandler {
mapping: Mapping,
}
#[message_handler_data]
impl MessageHandler<InputMapperMessage, InputMapperMessageContext<'_>> for InputMapperMessageHandler {
fn process_message(&mut self, message: InputMapperMessage, responses: &mut VecDeque<Message>, context: InputMapperMessageContext) {
let InputMapperMessageContext { input, actions } = context;
if let Some(message) = self.mapping.match_input_message(message, &input.keyboard, actions) {
responses.add(message);
}
}
advertise_actions!();
}
impl InputMapperMessageHandler {
pub fn set_mapping(&mut self, mapping: Mapping) {
self.mapping = mapping;
}
pub fn action_input_mapping(&self, action_to_find: &MessageDiscriminant) -> Option<KeysGroup> {
let all_key_mapping_entries = std::iter::empty()
.chain(self.mapping.key_up.iter())
.chain(self.mapping.key_down.iter())
.chain(self.mapping.key_up_no_repeat.iter())
.chain(self.mapping.key_down_no_repeat.iter())
.chain(self.mapping.double_click.iter())
.chain(std::iter::once(&self.mapping.wheel_scroll))
.chain(std::iter::once(&self.mapping.pointer_move));
let all_mapping_entries = all_key_mapping_entries.flat_map(|entry| entry.0.iter());
// Filter for the desired message
let found_actions = all_mapping_entries.filter(|entry| entry.action.to_discriminant() == *action_to_find);
// Get the `Key` for this platform's accelerator key
let platform_accel_key = if Editor::environment().is_mac() { Key::Command } else { Key::Control };
let entry_to_key = |entry: &MappingEntry| {
// Get the modifier keys for the entry (and convert them to Key)
let mut keys = entry
.modifiers
.iter()
.map(|i| {
// TODO: Use a safe solution eventually
assert!(
i < input_keyboard::NUMBER_OF_KEYS,
"Attempting to convert a Key with enum index {i}, which is larger than the number of Key enums",
);
(i as u8).try_into().unwrap()
})
.collect::<Vec<_>>();
// Append the key button for the entry
use InputMapperMessage as IMM;
match entry.input {
IMM::KeyDown(key) | IMM::KeyUp(key) | IMM::KeyDownNoRepeat(key) | IMM::KeyUpNoRepeat(key) => keys.push(key),
_ => (),
}
keys.sort_by(|&a, &b| {
// Order according to platform guidelines mentioned at https://ux.stackexchange.com/questions/58185/normative-ordering-for-modifier-key-combinations
const ORDER: [Key; 4] = [Key::Control, Key::Alt, Key::Shift, Key::Command];
// Treat the `Accel` virtual key as the platform's accel key for sorting comparison purposes
let a = if a == Key::Accel { platform_accel_key } else { a };
let b = if b == Key::Accel { platform_accel_key } else { b };
// Find where the keys are in the order, or put them at the end if they're not found
let a = ORDER.iter().position(|&key| key == a).unwrap_or(ORDER.len());
let b = ORDER.iter().position(|&key| key == b).unwrap_or(ORDER.len());
// Compare the positions of both keys
a.cmp(&b)
});
KeysGroup(keys)
};
// If a canonical key combination is found, return it
if let Some(canonical) = found_actions.clone().find(|entry| entry.canonical).map(entry_to_key) {
return Some(canonical);
}
// Find the key combinations for all keymaps matching the desired action
assert!(std::mem::size_of::<usize>() >= std::mem::size_of::<Key>());
let mut key_sequences = found_actions.map(entry_to_key).collect::<Vec<_>>();
// Return the shortest key sequence, if any
key_sequences.sort_by_key(|keys| keys.0.len());
key_sequences.first().cloned()
}
}
@@ -0,0 +1,496 @@
use crate::application::Editor;
use crate::consts::{BIG_NUDGE_AMOUNT, BRUSH_SIZE_CHANGE_KEYBOARD, NUDGE_AMOUNT};
use crate::messages::input_mapper::key_mapping::MappingVariant;
use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeyStates};
use crate::messages::input_mapper::utility_types::input_mouse::MouseButton;
use crate::messages::input_mapper::utility_types::macros::*;
use crate::messages::input_mapper::utility_types::misc::MappingEntry;
use crate::messages::input_mapper::utility_types::misc::{KeyMappingEntries, Mapping};
use crate::messages::portfolio::document::node_graph::utility_types::Direction;
use crate::messages::portfolio::document::utility_types::misc::GroupFolderType;
use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::brush_tool::BrushToolMessageOptionsUpdate;
use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys;
use glam::DVec2;
impl From<MappingVariant> for Mapping {
fn from(value: MappingVariant) -> Self {
match value {
MappingVariant::Default => input_mappings(false),
MappingVariant::ZoomWithScroll => input_mappings(true),
}
}
}
pub fn input_mappings(zoom_with_scroll: bool) -> Mapping {
use InputMapperMessage::*;
use Key::*;
let is_mac = Editor::environment().is_mac();
// NOTICE:
// If a new mapping you added here isn't working (and perhaps another lower-precedence one is instead), make sure to advertise
// it as an available action in the respective message handler file (such as the bottom of `document_message_handler.rs`).
let mappings = mapping![
// ===============
// HIGHER PRIORITY
// ===============
//
// NavigationMessage
entry!(PointerMove; refresh_keys=[Shift], action_dispatch=NavigationMessage::PointerMove { snap: Shift }),
entry!(KeyUp(MouseLeft); action_dispatch=NavigationMessage::EndCanvasPTZ { abort_transform: false }),
entry!(KeyUp(MouseMiddle); action_dispatch=NavigationMessage::EndCanvasPTZ { abort_transform: false }),
entry!(KeyUp(MouseRight); action_dispatch=NavigationMessage::EndCanvasPTZ { abort_transform: false }),
entry!(KeyDown(MouseRight); action_dispatch=NavigationMessage::EndCanvasPTZ { abort_transform: true }),
entry!(KeyDown(Escape); action_dispatch=NavigationMessage::EndCanvasPTZ { abort_transform: true }),
entry!(KeyDown(MouseLeft); action_dispatch=NavigationMessage::EndCanvasPTZWithClick { commit_key: MouseLeft }),
entry!(KeyDown(MouseMiddle); action_dispatch=NavigationMessage::EndCanvasPTZWithClick { commit_key: MouseMiddle }),
entry!(KeyDown(MouseRight); action_dispatch=NavigationMessage::EndCanvasPTZWithClick { commit_key: MouseRight }),
//
// ===============
// NORMAL PRIORITY
// ===============
//
// Hack to prevent Left Click + Accel + Z combo (this effectively blocks you from making a double undo with AbortTransaction)
entry!(KeyDown(KeyZ); modifiers=[Accel, MouseLeft], action_dispatch=DocumentMessage::Noop),
//
// AppWindowMessage
entry!(KeyDown(F11); disabled=is_mac, action_dispatch=AppWindowMessage::Fullscreen),
entry!(KeyDown(KeyF); modifiers=[Command, Control], disabled=!is_mac, action_dispatch=AppWindowMessage::Fullscreen),
entry!(KeyDown(KeyQ); modifiers=[Command], disabled=cfg!(not(target_os = "macos")), action_dispatch=AppWindowMessage::Close),
//
// ClipboardMessage
entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=ClipboardMessage::Cut),
entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=ClipboardMessage::Copy),
entry!(KeyDown(KeyV); modifiers=[Accel], action_dispatch=ClipboardMessage::Paste),
//
// NodeGraphMessage
entry!(KeyDown(MouseLeft); action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: false, alt_click: false, right_click: false }),
entry!(KeyDown(MouseLeft); modifiers=[Shift], action_dispatch=NodeGraphMessage::PointerDown { shift_click: true, control_click: false, alt_click: false, right_click: false }),
entry!(KeyDown(MouseLeft); modifiers=[Accel], action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: true, alt_click: false, right_click: false }),
entry!(KeyDown(MouseLeft); modifiers=[Shift, Accel], action_dispatch=NodeGraphMessage::PointerDown { shift_click: true, control_click: true, alt_click: false, right_click: false }),
entry!(KeyDown(MouseLeft); modifiers=[Alt], action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: false, alt_click: true, right_click: false }),
entry!(KeyDown(MouseRight); action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: false, alt_click: false, right_click: true }),
entry!(DoubleClick(MouseButton::Left); action_dispatch=NodeGraphMessage::EnterNestedNetwork),
entry!(PointerMove; refresh_keys=[Control, Alt, Shift], action_dispatch=NodeGraphMessage::PointerMove { shift: Shift }),
entry!(PointerShake; action_dispatch=NodeGraphMessage::ShakeNode),
entry!(KeyUp(MouseLeft); action_dispatch=NodeGraphMessage::PointerUp),
entry!(KeyDown(Delete); modifiers=[Accel], action_dispatch=NodeGraphMessage::DeleteSelectedNodes { delete_children: false }),
entry!(KeyDown(Backspace); modifiers=[Accel], action_dispatch=NodeGraphMessage::DeleteSelectedNodes { delete_children: false }),
entry!(KeyDown(Delete); action_dispatch=NodeGraphMessage::DeleteSelectedNodes { delete_children: true }),
entry!(KeyDown(Backspace); action_dispatch=NodeGraphMessage::DeleteSelectedNodes { delete_children: true }),
entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=NodeGraphMessage::Cut),
entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=NodeGraphMessage::Copy),
entry!(KeyDown(KeyD); modifiers=[Accel], action_dispatch=NodeGraphMessage::DuplicateSelectedNodes),
entry!(KeyDown(KeyH); modifiers=[Accel], action_dispatch=NodeGraphMessage::ToggleSelectedVisibility),
entry!(KeyDown(KeyL); modifiers=[Accel], action_dispatch=NodeGraphMessage::ToggleSelectedLocked),
entry!(KeyDown(KeyL); modifiers=[Alt], action_dispatch=NodeGraphMessage::ToggleSelectedAsLayersOrNodes),
entry!(KeyDown(KeyC); modifiers=[Alt], action_dispatch=NodeGraphMessage::SendClickTargets),
entry!(KeyDown(KeyM); modifiers=[Accel], action_dispatch=NodeGraphMessage::MergeSelectedNodes),
entry!(KeyUp(KeyC); action_dispatch=NodeGraphMessage::EndSendClickTargets),
entry!(KeyDown(ArrowUp); action_dispatch=NodeGraphMessage::ShiftSelectedNodes { direction: Direction::Up, rubber_band: false }),
entry!(KeyDown(ArrowRight); action_dispatch=NodeGraphMessage::ShiftSelectedNodes { direction: Direction::Right, rubber_band: false }),
entry!(KeyDown(ArrowDown); action_dispatch=NodeGraphMessage::ShiftSelectedNodes { direction: Direction::Down, rubber_band: false }),
entry!(KeyDown(ArrowLeft); action_dispatch=NodeGraphMessage::ShiftSelectedNodes { direction: Direction::Left, rubber_band: false }),
//
// TransformLayerMessage
entry!(KeyDown(Enter); action_dispatch=TransformLayerMessage::ApplyTransformOperation { final_transform: true }),
entry!(KeyDown(MouseLeft); action_dispatch=TransformLayerMessage::ApplyTransformOperation { final_transform: true }),
entry!(KeyDown(MouseRight); action_dispatch=TransformLayerMessage::CancelTransformOperation),
entry!(KeyDown(Escape); action_dispatch=TransformLayerMessage::CancelTransformOperation),
entry!(KeyDown(KeyX); action_dispatch=TransformLayerMessage::ConstrainX),
entry!(KeyDown(KeyY); action_dispatch=TransformLayerMessage::ConstrainY),
entry!(KeyDown(Backspace); action_dispatch=TransformLayerMessage::TypeBackspace),
entry!(KeyDown(Minus); action_dispatch=TransformLayerMessage::TypeNegate),
entry!(KeyDown(Comma); action_dispatch=TransformLayerMessage::TypeDecimalPoint),
entry!(KeyDown(Period); action_dispatch=TransformLayerMessage::TypeDecimalPoint),
entry!(PointerMove; refresh_keys=[Control, Shift], action_dispatch=TransformLayerMessage::PointerMove { slow_key: Shift, increments_key: Control }),
//
// SelectToolMessage
entry!(PointerMove; refresh_keys=[Control, Alt, Shift], action_dispatch=SelectToolMessage::PointerMove { modifier_keys: SelectToolPointerKeys { axis_align: Shift, snap_angle: Shift, center: Alt, duplicate: Alt } }),
entry!(KeyDown(MouseLeft); action_dispatch=SelectToolMessage::DragStart { extend_selection: Shift, remove_from_selection: Alt, select_deepest: Accel, lasso_select: Control, skew: Control }),
entry!(KeyUp(MouseLeft); action_dispatch=SelectToolMessage::DragStop { remove_from_selection: Alt }),
entry!(KeyDown(Enter); action_dispatch=SelectToolMessage::Enter),
entry!(DoubleClick(MouseButton::Left); action_dispatch=SelectToolMessage::EditLayer),
entry!(KeyDown(MouseRight); action_dispatch=SelectToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=SelectToolMessage::Abort),
//
// ArtboardToolMessage
entry!(KeyDown(MouseLeft); action_dispatch=ArtboardToolMessage::PointerDown),
entry!(PointerMove; refresh_keys=[Shift, Alt], action_dispatch=ArtboardToolMessage::PointerMove { constrain_axis_or_aspect: Shift, center: Alt }),
entry!(KeyUp(MouseLeft); action_dispatch=ArtboardToolMessage::PointerUp),
entry!(KeyDown(Delete); action_dispatch=ArtboardToolMessage::DeleteSelected),
entry!(KeyDown(Backspace); action_dispatch=ArtboardToolMessage::DeleteSelected),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowLeft], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowRight], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[Shift], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: 0., delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowLeft], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowRight], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: 0., delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowUp], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowDown], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -BIG_NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowUp], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowDown], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: BIG_NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowLeft], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowRight], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: 0., delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowLeft], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowRight], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: 0., delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowUp], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowDown], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: -NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowUp], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowDown], action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); action_dispatch=ArtboardToolMessage::NudgeSelected { delta_x: NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(MouseRight); action_dispatch=ArtboardToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=ArtboardToolMessage::Abort),
//
// NavigateToolMessage
entry!(KeyDown(MouseLeft); action_dispatch=NavigateToolMessage::ZoomCanvasBegin),
entry!(KeyDown(MouseLeft); modifiers=[Alt], action_dispatch=NavigateToolMessage::TiltCanvasBegin),
entry!(PointerMove; refresh_keys=[Shift], action_dispatch=NavigateToolMessage::PointerMove { snap: Shift }),
entry!(KeyUp(MouseLeft); action_dispatch=NavigateToolMessage::PointerUp { zoom_in: true }),
entry!(KeyUp(MouseLeft); modifiers=[Shift], action_dispatch=NavigateToolMessage::PointerUp { zoom_in: false }),
//
// EyedropperToolMessage
entry!(KeyDown(MouseLeft); action_dispatch=EyedropperToolMessage::SamplePrimaryColorBegin),
entry!(KeyDown(MouseLeft); modifiers=[Shift], action_dispatch=EyedropperToolMessage::SampleSecondaryColorBegin),
entry!(KeyUp(MouseLeft); action_dispatch=EyedropperToolMessage::SamplePrimaryColorEnd),
entry!(KeyUp(MouseLeft); modifiers=[Shift], action_dispatch=EyedropperToolMessage::SampleSecondaryColorEnd),
entry!(PointerMove; action_dispatch=EyedropperToolMessage::PointerMove),
entry!(KeyDown(MouseRight); action_dispatch=EyedropperToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=EyedropperToolMessage::Abort),
//
// TextToolMessage
entry!(PointerMove; refresh_keys=[Alt, Shift], action_dispatch=TextToolMessage::PointerMove { center: Alt, lock_ratio: Shift }),
entry!(KeyDown(MouseLeft); action_dispatch=TextToolMessage::DragStart),
entry!(KeyUp(MouseLeft); action_dispatch=TextToolMessage::DragStop),
entry!(KeyDown(MouseRight); action_dispatch=TextToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=TextToolMessage::Abort),
entry!(KeyDown(Enter); modifiers=[Accel], action_dispatch=TextToolMessage::Abort),
//
// GradientToolMessage
entry!(DoubleClick(MouseButton::Left); action_dispatch=GradientToolMessage::DoubleClick),
entry!(KeyDown(MouseLeft); action_dispatch=GradientToolMessage::PointerDown),
entry!(PointerMove; refresh_keys=[Shift, Control], action_dispatch=GradientToolMessage::PointerMove { constrain_axis: Shift, lock_angle: Control }),
entry!(KeyUp(MouseLeft); action_dispatch=GradientToolMessage::PointerUp),
entry!(KeyDown(Delete); action_dispatch=GradientToolMessage::DeleteStop),
entry!(KeyDown(Backspace); action_dispatch=GradientToolMessage::DeleteStop),
entry!(KeyDown(MouseRight); action_dispatch=GradientToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=GradientToolMessage::Abort),
//
// ShapeToolMessage
entry!(KeyDown(MouseLeft); action_dispatch=ShapeToolMessage::DragStart),
entry!(KeyUp(MouseLeft); action_dispatch=ShapeToolMessage::DragStop),
entry!(KeyDown(MouseRight); action_dispatch=ShapeToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=ShapeToolMessage::Abort),
entry!(KeyDown(BracketLeft); action_dispatch=ShapeToolMessage::DecreaseSides),
entry!(KeyDown(BracketRight); action_dispatch=ShapeToolMessage::IncreaseSides),
entry!(PointerMove; refresh_keys=[Alt, Shift, Control], action_dispatch=ShapeToolMessage::PointerMove { modifier: [Alt, Shift, Control] }),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowLeft], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowRight], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[Shift], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: 0., delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowLeft], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowRight], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: 0., delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowUp], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowDown], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowUp], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowDown], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowLeft], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowRight], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: 0., delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowLeft], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowRight], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: 0., delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowUp], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowDown], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowUp], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowDown], action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); action_dispatch=ShapeToolMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); action_dispatch=ShapeToolMessage::IncreaseSides),
entry!(KeyDown(ArrowDown); action_dispatch=ShapeToolMessage::DecreaseSides),
//
// PathToolMessage
entry!(KeyDown(Delete); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath),
entry!(KeyDown(Backspace); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath),
entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=PathToolMessage::Cut),
entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=PathToolMessage::Copy),
entry!(KeyDown(KeyD); modifiers=[Accel], action_dispatch=PathToolMessage::Duplicate),
entry!(KeyDownNoRepeat(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles),
entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { extend_selection: Shift, lasso_select: Control, handle_drag_from_anchor: Alt, drag_restore_handle: Control, segment_editing_modifier: Control }),
entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick),
entry!(KeyDown(Escape); action_dispatch=PathToolMessage::Escape),
entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }),
entry!(KeyDown(KeyR); action_dispatch=PathToolMessage::GRS { key: KeyR }),
entry!(KeyDown(KeyS); action_dispatch=PathToolMessage::GRS { key: KeyS }),
entry!(PointerMove; refresh_keys=[KeyC, Space, Control, Shift, Alt], action_dispatch=PathToolMessage::PointerMove { toggle_colinear: KeyC, equidistant: Alt, move_anchor_with_handles: Space, snap_angle: Shift, lock_angle: Control, delete_segment: Alt, break_colinear_molding: Alt, segment_editing_modifier: Control }),
entry!(KeyDown(Delete); action_dispatch=PathToolMessage::Delete),
entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=PathToolMessage::SelectAll),
entry!(KeyDown(KeyA); modifiers=[Accel, Shift], canonical, action_dispatch=PathToolMessage::DeselectAllSelected),
entry!(KeyDown(KeyA); modifiers=[Alt], action_dispatch=PathToolMessage::DeselectAllSelected),
entry!(KeyDown(Backspace); action_dispatch=PathToolMessage::Delete),
entry!(KeyUp(MouseLeft); action_dispatch=PathToolMessage::DragStop { extend_selection: Shift, shrink_selection: Alt }),
entry!(KeyDown(Enter); action_dispatch=PathToolMessage::Enter { extend_selection: Shift, shrink_selection: Alt }),
entry!(DoubleClick(MouseButton::Left); action_dispatch=PathToolMessage::DoubleClick { extend_selection: Shift, shrink_selection: Alt }),
entry!(KeyDown(ArrowRight); action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: NUDGE_AMOUNT, delta_y: 0. }),
entry!(KeyDown(ArrowRight); modifiers=[Shift], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: 0. }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowUp], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowDown], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowUp], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowDown], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowUp); action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: 0., delta_y: -NUDGE_AMOUNT }),
entry!(KeyDown(ArrowUp); modifiers=[Shift], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: 0., delta_y: -BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowLeft], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowRight], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT }),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowLeft], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowRight], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowLeft); action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -NUDGE_AMOUNT, delta_y: 0. }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -BIG_NUDGE_AMOUNT, delta_y: 0. }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowUp], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowDown], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowUp], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowDown], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowDown); action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: 0., delta_y: NUDGE_AMOUNT }),
entry!(KeyDown(ArrowDown); modifiers=[Shift], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: 0., delta_y: BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowLeft], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowRight], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowLeft], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowRight], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT }),
entry!(KeyDown(KeyJ); modifiers=[Accel], action_dispatch=ToolMessage::Path(PathToolMessage::ClosePath)),
//
// PenToolMessage
entry!(PointerMove; refresh_keys=[Control, Alt, Shift, KeyC], action_dispatch=PenToolMessage::PointerMove { snap_angle: Shift, break_handle: Alt, lock_angle: Control, colinear: KeyC, move_anchor_with_handles: Space }),
entry!(KeyDownNoRepeat(Tab); action_dispatch=PenToolMessage::SwapHandles),
entry!(KeyDown(MouseLeft); action_dispatch=PenToolMessage::DragStart { append_to_selected: Shift }),
entry!(KeyUp(MouseLeft); action_dispatch=PenToolMessage::DragStop),
entry!(KeyDown(MouseRight); action_dispatch=PenToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=PenToolMessage::Abort),
entry!(KeyDown(Enter); action_dispatch=PenToolMessage::Confirm),
entry!(KeyDown(Delete); action_dispatch=PenToolMessage::RemovePreviousHandle),
entry!(KeyDown(Backspace); action_dispatch=PenToolMessage::RemovePreviousHandle),
entry!(KeyDown(KeyG); action_dispatch=PenToolMessage::GRS { grab: KeyG, rotate: KeyR, scale: KeyS }),
entry!(KeyDown(KeyR); action_dispatch=PenToolMessage::GRS { grab: KeyG, rotate: KeyR, scale: KeyS }),
entry!(KeyDown(KeyS); action_dispatch=PenToolMessage::GRS { grab: KeyG, rotate: KeyR, scale: KeyS }),
//
// FreehandToolMessage
entry!(PointerMove; action_dispatch=FreehandToolMessage::PointerMove),
entry!(KeyDown(MouseLeft); action_dispatch=FreehandToolMessage::DragStart { append_to_selected: Shift }),
entry!(KeyUp(MouseLeft); action_dispatch=FreehandToolMessage::DragStop),
entry!(KeyDown(MouseRight); action_dispatch=FreehandToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=FreehandToolMessage::Abort),
//
// SplineToolMessage
entry!(PointerMove; action_dispatch=SplineToolMessage::PointerMove),
entry!(KeyDown(MouseLeft); action_dispatch=SplineToolMessage::DragStart { append_to_selected: Shift }),
entry!(KeyUp(MouseLeft); action_dispatch=SplineToolMessage::DragStop),
entry!(KeyDown(MouseRight); action_dispatch=SplineToolMessage::Confirm),
entry!(KeyDown(Escape); action_dispatch=SplineToolMessage::Confirm),
entry!(KeyDown(Enter); action_dispatch=SplineToolMessage::Confirm),
//
// FillToolMessage
entry!(PointerMove; refresh_keys=[Shift], action_dispatch=FillToolMessage::PointerMove),
entry!(KeyDown(MouseLeft); action_dispatch=FillToolMessage::FillPrimaryColor),
entry!(KeyDown(MouseLeft); modifiers=[Shift], action_dispatch=FillToolMessage::FillSecondaryColor),
entry!(KeyUp(MouseLeft); action_dispatch=FillToolMessage::PointerUp),
entry!(KeyDown(MouseRight); action_dispatch=FillToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=FillToolMessage::Abort),
//
// BrushToolMessage
entry!(PointerMove; action_dispatch=BrushToolMessage::PointerMove),
entry!(KeyDown(MouseLeft); action_dispatch=BrushToolMessage::DragStart),
entry!(KeyUp(MouseLeft); action_dispatch=BrushToolMessage::DragStop),
entry!(KeyDown(BracketLeft); action_dispatch=BrushToolMessage::UpdateOptions { options: BrushToolMessageOptionsUpdate::ChangeDiameter(-BRUSH_SIZE_CHANGE_KEYBOARD) }),
entry!(KeyDown(BracketRight); action_dispatch=BrushToolMessage::UpdateOptions { options: BrushToolMessageOptionsUpdate::ChangeDiameter(BRUSH_SIZE_CHANGE_KEYBOARD) }),
entry!(KeyDown(MouseRight); action_dispatch=BrushToolMessage::Abort),
entry!(KeyDown(Escape); action_dispatch=BrushToolMessage::Abort),
//
// ToolMessage
entry!(KeyDown(KeyV); action_dispatch=ToolMessage::ActivateToolSelect),
entry!(KeyDown(KeyZ); action_dispatch=ToolMessage::ActivateToolNavigate),
entry!(KeyDown(KeyI); action_dispatch=ToolMessage::ActivateToolEyedropper),
entry!(KeyDown(KeyT); action_dispatch=ToolMessage::ActivateToolText),
entry!(KeyDown(KeyF); action_dispatch=ToolMessage::ActivateToolFill),
entry!(KeyDown(KeyH); action_dispatch=ToolMessage::ActivateToolGradient),
entry!(KeyDown(KeyA); action_dispatch=ToolMessage::ActivateToolPath),
entry!(KeyDown(KeyP); action_dispatch=ToolMessage::ActivateToolPen),
entry!(KeyDown(KeyN); action_dispatch=ToolMessage::ActivateToolFreehand),
entry!(KeyDown(KeyL); action_dispatch=ToolMessage::ActivateToolShapeLine),
entry!(KeyDown(KeyM); action_dispatch=ToolMessage::ActivateToolShapeRectangle),
entry!(KeyDown(KeyE); action_dispatch=ToolMessage::ActivateToolShapeEllipse),
entry!(KeyDown(KeyY); action_dispatch=ToolMessage::ActivateToolShape),
entry!(KeyDown(KeyB); action_dispatch=ToolMessage::ActivateToolBrush),
entry!(KeyDown(KeyD); action_dispatch=ToolMessage::ResetColors),
entry!(KeyDown(KeyX); modifiers=[Shift], action_dispatch=ToolMessage::SwapColors),
entry!(KeyDown(KeyC); modifiers=[Alt], action_dispatch=ToolMessage::SelectRandomWorkingColor { primary: true }),
entry!(KeyDown(KeyC); modifiers=[Alt, Shift], action_dispatch=ToolMessage::SelectRandomWorkingColor { primary: false }),
// TODO: Change to KeyDownNoRepeat when https://github.com/GraphiteEditor/Graphite/issues/2266 is resolved
entry!(KeyDown(Tab); action_dispatch=ToolMessage::ToggleSelectVsPath),
//
// DocumentMessage
entry!(KeyDown(Space); modifiers=[Control], action_dispatch=DocumentMessage::GraphViewOverlayToggle),
entry!(KeyDownNoRepeat(Escape); action_dispatch=DocumentMessage::Escape),
entry!(KeyDown(Delete); action_dispatch=DocumentMessage::DeleteSelectedLayers),
entry!(KeyDown(Backspace); action_dispatch=DocumentMessage::DeleteSelectedLayers),
entry!(KeyDown(KeyO); modifiers=[Alt], action_dispatch=DocumentMessage::ToggleOverlaysVisibility),
entry!(KeyDown(KeyS); modifiers=[Alt], action_dispatch=DocumentMessage::ToggleSnapping),
entry!(KeyDown(KeyH); modifiers=[Accel], action_dispatch=DocumentMessage::ToggleSelectedVisibility),
entry!(KeyDown(KeyL); modifiers=[Accel], action_dispatch=DocumentMessage::ToggleSelectedLocked),
entry!(KeyDown(KeyG); modifiers=[Alt], action_dispatch=DocumentMessage::ToggleGridVisibility),
entry!(KeyDown(KeyZ); modifiers=[Accel, Shift], canonical, action_dispatch=DocumentMessage::Redo),
entry!(KeyDown(KeyY); modifiers=[Accel], action_dispatch=DocumentMessage::Redo),
entry!(KeyDown(KeyZ); modifiers=[Accel], action_dispatch=DocumentMessage::Undo),
entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=DocumentMessage::SelectAllLayers),
entry!(KeyDown(KeyA); modifiers=[Accel, Shift], canonical, action_dispatch=DocumentMessage::DeselectAllLayers),
entry!(KeyDown(KeyA); modifiers=[Alt], action_dispatch=DocumentMessage::DeselectAllLayers),
entry!(KeyDown(KeyS); modifiers=[Accel], action_dispatch=DocumentMessage::SaveDocument),
entry!(KeyDown(KeyS); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::SaveDocumentAs),
entry!(KeyDown(KeyD); modifiers=[Accel], canonical, action_dispatch=DocumentMessage::DuplicateSelectedLayers),
entry!(KeyDown(KeyJ); modifiers=[Accel], action_dispatch=DocumentMessage::DuplicateSelectedLayers),
entry!(KeyDown(KeyB); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::BlendSelectedLayers),
entry!(KeyDown(KeyM); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::MorphSelectedLayers), // Might get eaten by the GeForce Experience overlay for some Windows users
entry!(KeyDown(KeyG); modifiers=[Accel], action_dispatch=DocumentMessage::GroupSelectedLayers { group_folder_type: GroupFolderType::Layer }),
entry!(KeyDown(KeyG); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::UngroupSelectedLayers),
entry!(KeyDown(KeyN); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::CreateEmptyFolder),
entry!(KeyDown(Escape); modifiers=[Shift], action_dispatch=DocumentMessage::SelectParentLayer),
entry!(KeyDown(BracketLeft); modifiers=[Alt], canonical, action_dispatch=DocumentMessage::SelectionStepBack),
entry!(KeyDown(BracketRight); modifiers=[Alt], canonical, action_dispatch=DocumentMessage::SelectionStepForward),
entry!(KeyDown(MouseBack); action_dispatch=DocumentMessage::SelectionStepBack),
entry!(KeyDown(MouseForward); action_dispatch=DocumentMessage::SelectionStepForward),
entry!(KeyDown(Digit0); modifiers=[Accel], action_dispatch=DocumentMessage::ZoomCanvasToFitAll),
entry!(KeyDown(Digit1); modifiers=[Accel], action_dispatch=DocumentMessage::ZoomCanvasTo100Percent),
entry!(KeyDown(Digit2); modifiers=[Accel], action_dispatch=DocumentMessage::ZoomCanvasTo200Percent),
entry!(KeyDown(BracketLeft); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::SelectedLayersLowerToBack),
entry!(KeyDown(BracketRight); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::SelectedLayersRaiseToFront),
entry!(KeyDown(BracketLeft); modifiers=[Accel], action_dispatch=DocumentMessage::SelectedLayersLower),
entry!(KeyDown(BracketRight); modifiers=[Accel], action_dispatch=DocumentMessage::SelectedLayersRaise),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowLeft], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[Shift, ArrowRight], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[Shift], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: 0., delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowLeft], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowRight], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[Shift], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: 0., delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowUp], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift, ArrowDown], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[Shift], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -BIG_NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowUp], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: -BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift, ArrowDown], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[Shift], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: BIG_NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowLeft], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); modifiers=[ArrowRight], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowUp); action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: 0., delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowLeft], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); modifiers=[ArrowRight], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowDown); action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: 0., delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowUp], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); modifiers=[ArrowDown], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowLeft); action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: -NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowUp], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: -NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); modifiers=[ArrowDown], action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: NUDGE_AMOUNT, resize: Alt, resize_opposite: Control }),
entry!(KeyDown(ArrowRight); action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite: Control }),
//
// TransformLayerMessage
entry!(KeyDown(KeyG); action_dispatch=TransformLayerMessage::BeginGrab),
entry!(KeyDown(KeyR); action_dispatch=TransformLayerMessage::BeginRotate),
entry!(KeyDown(KeyS); action_dispatch=TransformLayerMessage::BeginScale),
entry!(KeyDown(Digit0); action_dispatch=TransformLayerMessage::TypeDigit { digit: 0 }),
entry!(KeyDown(Digit1); action_dispatch=TransformLayerMessage::TypeDigit { digit: 1 }),
entry!(KeyDown(Digit2); action_dispatch=TransformLayerMessage::TypeDigit { digit: 2 }),
entry!(KeyDown(Digit3); action_dispatch=TransformLayerMessage::TypeDigit { digit: 3 }),
entry!(KeyDown(Digit4); action_dispatch=TransformLayerMessage::TypeDigit { digit: 4 }),
entry!(KeyDown(Digit5); action_dispatch=TransformLayerMessage::TypeDigit { digit: 5 }),
entry!(KeyDown(Digit6); action_dispatch=TransformLayerMessage::TypeDigit { digit: 6 }),
entry!(KeyDown(Digit7); action_dispatch=TransformLayerMessage::TypeDigit { digit: 7 }),
entry!(KeyDown(Digit8); action_dispatch=TransformLayerMessage::TypeDigit { digit: 8 }),
entry!(KeyDown(Digit9); action_dispatch=TransformLayerMessage::TypeDigit { digit: 9 }),
//
// NavigationMessage
entry!(KeyDown(MouseMiddle); modifiers=[Alt], action_dispatch=NavigationMessage::BeginCanvasTilt { was_dispatched_from_menu: false }),
entry!(KeyDown(MouseLeft); modifiers=[Alt, Space], action_dispatch=NavigationMessage::BeginCanvasTilt { was_dispatched_from_menu: false }),
entry!(KeyDown(MouseMiddle); modifiers=[Control], action_dispatch=NavigationMessage::BeginCanvasZoom),
entry!(KeyDown(MouseLeft); modifiers=[Control, Space], action_dispatch=NavigationMessage::BeginCanvasZoom),
entry!(KeyDown(MouseMiddle); action_dispatch=NavigationMessage::BeginCanvasPan),
entry!(KeyDown(MouseLeft); modifiers=[Space], action_dispatch=NavigationMessage::BeginCanvasPan),
entry!(KeyDown(NumpadAdd); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }),
// `FakeKeyPlus` is a nonfunctional key mapping that must be accompanied by its real `Equal` key counterpart. This is used only to set the canonical key label so it shows "+" instead of "=" in the UI.
entry!(KeyDown(FakeKeyPlus); modifiers=[Accel], canonical, action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }),
entry!(KeyDown(Equal); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }),
entry!(KeyDown(Minus); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }),
entry!(WheelScroll; modifiers=[Control], disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(WheelScroll; modifiers=[Command], disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(WheelScroll; modifiers=[Shift], disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: true }),
entry!(WheelScroll; disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: false }),
// On Mac, the OS already converts Shift+scroll into horizontal scrolling so we have to reverse the behavior from normal to produce the same outcome
entry!(WheelScroll; modifiers=[Control], disabled=!zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: is_mac }),
entry!(WheelScroll; modifiers=[Shift], disabled=!zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: !is_mac }),
entry!(WheelScroll; disabled=!zoom_with_scroll, action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(KeyDown(PageUp); modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(1., 0.) }),
entry!(KeyDown(PageDown); modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(-1., 0.) }),
entry!(KeyDown(PageUp); action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(0., 1.) }),
entry!(KeyDown(PageDown); action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(0., -1.) }),
entry!(KeyDown(Period); action_dispatch=NavigationMessage::FitViewportToSelection),
//
// PortfolioMessage
entry!(KeyDown(Tab); modifiers=[Control], action_dispatch=PortfolioMessage::NextDocument),
entry!(KeyDown(Tab); modifiers=[Control, Shift], action_dispatch=PortfolioMessage::PrevDocument),
entry!(KeyDown(KeyW); modifiers=[Accel], action_dispatch=PortfolioMessage::CloseActiveDocumentWithConfirmation),
entry!(KeyDown(KeyW); modifiers=[Accel, Alt], action_dispatch=PortfolioMessage::CloseAllDocumentsWithConfirmation),
entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=PortfolioMessage::Open),
entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=PortfolioMessage::Import),
entry!(KeyDown(KeyR); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleRulers),
entry!(KeyDown(KeyD); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleDataPanelOpen),
entry!(KeyDown(Enter); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleFocusDocument),
//
// DialogMessage
entry!(KeyDown(KeyE); modifiers=[Accel], action_dispatch=DialogMessage::RequestExportDialog),
entry!(KeyDown(KeyN); modifiers=[Accel], action_dispatch=DialogMessage::RequestNewDocumentDialog),
entry!(KeyDown(Comma); modifiers=[Accel], action_dispatch=DialogMessage::RequestPreferencesDialog),
//
// DebugMessage
entry!(KeyDown(Digit0); modifiers=[Alt], action_dispatch=DebugMessage::MessageOff),
entry!(KeyDown(Digit1); modifiers=[Alt], action_dispatch=DebugMessage::MessageNames),
entry!(KeyDown(Digit2); modifiers=[Alt], action_dispatch=DebugMessage::MessageContents),
// AnimationMessage
entry!(KeyDown(Space); modifiers=[Shift], action_dispatch=AnimationMessage::ToggleLivePreview),
entry!(KeyDown(Home); modifiers=[Shift], action_dispatch=AnimationMessage::RestartAnimation),
];
let (mut key_up, mut key_down, mut key_up_no_repeat, mut key_down_no_repeat, mut double_click, mut wheel_scroll, mut pointer_move, mut pointer_shake) = mappings;
let sort = |list: &mut KeyMappingEntries| list.0.sort_by(|a, b| b.modifiers.count_ones().cmp(&a.modifiers.count_ones()));
// Sort the sublists of `key_up`, `key_down`, `key_up_no_repeat`, and `key_down_no_repeat`
for list in [&mut key_up, &mut key_down, &mut key_up_no_repeat, &mut key_down_no_repeat] {
for sublist in list {
sort(sublist);
}
}
// Sort the sublists of `double_click`
for sublist in &mut double_click {
sort(sublist)
}
// Sort `wheel_scroll`
sort(&mut wheel_scroll);
// Sort `pointer_move`
sort(&mut pointer_move);
// Sort `pointer_shake`
sort(&mut pointer_shake);
Mapping {
key_up,
key_down,
key_up_no_repeat,
key_down_no_repeat,
double_click,
wheel_scroll,
pointer_move,
pointer_shake,
}
}
@@ -0,0 +1,22 @@
use crate::messages::prelude::*;
#[impl_message(Message, KeyMapping)]
#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)]
pub enum KeyMappingMessage {
// Sub-messages
#[child]
Lookup(InputMapperMessage),
// Messages
ModifyMapping {
mapping: MappingVariant,
},
}
#[derive(PartialEq, Eq, Clone, Debug, Default, Hash, serde::Serialize, serde::Deserialize)]
pub enum MappingVariant {
#[default]
Default,
ZoomWithScroll,
}
@@ -0,0 +1,36 @@
use crate::messages::input_mapper::input_mapper_message_handler::InputMapperMessageContext;
use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup;
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct KeyMappingMessageContext<'a> {
pub input: &'a InputPreprocessorMessageHandler,
pub actions: ActionList,
}
#[derive(Debug, Default, ExtractField)]
pub struct KeyMappingMessageHandler {
mapping_handler: InputMapperMessageHandler,
}
#[message_handler_data]
impl MessageHandler<KeyMappingMessage, KeyMappingMessageContext<'_>> for KeyMappingMessageHandler {
fn process_message(&mut self, message: KeyMappingMessage, responses: &mut VecDeque<Message>, context: KeyMappingMessageContext) {
let KeyMappingMessageContext { input, actions } = context;
match message {
// Sub-messages
KeyMappingMessage::Lookup(input_message) => self.mapping_handler.process_message(input_message, responses, InputMapperMessageContext { input, actions }),
// Messages
KeyMappingMessage::ModifyMapping { mapping } => self.mapping_handler.set_mapping(mapping.into()),
}
}
advertise_actions!();
}
impl KeyMappingMessageHandler {
pub fn action_input_mapping(&self, action_to_find: &MessageDiscriminant) -> Option<KeysGroup> {
self.mapping_handler.action_input_mapping(action_to_find)
}
}
@@ -0,0 +1,7 @@
mod key_mapping_message;
mod key_mapping_message_handler;
#[doc(inline)]
pub use key_mapping_message::{KeyMappingMessage, KeyMappingMessageDiscriminant, MappingVariant};
#[doc(inline)]
pub use key_mapping_message_handler::{KeyMappingMessageContext, KeyMappingMessageHandler};
@@ -0,0 +1,11 @@
mod input_mapper_message;
mod input_mapper_message_handler;
pub mod input_mappings;
pub mod key_mapping;
pub mod utility_types;
#[doc(inline)]
pub use input_mapper_message::{InputMapperMessage, InputMapperMessageDiscriminant};
#[doc(inline)]
pub use input_mapper_message_handler::{InputMapperMessageContext, InputMapperMessageHandler};
@@ -0,0 +1,600 @@
use crate::application::Editor;
use crate::messages::prelude::*;
use bitflags::bitflags;
use std::fmt::{self, Display, Formatter};
use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign};
// ===========
// StorageType
// ===========
// TODO: Increase size of type
/// Edit this to specify the storage type used.
pub type StorageType = u128;
// =========
// KeyStates
// =========
const STORAGE_SIZE: u32 = (std::mem::size_of::<StorageType>() * 8).trailing_zeros();
const STORAGE_SIZE_BITS: usize = 1 << STORAGE_SIZE;
const KEY_MASK_STORAGE_LENGTH: usize = (NUMBER_OF_KEYS + STORAGE_SIZE_BITS - 1) >> STORAGE_SIZE;
pub type KeyStates = BitVector<KEY_MASK_STORAGE_LENGTH>;
pub fn all_required_modifiers_pressed(keyboard_state: &KeyStates, modifiers: &KeyStates) -> bool {
// Find which currently pressed keys are also the modifiers in this hotkey entry, then compare those against the required modifiers to see if there are zero missing
let pressed_modifiers = *keyboard_state & *modifiers;
let all_modifiers_without_pressed_modifiers = *modifiers ^ pressed_modifiers;
all_modifiers_without_pressed_modifiers.is_empty()
}
// ===========
// KeyPosition
// ===========
pub enum KeyPosition {
Pressed,
Released,
}
// ============
// ModifierKeys
// ============
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[repr(transparent)]
#[serde(transparent)]
pub struct ModifierKeys: u8 {
const SHIFT = 0b_0000_0001;
const ALT = 0b_0000_0010;
const CONTROL = 0b_0000_0100;
const META_OR_COMMAND = 0b_0000_1000;
}
}
// ===
// Key
// ===
// Currently this is mostly based on the JS `KeyboardEvent.code` list: <https://www.w3.org/TR/uievents-code/>
// But in the future, especially once users can customize keyboard mappings, we should deviate more from this so we have actual symbols
// like `+` (which doesn't exist because it's the shifted version of `=` on the US keyboard, after which these scan codes are named).
// We'd ideally like to bind shortcuts to symbols, not scan codes, so the shortcut for "zoom in" is `Ctrl +` which the user can press
// (although we ignore the shift key, so the user doesn't have to press `Ctrl Shift +` on a US keyboard), even if the keyboard layout
// is for a different locale where the `+` key is somewhere entirely different, shifted or not. This would then also work for numpad `+`.
#[impl_message(Message, InputMapperMessage, KeyDown)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, num_enum::TryFromPrimitive)]
#[repr(u8)]
pub enum Key {
// Writing system keys
Digit0,
Digit1,
Digit2,
Digit3,
Digit4,
Digit5,
Digit6,
Digit7,
Digit8,
Digit9,
KeyA,
KeyB,
KeyC,
KeyD,
KeyE,
KeyF,
KeyG,
KeyH,
KeyI,
KeyJ,
KeyK,
KeyL,
KeyM,
KeyN,
KeyO,
KeyP,
KeyQ,
KeyR,
KeyS,
KeyT,
KeyU,
KeyV,
KeyW,
KeyX,
KeyY,
KeyZ,
Backquote,
Backslash,
BracketLeft,
BracketRight,
Comma,
Equal,
Minus,
Period,
Quote,
Semicolon,
Slash,
// Functional keys
Alt,
Meta,
Shift,
Control,
Backspace,
CapsLock,
ContextMenu,
Enter,
Space,
Tab,
// Control pad keys
Delete,
End,
Help,
Home,
Insert,
PageDown,
PageUp,
// Arrow pad keys
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
// Numpad keys
// Numpad0,
// Numpad1,
// Numpad2,
// Numpad3,
// Numpad4,
// Numpad5,
// Numpad6,
// Numpad7,
// Numpad8,
// Numpad9,
NumLock,
NumpadAdd,
// NumpadBackspace,
// NumpadClear,
// NumpadClearEntry,
// NumpadComma,
// NumpadDecimal,
// NumpadDivide,
// NumpadEnter,
// NumpadEqual,
NumpadHash,
// NumpadMemoryAdd,
// NumpadMemoryClear,
// NumpadMemoryRecall,
// NumpadMemoryStore,
// NumpadMemorySubtract,
NumpadMultiply,
NumpadParenLeft,
NumpadParenRight,
// NumpadStar,
// NumpadSubtract,
// Function keys
Escape,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
F13,
F14,
F15,
F16,
F17,
F18,
F19,
F20,
F21,
F22,
F23,
F24,
Fn,
FnLock,
PrintScreen,
ScrollLock,
Pause,
// Unidentified keys
Unidentified,
// Other keys that aren't part of the W3C spec
//
/// "Cmd" on Mac (not present on other platforms).
Command,
/// "Ctrl" on Windows/Linux, "Cmd" on Mac.
Accel,
/// Left mouse button click (LMB).
MouseLeft,
/// Right mouse button click (RMB).
MouseRight,
/// Middle mouse button click (MMB).
MouseMiddle,
/// Mouse backward navigation button (typically on the side of the mouse).
MouseBack,
/// Mouse forward navigation button (typically on the side of the mouse).
MouseForward,
// Fake keys for displaying special labels in the UI
//
/// Not a physical key that can be pressed. May be used so that an actual shortcut bound to `Equal` can separately map this fake "key" as an additional binding to display the "+" shortcut label in the UI.
FakeKeyPlus,
/// Not a physical key that can be pressed. May be used so that an actual shortcut bound to all ten number keys (0, ..., 9) can separately map this fake "key" as an additional binding to display the "0–9" shortcut label in the UI.
FakeKeyNumbers,
_KeysVariantCount, // This has to be the last element in the enum
}
impl fmt::Display for Key {
// TODO: Relevant key labels should be localized when we get around to implementing localization/internationalization
fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result {
let key_name = format!("{self:?}");
// Writing system keys
const DIGIT_PREFIX: &str = "Digit";
if key_name.len() == DIGIT_PREFIX.len() + 1 && &key_name[0..DIGIT_PREFIX.len()] == DIGIT_PREFIX {
return write!(f, "{}", key_name.chars().skip(DIGIT_PREFIX.len()).collect::<String>());
}
const KEY_PREFIX: &str = "Key";
if key_name.len() == KEY_PREFIX.len() + 1 && &key_name[0..KEY_PREFIX.len()] == KEY_PREFIX {
return write!(f, "{}", key_name.chars().skip(KEY_PREFIX.len()).collect::<String>());
}
let is_mac = Editor::environment().is_mac();
let name = match self {
// Writing system keys
Self::Backquote => "`",
Self::Backslash => "\\",
Self::BracketLeft => "[",
Self::BracketRight => "]",
Self::Comma => ",",
Self::Equal => "=",
Self::Minus => "-",
Self::Period => ".",
Self::Quote => "'",
Self::Semicolon => ";",
Self::Slash => "/",
// Functional keys
Self::Alt => match is_mac {
true => "⌥",
false => "Alt",
},
Self::Meta => match is_mac {
true => "⌘",
false => "⊞",
},
Self::Shift => match is_mac {
true => "⇧",
false => "Shift",
},
Self::Control => match is_mac {
true => "⌃",
false => "Ctrl",
},
Self::Backspace => "⌫",
// Control pad keys
Self::Delete => "Del",
Self::PageDown => "PgDn",
Self::PageUp => "PgUp",
// Arrow pad keys
Self::ArrowDown => "↓",
Self::ArrowLeft => "←",
Self::ArrowRight => "→",
Self::ArrowUp => "↑",
// Numpad keys
Self::NumpadAdd => "Numpad +",
Self::NumpadHash => "Numpad #",
Self::NumpadMultiply => "Numpad *",
Self::NumpadParenLeft => "Numpad (",
Self::NumpadParenRight => "Numpad )",
// Function keys
Self::Escape => "Esc",
Self::PrintScreen => "PrtScr",
// Other keys that aren't part of the W3C spec
Self::Command => "⌘",
Self::Accel => match is_mac {
true => "⌘",
false => "Ctrl",
},
Self::MouseLeft => "Click",
Self::MouseRight => "R.Click",
Self::MouseMiddle => "M.Click",
Self::MouseBack => "Mouse Back",
Self::MouseForward => "Mouse Fwd",
// Fake keys for displaying special labels in the UI
Self::FakeKeyPlus => "+",
Self::FakeKeyNumbers => "0–9",
_ => key_name.as_str(),
};
write!(f, "{name}")
}
}
pub const NUMBER_OF_KEYS: usize = Key::_KeysVariantCount as usize - 1;
// =========
// KeysGroup
// =========
/// Only `Key`s that exist on a physical keyboard should be used.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct KeysGroup(pub Vec<Key>);
impl fmt::Display for KeysGroup {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
const JOINER_MARK: &str = " ";
let mut joined = self
.0
.iter()
.map(|key| {
let key_is_modifier = matches!(*key, Key::Control | Key::Command | Key::Alt | Key::Shift | Key::Meta | Key::Accel);
if Editor::environment().is_mac() && key_is_modifier {
key.to_string()
} else {
key.to_string() + JOINER_MARK
}
})
.collect::<String>();
// Cut the joining character off the end, if present
if joined.ends_with(JOINER_MARK) {
joined.truncate(joined.len() - JOINER_MARK.len());
}
write!(f, "{joined}")
}
}
// ==========
// LabeledKey
// ==========
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct LabeledKey {
key: Key,
label: String,
}
impl LabeledKey {
pub fn key(&self) -> Key {
self.key
}
}
// ===========
// MouseMotion
// ===========
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum MouseMotion {
None,
Lmb,
Rmb,
Mmb,
ScrollUp,
ScrollDown,
Drag,
LmbDouble,
LmbDrag,
RmbDrag,
RmbDouble,
MmbDrag,
}
// =======================
// LabeledKeyOrMouseMotion
// =======================
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum LabeledKeyOrMouseMotion {
Key(LabeledKey),
MouseMotion(MouseMotion),
}
impl From<Key> for LabeledKeyOrMouseMotion {
fn from(key: Key) -> Self {
match key {
Key::MouseLeft => Self::MouseMotion(MouseMotion::Lmb),
Key::MouseRight => Self::MouseMotion(MouseMotion::Rmb),
Key::MouseMiddle => Self::MouseMotion(MouseMotion::Mmb),
_ => Self::Key(LabeledKey { key, label: key.to_string() }),
}
}
}
// ===============
// LabeledShortcut
// ===============
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LabeledShortcut(pub Vec<LabeledKeyOrMouseMotion>);
impl From<KeysGroup> for LabeledShortcut {
fn from(keys_group: KeysGroup) -> Self {
Self(keys_group.0.into_iter().map(|key| key.into()).collect())
}
}
// =========
// BitVector
// =========
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BitVector<const LENGTH: usize>([StorageType; LENGTH]);
impl<const LENGTH: usize> BitVector<LENGTH> {
#[inline]
fn convert_index(bitvector_index: usize) -> (usize, StorageType) {
let bit = 1 << (bitvector_index & (STORAGE_SIZE_BITS as StorageType - 1) as usize);
let offset = bitvector_index >> STORAGE_SIZE;
(offset, bit)
}
pub const fn new() -> Self {
Self([0; LENGTH])
}
pub fn set(&mut self, bitvector_index: usize) {
let (offset, bit) = Self::convert_index(bitvector_index);
self.0[offset] |= bit;
}
pub fn unset(&mut self, bitvector_index: usize) {
let (offset, bit) = Self::convert_index(bitvector_index);
self.0[offset] &= !bit;
}
pub fn toggle(&mut self, bitvector_index: usize) {
let (offset, bit) = Self::convert_index(bitvector_index);
self.0[offset] ^= bit;
}
pub fn get(&self, bitvector_index: usize) -> bool {
let (offset, bit) = Self::convert_index(bitvector_index);
(self.0[offset] & bit) != 0
}
pub fn key(&self, key: Key) -> bool {
self.get(key as usize)
}
pub fn is_empty(&self) -> bool {
let mut result = 0;
for storage in self.0.iter() {
result |= storage;
}
result == 0
}
pub fn count_ones(&self) -> u32 {
let mut result = 0;
for storage in self.0.iter() {
result += storage.count_ones();
}
result
}
pub fn iter(&self) -> impl Iterator<Item = usize> + '_ {
BitVectorIter::<LENGTH> { bitvector: self, iter_index: 0 }
}
}
impl<const LENGTH: usize> Default for BitVector<LENGTH> {
fn default() -> Self {
Self::new()
}
}
struct BitVectorIter<'a, const LENGTH: usize> {
bitvector: &'a BitVector<LENGTH>,
iter_index: usize,
}
impl<const LENGTH: usize> Iterator for BitVectorIter<'_, LENGTH> {
type Item = usize;
fn next(&mut self) -> Option<Self::Item> {
while self.iter_index < STORAGE_SIZE_BITS * LENGTH {
let bit_value = self.bitvector.get(self.iter_index);
self.iter_index += 1;
if bit_value {
return Some(self.iter_index - 1);
}
}
None
}
}
impl<const LENGTH: usize> Display for BitVector<LENGTH> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
for storage in self.0.iter().rev() {
write!(f, "{storage:0STORAGE_SIZE_BITS$b}")?;
}
Ok(())
}
}
macro_rules! bit_ops {
($(($op:ident, $func:ident)),* $(,)?) => {
$(
impl<const LENGTH: usize> $op for BitVector<LENGTH> {
type Output = Self;
fn $func(self, right: Self) -> Self::Output {
let mut result = Self::new();
for ((left, right), new) in self.0.iter().zip(right.0.iter()).zip(result.0.iter_mut()) {
*new = $op::$func(left, right);
}
result
}
}
impl<const LENGTH: usize> $op for &BitVector<LENGTH> {
type Output = BitVector<LENGTH>;
fn $func(self, right: Self) -> Self::Output {
let mut result = BitVector::<LENGTH>::new();
for ((left, right), new) in self.0.iter().zip(right.0.iter()).zip(result.0.iter_mut()) {
*new = $op::$func(left, right);
}
result
}
}
)*
};
}
macro_rules! bit_ops_assign {
($(($op:ident, $func:ident)),* $(,)?) => {
$(impl<const LENGTH: usize> $op for BitVector<LENGTH> {
fn $func(&mut self, right: Self) {
for (left, right) in self.0.iter_mut().zip(right.0.iter()) {
$op::$func(left, right);
}
}
})*
};
}
bit_ops!((BitAnd, bitand), (BitOr, bitor), (BitXor, bitxor));
bit_ops_assign!((BitAndAssign, bitand_assign), (BitOrAssign, bitor_assign), (BitXorAssign, bitxor_assign));
@@ -0,0 +1,124 @@
use crate::consts::DRAG_THRESHOLD;
use crate::messages::prelude::*;
use bitflags::bitflags;
use glam::DVec2;
use std::collections::VecDeque;
use std::hash::{Hash, Hasher};
// Origin is top left
pub type DocumentPosition = DVec2;
pub type ViewportPosition = DVec2;
pub type EditorPosition = DVec2;
#[derive(Debug, Copy, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ScrollDelta {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl PartialEq for ScrollDelta {
fn eq(&self, other: &Self) -> bool {
self.x == other.x && self.y == other.y && self.z == other.z
}
}
impl Eq for ScrollDelta {}
impl Hash for ScrollDelta {
fn hash<H: Hasher>(&self, state: &mut H) {
let no_negative_zero = |value: f64| if value == 0. { 0. } else { value };
no_negative_zero(self.x).to_bits().hash(state);
no_negative_zero(self.y).to_bits().hash(state);
no_negative_zero(self.z).to_bits().hash(state);
}
}
impl ScrollDelta {
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
pub fn as_dvec2(&self) -> DVec2 {
DVec2::new(self.x, self.y)
}
pub fn scroll_delta(&self) -> f64 {
let (dx, dy) = (self.x, self.y);
dy.signum() * (dy * dy + f64::min(dy.abs(), dx.abs()).powi(2)).sqrt()
}
}
// TODO: Document the difference between this and EditorMouseState
#[derive(Debug, Copy, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MouseState {
pub position: ViewportPosition,
pub mouse_keys: MouseKeys,
pub scroll_delta: ScrollDelta,
}
impl MouseState {
pub fn finish_transaction(&self, drag_start: DVec2, responses: &mut VecDeque<Message>) {
let drag_too_small = drag_start.distance(self.position) <= DRAG_THRESHOLD;
let response = if drag_too_small { DocumentMessage::AbortTransaction } else { DocumentMessage::EndTransaction };
responses.add(response);
}
}
// TODO: Document the difference between this and MouseState
#[derive(Debug, Copy, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EditorMouseState {
pub editor_position: EditorPosition,
pub mouse_keys: MouseKeys,
pub scroll_delta: ScrollDelta,
}
impl EditorMouseState {
pub fn from_keys_and_editor_position(keys: u8, editor_position: EditorPosition) -> Self {
// TODO: Some graphic tablets send key codes not mentioned in the spec. In the future we would like to support these as well.
let mouse_keys = MouseKeys::from_bits_truncate(keys);
Self {
editor_position,
mouse_keys,
scroll_delta: ScrollDelta::default(),
}
}
pub fn to_mouse_state(&self, viewport: &ViewportMessageHandler) -> MouseState {
MouseState {
position: (viewport.logical(self.editor_position) - viewport.offset()).into(),
mouse_keys: self.mouse_keys,
scroll_delta: self.scroll_delta,
}
}
}
bitflags! {
/// Based on <https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons#value>.
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[repr(transparent)]
pub struct MouseKeys: u8 {
const NONE = 0b0000_0000;
const LEFT = 0b0000_0001;
const RIGHT = 0b0000_0010;
const MIDDLE = 0b0000_0100;
const BACK = 0b0000_1000;
const FORWARD = 0b0001_0000;
}
}
#[impl_message(Message, InputMapperMessage, DoubleClick)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, num_enum::TryFromPrimitive)]
#[repr(u8)]
pub enum MouseButton {
Left,
Right,
Middle,
Back,
Forward,
}
pub const NUMBER_OF_MOUSE_BUTTONS: usize = 5; // Should be the number of variants in MouseButton
@@ -0,0 +1,174 @@
/// Constructs a `KeyStates` bit vector and sets the bit flags for all the given modifier `Key`s.
macro_rules! modifiers {
($($m:ident),*) => {{
#[allow(unused_mut)]
let mut state = KeyStates::new();
$(
state.set(Key::$m as usize);
)*
state
}};
}
/// Builds a slice of `MappingEntry` struct(s) that are used to:
/// - ...dispatch the given `action_dispatch` as an output `Message` if its discriminant is a currently available action
/// - ...when the `InputMapperMessage` enum variant, as specified at the start and followed by a semicolon, is received
/// - ...while the optional `modifiers` being pressed.
///
/// Syntax:
/// ```rs
/// entry_for_layout!(Key; modifiers?: Key[], refresh_keys?: Key[], action_dispatch: Message)
/// ```
///
/// The actions system controls which actions are currently available. Those are provided by the different message handlers based on the current application state and context.
/// Each handler adds or removes actions in the form of message discriminants. Here, we tie an input condition (such as a hotkey) to an action's full message.
/// When an action is currently available, and the user enters that input, the action's message is dispatched on the message bus.
macro_rules! entry {
// Pattern with canonical parameter
(
$input:expr_2021;
$(modifiers=[$($modifier:ident),*],)?
$(refresh_keys=[$($refresh:ident),* $(,)?],)?
canonical,
$(disabled=$disabled:expr,)?
action_dispatch=$action_dispatch:expr_2021$(,)?
) => {
entry!(
$input;
$($($modifier),*)?;
$($($refresh),*)?;
$action_dispatch;
true;
false $( || $disabled )?
)
};
// Pattern without canonical parameter
(
$input:expr_2021;
$(modifiers=[$($modifier:ident),*],)?
$(refresh_keys=[$($refresh:ident),* $(,)?],)?
$(disabled=$disabled:expr,)?
action_dispatch=$action_dispatch:expr_2021$(,)?
) => {
entry!(
$input;
$($($modifier),*)?;
$($($refresh),*)?;
$action_dispatch;
false;
false $( || $disabled )?
)
};
// Implementation macro to avoid code duplication
($input:expr; $($modifier:ident),*; $($refresh:ident),*; $action_dispatch:expr; $canonical:expr; $disabled:expr) => {
&[&[
// Cause the `action_dispatch` message to be sent when the specified input occurs.
MappingEntry {
action: $action_dispatch.into(),
input: $input,
modifiers: modifiers!($($modifier),*),
canonical: $canonical,
disabled: $disabled,
},
$(
MappingEntry {
action: $action_dispatch.into(),
input: InputMapperMessage::KeyDown(Key::$refresh),
modifiers: modifiers!(),
canonical: $canonical,
disabled: $disabled,
},
MappingEntry {
action: $action_dispatch.into(),
input: InputMapperMessage::KeyUp(Key::$refresh),
modifiers: modifiers!(),
canonical: $canonical,
disabled: $disabled,
},
MappingEntry {
action: $action_dispatch.into(),
input: InputMapperMessage::KeyDownNoRepeat(Key::$refresh),
modifiers: modifiers!(),
canonical: $canonical,
disabled: $disabled,
},
MappingEntry {
action: $action_dispatch.into(),
input: InputMapperMessage::KeyUpNoRepeat(Key::$refresh),
modifiers: modifiers!(),
canonical: $canonical,
disabled: $disabled,
},
)*
]]
};
}
/// Constructs a `KeyMappingEntries` list for each input type and inserts every given entry into the list corresponding to its input type.
/// Returns a tuple of `KeyMappingEntries` in the order:
/// ```rs
/// (key_up, key_down, double_click, wheel_scroll, pointer_move)
/// ```
macro_rules! mapping {
[$($entry:expr_2021),* $(,)?] => {{
let mut key_up = KeyMappingEntries::key_array();
let mut key_down = KeyMappingEntries::key_array();
let mut key_up_no_repeat = KeyMappingEntries::key_array();
let mut key_down_no_repeat = KeyMappingEntries::key_array();
let mut double_click = KeyMappingEntries::mouse_buttons_arrays();
let mut wheel_scroll = KeyMappingEntries::new();
let mut pointer_move = KeyMappingEntries::new();
let mut pointer_shake = KeyMappingEntries::new();
$(
// Each of the many entry slices, one specified per action
for entry_slice in $entry {
// Each entry in the slice (usually just one, except when `refresh_keys` adds additional key entries)
for entry in entry_slice.into_iter() {
if entry.disabled {
continue;
}
let corresponding_list = match entry.input {
InputMapperMessage::KeyDown(key) => &mut key_down[key as usize],
InputMapperMessage::KeyUp(key) => &mut key_up[key as usize],
InputMapperMessage::KeyDownNoRepeat(key) => &mut key_down_no_repeat[key as usize],
InputMapperMessage::KeyUpNoRepeat(key) => &mut key_up_no_repeat[key as usize],
InputMapperMessage::DoubleClick(key) => &mut double_click[key as usize],
InputMapperMessage::WheelScroll => &mut wheel_scroll,
InputMapperMessage::PointerMove => &mut pointer_move,
InputMapperMessage::PointerShake => &mut pointer_shake,
};
// Push each entry to the corresponding `KeyMappingEntries` list for its input type
corresponding_list.push(entry.clone());
}
}
)*
(key_up, key_down, key_up_no_repeat, key_down_no_repeat, double_click, wheel_scroll, pointer_move, pointer_shake)
}};
}
/// Constructs an `ActionShortcut` macro with a certain `Action` variant, conveniently wrapped in `Some()`.
macro_rules! action_shortcut {
($action:expr_2021) => {
Some(crate::messages::input_mapper::utility_types::misc::ActionShortcut::Action($action.into()))
};
}
macro_rules! action_shortcut_manual {
($($keys:expr),*) => {
Some(crate::messages::input_mapper::utility_types::misc::ActionShortcut::Shortcut(
crate::messages::input_mapper::utility_types::input_keyboard::LabeledShortcut(vec![$($keys.into()),*]).into(),
))
};
}
pub(crate) use action_shortcut;
pub(crate) use action_shortcut_manual;
pub(crate) use entry;
pub(crate) use mapping;
pub(crate) use modifiers;
@@ -0,0 +1,148 @@
use super::input_keyboard::{KeysGroup, LabeledShortcut, all_required_modifiers_pressed};
use crate::messages::input_mapper::key_mapping::MappingVariant;
use crate::messages::input_mapper::utility_types::input_keyboard::{KeyStates, NUMBER_OF_KEYS};
use crate::messages::input_mapper::utility_types::input_mouse::NUMBER_OF_MOUSE_BUTTONS;
use crate::messages::prelude::*;
use core::time::Duration;
#[derive(Debug, Clone)]
pub struct Mapping {
pub key_up: [KeyMappingEntries; NUMBER_OF_KEYS],
pub key_down: [KeyMappingEntries; NUMBER_OF_KEYS],
pub key_up_no_repeat: [KeyMappingEntries; NUMBER_OF_KEYS],
pub key_down_no_repeat: [KeyMappingEntries; NUMBER_OF_KEYS],
pub double_click: [KeyMappingEntries; NUMBER_OF_MOUSE_BUTTONS],
pub wheel_scroll: KeyMappingEntries,
pub pointer_move: KeyMappingEntries,
pub pointer_shake: KeyMappingEntries,
}
impl Default for Mapping {
fn default() -> Self {
MappingVariant::Default.into()
}
}
impl Mapping {
pub fn match_input_message(&self, message: InputMapperMessage, keyboard_state: &KeyStates, actions: ActionList) -> Option<Message> {
let list = self.associated_entries(&message);
list.match_mapping(keyboard_state, actions)
}
fn associated_entries(&self, message: &InputMapperMessage) -> &KeyMappingEntries {
match message {
InputMapperMessage::KeyDown(key) => &self.key_down[*key as usize],
InputMapperMessage::KeyUp(key) => &self.key_up[*key as usize],
InputMapperMessage::KeyDownNoRepeat(key) => &self.key_down_no_repeat[*key as usize],
InputMapperMessage::KeyUpNoRepeat(key) => &self.key_up_no_repeat[*key as usize],
InputMapperMessage::DoubleClick(key) => &self.double_click[*key as usize],
InputMapperMessage::WheelScroll => &self.wheel_scroll,
InputMapperMessage::PointerMove => &self.pointer_move,
InputMapperMessage::PointerShake => &self.pointer_shake,
}
}
}
#[derive(Debug, Clone)]
pub struct KeyMappingEntries(pub Vec<MappingEntry>);
impl KeyMappingEntries {
pub fn match_mapping(&self, keyboard_state: &KeyStates, actions: ActionList) -> Option<Message> {
for mapping in self.0.iter() {
// Skip this entry if any of the required modifiers are missing
if all_required_modifiers_pressed(keyboard_state, &mapping.modifiers) {
// Search for the action in the list of available actions to see if it's currently available to activate
let matching_action_found = actions.iter().flatten().any(|action| mapping.action.to_discriminant() == *action);
if matching_action_found {
return Some(mapping.action.clone());
}
}
}
None
}
pub fn push(&mut self, entry: MappingEntry) {
self.0.push(entry);
}
pub fn remove(&mut self, target_entry: &MappingEntry) {
self.0.retain(|entry| entry != target_entry);
}
pub const fn new() -> Self {
Self(Vec::new())
}
pub fn key_array() -> [Self; NUMBER_OF_KEYS] {
const DEFAULT: KeyMappingEntries = KeyMappingEntries::new();
[DEFAULT; NUMBER_OF_KEYS]
}
pub fn mouse_buttons_arrays() -> [Self; NUMBER_OF_MOUSE_BUTTONS] {
const DEFAULT: KeyMappingEntries = KeyMappingEntries::new();
[DEFAULT; NUMBER_OF_MOUSE_BUTTONS]
}
}
impl Default for KeyMappingEntries {
fn default() -> Self {
Self::new()
}
}
#[derive(PartialEq, Clone, Debug)]
pub struct MappingEntry {
/// Serves two purposes:
/// - This is the message that gets dispatched when the hotkey is matched
/// - This message's discriminant is the action; it must be a currently active action to be considered as a shortcut
pub action: Message,
/// The user input event from an input device which this input mapping matches on
pub input: InputMapperMessage,
/// Any additional keys that must be also pressed for this input mapping to match
pub modifiers: KeyStates,
/// True indicates that this takes priority as the labeled hotkey shown in UI menus and tooltips instead of an alternate binding for the same action
pub canonical: bool,
/// Whether this mapping is disabled
pub disabled: bool,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ActionShortcut {
#[serde(skip)]
Action(MessageDiscriminant),
#[serde(rename = "shortcut")]
Shortcut(LabeledShortcut),
}
impl ActionShortcut {
pub fn realize_shortcut(&mut self, action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option<KeysGroup>) {
if let Self::Action(action) = self {
if let Some(keys) = action_input_mapping(action) {
*self = Self::Shortcut(keys.into());
} else {
*self = Self::Shortcut(KeysGroup::default().into());
}
}
}
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FrameTimeInfo {
timestamp: Duration,
prev_timestamp: Option<Duration>,
}
impl FrameTimeInfo {
pub fn frame_duration(&self) -> Option<Duration> {
self.prev_timestamp.map(|prev| self.timestamp - prev)
}
pub fn advance_timestamp(&mut self, next_timestamp: Duration) {
// Guard against non-monotonic timestamps from the browser (Keavon observed this once in Chrome)
let next_timestamp = next_timestamp.max(self.timestamp);
self.prev_timestamp = Some(self.timestamp);
self.timestamp = next_timestamp;
}
}
@@ -0,0 +1,4 @@
pub mod input_keyboard;
pub mod input_mouse;
pub mod macros;
pub mod misc;
@@ -0,0 +1,17 @@
use crate::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
use crate::messages::input_mapper::utility_types::input_mouse::EditorMouseState;
use crate::messages::prelude::*;
#[impl_message(Message, InputPreprocessor)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum InputPreprocessorMessage {
DoubleClick { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
KeyDown { key: Key, key_repeat: bool, modifier_keys: ModifierKeys },
KeyUp { key: Key, key_repeat: bool, modifier_keys: ModifierKeys },
PointerDown { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
PointerMove { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
PointerUp { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
PointerShake { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
CurrentTime { timestamp: u64 },
WheelScroll { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
}
@@ -0,0 +1,295 @@
use crate::application::Editor;
use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeyStates, ModifierKeys};
use crate::messages::input_mapper::utility_types::input_mouse::{MouseButton, MouseKeys, MouseState};
use crate::messages::input_mapper::utility_types::misc::FrameTimeInfo;
use crate::messages::prelude::*;
use std::time::Duration;
#[derive(ExtractField)]
pub struct InputPreprocessorMessageContext<'a> {
pub viewport: &'a ViewportMessageHandler,
}
#[derive(Debug, Default, ExtractField)]
pub struct InputPreprocessorMessageHandler {
pub frame_time: FrameTimeInfo,
pub time: u64,
pub keyboard: KeyStates,
pub mouse: MouseState,
}
#[message_handler_data]
impl<'a> MessageHandler<InputPreprocessorMessage, InputPreprocessorMessageContext<'a>> for InputPreprocessorMessageHandler {
fn process_message(&mut self, message: InputPreprocessorMessage, responses: &mut VecDeque<Message>, context: InputPreprocessorMessageContext<'a>) {
let InputPreprocessorMessageContext { viewport } = context;
match message {
InputPreprocessorMessage::DoubleClick { editor_mouse_state, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
let mouse_state = editor_mouse_state.to_mouse_state(viewport);
self.mouse.position = mouse_state.position;
for key in mouse_state.mouse_keys {
responses.add(InputMapperMessage::DoubleClick(match key {
MouseKeys::LEFT => MouseButton::Left,
MouseKeys::RIGHT => MouseButton::Right,
MouseKeys::MIDDLE => MouseButton::Middle,
MouseKeys::BACK => MouseButton::Back,
MouseKeys::FORWARD => MouseButton::Forward,
_ => unimplemented!(),
}));
}
}
InputPreprocessorMessage::KeyDown { key, key_repeat, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
self.keyboard.set(key as usize);
if !key_repeat {
responses.add(InputMapperMessage::KeyDownNoRepeat(key));
}
responses.add(InputMapperMessage::KeyDown(key));
}
InputPreprocessorMessage::KeyUp { key, key_repeat, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
self.keyboard.unset(key as usize);
if !key_repeat {
responses.add(InputMapperMessage::KeyUpNoRepeat(key));
}
responses.add(InputMapperMessage::KeyUp(key));
}
InputPreprocessorMessage::PointerDown { editor_mouse_state, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
let mouse_state = editor_mouse_state.to_mouse_state(viewport);
self.mouse.position = mouse_state.position;
self.translate_mouse_event(mouse_state, true, responses);
}
InputPreprocessorMessage::PointerMove { editor_mouse_state, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
let mouse_state = editor_mouse_state.to_mouse_state(viewport);
self.mouse.position = mouse_state.position;
responses.add(InputMapperMessage::PointerMove);
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events
self.translate_mouse_event(mouse_state, false, responses);
}
InputPreprocessorMessage::PointerUp { editor_mouse_state, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
let mouse_state = editor_mouse_state.to_mouse_state(viewport);
self.mouse.position = mouse_state.position;
self.translate_mouse_event(mouse_state, false, responses);
}
InputPreprocessorMessage::PointerShake { editor_mouse_state, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
let mouse_state = editor_mouse_state.to_mouse_state(viewport);
self.mouse.position = mouse_state.position;
responses.add(InputMapperMessage::PointerShake);
}
InputPreprocessorMessage::CurrentTime { timestamp } => {
responses.add(AnimationMessage::SetTime { time: timestamp as f64 });
self.time = timestamp;
self.frame_time.advance_timestamp(Duration::from_millis(timestamp));
}
InputPreprocessorMessage::WheelScroll { editor_mouse_state, modifier_keys } => {
self.update_states_of_modifier_keys(modifier_keys, responses);
let mouse_state = editor_mouse_state.to_mouse_state(viewport);
self.mouse.position = mouse_state.position;
self.mouse.scroll_delta = mouse_state.scroll_delta;
responses.add(InputMapperMessage::WheelScroll);
}
};
}
// Clean user input and if possible reconstruct it.
// Store the changes in the keyboard if it is a key event.
// Transform canvas coordinates to document coordinates.
advertise_actions!();
}
impl InputPreprocessorMessageHandler {
fn translate_mouse_event(&mut self, mut new_state: MouseState, allow_first_button_down: bool, responses: &mut VecDeque<Message>) {
let click_mappings = [
(MouseKeys::LEFT, Key::MouseLeft),
(MouseKeys::RIGHT, Key::MouseRight),
(MouseKeys::MIDDLE, Key::MouseMiddle),
(MouseKeys::BACK, Key::MouseBack),
(MouseKeys::FORWARD, Key::MouseForward),
];
for (bit_flag, key) in click_mappings {
// Calculate the intersection between the two key states
let old_down = self.mouse.mouse_keys & bit_flag == bit_flag;
let new_down = new_state.mouse_keys & bit_flag == bit_flag;
if !old_down && new_down {
if allow_first_button_down || self.mouse.mouse_keys != MouseKeys::empty() {
self.keyboard.set(key as usize);
responses.add(InputMapperMessage::KeyDown(key));
} else {
// Required to stop a keyup being emitted for a keydown outside canvas
new_state.mouse_keys ^= bit_flag;
}
}
if old_down && !new_down {
self.keyboard.unset(key as usize);
responses.add(InputMapperMessage::KeyUp(key));
}
}
self.mouse = new_state;
}
fn update_states_of_modifier_keys(&mut self, pressed_modifier_keys: ModifierKeys, responses: &mut VecDeque<Message>) {
let is_key_pressed = |key_to_check: ModifierKeys| pressed_modifier_keys.contains(key_to_check);
// Update the state of the concrete modifier keys based on the source state
self.update_modifier_key(Key::Shift, is_key_pressed(ModifierKeys::SHIFT), responses);
self.update_modifier_key(Key::Alt, is_key_pressed(ModifierKeys::ALT), responses);
self.update_modifier_key(Key::Control, is_key_pressed(ModifierKeys::CONTROL), responses);
// Update the state of either the concrete Meta or the Command keys based on which one is applicable for this platform
let meta_or_command = match Editor::environment().is_mac() {
true => Key::Command,
false => Key::Meta,
};
self.update_modifier_key(meta_or_command, is_key_pressed(ModifierKeys::META_OR_COMMAND), responses);
// Update the state of the virtual Accel key (the primary accelerator key) based on the source state of the Control or Command key, whichever is relevant on this platform
let accel_virtual_key_state = match Editor::environment().is_mac() {
true => is_key_pressed(ModifierKeys::META_OR_COMMAND),
false => is_key_pressed(ModifierKeys::CONTROL),
};
self.update_modifier_key(Key::Accel, accel_virtual_key_state, responses);
}
fn update_modifier_key(&mut self, key: Key, key_is_down: bool, responses: &mut VecDeque<Message>) {
let key_was_down = self.keyboard.get(key as usize);
if key_was_down && !key_is_down {
self.keyboard.unset(key as usize);
responses.add(InputMapperMessage::KeyUp(key));
} else if !key_was_down && key_is_down {
self.keyboard.set(key as usize);
responses.add(InputMapperMessage::KeyDown(key));
}
}
}
#[cfg(test)]
mod test {
use crate::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
use crate::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, MouseKeys, ScrollDelta};
use crate::messages::prelude::*;
#[test]
fn process_action_mouse_move_handle_modifier_keys() {
let mut input_preprocessor = InputPreprocessorMessageHandler::default();
let editor_mouse_state = EditorMouseState {
editor_position: (4., 809.).into(),
mouse_keys: MouseKeys::default(),
scroll_delta: ScrollDelta::default(),
};
let modifier_keys = ModifierKeys::ALT;
let message = InputPreprocessorMessage::PointerMove { editor_mouse_state, modifier_keys };
let mut responses = VecDeque::new();
let context = InputPreprocessorMessageContext {
viewport: &ViewportMessageHandler::default(),
};
input_preprocessor.process_message(message, &mut responses, context);
assert!(input_preprocessor.keyboard.get(Key::Alt as usize));
assert_eq!(responses.pop_front(), Some(InputMapperMessage::KeyDown(Key::Alt).into()));
}
#[test]
fn process_action_mouse_down_handle_modifier_keys() {
let mut input_preprocessor = InputPreprocessorMessageHandler::default();
let editor_mouse_state = EditorMouseState::default();
let modifier_keys = ModifierKeys::CONTROL;
let message = InputPreprocessorMessage::PointerDown { editor_mouse_state, modifier_keys };
let mut responses = VecDeque::new();
let context = InputPreprocessorMessageContext {
viewport: &ViewportMessageHandler::default(),
};
input_preprocessor.process_message(message, &mut responses, context);
assert!(input_preprocessor.keyboard.get(Key::Control as usize));
assert_eq!(responses.pop_front(), Some(InputMapperMessage::KeyDown(Key::Control).into()));
}
#[test]
fn process_action_mouse_up_handle_modifier_keys() {
let mut input_preprocessor = InputPreprocessorMessageHandler::default();
let editor_mouse_state = EditorMouseState::default();
let modifier_keys = ModifierKeys::SHIFT;
let message = InputPreprocessorMessage::PointerUp { editor_mouse_state, modifier_keys };
let mut responses = VecDeque::new();
let context = InputPreprocessorMessageContext {
viewport: &ViewportMessageHandler::default(),
};
input_preprocessor.process_message(message, &mut responses, context);
assert!(input_preprocessor.keyboard.get(Key::Shift as usize));
assert_eq!(responses.pop_front(), Some(InputMapperMessage::KeyDown(Key::Shift).into()));
}
#[test]
fn process_action_key_down_handle_modifier_keys() {
let mut input_preprocessor = InputPreprocessorMessageHandler::default();
input_preprocessor.keyboard.set(Key::Control as usize);
let key = Key::KeyA;
let key_repeat = false;
let modifier_keys = ModifierKeys::empty();
let message = InputPreprocessorMessage::KeyDown { key, key_repeat, modifier_keys };
let mut responses = VecDeque::new();
let context = InputPreprocessorMessageContext {
viewport: &ViewportMessageHandler::default(),
};
input_preprocessor.process_message(message, &mut responses, context);
assert!(!input_preprocessor.keyboard.get(Key::Control as usize));
assert_eq!(responses.pop_front(), Some(InputMapperMessage::KeyUp(Key::Control).into()));
}
#[test]
fn process_action_key_up_handle_modifier_keys() {
let mut input_preprocessor = InputPreprocessorMessageHandler::default();
let key = Key::KeyS;
let key_repeat = false;
let modifier_keys = ModifierKeys::CONTROL | ModifierKeys::SHIFT;
let message = InputPreprocessorMessage::KeyUp { key, key_repeat, modifier_keys };
let mut responses = VecDeque::new();
let context = InputPreprocessorMessageContext {
viewport: &ViewportMessageHandler::default(),
};
input_preprocessor.process_message(message, &mut responses, context);
assert!(input_preprocessor.keyboard.get(Key::Control as usize));
assert!(input_preprocessor.keyboard.get(Key::Shift as usize));
assert!(responses.contains(&InputMapperMessage::KeyDown(Key::Control).into()));
assert!(responses.contains(&InputMapperMessage::KeyDown(Key::Control).into()));
}
}
@@ -0,0 +1,7 @@
mod input_preprocessor_message;
mod input_preprocessor_message_handler;
#[doc(inline)]
pub use input_preprocessor_message::{InputPreprocessorMessage, InputPreprocessorMessageDiscriminant};
#[doc(inline)]
pub use input_preprocessor_message_handler::{InputPreprocessorMessageContext, InputPreprocessorMessageHandler};
@@ -0,0 +1,33 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
#[impl_message(Message, Layout)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum LayoutMessage {
ResendActiveWidget {
layout_target: LayoutTarget,
widget_id: WidgetId,
},
ResendAllLayouts,
SendLayout {
layout: Layout,
layout_target: LayoutTarget,
},
DestroyLayout {
layout_target: LayoutTarget,
},
WidgetValueCommit {
layout_target: LayoutTarget,
widget_id: WidgetId,
value: serde_json::Value,
},
WidgetValueUpdate {
layout_target: LayoutTarget,
widget_id: WidgetId,
value: serde_json::Value,
},
WidgetValueDragDrop {
layout_target: LayoutTarget,
widget_id: WidgetId,
},
}
@@ -0,0 +1,574 @@
use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use crate::messages::prelude::*;
use graphene_std::color::SRGBA8;
use graphene_std::vector::style::FillChoiceUI;
use serde_json::Value;
use std::collections::HashMap;
#[derive(ExtractField)]
pub struct LayoutMessageContext<'a> {
pub action_input_mapping: &'a dyn Fn(&MessageDiscriminant) -> Option<KeysGroup>,
}
#[derive(Debug, Clone, Default, ExtractField)]
pub struct LayoutMessageHandler {
layouts: [Layout; LayoutTarget::_LayoutTargetLength as usize],
}
#[message_handler_data]
impl MessageHandler<LayoutMessage, LayoutMessageContext<'_>> for LayoutMessageHandler {
fn process_message(&mut self, message: LayoutMessage, responses: &mut std::collections::VecDeque<Message>, context: LayoutMessageContext) {
let action_input_mapping = &context.action_input_mapping;
match message {
LayoutMessage::ResendActiveWidget { layout_target, widget_id } => {
// Find the updated diff based on the specified layout target
let Some(diff) = Self::get_widget_path(&self.layouts[layout_target as usize], widget_id).map(|(widget, widget_path)| {
// Create a widget update diff for the relevant id
let new_value = DiffUpdate::Widget(widget.clone());
WidgetDiff { widget_path, new_value }
}) else {
return;
};
// Resend that diff
self.send_diff(vec![diff], layout_target, responses, action_input_mapping);
}
LayoutMessage::ResendAllLayouts => {
// Collect non-empty layouts and their indices, then clear the stored copies so diffs compute as full re-sends
let layouts_to_resend: Vec<_> = self
.layouts
.iter_mut()
.enumerate()
.filter(|(_, layout)| !layout.0.is_empty())
.map(|(i, layout)| (LayoutTarget::from(i as u8), std::mem::take(layout)))
.collect();
for (layout_target, layout) in layouts_to_resend {
self.diff_and_send_layout_to_frontend(layout_target, layout, responses, action_input_mapping);
}
}
LayoutMessage::SendLayout { layout, layout_target } => {
self.diff_and_send_layout_to_frontend(layout_target, layout, responses, action_input_mapping);
}
LayoutMessage::DestroyLayout { layout_target } => {
if let Some(layout) = self.layouts.get_mut(layout_target as usize) {
*layout = Layout::default();
}
}
LayoutMessage::WidgetValueCommit { layout_target, widget_id, value } => {
self.handle_widget_callback(layout_target, widget_id, value, WidgetValueAction::Commit, responses);
}
LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value } => {
self.handle_widget_callback(layout_target, widget_id, value, WidgetValueAction::Update, responses);
}
LayoutMessage::WidgetValueDragDrop { layout_target, widget_id } => {
let Some(layout) = self.layouts.get_mut(layout_target as usize) else {
warn!("WidgetValueDragDrop referenced an invalid layout. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`");
return;
};
let Some(widget_instance) = layout.iter_mut().find(|widget| widget.widget_id == widget_id) else {
warn!("WidgetValueDragDrop referenced an invalid widget ID. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`");
return;
};
if let Widget::IconButton(icon_button) = &mut *widget_instance.widget {
responses.add((icon_button.on_drag_drop.callback)(icon_button));
}
}
}
}
fn actions(&self) -> ActionList {
actions!(LayoutMessageDiscriminant;)
}
}
impl LayoutMessageHandler {
/// Get the widget path for the widget with the specified id
fn get_widget_path(widget_layout: &Layout, widget_id: WidgetId) -> Option<(&WidgetInstance, Vec<usize>)> {
let mut stack = widget_layout.0.iter().enumerate().map(|(index, val)| (vec![index], val)).collect::<Vec<_>>();
while let Some((mut widget_path, layout_group)) = stack.pop() {
match layout_group {
// Check if any of the widgets in the current column or row have the correct id
LayoutGroup::Column(WidgetColumn { widgets }) | LayoutGroup::Row(WidgetRow { widgets }) => {
for (index, widget) in widgets.iter().enumerate() {
// Return if this is the correct ID
if widget.widget_id == widget_id {
widget_path.push(index);
return Some((widget, widget_path));
}
if let Widget::PopoverButton(popover) = &*widget.widget {
stack.extend(
popover
.popover_layout
.0
.iter()
.enumerate()
.map(|(child, val)| ([widget_path.as_slice(), &[index, child]].concat(), val)),
);
}
}
}
// A section contains more LayoutGroups which we add to the stack.
LayoutGroup::Section(WidgetSection { layout, .. }) => {
stack.extend(layout.0.iter().enumerate().map(|(index, val)| ([widget_path.as_slice(), &[index]].concat(), val)));
}
LayoutGroup::Table(WidgetTable { rows, .. }) => {
for (row_index, row) in rows.iter().enumerate() {
for (value_index, value) in row.iter().enumerate() {
// Return if this is the correct ID
if value.widget_id == widget_id {
widget_path.push(row_index);
widget_path.push(value_index);
return Some((value, widget_path));
}
if let Widget::PopoverButton(popover) = &*value.widget {
stack.extend(
popover
.popover_layout
.0
.iter()
.enumerate()
.map(|(child, val)| ([widget_path.as_slice(), &[row_index, value_index, child]].concat(), val)),
);
}
}
}
}
}
}
None
}
fn handle_widget_callback(&mut self, layout_target: LayoutTarget, widget_id: WidgetId, value: Value, action: WidgetValueAction, responses: &mut std::collections::VecDeque<Message>) {
let Some(layout) = self.layouts.get_mut(layout_target as usize) else {
warn!("handle_widget_callback was called referencing an invalid layout. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`",);
return;
};
let Some(widget_instance) = layout.iter_mut().find(|widget| widget.widget_id == widget_id) else {
warn!("handle_widget_callback was called referencing an invalid widget ID, although the layout target was valid. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`",);
return;
};
match &mut *widget_instance.widget {
Widget::BreadcrumbTrailButtons(breadcrumb_trail_buttons) => {
let callback_message = match action {
WidgetValueAction::Commit => (breadcrumb_trail_buttons.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Some(update_value) = value.as_u64() else {
error!("BreadcrumbTrailButtons update was not of type: u64");
return;
};
(breadcrumb_trail_buttons.on_update.callback)(&update_value)
}
};
responses.add(callback_message);
}
Widget::CheckboxInput(checkbox_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (checkbox_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Some(update_value) = value.as_bool() else {
error!("CheckboxInput update was not of type: bool");
return;
};
checkbox_input.checked = update_value;
(checkbox_input.on_update.callback)(checkbox_input)
}
};
responses.add(callback_message);
}
Widget::ColorComparisonInput(color_comparison_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (color_comparison_input.on_commit.callback)(&()),
WidgetValueAction::Update => (color_comparison_input.on_update.callback)(&()),
};
responses.add(callback_message);
}
Widget::ColorInput(color_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (color_button.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(fill_choice_ui) = serde_json::from_value::<FillChoiceUI>(value) else {
warn!("ColorInput update was not able to be parsed as FillChoiceUI: {color_button:?}");
return;
};
color_button.value = fill_choice_ui;
(color_button.on_update.callback)(color_button)
}
};
responses.add(callback_message);
}
Widget::ColorPresetsInput(color_presets_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (color_presets_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(update) = serde_json::from_value::<ColorPresetsInputUpdate>(value) else {
warn!("ColorPresetsInput update was not able to be parsed as ColorPresetsInputUpdate");
return;
};
(color_presets_input.on_update.callback)(&update)
}
};
responses.add(callback_message);
}
Widget::DropdownInput(dropdown_input) => {
let callback_message = match action {
WidgetValueAction::Commit => {
let Some(update_value) = value.as_u64() else {
error!("DropdownInput commit was not of type `u64`, found {value:?}");
return;
};
let Some(entry) = dropdown_input.entries.iter().flatten().nth(update_value as usize) else {
error!("DropdownInput commit was not able to find entry for index {update_value}");
return;
};
(entry.on_commit.callback)(&())
}
WidgetValueAction::Update => {
let Some(update_value) = value.as_u64() else {
error!("DropdownInput update was not of type `u64`, found {value:?}");
return;
};
dropdown_input.selected_index = Some(update_value as u32);
let Some(entry) = dropdown_input.entries.iter().flatten().nth(update_value as usize) else {
error!("DropdownInput update was not able to find entry for index {update_value}");
return;
};
(entry.on_update.callback)(&())
}
};
responses.add(callback_message);
}
Widget::SpectrumInput(spectrum_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (spectrum_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(update) = serde_json::from_value::<SpectrumInputUpdate>(value) else {
warn!("SpectrumInput update was not able to be parsed as SpectrumInputUpdate");
return;
};
// Don't mutate the stored widget here: leaving its old values lets the layout diff detect a change
// when the new layout is rebuilt with the updated state. Otherwise the frontend's stored layout
// keeps stale values for `activeMarkerIndex`, etc., and any other widget's diff (e.g. the position
// NumberInput) will trigger Svelte to re-spread those stale props onto SpectrumInput, clobbering
// its local `activeMarkerIndex` and making subsequent drags target the wrong stop.
(spectrum_input.on_update.callback)(&update)
}
};
responses.add(callback_message);
}
Widget::IconButton(icon_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (icon_button.on_commit.callback)(&()),
WidgetValueAction::Update => (icon_button.on_update.callback)(icon_button),
};
responses.add(callback_message);
}
Widget::ImageButton(image_label) => {
let callback_message = match action {
WidgetValueAction::Commit => (image_label.on_commit.callback)(&()),
WidgetValueAction::Update => (image_label.on_update.callback)(&()),
};
responses.add(callback_message);
}
Widget::ImageLabel(_) => {}
Widget::ShortcutLabel(_) => {}
Widget::IconLabel(_) => {}
Widget::NodeCatalog(node_type_input) => match action {
WidgetValueAction::Commit => {
let callback_message = (node_type_input.on_commit.callback)(&());
responses.add(callback_message);
}
WidgetValueAction::Update => {
let Value::String(ref node_type) = value else {
error!("NodeCatalog update was not of type: String, found {value:?}");
return;
};
let callback_message = (node_type_input.on_update.callback)(&DefinitionIdentifier::from_serialized(node_type));
responses.add(callback_message);
}
},
Widget::NumberInput(number_input) => match action {
WidgetValueAction::Commit => {
let callback_message = (number_input.on_commit.callback)(&());
responses.add(callback_message);
}
WidgetValueAction::Update => match value {
Value::Number(ref num) => {
let Some(update_value) = num.as_f64() else {
error!("NumberInput update was not of type: f64, found {value:?}");
return;
};
number_input.value = Some(update_value);
let callback_message = (number_input.on_update.callback)(number_input);
responses.add(callback_message);
}
// A text-field commit sends the user's raw entry as a math expression to evaluate and validate.
Value::String(expression) => {
let Some(evaluated) = evaluate_and_validate_number_input(&expression, number_input) else { return };
// Skip the update (and its history transaction) when the value is unchanged, since the network interface would short-circuit it anyway.
if number_input.value == Some(evaluated) {
return;
}
// Snapshot the pre-change state for undo via `on_commit`, then apply the new value via `on_update`.
responses.add((number_input.on_commit.callback)(&()));
number_input.value = Some(evaluated);
responses.add((number_input.on_update.callback)(number_input));
}
// The increment arrows send `{ "increment": "Increase" | "Decrease" }` to invoke the backend's directional step callback.
Value::Object(ref command) => match command.get("increment").and_then(Value::as_str) {
Some("Increase") => responses.add((number_input.increment_callback_increase.callback)(number_input)),
Some("Decrease") => responses.add((number_input.increment_callback_decrease.callback)(number_input)),
_ => error!("NumberInput received an unrecognized command: {value:?}"),
},
_ => {}
},
},
Widget::ParameterExposeButton(parameter_expose_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (parameter_expose_button.on_commit.callback)(&()),
WidgetValueAction::Update => (parameter_expose_button.on_update.callback)(parameter_expose_button),
};
responses.add(callback_message);
}
Widget::ReferencePointInput(reference_point_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (reference_point_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Some(update_value) = value.as_str() else {
error!("ReferencePointInput update was not of type: u64");
return;
};
reference_point_input.value = update_value.into();
(reference_point_input.on_update.callback)(reference_point_input)
}
};
responses.add(callback_message);
}
Widget::PopoverButton(_) => {}
Widget::RadioInput(radio_input) => {
let Some(update_value) = value.as_u64() else {
error!("RadioInput update was not of type: u64");
return;
};
radio_input.selected_index = Some(update_value as u32);
let callback_message = match action {
WidgetValueAction::Commit => (radio_input.entries[update_value as usize].on_commit.callback)(&()),
WidgetValueAction::Update => (radio_input.entries[update_value as usize].on_update.callback)(&()),
};
responses.add(callback_message);
}
Widget::Separator(_) => {}
Widget::TextAreaInput(text_area_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (text_area_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Some(update_value) = value.as_str() else {
error!("TextAreaInput update was not of type: string");
return;
};
text_area_input.value = update_value.into();
(text_area_input.on_update.callback)(text_area_input)
}
};
responses.add(callback_message);
}
Widget::TextButton(text_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (text_button.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Some(value_path) = value.as_array() else {
error!("TextButton update was not of type: array");
return;
};
// Process the text button click, since no menu is involved if we're given an empty array.
if value_path.is_empty() {
(text_button.on_update.callback)(text_button)
}
// Process the text button's menu list entry click, since we have a path to the value of the contained menu entry.
else {
let mut current_submenu = &text_button.menu_list_children;
let mut final_entry: Option<&MenuListEntry> = None;
// Loop through all menu entry value strings in the path until we reach the final entry (which we store).
// Otherwise we exit early if we can't traverse the full path.
for value in value_path.iter().filter_map(|v| v.as_str().map(|s| s.to_string())) {
let Some(next_entry) = current_submenu.iter().flatten().find(|e| e.value == value) else { return };
current_submenu = &next_entry.children;
final_entry = Some(next_entry);
}
// If we've reached here without returning early, we have a final entry in the path and we should now execute its callback.
(final_entry.unwrap().on_commit.callback)(&())
}
}
};
responses.add(callback_message);
}
Widget::TextInput(text_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (text_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Some(update_value) = value.as_str() else {
error!("TextInput update was not of type: string");
return;
};
text_input.value = update_value.into();
(text_input.on_update.callback)(text_input)
}
};
responses.add(callback_message);
}
Widget::TextLabel(_) => {}
Widget::VisualColorPickersInput(visual_color_pickers_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (visual_color_pickers_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(update) = serde_json::from_value::<VisualColorPickersInputUpdate>(value) else {
warn!("VisualColorPickersInput update was not able to be parsed as VisualColorPickersInputUpdate");
return;
};
// Don't mutate the stored widget here: leaving its old values lets the layout diff detect a change
// when the new layout is rebuilt with the updated state, so the visual indicators (selection circle,
// hue/alpha needles, saturation-val ue gradient background) actually re-render after a drag.
(visual_color_pickers_input.on_update.callback)(&update)
}
};
responses.add(callback_message);
}
Widget::WorkingColorsInput(_) => {}
};
}
/// Diff the update and send to the frontend where necessary
fn diff_and_send_layout_to_frontend(
&mut self,
layout_target: LayoutTarget,
mut new_layout: Layout,
responses: &mut VecDeque<Message>,
action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option<KeysGroup>,
) {
// Collect CheckboxId mappings from new layout
let mut checkbox_map = HashMap::new();
new_layout.collect_checkbox_ids(layout_target, &mut Vec::new(), &mut checkbox_map);
// Replace all IDs in new layout with deterministic ones
new_layout.replace_widget_ids(layout_target, &mut Vec::new(), &checkbox_map);
// Populate computed display fields on widgets that need derived values
populate_computed_display_fields(&mut new_layout);
// Diff with deterministic IDs
let mut widget_diffs = Vec::new();
self.layouts[layout_target as usize].diff(new_layout, &mut Vec::new(), &mut widget_diffs);
// Skip sending if no diff
if widget_diffs.is_empty() {
return;
}
// On Mac we need the full MenuBar layout to construct the native menu
#[cfg(target_os = "macos")]
if layout_target == LayoutTarget::MenuBar {
widget_diffs = vec![WidgetDiff {
widget_path: Vec::new(),
new_value: DiffUpdate::Layout(self.layouts[LayoutTarget::MenuBar as usize].clone()),
}];
}
self.send_diff(widget_diffs, layout_target, responses, action_input_mapping);
}
/// Send a diff to the frontend based on the layout target.
fn send_diff(&self, mut diff: Vec<WidgetDiff>, layout_target: LayoutTarget, responses: &mut VecDeque<Message>, action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option<KeysGroup>) {
diff.iter_mut().for_each(|diff| diff.new_value.apply_keyboard_shortcut(action_input_mapping));
if matches!(layout_target, LayoutTarget::_LayoutTargetLength) {
panic!("`_LayoutTargetLength` is not a valid `LayoutTarget` and is used for array indexing");
}
responses.add(FrontendMessage::UpdateLayout { layout_target, diff });
}
}
enum WidgetValueAction {
Commit,
Update,
}
/// Walk all widgets in the layout and populate computed display fields (e.g., precomputed CSS gradient strings) so the frontend can render them without making Wasm round-trip calls. Mutates fields in place.
fn populate_computed_display_fields(layout: &mut Layout) {
for instance in layout.iter_mut() {
match &mut *instance.widget {
Widget::ColorInput(color_input) => {
color_input.chosen_gradient = color_input.value.to_css_background_image();
}
Widget::SpectrumInput(spectrum_input) => {
spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient();
spectrum_input.track_start_css = spectrum_input.track.color.first().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string());
spectrum_input.track_end_css = spectrum_input.track.color.last().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string());
}
Widget::ColorComparisonInput(comparison) => {
let contrasting = |color: Option<SRGBA8>| color.map_or(SRGBA8::BLACK, |color| color.contrasting_text_color()).to_css_hex();
comparison.new_color_css = comparison.new_color.map(|color| color.to_css_hex()).unwrap_or_default();
comparison.new_color_contrasting = contrasting(comparison.new_color);
comparison.old_color_css = comparison.old_color.map(|color| color.to_css_hex()).unwrap_or_default();
comparison.old_color_contrasting = contrasting(comparison.old_color);
}
_ => {}
}
}
}
/// Evaluates a math expression committed in a `NumberInput`'s text field, then clamps and rounds it to the widget's constraints.
/// Returns `None` if the expression fails to parse, fails to evaluate, or yields a non-real number (such as `sqrt(-1)`).
fn evaluate_and_validate_number_input(expression: &str, number_input: &NumberInput) -> Option<f64> {
let value = math_parser::evaluate(expression)
.inspect_err(|err| error!("Math parser error on \"{expression}\": {err}"))
.ok()?
.0
.inspect_err(|err| error!("Math evaluate error on \"{expression}\": {err}"))
.ok()?;
let real = value.as_real()?;
if real.is_nan() {
return None;
}
let mut validated = real;
if let Some(min) = number_input.min {
validated = validated.max(min);
}
if let Some(max) = number_input.max {
validated = validated.min(max);
}
if number_input.is_integer {
validated = validated.round();
}
Some(validated)
}
@@ -0,0 +1,9 @@
mod layout_message;
pub mod layout_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use layout_message::{LayoutMessage, LayoutMessageDiscriminant};
#[doc(inline)]
pub use layout_message_handler::LayoutMessageHandler;
@@ -0,0 +1,958 @@
use super::widgets::button_widgets::*;
use super::widgets::input_widgets::*;
use super::widgets::label_widgets::*;
use crate::application::generate_uuid;
use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup;
use crate::messages::prelude::*;
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
#[repr(transparent)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct WidgetId(pub u64);
impl core::fmt::Display for WidgetId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}
macro_rules! define_layout_target {
($($(#[$attr:meta])* $variant:ident),* $(,)?) => {
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(PartialEq, Clone, Debug, Hash, Eq, Copy, serde::Serialize, serde::Deserialize)]
#[repr(u8)]
pub enum LayoutTarget {
$($(#[$attr])* $variant,)*
// KEEP THIS ENUM LAST
// This is a marker that is used to define an array that is used to hold widgets
#[serde(skip)]
_LayoutTargetLength,
}
impl From<u8> for LayoutTarget {
fn from(value: u8) -> Self {
match value {
$(x if x == Self::$variant as u8 => Self::$variant,)*
_ => panic!("Invalid LayoutTarget discriminant: {value}"),
}
}
}
};
}
define_layout_target!(
/// Left column of the color picker popover, containing the visual H/S/V/A sliders and (optionally) the gradient editor.
ColorPickerPickersAndGradient,
/// Right column of the color picker popover, containing the new/old color comparison swatch, hex/RGB/HSV/alpha numeric inputs, and color preset buttons.
ColorPickerDetails,
/// The Data panel visualizes the output data flowing through the selected node in the graph.
DataPanel,
/// Contains the action buttons at the bottom of the dialog. Must be shown with the `FrontendMessage::DisplayDialog` message.
DialogButtons,
/// Contains the contents of the dialog's primary column. Must be shown with the `FrontendMessage::DisplayDialog` message.
DialogColumn1,
/// Contains the contents of the dialog's secondary column (often blank). Must be shown with the `FrontendMessage::DisplayDialog` message.
DialogColumn2,
/// Contains the widgets located directly above the canvas to the right, for example the zoom in and out buttons.
DocumentBar,
/// Controls for adding, grouping, and deleting layers at the bottom of the Layers panel.
LayersPanelBottomBar,
/// Blending options at the top of the Layers panel.
LayersPanelControlLeftBar,
/// Selected layer status (locked/hidden) at the top of the Layers panel.
LayersPanelControlRightBar,
/// The dropdown menu at the very top of the application: File, Edit, etc.
MenuBar,
/// Bar at the top of the node graph containing the location and the "Preview" and "Hide" buttons.
NodeGraphControlBar,
/// The body of the Properties panel containing many collapsable sections.
PropertiesPanel,
/// The contextual input key/mouse combination shortcuts shown in the status bar at the bottom of the window.
StatusBarHints,
/// The version information shown in the status bar at the bottom right of the window.
StatusBarInfo,
/// The left side of the control bar directly above the canvas.
ToolOptions,
/// The vertical buttons for all of the tools on the left of the canvas.
ToolShelf,
/// The quick access buttons found on the welcome screen, shown when no documents are open.
WelcomeScreenButtons,
/// The color swatch for the working colors and a flip and reset button found at the bottom of the tool shelf.
WorkingColors,
);
/// For use by structs that define a UI widget layout by implementing the layout() function belonging to this trait.
/// The send_layout() function can then be called by other code which is a part of the same struct so as to send the layout to the frontend.
pub trait LayoutHolder {
fn layout(&self) -> Layout;
fn send_layout(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget) {
responses.add(LayoutMessage::SendLayout { layout: self.layout(), layout_target });
}
}
/// Structs implementing this hold the layout (like [`LayoutHolder`]) for dialog content, but it also requires defining the dialog's title, icon, and action buttons.
pub trait DialogLayoutHolder: LayoutHolder {
const ICON: &'static str;
const TITLE: &'static str;
fn layout_buttons(&self) -> Layout;
fn send_layout_buttons(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget) {
responses.add(LayoutMessage::SendLayout {
layout: self.layout_buttons(),
layout_target,
});
}
fn layout_column_2(&self) -> Layout {
Layout::default()
}
fn send_layout_column_2(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget) {
responses.add(LayoutMessage::SendLayout {
layout: self.layout_column_2(),
layout_target,
});
}
fn send_dialog_to_frontend(&self, responses: &mut VecDeque<Message>) {
self.send_layout(responses, LayoutTarget::DialogColumn1);
self.send_layout_column_2(responses, LayoutTarget::DialogColumn2);
self.send_layout_buttons(responses, LayoutTarget::DialogButtons);
responses.add(FrontendMessage::DisplayDialog {
icon: Self::ICON.into(),
title: Self::TITLE.into(),
});
}
}
/// Trait for types that can compute incremental diffs for UI updates.
///
/// This trait unifies the diffing behavior across Layout, LayoutGroup, and WidgetInstance,
/// allowing each type to specify how it should be represented in a DiffUpdate.
pub trait Diffable: Clone + PartialEq {
/// Converts this value into a DiffUpdate variant.
fn into_diff_update(self) -> DiffUpdate;
/// Computes the diff between self (old) and new, updating self and recording changes.
fn diff(&mut self, new: Self, widget_path: &mut Vec<usize>, widget_diffs: &mut Vec<WidgetDiff>);
/// Collects all CheckboxIds currently in use in this layout, computing stable replacements.
fn collect_checkbox_ids(&self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &mut HashMap<CheckboxId, CheckboxId>);
/// Replaces all widget IDs with deterministic IDs based on position and type.
/// Also replaces CheckboxIds using the provided mapping.
fn replace_widget_ids(&mut self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &HashMap<CheckboxId, CheckboxId>);
}
/// Computes a deterministic WidgetId based on layout target, path, and widget type.
fn compute_widget_id(layout_target: LayoutTarget, widget_path: &[usize], widget: &Widget) -> WidgetId {
let mut hasher = DefaultHasher::new();
(layout_target as u8).hash(&mut hasher);
widget_path.hash(&mut hasher);
std::mem::discriminant(widget).hash(&mut hasher);
WidgetId(hasher.finish())
}
/// Computes a deterministic CheckboxId based on the same WidgetId algorithm.
fn compute_checkbox_id(layout_target: LayoutTarget, widget_path: &[usize], widget: &Widget) -> CheckboxId {
let mut hasher = DefaultHasher::new();
(layout_target as u8).hash(&mut hasher);
widget_path.hash(&mut hasher);
std::mem::discriminant(widget).hash(&mut hasher);
// Add extra salt for checkbox to differentiate from widget ID
"checkbox".hash(&mut hasher);
CheckboxId(hasher.finish())
}
/// Contains an arrangement of widgets mounted somewhere specific in the frontend.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct Layout(pub Vec<LayoutGroup>);
impl Layout {
pub fn iter(&self) -> WidgetIter<'_> {
WidgetIter {
stack: self.0.iter().collect(),
..Default::default()
}
}
pub fn iter_mut(&mut self) -> WidgetIterMut<'_> {
WidgetIterMut {
stack: self.0.iter_mut().collect(),
..Default::default()
}
}
}
impl Diffable for Layout {
fn into_diff_update(self) -> DiffUpdate {
DiffUpdate::Layout(self)
}
fn diff(&mut self, new: Self, widget_path: &mut Vec<usize>, widget_diffs: &mut Vec<WidgetDiff>) {
// Check if the length of items is different
// TODO: Diff insersion and deletion of items
if self.0.len() != new.0.len() {
// Update the layout to the new layout
self.0.clone_from(&new.0);
// Push an update sublayout to the diff
widget_diffs.push(WidgetDiff {
widget_path: widget_path.to_vec(),
new_value: new.into_diff_update(),
});
return;
}
// Diff all of the children
for (index, (current_child, new_child)) in self.0.iter_mut().zip(new.0).enumerate() {
widget_path.push(index);
current_child.diff(new_child, widget_path, widget_diffs);
widget_path.pop();
}
}
fn collect_checkbox_ids(&self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &mut HashMap<CheckboxId, CheckboxId>) {
for (index, child) in self.0.iter().enumerate() {
widget_path.push(index);
child.collect_checkbox_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
}
}
fn replace_widget_ids(&mut self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &HashMap<CheckboxId, CheckboxId>) {
for (index, child) in self.0.iter_mut().enumerate() {
widget_path.push(index);
child.replace_widget_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
}
}
}
#[derive(Debug, Default)]
pub struct WidgetIter<'a> {
pub stack: Vec<&'a LayoutGroup>,
pub table: Vec<&'a WidgetInstance>,
pub current_slice: Option<&'a [WidgetInstance]>,
}
impl<'a> Iterator for WidgetIter<'a> {
type Item = &'a WidgetInstance;
fn next(&mut self) -> Option<Self::Item> {
let widget = self.table.pop().or_else(|| {
let (first, rest) = self.current_slice.take()?.split_first()?;
self.current_slice = Some(rest);
Some(first)
});
if let Some(instance) = widget {
if let Widget::PopoverButton(popover_button) = &*instance.widget {
self.stack.extend(popover_button.popover_layout.0.iter());
return self.next();
}
return Some(instance);
}
match self.stack.pop() {
Some(LayoutGroup::Column(WidgetColumn { widgets })) => {
self.current_slice = Some(widgets);
self.next()
}
Some(LayoutGroup::Row(WidgetRow { widgets })) => {
self.current_slice = Some(widgets);
self.next()
}
Some(LayoutGroup::Table(WidgetTable { rows, .. })) => {
self.table.extend(rows.iter().flatten().rev());
self.next()
}
Some(LayoutGroup::Section(WidgetSection { layout, .. })) => {
for layout_row in &layout.0 {
self.stack.push(layout_row);
}
self.next()
}
None => None,
}
}
}
#[derive(Debug, Default)]
pub struct WidgetIterMut<'a> {
pub stack: Vec<&'a mut LayoutGroup>,
pub table: Vec<&'a mut WidgetInstance>,
pub current_slice: Option<&'a mut [WidgetInstance]>,
}
impl<'a> Iterator for WidgetIterMut<'a> {
type Item = &'a mut WidgetInstance;
fn next(&mut self) -> Option<Self::Item> {
let widget = self.table.pop().or_else(|| {
let (first, rest) = self.current_slice.take()?.split_first_mut()?;
self.current_slice = Some(rest);
Some(first)
});
if let Some(instance) = widget {
// We have to check that we're not a popover and return first, then extract the popover with an unreachable else condition second, to satisfy the borrow checker.
// After Rust's Polonius is stable, we can reverse that order of steps to avoid the redundancy and unreachable statement.
if !matches!(*instance.widget, Widget::PopoverButton(_)) {
return Some(instance);
}
let Widget::PopoverButton(popover_button) = &mut *instance.widget else { unreachable!() };
self.stack.extend(popover_button.popover_layout.0.iter_mut());
return self.next();
}
match self.stack.pop() {
Some(LayoutGroup::Column(WidgetColumn { widgets })) => {
self.current_slice = Some(widgets);
self.next()
}
Some(LayoutGroup::Row(WidgetRow { widgets })) => {
self.current_slice = Some(widgets);
self.next()
}
Some(LayoutGroup::Table(WidgetTable { rows, .. })) => {
self.table.extend(rows.iter_mut().flatten().rev());
self.next()
}
Some(LayoutGroup::Section(WidgetSection { layout, .. })) => {
for layout_row in &mut layout.0 {
self.stack.push(layout_row);
}
self.next()
}
None => None,
}
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum LayoutGroup {
Column(WidgetColumn),
Row(WidgetRow),
Table(WidgetTable),
Section(WidgetSection),
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct WidgetColumn {
#[serde(rename = "columnWidgets")]
pub widgets: Vec<WidgetInstance>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct WidgetRow {
#[serde(rename = "rowWidgets")]
pub widgets: Vec<WidgetInstance>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct WidgetTable {
#[serde(rename = "tableWidgets")]
pub rows: Vec<Vec<WidgetInstance>>,
pub unstyled: bool,
}
/// A collapsible Properties panel section for a single node: a header with its name and pin, delete, and visibility controls, above its input parameter widgets.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct WidgetSection {
/// The node's name shown in the section header: its given alias with the implementation name in parentheses, or just the implementation name.
pub name: String,
/// The node definition's description, shown as the header's tooltip (empty when it has none).
pub description: String,
/// Whether the node is visible rather than hidden (shown as the eye icon).
pub visible: bool,
/// Whether the node is pinned, keeping its section shown in the Properties panel when nothing is selected (shown as the pin icon).
pub pinned: bool,
/// Whether this section can be dragged to reorder it within its layer's chain of nodes (true for a layer chain's nodes, but not its terminal layer node).
pub draggable: bool,
/// Whether this section is expanded to show its contents, as opposed to being collapsed down to just its header.
pub expanded: bool,
/// The ID of the node whose properties this section displays.
pub id: u64,
/// The node's properties content, rendered as the section's body when expanded.
pub layout: Layout,
}
impl Default for LayoutGroup {
fn default() -> Self {
Self::Row(Default::default())
}
}
impl From<Vec<WidgetInstance>> for LayoutGroup {
fn from(widgets: Vec<WidgetInstance>) -> LayoutGroup {
LayoutGroup::Row(WidgetRow { widgets })
}
}
impl LayoutGroup {
pub fn row(widgets: Vec<WidgetInstance>) -> Self {
Self::Row(WidgetRow { widgets })
}
pub fn column(widgets: Vec<WidgetInstance>) -> Self {
Self::Column(WidgetColumn { widgets })
}
pub fn table(rows: Vec<Vec<WidgetInstance>>, unstyled: bool) -> Self {
Self::Table(WidgetTable { rows, unstyled })
}
pub fn section(name: impl Into<String>, description: impl Into<String>, visible: bool, pinned: bool, expanded: bool, id: u64, layout: Layout) -> Self {
Self::Section(WidgetSection {
name: name.into(),
description: description.into(),
visible,
pinned,
draggable: false,
expanded,
id,
layout,
})
}
/// Applies a tooltip description to all widgets without a tooltip in this row or column.
pub fn with_tooltip_description(self, description: impl Into<String>) -> Self {
let (is_col, mut widgets) = match self {
LayoutGroup::Column(WidgetColumn { widgets }) => (true, widgets),
LayoutGroup::Row(WidgetRow { widgets }) => (false, widgets),
_ => unimplemented!(),
};
let description = description.into();
for widget in &mut widgets {
let val = match &mut *widget.widget {
Widget::CheckboxInput(x) => &mut x.tooltip_description,
Widget::ColorInput(x) => &mut x.tooltip_description,
Widget::DropdownInput(x) => &mut x.tooltip_description,
Widget::IconButton(x) => &mut x.tooltip_description,
Widget::IconLabel(x) => &mut x.tooltip_description,
Widget::ImageButton(x) => &mut x.tooltip_description,
Widget::ImageLabel(x) => &mut x.tooltip_description,
Widget::NumberInput(x) => &mut x.tooltip_description,
Widget::PopoverButton(x) => &mut x.tooltip_description,
Widget::TextAreaInput(x) => &mut x.tooltip_description,
Widget::TextButton(x) => &mut x.tooltip_description,
Widget::TextInput(x) => &mut x.tooltip_description,
Widget::TextLabel(x) => &mut x.tooltip_description,
Widget::BreadcrumbTrailButtons(x) => &mut x.tooltip_description,
Widget::ReferencePointInput(_)
| Widget::RadioInput(_)
| Widget::Separator(_)
| Widget::ShortcutLabel(_)
| Widget::WorkingColorsInput(_)
| Widget::NodeCatalog(_)
| Widget::ParameterExposeButton(_)
| Widget::ColorComparisonInput(_)
| Widget::ColorPresetsInput(_)
| Widget::SpectrumInput(_)
| Widget::VisualColorPickersInput(_) => continue,
};
if val.is_empty() {
val.clone_from(&description);
}
}
if is_col { Self::Column(WidgetColumn { widgets }) } else { Self::Row(WidgetRow { widgets }) }
}
pub fn iter_mut(&mut self) -> WidgetIterMut<'_> {
WidgetIterMut {
stack: vec![self],
..Default::default()
}
}
}
impl Diffable for LayoutGroup {
fn into_diff_update(self) -> DiffUpdate {
DiffUpdate::LayoutGroup(self)
}
fn diff(&mut self, new: Self, widget_path: &mut Vec<usize>, widget_diffs: &mut Vec<WidgetDiff>) {
let is_column = matches!(new, Self::Column { .. });
match (self, new) {
(Self::Column(WidgetColumn { widgets: current_widgets }), Self::Column(WidgetColumn { widgets: new_widgets }))
| (Self::Row(WidgetRow { widgets: current_widgets }), Self::Row(WidgetRow { widgets: new_widgets })) => {
// If the lengths are different then resend the entire panel
// TODO: Diff insersion and deletion of items
if current_widgets.len() != new_widgets.len() {
// Update to the new value
current_widgets.clone_from(&new_widgets);
// Push back a LayoutGroup update to the diff
let new_value = (if is_column {
Self::Column(WidgetColumn { widgets: new_widgets })
} else {
Self::Row(WidgetRow { widgets: new_widgets })
})
.into_diff_update();
let widget_path = widget_path.to_vec();
widget_diffs.push(WidgetDiff { widget_path, new_value });
return;
}
// Diff all of the children
for (index, (current_child, new_child)) in current_widgets.iter_mut().zip(new_widgets).enumerate() {
widget_path.push(index);
current_child.diff(new_child, widget_path, widget_diffs);
widget_path.pop();
}
}
(
Self::Section(WidgetSection {
name: current_name,
description: current_description,
visible: current_visible,
pinned: current_pinned,
draggable: current_draggable,
expanded: current_expanded,
id: current_id,
layout: current_layout,
}),
Self::Section(WidgetSection {
name: new_name,
description: new_description,
visible: new_visible,
pinned: new_pinned,
draggable: new_draggable,
expanded: new_expanded,
id: new_id,
layout: new_layout,
}),
) => {
// Resend the entire panel if the lengths, names, visibility, or node IDs are different
// TODO: Diff insersion and deletion of items
if current_layout.0.len() != new_layout.0.len()
|| *current_name != new_name
|| *current_description != new_description
|| *current_visible != new_visible
|| *current_pinned != new_pinned
|| *current_draggable != new_draggable
|| *current_expanded != new_expanded
|| *current_id != new_id
{
// Update self to reflect new changes
current_name.clone_from(&new_name);
current_description.clone_from(&new_description);
*current_visible = new_visible;
*current_pinned = new_pinned;
*current_draggable = new_draggable;
*current_expanded = new_expanded;
*current_id = new_id;
current_layout.clone_from(&new_layout);
// Push an update layout group to the diff
let new_value = Self::Section(WidgetSection {
name: new_name,
description: new_description,
visible: new_visible,
pinned: new_pinned,
draggable: new_draggable,
expanded: new_expanded,
id: new_id,
layout: new_layout,
})
.into_diff_update();
let widget_path = widget_path.to_vec();
widget_diffs.push(WidgetDiff { widget_path, new_value });
}
// Diff all of the children
else {
for (index, (current_child, new_child)) in current_layout.0.iter_mut().zip(new_layout.0).enumerate() {
widget_path.push(index);
current_child.diff(new_child, widget_path, widget_diffs);
widget_path.pop();
}
}
}
(current, new) => {
*current = new.clone();
let new_value = new.into_diff_update();
let widget_path = widget_path.to_vec();
widget_diffs.push(WidgetDiff { widget_path, new_value });
}
}
}
fn collect_checkbox_ids(&self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &mut HashMap<CheckboxId, CheckboxId>) {
match self {
Self::Column(WidgetColumn { widgets }) | Self::Row(WidgetRow { widgets }) => {
for (index, widget) in widgets.iter().enumerate() {
widget_path.push(index);
widget.collect_checkbox_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
}
}
Self::Table(WidgetTable { rows, .. }) => {
for (row_idx, row) in rows.iter().enumerate() {
for (col_idx, widget) in row.iter().enumerate() {
widget_path.push(row_idx);
widget_path.push(col_idx);
widget.collect_checkbox_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
widget_path.pop();
}
}
}
Self::Section(WidgetSection { layout, .. }) => {
layout.collect_checkbox_ids(layout_target, widget_path, checkbox_map);
}
}
}
fn replace_widget_ids(&mut self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &HashMap<CheckboxId, CheckboxId>) {
match self {
Self::Column(WidgetColumn { widgets }) | Self::Row(WidgetRow { widgets }) => {
for (index, widget) in widgets.iter_mut().enumerate() {
widget_path.push(index);
widget.replace_widget_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
}
}
Self::Table(WidgetTable { rows, .. }) => {
for (row_idx, row) in rows.iter_mut().enumerate() {
for (col_idx, widget) in row.iter_mut().enumerate() {
widget_path.push(row_idx);
widget_path.push(col_idx);
widget.replace_widget_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
widget_path.pop();
}
}
}
Self::Section(WidgetSection { layout, .. }) => {
layout.replace_widget_ids(layout_target, widget_path, checkbox_map);
}
}
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WidgetInstance {
#[serde(rename = "widgetId")]
pub widget_id: WidgetId,
pub widget: Box<Widget>,
}
impl PartialEq for WidgetInstance {
fn eq(&self, other: &Self) -> bool {
self.widget_id == other.widget_id && self.widget == other.widget
}
}
impl WidgetInstance {
#[deprecated(since = "0.0.0", note = "Please use the builder pattern, e.g. TextLabel::new(\"hello\").widget_instance()")]
pub fn new(widget: Widget) -> Self {
Self {
widget_id: WidgetId(generate_uuid()),
widget: Box::new(widget),
}
}
}
impl Diffable for WidgetInstance {
fn into_diff_update(self) -> DiffUpdate {
DiffUpdate::Widget(self)
}
fn diff(&mut self, new: Self, widget_path: &mut Vec<usize>, widget_diffs: &mut Vec<WidgetDiff>) {
if self == &new {
// Still need to update callbacks since PartialEq skips them
self.widget = new.widget;
return;
}
// Special handling for PopoverButton: recursively diff nested layout if only the layout changed
if let (Widget::PopoverButton(button1), Widget::PopoverButton(button2)) = (&mut *self.widget, &*new.widget) {
// Check if only the popover layout changed (all other fields are the same)
if self.widget_id == new.widget_id
&& button1.disabled == button2.disabled
&& button1.style == button2.style
&& button1.menu_direction == button2.menu_direction
&& button1.icon == button2.icon
&& button1.tooltip_label == button2.tooltip_label
&& 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() {
widget_path.push(i);
a.diff(b.clone(), widget_path, widget_diffs);
widget_path.pop();
}
return;
}
}
// Widget or ID changed, send full update
*self = new.clone();
let new_value = new.into_diff_update();
let widget_path = widget_path.to_vec();
widget_diffs.push(WidgetDiff { widget_path, new_value });
}
fn collect_checkbox_ids(&self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &mut HashMap<CheckboxId, CheckboxId>) {
match &*self.widget {
Widget::CheckboxInput(checkbox) => {
// Compute stable ID based on position and insert mapping
let checkbox_id = checkbox.for_label;
let stable_id = compute_checkbox_id(layout_target, widget_path, &self.widget);
checkbox_map.entry(checkbox_id).or_insert(stable_id);
}
Widget::TextLabel(label) => {
// Compute stable ID based on position and insert mapping
let checkbox_id = label.for_checkbox;
let stable_id = compute_checkbox_id(layout_target, widget_path, &self.widget);
checkbox_map.entry(checkbox_id).or_insert(stable_id);
}
Widget::PopoverButton(button) => {
// Recursively collect from nested popover layout
for (index, child) in button.popover_layout.0.iter().enumerate() {
widget_path.push(index);
child.collect_checkbox_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
}
}
_ => {}
}
}
fn replace_widget_ids(&mut self, layout_target: LayoutTarget, widget_path: &mut Vec<usize>, checkbox_map: &HashMap<CheckboxId, CheckboxId>) {
// 1. Generate deterministic WidgetId
self.widget_id = compute_widget_id(layout_target, widget_path, &self.widget);
// 2. Replace CheckboxIds if present
match &mut *self.widget {
Widget::CheckboxInput(checkbox) => {
let old_id = checkbox.for_label;
if let Some(&new_id) = checkbox_map.get(&old_id) {
checkbox.for_label = new_id;
}
}
Widget::TextLabel(label) => {
let old_id = label.for_checkbox;
if let Some(&new_id) = checkbox_map.get(&old_id) {
label.for_checkbox = new_id;
}
}
Widget::PopoverButton(button) => {
// Recursively replace in nested popover layout
for (index, child) in button.popover_layout.0.iter_mut().enumerate() {
widget_path.push(index);
child.replace_widget_ids(layout_target, widget_path, checkbox_map);
widget_path.pop();
}
}
_ => {}
}
}
}
#[derive(Clone)]
pub struct WidgetCallback<T> {
pub callback: Arc<dyn Fn(&T) -> Message + 'static + Send + Sync>,
}
impl<T> WidgetCallback<T> {
pub fn new(callback: impl Fn(&T) -> Message + 'static + Send + Sync) -> Self {
Self { callback: Arc::new(callback) }
}
}
impl<T> Default for WidgetCallback<T> {
fn default() -> Self {
Self::new(|_| Message::NoOp)
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Widget {
BreadcrumbTrailButtons(BreadcrumbTrailButtons),
CheckboxInput(CheckboxInput),
ColorComparisonInput(ColorComparisonInput),
ColorInput(ColorInput),
ColorPresetsInput(ColorPresetsInput),
DropdownInput(DropdownInput),
IconButton(IconButton),
IconLabel(IconLabel),
ImageButton(ImageButton),
ImageLabel(ImageLabel),
ShortcutLabel(ShortcutLabel),
NodeCatalog(NodeCatalog),
NumberInput(NumberInput),
ParameterExposeButton(ParameterExposeButton),
ReferencePointInput(ReferencePointInput),
PopoverButton(PopoverButton),
RadioInput(RadioInput),
Separator(Separator),
SpectrumInput(SpectrumInput),
TextAreaInput(TextAreaInput),
TextButton(TextButton),
TextInput(TextInput),
TextLabel(TextLabel),
VisualColorPickersInput(VisualColorPickersInput),
WorkingColorsInput(WorkingColorsInput),
}
/// A single change to part of the UI, containing the location of the change and the new value.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct WidgetDiff {
/// A path to the change
/// e.g. [0, 1, 2] in the Properties panel is the first section, second row and third widget.
/// An empty path [] shows that the entire panel has changed and is sent when the UI is first created.
#[serde(rename = "widgetPath")]
pub widget_path: Vec<usize>,
/// What the specified part of the UI has changed to.
#[serde(rename = "newValue")]
pub new_value: DiffUpdate,
}
/// The new value of the UI, sent as part of a diff.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DiffUpdate {
#[serde(rename = "layout")]
Layout(Layout),
#[serde(rename = "layoutGroup")]
LayoutGroup(LayoutGroup),
#[serde(rename = "widget")]
Widget(WidgetInstance),
}
impl DiffUpdate {
/// Append the keyboard shortcut to the tooltip where applicable
pub fn apply_keyboard_shortcut(&mut self, action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option<KeysGroup>) {
// Go through each widget to convert `ActionShortcut::Action` to `ActionShortcut::Shortcut` and append the key combination to the widget tooltip
let convert_tooltip = |widget_instance: &mut WidgetInstance| {
// Handle all the widgets that have tooltips
let tooltip_shortcut = match &mut *widget_instance.widget {
Widget::BreadcrumbTrailButtons(widget) => widget.tooltip_shortcut.as_mut(),
Widget::CheckboxInput(widget) => widget.tooltip_shortcut.as_mut(),
Widget::ColorInput(widget) => widget.tooltip_shortcut.as_mut(),
Widget::DropdownInput(widget) => widget.tooltip_shortcut.as_mut(),
Widget::IconButton(widget) => widget.tooltip_shortcut.as_mut(),
Widget::NumberInput(widget) => widget.tooltip_shortcut.as_mut(),
Widget::ParameterExposeButton(widget) => widget.tooltip_shortcut.as_mut(),
Widget::PopoverButton(widget) => widget.tooltip_shortcut.as_mut(),
Widget::TextButton(widget) => widget.tooltip_shortcut.as_mut(),
Widget::ImageButton(widget) => widget.tooltip_shortcut.as_mut(),
Widget::ShortcutLabel(widget) => widget.shortcut.as_mut(),
Widget::IconLabel(_)
| Widget::ImageLabel(_)
| Widget::NodeCatalog(_)
| Widget::ReferencePointInput(_)
| Widget::RadioInput(_)
| Widget::Separator(_)
| Widget::TextAreaInput(_)
| Widget::TextInput(_)
| Widget::TextLabel(_)
| Widget::WorkingColorsInput(_)
| Widget::ColorComparisonInput(_)
| Widget::ColorPresetsInput(_)
| Widget::SpectrumInput(_)
| Widget::VisualColorPickersInput(_) => None,
};
// Convert `ActionShortcut::Action` to `ActionShortcut::Shortcut`
if let Some(tooltip_shortcut) = tooltip_shortcut {
tooltip_shortcut.realize_shortcut(action_input_mapping);
}
// Handle RadioInput separately because its tooltips are children of the widget
if let Widget::RadioInput(radio_input) = &mut *widget_instance.widget {
for radio_entry_data in &mut radio_input.entries {
// Convert `ActionShortcut::Action` to `ActionShortcut::Shortcut`
if let Some(tooltip_shortcut) = radio_entry_data.tooltip_shortcut.as_mut() {
tooltip_shortcut.realize_shortcut(action_input_mapping);
}
}
}
};
// Recursively fill menu list entries with their realized shortcut keys specific to the current bindings and platform
fn apply_action_shortcut_to_menu_lists(entry_sections: &mut MenuListEntrySections, action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option<KeysGroup>) {
for entries in entry_sections {
for entry in entries {
// Convert `ActionShortcut::Action` to `ActionShortcut::Shortcut`
if let Some(tooltip_shortcut) = &mut entry.tooltip_shortcut {
tooltip_shortcut.realize_shortcut(action_input_mapping);
}
// Recursively call this function on the menu's children
apply_action_shortcut_to_menu_lists(&mut entry.children, action_input_mapping);
}
}
}
// Hash the menu list entry sections for caching purposes
let hash_menu_list_entry_sections = |entry_sections: &MenuListEntrySections| {
struct RecursiveHasher<'a> {
hasher: DefaultHasher,
hash_fn: &'a dyn Fn(&mut RecursiveHasher, &MenuListEntrySections),
}
let mut recursive_hasher = RecursiveHasher {
hasher: DefaultHasher::new(),
hash_fn: &|recursive_hasher, entry_sections| {
for (index, entries) in entry_sections.iter().enumerate() {
index.hash(&mut recursive_hasher.hasher);
for entry in entries {
entry.hash(&mut recursive_hasher.hasher);
(recursive_hasher.hash_fn)(recursive_hasher, &entry.children);
}
}
},
};
(recursive_hasher.hash_fn)(&mut recursive_hasher, entry_sections);
recursive_hasher.hasher.finish()
};
// Apply shortcut conversions to all widgets that have menu lists
let convert_menu_lists = |widget_instance: &mut WidgetInstance| match &mut *widget_instance.widget {
Widget::DropdownInput(dropdown_input) => {
apply_action_shortcut_to_menu_lists(&mut dropdown_input.entries, action_input_mapping);
dropdown_input.entries_hash = hash_menu_list_entry_sections(&dropdown_input.entries);
}
Widget::TextButton(text_button) => {
apply_action_shortcut_to_menu_lists(&mut text_button.menu_list_children, action_input_mapping);
text_button.menu_list_children_hash = hash_menu_list_entry_sections(&text_button.menu_list_children);
}
_ => {}
};
match self {
Self::Layout(layout) => layout.0.iter_mut().flat_map(|layout_group| layout_group.iter_mut()).for_each(|widget_instance| {
convert_tooltip(widget_instance);
convert_menu_lists(widget_instance);
}),
Self::LayoutGroup(layout_group) => layout_group.iter_mut().for_each(|widget_instance| {
convert_tooltip(widget_instance);
convert_menu_lists(widget_instance);
}),
Self::Widget(widget_instance) => {
convert_tooltip(widget_instance);
convert_menu_lists(widget_instance);
}
}
}
}
@@ -0,0 +1,9 @@
pub mod layout_widget;
pub mod widgets;
pub mod widget_prelude {
pub use super::layout_widget::*;
pub use super::widgets::button_widgets::*;
pub use super::widgets::input_widgets::*;
pub use super::widgets::label_widgets::*;
}
@@ -0,0 +1,259 @@
use crate::messages::frontend::IconName;
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType;
use crate::messages::tool::tool_messages::tool_prelude::WidgetCallback;
use derivative::*;
use graphene_std::vector::style::FillChoiceUI;
use graphite_proc_macros::WidgetBuilder;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct IconButton {
// Content
#[widget_builder(constructor)]
#[widget_builder(string)]
pub icon: IconName,
#[serde(rename = "hoverIcon")]
pub hover_icon: Option<String>,
#[widget_builder(constructor)]
pub size: u32, // TODO: Convert to an `IconSize` enum
pub disabled: bool,
// Styling
pub emphasized: bool,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<IconButton>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_drag_drop: WidgetCallback<IconButton>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct PopoverButton {
// Content
pub style: Option<String>,
#[widget_builder(string)]
pub icon: Option<IconName>,
pub disabled: bool,
// Children
#[serde(rename = "popoverLayout")]
pub popover_layout: Layout,
#[serde(rename = "popoverMinWidth")]
pub popover_min_width: Option<u32>,
#[serde(rename = "menuDirection")]
pub menu_direction: Option<MenuDirection>,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum MenuDirection {
Top,
#[default]
Bottom,
Left,
Right,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
Center,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ParameterExposeButton {
// Content
pub exposed: bool,
#[serde(rename = "dataType")]
pub data_type: FrontendGraphDataType,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<ParameterExposeButton>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct TextButton {
// Content
#[widget_builder(constructor)]
pub label: String,
#[widget_builder(string)]
pub icon: Option<IconName>,
#[serde(rename = "hoverIcon")]
pub hover_icon: Option<String>,
pub disabled: bool,
// Children
#[serde(rename = "menuListChildren")]
pub menu_list_children: MenuListEntrySections,
#[serde(rename = "menuListChildrenHash")]
#[widget_builder(skip)]
pub menu_list_children_hash: u64,
// Styling
pub emphasized: bool,
pub flush: bool,
pub narrow: bool,
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<TextButton>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ImageButton {
// Content
#[widget_builder(constructor)]
pub image: String,
pub width: Option<String>,
pub height: Option<String>,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<()>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct ColorInput {
// Content
#[widget_builder(constructor)]
pub value: FillChoiceUI,
/// CSS `linear-gradient(...)` (or solid-color stand-in) for the swatch's `background-image`. Auto-populated from `value` at layout-send time.
/// `None` when `value` is `FillChoiceUI::None`, in which case the frontend uses its "none" fallback styling.
#[serde(rename = "chosenGradient")]
#[widget_builder(skip)]
pub chosen_gradient: Option<String>,
#[serde(rename = "allowNone")]
#[derivative(Default(value = "true"))]
pub allow_none: bool,
// #[serde(rename = "allowTransparency")] pub allow_transparency: bool, // TODO: Implement
#[serde(rename = "menuDirection")]
pub menu_direction: Option<MenuDirection>,
pub disabled: bool,
pub mixed: bool,
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
// Styling
pub narrow: bool,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<ColorInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct BreadcrumbTrailButtons {
// Content
#[widget_builder(constructor)]
pub labels: Vec<String>,
pub disabled: bool,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<u64>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
@@ -0,0 +1,664 @@
use crate::messages::frontend::IconName;
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use derivative::*;
use graphene_std::Color;
use graphene_std::color::SRGBA8;
use graphene_std::transform::ReferencePoint;
use graphene_std::vector::style::{FillChoiceUI, GradientUI};
use graphite_proc_macros::WidgetBuilder;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, Default, PartialEq)]
pub struct CheckboxInput {
// Content
#[widget_builder(constructor)]
pub checked: bool,
#[widget_builder(string)]
pub icon: Option<IconName>,
#[serde(rename = "forLabel")]
pub for_label: CheckboxId,
pub disabled: bool,
pub mixed: bool,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<CheckboxInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
pub struct CheckboxId(pub u64);
impl CheckboxId {
pub fn new() -> Self {
Self(graphene_std::uuid::generate_uuid())
}
}
impl Default for CheckboxId {
fn default() -> Self {
Self::new()
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct DropdownInput {
// Content
// This uses `u32` instead of `usize` since it will be serialized as a normal JS number (we can replace this with `usize` if we switch to a Rust-based GUI)
#[serde(rename = "selectedIndex")]
pub selected_index: Option<u32>,
#[serde(rename = "drawIcon")]
pub draw_icon: bool,
pub disabled: bool,
// Children
#[widget_builder(constructor)]
pub entries: MenuListEntrySections,
#[serde(rename = "entriesHash")]
#[widget_builder(skip)]
pub entries_hash: u64,
// Styling
pub narrow: bool,
// Behavior
#[serde(rename = "virtualScrolling")]
pub virtual_scrolling: bool,
#[derivative(Default(value = "true"))]
pub interactive: bool,
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
//
// Callbacks exists on the `MenuListEntry` children, not this parent `DropdownInput`
}
pub type MenuListEntrySections = Vec<Vec<MenuListEntry>>;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
#[widget_builder(not_widget_instance)]
pub struct MenuListEntry {
// Content
#[widget_builder(constructor)]
pub value: String,
pub label: String,
#[widget_builder(string)]
pub icon: Option<IconName>,
pub disabled: bool,
// Children
pub children: MenuListEntrySections,
#[serde(rename = "childrenHash")]
#[widget_builder(skip)]
pub children_hash: u64,
// Styling
pub font: Option<String>,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<()>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
impl std::hash::Hash for MenuListEntry {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.value.hash(state);
self.label.hash(state);
self.icon.hash(state);
self.disabled.hash(state);
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct NumberInput {
// Content
#[widget_builder(constructor)]
pub value: Option<f64>,
pub label: String,
pub disabled: bool,
// Styling
pub narrow: bool,
// Behavior
pub mode: NumberInputMode,
#[widget_builder(skip)]
pub min: Option<f64>,
#[widget_builder(skip)]
pub max: Option<f64>,
// TODO: Make this (and range_max) apply to both Range and Increment modes when dragging with the mouse
#[serde(rename = "rangeMin")]
pub range_min: Option<f64>,
#[serde(rename = "rangeMax")]
pub range_max: Option<f64>,
#[derivative(Default(value = "1."))]
pub step: f64,
#[serde(rename = "isInteger")]
pub is_integer: bool,
#[serde(rename = "incrementBehavior")]
pub increment_behavior: NumberInputIncrementBehavior,
#[serde(rename = "displayDecimalPlaces")]
#[derivative(Default(value = "2"))]
pub display_decimal_places: u32,
pub unit: String,
#[serde(rename = "unitIsHiddenWhenEditing")]
#[derivative(Default(value = "true"))]
pub unit_is_hidden_when_editing: bool,
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub increment_callback_increase: WidgetCallback<NumberInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub increment_callback_decrease: WidgetCallback<NumberInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<NumberInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
impl NumberInput {
pub fn int(mut self) -> Self {
self.is_integer = true;
self
}
pub fn min(mut self, val: f64) -> Self {
self.min = Some(val);
self.range_min = Some(val);
self
}
pub fn max(mut self, val: f64) -> Self {
self.max = Some(val);
self.range_max = Some(val);
self
}
pub fn mode_range(mut self) -> Self {
self.mode = NumberInputMode::Range;
self
}
pub fn mode_increment(mut self) -> Self {
self.mode = NumberInputMode::Increment;
self
}
pub fn increment_step(mut self, step: f64) -> Self {
self.step = step;
self
}
pub fn percentage(self) -> Self {
self.min(0.).max(100.).mode_range().unit("%").display_decimal_places(2)
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default, PartialEq, Eq)]
pub enum NumberInputIncrementBehavior {
/// The value is added by `step`.
#[default]
Add,
/// The value is multiplied by `step`.
Multiply,
/// The functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
Callback,
/// The increment arrows are not shown.
None,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default, PartialEq, Eq)]
pub enum NumberInputMode {
#[default]
Increment,
Range,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct NodeCatalog {
// Content
pub disabled: bool,
// Behavior
#[serde(rename = "initialSearchTerm")]
pub intial_search: String,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<DefinitionIdentifier>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct RadioInput {
// Content
// This uses `u32` instead of `usize` since it will be serialized as a normal JS number (replace this with `usize` after switching to a Rust-based GUI)
#[serde(rename = "selectedIndex")]
pub selected_index: Option<u32>,
pub disabled: bool,
// Children
#[widget_builder(constructor)]
pub entries: Vec<RadioEntryData>,
// Styling
pub narrow: bool,
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
//
// Callbacks exists on the `RadioEntryData` children, not this parent `RadioInput`
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
#[widget_builder(not_widget_instance)]
pub struct RadioEntryData {
// Content
#[widget_builder(constructor)]
pub value: String,
pub label: String,
#[widget_builder(string)]
pub icon: Option<IconName>,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<()>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct WorkingColorsInput {
// Content
#[widget_builder(constructor)]
pub primary: SRGBA8,
#[widget_builder(constructor)]
pub secondary: SRGBA8,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct TextAreaInput {
// Content
#[widget_builder(constructor)]
pub value: String,
pub label: Option<String>,
pub disabled: bool,
pub monospace: bool,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<TextAreaInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct TextInput {
// Content
#[widget_builder(constructor)]
pub value: String,
pub label: Option<String>,
pub placeholder: Option<String>,
pub disabled: bool,
// Styling
pub narrow: bool,
pub centered: bool,
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<TextInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct VisualColorPickersInput {
// Content
pub hue: f64,
pub saturation: f64,
pub value: f64,
pub alpha: f64,
#[serde(rename = "isNone")]
pub is_none: bool,
pub disabled: bool,
// Callbacks
// `on_update` receives the raw `VisualColorPickersInputUpdate` (not the mutated widget) so the layout diffing can still detect a change between the stored layout and the rebuilt one, otherwise the selection circle and slider needles never re-render after a drag.
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<VisualColorPickersInputUpdate>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct VisualColorPickersInputUpdate {
pub hue: f64,
pub saturation: f64,
pub value: f64,
pub alpha: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ColorComparisonInput {
// Content
#[widget_builder(constructor)]
#[serde(rename = "newColor")]
pub new_color: Option<SRGBA8>,
#[widget_builder(constructor)]
#[serde(rename = "oldColor")]
pub old_color: Option<SRGBA8>,
#[serde(rename = "isNone")]
pub is_none: bool,
#[serde(rename = "oldIsNone")]
pub old_is_none: bool,
pub disabled: bool,
pub differs: bool,
#[serde(rename = "outlineAmount")]
pub outline_amount: f64,
/// Hex CSS string for the new color (with alpha if not fully opaque), or empty when `is_none`. Auto-populated from `new_color` at layout-send time.
#[serde(rename = "newColorCSS")]
#[widget_builder(skip)]
pub new_color_css: String,
/// Black or white, whichever contrasts the new color for legible label text. Auto-populated.
#[serde(rename = "newColorContrasting")]
#[widget_builder(skip)]
pub new_color_contrasting: String,
/// Hex CSS string for the old color (with alpha if not fully opaque), or empty when `old_is_none`. Auto-populated.
#[serde(rename = "oldColorCSS")]
#[widget_builder(skip)]
pub old_color_css: String,
/// Black or white, whichever contrasts the old color for legible label text. Auto-populated.
#[serde(rename = "oldColorContrasting")]
#[widget_builder(skip)]
pub old_color_contrasting: String,
// Callbacks
// The `swap` event has no payload — it just signals the user requested a swap.
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<()>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ColorPresetsInput {
// Content
pub disabled: bool,
#[serde(rename = "showNoneOption")]
pub show_none_option: bool,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<ColorPresetsInputUpdate>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ColorPresetsInputUpdate {
Preset(FillChoiceUI),
EyedropperColorCode(String),
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct SpectrumInput {
// Content
/// The colored gradient drawn behind the markers (display-only, caller-owned).
#[widget_builder(constructor)]
pub track: GradientUI,
/// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time.
#[serde(rename = "trackCSS")]
#[widget_builder(skip)]
pub track_css: String,
/// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated from `track`'s first stop.
#[serde(rename = "trackStartCSS")]
#[widget_builder(skip)]
pub track_start_css: String,
/// Hex string for the track strip's rightmost solid-color end-cap. Auto-populated from `track`'s last stop.
#[serde(rename = "trackEndCSS")]
#[widget_builder(skip)]
pub track_end_css: String,
/// The handles the user can drag along the track. Their handle colors are caller-owned (e.g., for a gradient editor they follow the stop colors, for a "Shadows/Midpoints/Highlights" widget they're hardcoded).
pub markers: Vec<SpectrumMarker>,
#[serde(rename = "activeMarkerIndex")]
pub active_marker_index: Option<u32>,
#[serde(rename = "activeMarkerIsMidpoint")]
pub active_marker_is_midpoint: bool,
/// Whether to render midpoint diamonds between adjacent markers (only meaningful for gradient-like uses).
#[serde(rename = "showMidpoints")]
pub show_midpoints: bool,
/// Whether clicking the track inserts a new marker at the click position.
#[serde(rename = "allowInsert")]
pub allow_insert: bool,
/// Whether right-click or pressing Delete removes a marker. The handler still has the final say on whether the deletion goes through (e.g., enforcing a minimum count).
#[serde(rename = "allowDelete")]
pub allow_delete: bool,
/// Whether dragging a marker past another reorders them. If false, the dragged marker is clamped between its neighbors.
#[serde(rename = "allowReorder")]
pub allow_reorder: bool,
/// Compact mode: 8px track height with 8px top padding, for use in rows alongside other widgets.
pub narrow: bool,
/// Whether the input is disabled (dimmed and read-only).
pub disabled: bool,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<SpectrumInputUpdate>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SpectrumMarker {
/// Position (0..1) of the marker along the spectrum track.
position: f64,
/// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. The last marker's value is ignored.
midpoint: f64,
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`].
#[serde(rename = "handleColorCSS")]
handle_color_css: String,
}
impl SpectrumMarker {
pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self {
let handle_color_css = SRGBA8::from(handle_color).to_css_hex();
Self { position, midpoint, handle_color_css }
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SpectrumInputUpdate {
MoveMarker {
index: u32,
position: f64,
},
MoveMidpoint {
index: u32,
position: f64,
},
InsertMarker {
position: f64,
},
DeleteMarker {
index: u32,
},
/// Insert a copy (same color and midpoint) of the marker at `index` at `position`, keeping the marker at `index` active.
InsertDuplicate {
index: u32,
position: f64,
},
/// Remove the marker at `index` while keeping the currently active marker active (renumbered to account for the removal).
/// Used to un-duplicate when Alt is released mid-drag, deleting the frozen copy left by [`InsertDuplicate`](Self::InsertDuplicate).
RemoveDuplicate {
index: u32,
},
/// Emitted when the user double-clicks a marker. The consumer decides what (if anything) to reset the marker to.
ResetMarker {
index: u32,
},
ResetMidpoint {
index: u32,
},
ActiveMarker {
#[serde(rename = "activeMarkerIndex")]
active_marker_index: Option<u32>,
#[serde(rename = "activeMarkerIsMidpoint")]
active_marker_is_midpoint: bool,
},
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ReferencePointInput {
// Content
#[widget_builder(constructor)]
pub value: ReferencePoint,
pub disabled: bool,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<ReferencePointInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
@@ -0,0 +1,116 @@
use super::input_widgets::CheckboxId;
use crate::messages::{frontend::IconName, input_mapper::utility_types::misc::ActionShortcut};
use derivative::*;
use graphite_proc_macros::WidgetBuilder;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Debug, Default, PartialEq, Eq, WidgetBuilder)]
pub struct IconLabel {
// Content
#[widget_builder(constructor)]
#[widget_builder(string)]
pub icon: IconName,
pub disabled: bool,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, WidgetBuilder)]
pub struct Separator {
// Content
pub direction: SeparatorDirection,
#[widget_builder(constructor)]
pub style: SeparatorStyle,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SeparatorDirection {
#[default]
Horizontal,
Vertical,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SeparatorStyle {
Related,
#[default]
Unrelated,
Section,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Debug, Eq, Default, WidgetBuilder)]
#[derivative(PartialEq)]
pub struct TextLabel {
// Content
#[widget_builder(constructor)]
pub value: String,
pub disabled: bool,
#[serde(rename = "forCheckbox")]
pub for_checkbox: CheckboxId,
// Styling
pub narrow: bool,
pub bold: bool,
pub italic: bool,
pub monospace: bool,
pub multiline: bool,
#[serde(rename = "centerAlign")]
pub center_align: bool,
#[serde(rename = "tableAlign")]
pub table_align: bool,
// Sizing
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
#[serde(rename = "minWidthCharacters")]
pub min_width_characters: u32,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ImageLabel {
// Content
#[widget_builder(constructor)]
pub url: String,
pub width: Option<String>,
pub height: Option<String>,
// Tooltips
#[serde(rename = "tooltipLabel")]
pub tooltip_label: String,
#[serde(rename = "tooltipDescription")]
pub tooltip_description: String,
#[serde(rename = "tooltipShortcut")]
pub tooltip_shortcut: Option<ActionShortcut>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ShortcutLabel {
// Content
// This is wrapped in an Option to satisfy the requirement that widgets implement Default
#[widget_builder(constructor)]
pub shortcut: Option<ActionShortcut>,
}
@@ -0,0 +1,3 @@
pub mod button_widgets;
pub mod input_widgets;
pub mod label_widgets;
@@ -0,0 +1,8 @@
use crate::messages::prelude::*;
#[impl_message(Message, MenuBar)]
#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)]
pub enum MenuBarMessage {
// Messages
SendLayout,
}
@@ -0,0 +1,784 @@
use crate::messages::debug::utility_types::MessageLoggingVerbosity;
use crate::messages::input_mapper::utility_types::macros::action_shortcut;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType};
use crate::messages::prelude::*;
use graphene_std::vector::misc::BooleanOperation;
#[derive(Debug, Clone, Default, ExtractField)]
pub struct MenuBarMessageHandler {
pub has_active_document: bool,
pub canvas_tilted: bool,
pub canvas_flipped: bool,
pub rulers_visible: bool,
pub node_graph_open: bool,
pub has_selected_nodes: bool,
pub has_selected_layers: bool,
pub has_selection_history: (bool, bool),
pub message_logging_verbosity: MessageLoggingVerbosity,
pub reset_node_definitions_on_open: bool,
pub show_storage_preferences: bool,
pub make_path_editable_is_allowed: bool,
pub data_panel_open: bool,
pub layers_panel_open: bool,
pub properties_panel_open: bool,
pub focus_document: bool,
}
#[message_handler_data]
impl MessageHandler<MenuBarMessage, ()> for MenuBarMessageHandler {
fn process_message(&mut self, message: MenuBarMessage, responses: &mut VecDeque<Message>, _: ()) {
match message {
MenuBarMessage::SendLayout => {
self.send_layout(responses, LayoutTarget::MenuBar);
}
}
}
fn actions(&self) -> ActionList {
actions!(MenuBarMessageDiscriminant;)
}
}
impl LayoutHolder for MenuBarMessageHandler {
fn layout(&self) -> Layout {
let no_active_document = !self.has_active_document;
let node_graph_open = self.node_graph_open;
let has_selected_nodes = self.has_selected_nodes;
let has_selected_layers = self.has_selected_layers;
let has_selection_history = self.has_selection_history;
let message_logging_verbosity_off = self.message_logging_verbosity == MessageLoggingVerbosity::Off;
let message_logging_verbosity_names = self.message_logging_verbosity == MessageLoggingVerbosity::Names;
let message_logging_verbosity_contents = self.message_logging_verbosity == MessageLoggingVerbosity::Contents;
let reset_node_definitions_on_open = self.reset_node_definitions_on_open;
let show_storage_preferences = self.show_storage_preferences;
let make_path_editable_is_allowed = self.make_path_editable_is_allowed;
let about = MenuListEntry::new("About Graphite…")
.label({
#[cfg(not(target_os = "macos"))]
{
"About Graphite…"
}
#[cfg(target_os = "macos")]
{
"About Graphite"
}
})
.icon("GraphiteLogo")
.on_commit(|_| DialogMessage::RequestAboutGraphiteDialog.into());
let preferences = MenuListEntry::new("Preferences…")
.label("Preferences…")
.icon("Settings")
.tooltip_shortcut(action_shortcut!(DialogMessageDiscriminant::RequestPreferencesDialog))
.on_commit(|_| DialogMessage::RequestPreferencesDialog.into());
let menu_bar_buttons = vec![
#[cfg(not(target_os = "macos"))]
TextButton::new("Graphite")
.label("")
.flush(true)
.icon("GraphiteLogo")
.on_commit(|_| FrontendMessage::TriggerVisitLink { url: "https://graphite.art".into() }.into())
.widget_instance(),
#[cfg(target_os = "macos")]
TextButton::new("Graphite")
.label("")
.flush(true)
.menu_list_children(vec![
vec![about],
vec![preferences],
vec![
MenuListEntry::new("Hide Graphite")
.label("Hide Graphite")
.tooltip_shortcut(action_shortcut!(AppWindowMessageDiscriminant::Hide))
.on_commit(|_| AppWindowMessage::Hide.into()),
MenuListEntry::new("Hide Others")
.label("Hide Others")
.tooltip_shortcut(action_shortcut!(AppWindowMessageDiscriminant::HideOthers))
.on_commit(|_| AppWindowMessage::HideOthers.into()),
MenuListEntry::new("Show All")
.label("Show All")
.tooltip_shortcut(action_shortcut!(AppWindowMessageDiscriminant::ShowAll))
.on_commit(|_| AppWindowMessage::ShowAll.into()),
],
vec![
MenuListEntry::new("Quit Graphite")
.label("Quit Graphite")
.tooltip_shortcut(action_shortcut!(AppWindowMessageDiscriminant::Close))
.on_commit(|_| AppWindowMessage::Close.into()),
],
])
.widget_instance(),
TextButton::new("File")
.label("File")
.flush(true)
.menu_list_children(vec![
vec![
MenuListEntry::new("New…")
.label("New…")
.icon("File")
.on_commit(|_| DialogMessage::RequestNewDocumentDialog.into())
.tooltip_shortcut(action_shortcut!(DialogMessageDiscriminant::RequestNewDocumentDialog)),
MenuListEntry::new("Open…")
.label("Open…")
.icon("Folder")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Open))
.on_commit(|_| PortfolioMessage::Open.into()),
MenuListEntry::new("Open Demo Artwork…")
.label("Open Demo Artwork…")
.icon("Image")
.on_commit(|_| DialogMessage::RequestDemoArtworkDialog.into()),
],
vec![
MenuListEntry::new("Close")
.label("Close")
.icon("Close")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::CloseActiveDocumentWithConfirmation))
.on_commit(|_| PortfolioMessage::CloseActiveDocumentWithConfirmation.into())
.disabled(no_active_document),
MenuListEntry::new("Close All")
.label("Close All")
.icon("CloseAll")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::CloseAllDocumentsWithConfirmation))
.on_commit(|_| PortfolioMessage::CloseAllDocumentsWithConfirmation.into())
.disabled(no_active_document),
],
vec![
MenuListEntry::new("Save")
.label("Save")
.icon("Save")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SaveDocument))
.on_commit(|_| DocumentMessage::SaveDocument.into())
.disabled(no_active_document),
#[cfg(not(target_family = "wasm"))]
MenuListEntry::new("Save As…")
.label("Save As…")
.icon("Save")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SaveDocumentAs))
.on_commit(|_| DocumentMessage::SaveDocumentAs.into())
.disabled(no_active_document),
],
vec![
MenuListEntry::new("Import…")
.label("Import…")
.icon("FileImport")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Import))
.on_commit(|_| PortfolioMessage::Import.into())
.disabled(no_active_document),
MenuListEntry::new("Export…")
.label("Export…")
.icon("FileExport")
.tooltip_shortcut(action_shortcut!(DialogMessageDiscriminant::RequestExportDialog))
.on_commit(|_| DialogMessage::RequestExportDialog.into())
.disabled(no_active_document),
],
#[cfg(not(target_os = "macos"))]
vec![preferences],
])
.widget_instance(),
TextButton::new("Edit")
.label("Edit")
.flush(true)
.menu_list_children(vec![
vec![
MenuListEntry::new("Undo")
.label("Undo")
.icon("HistoryUndo")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::Undo))
.on_commit(|_| DocumentMessage::Undo.into())
.disabled(no_active_document),
MenuListEntry::new("Redo")
.label("Redo")
.icon("HistoryRedo")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::Redo))
.on_commit(|_| DocumentMessage::Redo.into())
.disabled(no_active_document),
],
vec![
MenuListEntry::new("Cut")
.label("Cut")
.icon("Cut")
.tooltip_shortcut(action_shortcut!(ClipboardMessageDiscriminant::Cut))
.on_commit(|_| ClipboardMessage::Cut.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Copy")
.label("Copy")
.icon("Copy")
.tooltip_shortcut(action_shortcut!(ClipboardMessageDiscriminant::Copy))
.on_commit(|_| ClipboardMessage::Copy.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Paste")
.label("Paste")
.icon("Paste")
.tooltip_shortcut(action_shortcut!(ClipboardMessageDiscriminant::Paste))
.on_commit(|_| ClipboardMessage::Paste.into())
.disabled(no_active_document),
],
vec![
MenuListEntry::new("Duplicate")
.label("Duplicate")
.icon("Copy")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::DuplicateSelectedLayers))
.on_commit(|_| DocumentMessage::DuplicateSelectedLayers.into())
.disabled(no_active_document || !has_selected_nodes),
MenuListEntry::new("Delete")
.label("Delete")
.icon("Trash")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::DeleteSelectedLayers))
.on_commit(|_| DocumentMessage::DeleteSelectedLayers.into())
.disabled(no_active_document || !has_selected_nodes),
],
vec![
MenuListEntry::new("Convert to Infinite Canvas")
.label("Convert to Infinite Canvas")
.icon("Artboard")
.on_commit(|_| DocumentMessage::RemoveArtboards.into())
.disabled(no_active_document),
],
])
.widget_instance(),
TextButton::new("Layer")
.label("Layer")
.flush(true)
.menu_list_children(vec![
vec![
MenuListEntry::new("New")
.label("New")
.icon("NewLayer")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::CreateEmptyFolder))
.on_commit(|_| DocumentMessage::CreateEmptyFolder.into())
.disabled(no_active_document),
],
vec![
MenuListEntry::new("Group")
.label("Group")
.icon("Folder")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::GroupSelectedLayers))
.on_commit(|_| {
DocumentMessage::GroupSelectedLayers {
group_folder_type: GroupFolderType::Layer,
}
.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Ungroup")
.label("Ungroup")
.icon("FolderOpen")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::UngroupSelectedLayers))
.on_commit(|_| DocumentMessage::UngroupSelectedLayers.into())
.disabled(no_active_document || !has_selected_layers),
],
vec![
MenuListEntry::new("Hide/Show")
.label("Hide/Show")
.icon("EyeHide")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::ToggleSelectedVisibility))
.on_commit(|_| DocumentMessage::ToggleSelectedVisibility.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Lock/Unlock")
.label("Lock/Unlock")
.icon("PadlockLocked")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::ToggleSelectedLocked))
.on_commit(|_| DocumentMessage::ToggleSelectedLocked.into())
.disabled(no_active_document || !has_selected_layers),
],
vec![
MenuListEntry::new("Grab")
.label("Grab")
.icon("TransformationGrab")
.tooltip_shortcut(action_shortcut!(TransformLayerMessageDiscriminant::BeginGrab))
.on_commit(|_| TransformLayerMessage::BeginGrab.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Rotate")
.label("Rotate")
.icon("TransformationRotate")
.tooltip_shortcut(action_shortcut!(TransformLayerMessageDiscriminant::BeginRotate))
.on_commit(|_| TransformLayerMessage::BeginRotate.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Scale")
.label("Scale")
.icon("TransformationScale")
.tooltip_shortcut(action_shortcut!(TransformLayerMessageDiscriminant::BeginScale))
.on_commit(|_| TransformLayerMessage::BeginScale.into())
.disabled(no_active_document || !has_selected_layers),
],
vec![
MenuListEntry::new("Arrange")
.label("Arrange")
.icon("StackHollow")
.disabled(no_active_document || !has_selected_layers)
.children(vec![
vec![
MenuListEntry::new("Raise To Front")
.label("Raise To Front")
.icon("Stack")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectedLayersRaiseToFront))
.on_commit(|_| DocumentMessage::SelectedLayersRaiseToFront.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Raise")
.label("Raise")
.icon("StackRaise")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectedLayersRaise))
.on_commit(|_| DocumentMessage::SelectedLayersRaise.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Lower")
.label("Lower")
.icon("StackLower")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectedLayersLower))
.on_commit(|_| DocumentMessage::SelectedLayersLower.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Lower to Back")
.label("Lower to Back")
.icon("StackBottom")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectedLayersLowerToBack))
.on_commit(|_| DocumentMessage::SelectedLayersLowerToBack.into())
.disabled(no_active_document || !has_selected_layers),
],
vec![
MenuListEntry::new("Reverse")
.label("Reverse")
.icon("StackReverse")
.on_commit(|_| DocumentMessage::SelectedLayersReverse.into())
.disabled(no_active_document || !has_selected_layers),
],
]),
MenuListEntry::new("Align")
.label("Align")
.icon("AlignVerticalCenter")
.disabled(no_active_document || !has_selected_layers)
.children(vec![
vec![
MenuListEntry::new("Align Left")
.label("Align Left")
.icon("AlignLeft")
.on_commit(|_| {
DocumentMessage::AlignSelectedLayers {
axis: AlignAxis::X,
aggregate: AlignAggregate::Min,
}
.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Align Horizontal Center")
.label("Align Horizontal Center")
.icon("AlignHorizontalCenter")
.on_commit(|_| {
DocumentMessage::AlignSelectedLayers {
axis: AlignAxis::X,
aggregate: AlignAggregate::Center,
}
.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Align Right")
.label("Align Right")
.icon("AlignRight")
.on_commit(|_| {
DocumentMessage::AlignSelectedLayers {
axis: AlignAxis::X,
aggregate: AlignAggregate::Max,
}
.into()
})
.disabled(no_active_document || !has_selected_layers),
],
vec![
MenuListEntry::new("Align Top")
.label("Align Top")
.icon("AlignTop")
.on_commit(|_| {
DocumentMessage::AlignSelectedLayers {
axis: AlignAxis::Y,
aggregate: AlignAggregate::Min,
}
.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Align Vertical Center")
.label("Align Vertical Center")
.icon("AlignVerticalCenter")
.on_commit(|_| {
DocumentMessage::AlignSelectedLayers {
axis: AlignAxis::Y,
aggregate: AlignAggregate::Center,
}
.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Align Bottom")
.label("Align Bottom")
.icon("AlignBottom")
.on_commit(|_| {
DocumentMessage::AlignSelectedLayers {
axis: AlignAxis::Y,
aggregate: AlignAggregate::Max,
}
.into()
})
.disabled(no_active_document || !has_selected_layers),
],
]),
MenuListEntry::new("Flip")
.label("Flip")
.icon("FlipVertical")
.disabled(no_active_document || !has_selected_layers)
.children(vec![vec![
MenuListEntry::new("Flip Horizontal")
.label("Flip Horizontal")
.icon("FlipHorizontal")
.on_commit(|_| DocumentMessage::FlipSelectedLayers { flip_axis: FlipAxis::X }.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Flip Vertical")
.label("Flip Vertical")
.icon("FlipVertical")
.on_commit(|_| DocumentMessage::FlipSelectedLayers { flip_axis: FlipAxis::Y }.into())
.disabled(no_active_document || !has_selected_layers),
]]),
MenuListEntry::new("Turn")
.label("Turn")
.icon("TurnPositive90")
.disabled(no_active_document || !has_selected_layers)
.children(vec![vec![
MenuListEntry::new("Turn -90°")
.label("Turn -90°")
.icon("TurnNegative90")
.on_commit(|_| DocumentMessage::RotateSelectedLayers { degrees: -90. }.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Turn 90°")
.label("Turn 90°")
.icon("TurnPositive90")
.on_commit(|_| DocumentMessage::RotateSelectedLayers { degrees: 90. }.into())
.disabled(no_active_document || !has_selected_layers),
]]),
MenuListEntry::new("Boolean")
.label("Boolean")
.icon("BooleanSubtractFront")
.disabled(no_active_document || !has_selected_layers)
.children(vec![vec![
MenuListEntry::new("Union")
.label("Union")
.icon("BooleanUnion")
.on_commit(|_| {
let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::Union);
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Subtract Front")
.label("Subtract Front")
.icon("BooleanSubtractFront")
.on_commit(|_| {
let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::SubtractFront);
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Subtract Back")
.label("Subtract Back")
.icon("BooleanSubtractBack")
.on_commit(|_| {
let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::SubtractBack);
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Intersect")
.label("Intersect")
.icon("BooleanIntersect")
.on_commit(|_| {
let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::Intersect);
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Difference")
.label("Difference")
.icon("BooleanDifference")
.on_commit(|_| {
let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::Difference);
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(no_active_document || !has_selected_layers),
]]),
MenuListEntry::new("Blend")
.label("Blend")
.icon("InterpolationBlend")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::BlendSelectedLayers))
.on_commit(|_| DocumentMessage::BlendSelectedLayers.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Morph")
.label("Morph")
.icon("InterpolationMorph")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::MorphSelectedLayers))
.on_commit(|_| DocumentMessage::MorphSelectedLayers.into())
.disabled(no_active_document || !has_selected_layers),
],
vec![
MenuListEntry::new("Make Path Editable")
.label("Make Path Editable")
.icon("NodeShape")
.on_commit(|_| NodeGraphMessage::AddPathNode.into())
.disabled(!make_path_editable_is_allowed),
MenuListEntry::new("Expand Fill/Stroke")
.label("Expand Fill/Stroke")
.icon("ExpandFillStroke")
.on_commit(|_| DocumentMessage::ExpandFillStrokeOnSelectedLayers.into())
.disabled(no_active_document || !has_selected_layers),
],
])
.widget_instance(),
TextButton::new("Select")
.label("Select")
.flush(true)
.menu_list_children(vec![
vec![
MenuListEntry::new("Select All")
.label("Select All")
.icon("SelectAll")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectAllLayers))
.on_commit(|_| DocumentMessage::SelectAllLayers.into())
.disabled(no_active_document),
MenuListEntry::new("Deselect All")
.label("Deselect All")
.icon("DeselectAll")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::DeselectAllLayers))
.on_commit(|_| DocumentMessage::DeselectAllLayers.into())
.disabled(no_active_document || !has_selected_nodes),
MenuListEntry::new("Select Parent")
.label("Select Parent")
.icon("SelectParent")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectParentLayer))
.on_commit(|_| DocumentMessage::SelectParentLayer.into())
.disabled(no_active_document || !has_selected_nodes),
],
vec![
MenuListEntry::new("Previous Selection")
.label("Previous Selection")
.icon("HistoryUndo")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectionStepBack))
.on_commit(|_| DocumentMessage::SelectionStepBack.into())
.disabled(!has_selection_history.0),
MenuListEntry::new("Next Selection")
.label("Next Selection")
.icon("HistoryRedo")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::SelectionStepForward))
.on_commit(|_| DocumentMessage::SelectionStepForward.into())
.disabled(!has_selection_history.1),
],
])
.widget_instance(),
TextButton::new("View")
.label("View")
.flush(true)
.menu_list_children(vec![
vec![
MenuListEntry::new("Tilt")
.label("Tilt")
.icon("Tilt")
.tooltip_shortcut(action_shortcut!(NavigationMessageDiscriminant::BeginCanvasTilt))
.on_commit(|_| NavigationMessage::BeginCanvasTilt { was_dispatched_from_menu: true }.into())
.disabled(no_active_document || node_graph_open),
MenuListEntry::new("Reset Tilt")
.label("Reset Tilt")
.icon("TiltReset")
.tooltip_shortcut(action_shortcut!(NavigationMessageDiscriminant::CanvasTiltSet))
.on_commit(|_| NavigationMessage::CanvasTiltSet { angle_radians: 0.into() }.into())
.disabled(no_active_document || node_graph_open || !self.canvas_tilted),
],
vec![
MenuListEntry::new("Zoom In")
.label("Zoom In")
.icon("ZoomIn")
.tooltip_shortcut(action_shortcut!(NavigationMessageDiscriminant::CanvasZoomIncrease))
.on_commit(|_| NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }.into())
.disabled(no_active_document),
MenuListEntry::new("Zoom Out")
.label("Zoom Out")
.icon("ZoomOut")
.tooltip_shortcut(action_shortcut!(NavigationMessageDiscriminant::CanvasZoomDecrease))
.on_commit(|_| NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }.into())
.disabled(no_active_document),
MenuListEntry::new("Zoom to Selection")
.label("Zoom to Selection")
.icon("FrameSelected")
.tooltip_shortcut(action_shortcut!(NavigationMessageDiscriminant::FitViewportToSelection))
.on_commit(|_| NavigationMessage::FitViewportToSelection.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Zoom to Fit")
.label("Zoom to Fit")
.icon("FrameAll")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::ZoomCanvasToFitAll))
.on_commit(|_| DocumentMessage::ZoomCanvasToFitAll.into())
.disabled(no_active_document),
MenuListEntry::new("Zoom to 100%")
.label("Zoom to 100%")
.icon("Zoom1x")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::ZoomCanvasTo100Percent))
.on_commit(|_| DocumentMessage::ZoomCanvasTo100Percent.into())
.disabled(no_active_document),
MenuListEntry::new("Zoom to 200%")
.label("Zoom to 200%")
.icon("Zoom2x")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::ZoomCanvasTo200Percent))
.on_commit(|_| DocumentMessage::ZoomCanvasTo200Percent.into())
.disabled(no_active_document),
],
vec![
MenuListEntry::new("Flip")
.label("Flip")
.icon(if self.canvas_flipped { "CheckboxChecked" } else { "CheckboxUnchecked" })
.tooltip_shortcut(action_shortcut!(NavigationMessageDiscriminant::CanvasFlip))
.on_commit(|_| NavigationMessage::CanvasFlip.into())
.disabled(no_active_document || node_graph_open),
],
vec![
MenuListEntry::new("Rulers")
.label("Rulers")
.icon(if self.rulers_visible { "CheckboxChecked" } else { "CheckboxUnchecked" })
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleRulers))
.on_commit(|_| PortfolioMessage::ToggleRulers.into())
.disabled(no_active_document),
],
])
.widget_instance(),
TextButton::new("Window")
.label("Window")
.flush(true)
.menu_list_children(vec![
vec![
MenuListEntry::new("Fullscreen")
.label("Fullscreen")
.icon("FullscreenEnter")
.tooltip_shortcut(action_shortcut!(AppWindowMessageDiscriminant::Fullscreen))
.on_commit(|_| AppWindowMessage::Fullscreen.into()),
MenuListEntry::new("Focus Document")
.label("Focus Document")
.icon(if self.focus_document { "CheckboxChecked" } else { "CheckboxUnchecked" })
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleFocusDocument))
.on_commit(|_| PortfolioMessage::ToggleFocusDocument.into()),
],
vec![
MenuListEntry::new("Reset Workspace")
.label("Reset Workspace")
.icon("Reset")
.on_commit(|_| PortfolioMessage::ResetWorkspaceLayout.into()),
],
vec![
MenuListEntry::new("Properties")
.label("Properties")
.icon(if self.properties_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::TogglePropertiesPanelOpen))
.on_commit(|_| PortfolioMessage::TogglePropertiesPanelOpen.into())
.disabled(self.focus_document),
MenuListEntry::new("Layers")
.label("Layers")
.icon(if self.layers_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleLayersPanelOpen))
.on_commit(|_| PortfolioMessage::ToggleLayersPanelOpen.into())
.disabled(self.focus_document),
MenuListEntry::new("Data")
.label("Data")
.icon(if self.data_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleDataPanelOpen))
.on_commit(|_| PortfolioMessage::ToggleDataPanelOpen.into())
.disabled(self.focus_document),
],
])
.widget_instance(),
TextButton::new("Help")
.label("Help")
.flush(true)
.menu_list_children(vec![
#[cfg(not(target_os = "macos"))]
vec![about],
vec![
MenuListEntry::new("Donate to Graphite").label("Donate to Graphite").icon("Heart").on_commit(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://graphite.art/donate/".into(),
}
.into()
}),
MenuListEntry::new("User Manual").label("User Manual").icon("UserManual").on_commit(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://graphite.art/learn/".into(),
}
.into()
}),
MenuListEntry::new("Report a Bug").label("Report a Bug").icon("Bug").on_commit(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://github.com/GraphiteEditor/Graphite/issues/new".into(),
}
.into()
}),
MenuListEntry::new("Visit on GitHub").label("Visit on GitHub").icon("Website").on_commit(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://github.com/GraphiteEditor/Graphite".into(),
}
.into()
}),
],
vec![MenuListEntry::new("Developer Debug").label("Developer Debug").icon("Code").children(vec![
vec![
MenuListEntry::new("Reset Nodes to Definitions on Open")
.label("Reset Nodes to Definitions on Open")
.icon(if reset_node_definitions_on_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
.on_commit(|_| PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen.into()),
MenuListEntry::new("Show Storage Preferences")
.label("Show Storage Preferences")
.icon(if show_storage_preferences { "CheckboxChecked" } else { "CheckboxUnchecked" })
.on_commit(|_| PreferencesMessage::ToggleShowStoragePreferences.into()),
],
vec![
MenuListEntry::new("Print Trace Logs")
.label("Print Trace Logs")
.icon(if log::max_level() == log::LevelFilter::Trace { "CheckboxChecked" } else { "CheckboxUnchecked" })
.on_commit(|_| DebugMessage::ToggleTraceLogs.into()),
MenuListEntry::new("Print Messages: Off")
.label("Print Messages: Off")
.icon(if message_logging_verbosity_off {
#[cfg(not(target_os = "macos"))]
{
"SmallDot".to_string()
}
#[cfg(target_os = "macos")]
{
"CheckboxChecked".to_string()
}
} else { Default::default() })
.tooltip_shortcut(action_shortcut!(DebugMessageDiscriminant::MessageOff))
.on_commit(|_| DebugMessage::MessageOff.into()),
MenuListEntry::new("Print Messages: Only Names")
.label("Print Messages: Only Names")
.icon(if message_logging_verbosity_names {
#[cfg(not(target_os = "macos"))]
{
"SmallDot".to_string()
}
#[cfg(target_os = "macos")]
{
"CheckboxChecked".to_string()
}
} else { Default::default() })
.tooltip_shortcut(action_shortcut!(DebugMessageDiscriminant::MessageNames))
.on_commit(|_| DebugMessage::MessageNames.into()),
MenuListEntry::new("Print Messages: Full Contents")
.label("Print Messages: Full Contents")
.icon(if message_logging_verbosity_contents {
#[cfg(not(target_os = "macos"))]
{
"SmallDot".to_string()
}
#[cfg(target_os = "macos")]
{
"CheckboxChecked".to_string()
}
} else { Default::default() })
.tooltip_shortcut(action_shortcut!(DebugMessageDiscriminant::MessageContents))
.on_commit(|_| DebugMessage::MessageContents.into()),
],
vec![MenuListEntry::new("Trigger a Crash").label("Trigger a Crash").icon("Warning").on_commit(|_| panic!())],
])],
])
.widget_instance(),
];
Layout(vec![LayoutGroup::row(menu_bar_buttons)])
}
}
@@ -0,0 +1,7 @@
mod menu_bar_message;
mod menu_bar_message_handler;
#[doc(inline)]
pub use menu_bar_message::{MenuBarMessage, MenuBarMessageDiscriminant};
#[doc(inline)]
pub use menu_bar_message_handler::MenuBarMessageHandler;
@@ -0,0 +1,60 @@
use crate::messages::prelude::*;
use graphite_proc_macros::*;
#[impl_message]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Message {
// Sub-messages
#[child]
Animation(AnimationMessage),
#[child]
AppWindow(AppWindowMessage),
#[child]
Broadcast(BroadcastMessage),
#[child]
Clipboard(ClipboardMessage),
#[child]
ColorPicker(ColorPickerMessage),
#[child]
Debug(DebugMessage),
#[child]
Defer(DeferMessage),
#[child]
Dialog(DialogMessage),
#[child]
Frontend(FrontendMessage),
#[child]
Future(FutureMessage),
#[child]
InputPreprocessor(InputPreprocessorMessage),
#[child]
KeyMapping(KeyMappingMessage),
#[child]
Layout(LayoutMessage),
#[child]
MenuBar(MenuBarMessage),
#[child]
Network(NetworkMessage),
#[child]
Portfolio(PortfolioMessage),
#[child]
Preferences(PreferencesMessage),
#[child]
ResourceStorage(ResourceStorageMessage),
#[child]
Tool(ToolMessage),
#[child]
Viewport(ViewportMessage),
// Messages
Batched {
messages: Box<[Message]>,
},
NoOp,
}
impl Message {
pub fn message_tree() -> DebugMessageTree {
Self::build_message_tree()
}
}
@@ -0,0 +1,24 @@
//! The root-level messages forming the first layer of the message system architecture.
pub mod animation;
pub mod app_window;
pub mod broadcast;
pub mod clipboard;
pub mod color_picker;
pub mod debug;
pub mod defer;
pub mod dialog;
pub mod frontend;
pub mod future;
pub mod input_mapper;
pub mod input_preprocessor;
pub mod layout;
pub mod menu_bar;
pub mod message;
pub mod network;
pub mod portfolio;
pub mod preferences;
pub mod prelude;
pub mod resource_storage;
pub mod tool;
pub mod viewport;
@@ -0,0 +1,10 @@
mod network_message;
mod network_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use network_message::{NetworkMessage, NetworkMessageDiscriminant};
#[doc(inline)]
pub use network_message_handler::{NetworkMessageContext, NetworkMessageHandler};
#[doc(inline)]
pub use utility_types::Client;
@@ -0,0 +1,49 @@
use std::pin::Pin;
use dyn_any::WasmNotSend;
use crate::messages::network::utility_types::Client;
use crate::messages::prelude::*;
#[impl_message(Message, Network)]
#[derive(derivative::Derivative, serde::Serialize, serde::Deserialize)]
#[derivative(Debug, PartialEq)]
pub enum NetworkMessage {
Request {
#[serde(skip, default)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
request: Option<RequestFn>,
},
}
impl NetworkMessage {
pub fn request<F, Fut>(f: F) -> Self
where
F: FnOnce(Client) -> Fut + WasmNotSend + 'static,
Fut: Future<Output = Message> + WasmNotSend + 'static,
{
NetworkMessage::Request {
request: Some(Box::new(move |c| Box::pin(f(c)))),
}
}
}
#[cfg(not(target_family = "wasm"))]
type RequestFuture = Pin<Box<dyn Future<Output = Message> + Send>>;
#[cfg(target_family = "wasm")]
type RequestFuture = Pin<Box<dyn Future<Output = Message>>>;
#[cfg(not(target_family = "wasm"))]
type RequestFn = Box<dyn FnOnce(Client) -> RequestFuture + Send>;
#[cfg(target_family = "wasm")]
type RequestFn = Box<dyn FnOnce(Client) -> RequestFuture>;
impl Clone for NetworkMessage {
fn clone(&self) -> Self {
match self {
NetworkMessage::Request { .. } => {
log::error!("Cloning a NetworkMessage::Request is not supported");
NetworkMessage::Request { request: None }
}
}
}
}
@@ -0,0 +1,27 @@
use crate::messages::network::utility_types::Client;
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct NetworkMessageContext {}
#[derive(Debug, Default, ExtractField)]
pub struct NetworkMessageHandler {
client: Client,
}
#[message_handler_data]
impl MessageHandler<NetworkMessage, NetworkMessageContext> for NetworkMessageHandler {
fn process_message(&mut self, message: NetworkMessage, responses: &mut VecDeque<Message>, _context: NetworkMessageContext) {
match message {
NetworkMessage::Request { request } => {
if let Some(request) = request {
responses.add(request(self.client.clone()));
} else {
log::error!("received a empty NetworkMessage::Request");
}
}
}
}
advertise_actions!(NetworkMessageDiscriminant;
);
}
@@ -0,0 +1,30 @@
use reqwest::IntoUrl;
#[derive(Debug, Clone)]
pub struct Client {
inner: Option<reqwest::Client>,
}
impl Default for Client {
fn default() -> Self {
Self {
#[cfg(not(target_family = "wasm"))]
inner: reqwest::Client::builder().timeout(std::time::Duration::from_secs(100)).build().ok(),
#[cfg(target_family = "wasm")]
inner: reqwest::Client::builder().build().ok(),
}
}
}
impl Client {
pub async fn fetch<U: IntoUrl>(&self, url: U) -> Option<Box<[u8]>> {
let Some(client) = &self.inner else {
log::error!("HTTP client failed to initialize, cannot fetch");
return None;
};
let response = client.get(url).send().await;
let response = response.and_then(|r| r.error_for_status()).map_err(|err| log::error!("failed to fetch: {err}")).ok()?;
let bytes = response.bytes().await.map_err(|err| log::error!("failed to read response body: {err}")).ok()?;
Some(bytes.to_vec().into_boxed_slice())
}
}
@@ -0,0 +1,44 @@
use crate::messages::prelude::*;
use crate::node_graph_executor::InspectResult;
/// The Data panel UI allows the user to visualize the output data of the selected node.
#[impl_message(Message, DocumentMessage, DataPanel)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DataPanelMessage {
UpdateLayout {
#[serde(skip)]
inspect_result: InspectResult,
},
ClearLayout,
/// Re-render the existing layout against the latest network interface state. Use this when node metadata
/// (display name, visibility, locked, etc.) changes but the introspected output value hasn't.
Refresh,
PushToElementPath {
step: PathStep,
},
TruncateElementPath {
len: usize,
},
ViewVectorTableTab {
tab: VectorTableTab,
},
}
/// One hop in the breadcrumb path through nested data the Data panel is displaying.
/// Drilling into an item's element produces an `Element` step; drilling into one of an item's attributes produces an `Attribute` step.
#[derive(PartialEq, Eq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum PathStep {
Element(usize),
Attribute { row: usize, key: String },
}
#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum VectorTableTab {
#[default]
Properties,
Points,
Segments,
Regions,
}
@@ -0,0 +1,7 @@
mod data_panel_message;
mod data_panel_message_handler;
#[doc(inline)]
pub use data_panel_message::*;
#[doc(inline)]
pub use data_panel_message_handler::*;
@@ -0,0 +1,276 @@
//! Human-readable pretty-printers for the dual-write soak's validation checks: registry-vs-registry and
//! runtime-network-vs-runtime-network diffs. The callers are gated behind the `validate_storage_round_trip`
//! preference, so these ship in release but only run when the soak is turned on.
use std::fmt::Write;
pub(crate) fn diff_registries(stored: &document_graph_storage::Registry, target: &document_graph_storage::Registry) -> String {
let mut out = String::new();
let stored_node_ids: std::collections::BTreeSet<_> = stored.node_instances.keys().copied().collect();
let target_node_ids: std::collections::BTreeSet<_> = target.node_instances.keys().copied().collect();
let missing_nodes: Vec<_> = target_node_ids.difference(&stored_node_ids).collect();
let extra_nodes: Vec<_> = stored_node_ids.difference(&target_node_ids).collect();
let shared_node_diffs: Vec<_> = stored_node_ids
.intersection(&target_node_ids)
.filter(|id| !stored.node_instances[id].value_equal(&target.node_instances[id]))
.collect();
let stored_network_ids: std::collections::BTreeSet<_> = stored.networks.keys().copied().collect();
let target_network_ids: std::collections::BTreeSet<_> = target.networks.keys().copied().collect();
let missing_networks: Vec<_> = target_network_ids.difference(&stored_network_ids).collect();
let extra_networks: Vec<_> = stored_network_ids.difference(&target_network_ids).collect();
let shared_network_diffs: Vec<_> = stored_network_ids
.intersection(&target_network_ids)
.filter(|id| !stored.networks[id].value_equal(&target.networks[id]))
.collect();
let _ = writeln!(out, " nodes: stored={} target={}", stored_node_ids.len(), target_node_ids.len());
if !missing_nodes.is_empty() {
let _ = writeln!(out, " missing from stored: {missing_nodes:?}");
}
if !extra_nodes.is_empty() {
let _ = writeln!(out, " extra in stored: {extra_nodes:?}");
}
if !shared_node_diffs.is_empty() {
let _ = writeln!(out, " differing payloads: {shared_node_diffs:?}");
for id in &shared_node_diffs {
let stored_node = &stored.node_instances[id];
let target_node = &target.node_instances[id];
let _ = writeln!(out, " node {id}:");
diff_node(&mut out, stored_node, target_node);
}
}
let _ = writeln!(out, " networks: stored={} target={}", stored_network_ids.len(), target_network_ids.len());
if !missing_networks.is_empty() {
let _ = writeln!(out, " missing from stored: {missing_networks:?}");
}
if !extra_networks.is_empty() {
let _ = writeln!(out, " extra in stored: {extra_networks:?}");
}
if !shared_network_diffs.is_empty() {
let _ = writeln!(out, " differing payloads: {shared_network_diffs:?}");
for id in &shared_network_diffs {
let stored_network = &stored.networks[id];
let target_network = &target.networks[id];
let _ = writeln!(out, " network {id}:");
diff_network(&mut out, stored_network, target_network);
}
}
let stored_resources: std::collections::BTreeSet<_> = stored.resources.keys().copied().collect();
let target_resources: std::collections::BTreeSet<_> = target.resources.keys().copied().collect();
let missing_resources: Vec<_> = target_resources.difference(&stored_resources).collect();
let extra_resources: Vec<_> = stored_resources.difference(&target_resources).collect();
let shared_resource_diffs: Vec<_> = stored_resources
.intersection(&target_resources)
.filter(|id| !resource_value_equal(&stored.resources[id], &target.resources[id]))
.collect();
if !missing_resources.is_empty() || !extra_resources.is_empty() || !shared_resource_diffs.is_empty() {
let _ = writeln!(out, " resources: stored={} target={}", stored_resources.len(), target_resources.len());
if !missing_resources.is_empty() {
let _ = writeln!(out, " missing from stored: {missing_resources:?}");
}
if !extra_resources.is_empty() {
let _ = writeln!(out, " extra in stored: {extra_resources:?}");
}
if !shared_resource_diffs.is_empty() {
let _ = writeln!(out, " differing payloads: {shared_resource_diffs:?}");
for id in &shared_resource_diffs {
let _ = writeln!(out, " resource {id}:");
diff_resource(&mut out, &stored.resources[id], &target.resources[id]);
}
}
}
if stored.attributes != target.attributes {
diff_attributes(&mut out, " document attributes", &stored.attributes, &target.attributes);
}
out
}
fn diff_node(out: &mut String, stored: &document_graph_storage::Node, target: &document_graph_storage::Node) {
if stored.implementation() != target.implementation() {
let _ = writeln!(out, " implementation: stored={:?} target={:?}", stored.implementation(), target.implementation());
}
if stored.network() != target.network() {
let _ = writeln!(out, " network back-pointer: stored={} target={}", stored.network(), target.network());
}
let stored_inputs = stored.inputs();
let target_inputs = target.inputs();
if stored_inputs.len() != target_inputs.len() {
let _ = writeln!(out, " inputs.len: stored={} target={}", stored_inputs.len(), target_inputs.len());
}
for (i, (s, t)) in stored_inputs.iter().zip(target_inputs.iter()).enumerate() {
if s != t {
let value_differs = s.input != t.input;
let timestamp_differs = s.timestamp != t.timestamp;
let _ = writeln!(out, " input[{i}]: value_differs={value_differs} timestamp_differs={timestamp_differs}");
if value_differs {
let _ = writeln!(out, " stored.value={:?}\n target.value={:?}", s.input, t.input);
}
if s.attributes != t.attributes {
diff_attributes(out, &format!(" input[{i}].attributes"), &s.attributes, &t.attributes);
}
}
}
if stored.attributes() != target.attributes() {
diff_attributes(out, " attributes", stored.attributes(), target.attributes());
}
}
fn diff_network(out: &mut String, stored: &document_graph_storage::Network, target: &document_graph_storage::Network) {
if stored.exports.len() != target.exports.len() {
let _ = writeln!(out, " exports.len: stored={} target={}", stored.exports.len(), target.exports.len());
}
for (i, (s, t)) in stored.exports.iter().zip(target.exports.iter()).enumerate() {
if s != t {
let target_differs = s.target != t.target;
let timestamp_differs = s.timestamp != t.timestamp;
let _ = writeln!(out, " export[{i}]: target_differs={target_differs} timestamp_differs={timestamp_differs}");
if target_differs {
let _ = writeln!(out, " stored.target={:?}\n target.target={:?}", s.target, t.target);
}
}
}
if stored.attributes != target.attributes {
diff_attributes(out, " attributes", &stored.attributes, &target.attributes);
}
}
/// Value-level resource comparison (same resolved hash, same source bodies keyed by `SourceKey`),
/// ignoring LWW timestamps. Mirrors `document_graph_storage`'s internal `resources_value_equal` for a single
/// entry, since that helper is crate-private and only operates over a whole store.
fn resource_value_equal(stored: &document_graph_storage::ResourceEntry, target: &document_graph_storage::ResourceEntry) -> bool {
stored.hash == target.hash && stored.sources.len() == target.sources.len() && stored.sources.iter().all(|(key, value)| target.source(key).is_some_and(|other| value.source == other.source))
}
fn diff_resource(out: &mut String, stored: &document_graph_storage::ResourceEntry, target: &document_graph_storage::ResourceEntry) {
if stored.hash != target.hash {
let _ = writeln!(out, " hash: stored={:?} target={:?}", stored.hash, target.hash);
}
if stored.sources.len() != target.sources.len() {
let _ = writeln!(out, " sources.len: stored={} target={}", stored.sources.len(), target.sources.len());
}
// Report source bodies that drift for a shared key, plus keys present on only one side.
for (key, value) in &stored.sources {
match target.source(key) {
Some(other) if other.source != value.source => {
let _ = writeln!(out, " source[{key:?}]: stored={:?} target={:?}", value.source, other.source);
}
None => {
let _ = writeln!(out, " source[{key:?}]: only in stored");
}
Some(_) => {}
}
}
for (key, _) in &target.sources {
if stored.source(key).is_none() {
let _ = writeln!(out, " source[{key:?}]: only in target");
}
}
}
fn diff_attributes(out: &mut String, label: &str, stored: &document_graph_storage::Attributes, target: &document_graph_storage::Attributes) {
let stored_keys: std::collections::BTreeSet<_> = stored.keys().collect();
let target_keys: std::collections::BTreeSet<_> = target.keys().collect();
let missing: Vec<_> = target_keys.difference(&stored_keys).collect();
let extra: Vec<_> = stored_keys.difference(&target_keys).collect();
// Split value drift from timestamp-only drift: each attribute carries an LWW timestamp, so comparing
// whole records would flag equal-value entries that merely differ in when they were last set.
let (differing_values, differing_timestamps): (Vec<_>, Vec<_>) = stored_keys
.intersection(&target_keys)
.copied()
.filter(|k| stored.get(*k) != target.get(*k))
.partition(|k| stored.get(*k).map(|v| &v.value) != target.get(*k).map(|v| &v.value));
let _ = writeln!(
out,
"{label}: missing_from_stored={missing:?} extra_in_stored={extra:?} differing_values={differing_values:?} differing_timestamps={differing_timestamps:?}"
);
}
/// Human-readable summary of how two networks differ (exports, node set, per-node payloads, scope injections).
pub(crate) fn diff_networks(expected: &graph_craft::document::NodeNetwork, actual: &graph_craft::document::NodeNetwork) -> String {
let mut out = String::new();
if expected.exports != actual.exports {
let _ = writeln!(out, " exports differ: expected={} actual={}", expected.exports.len(), actual.exports.len());
for (i, (exp, act)) in expected.exports.iter().zip(actual.exports.iter()).enumerate() {
if exp != act {
let _ = writeln!(out, " [{i}] expected={exp:?}\n actual= {act:?}");
}
}
}
let expected_ids: std::collections::BTreeSet<_> = expected.nodes.keys().copied().collect();
let actual_ids: std::collections::BTreeSet<_> = actual.nodes.keys().copied().collect();
let missing: Vec<_> = expected_ids.difference(&actual_ids).collect();
let extra: Vec<_> = actual_ids.difference(&expected_ids).collect();
let differing: Vec<_> = expected_ids.intersection(&actual_ids).filter(|id| expected.nodes.get(id) != actual.nodes.get(id)).collect();
if !missing.is_empty() || !extra.is_empty() || !differing.is_empty() {
let _ = writeln!(out, " nodes: expected={} actual={}", expected_ids.len(), actual_ids.len());
if !missing.is_empty() {
let _ = writeln!(out, " missing from actual: {missing:?}");
}
if !extra.is_empty() {
let _ = writeln!(out, " extra in actual: {extra:?}");
}
if !differing.is_empty() {
let _ = writeln!(out, " differing payloads: {differing:?}");
for id in &differing {
if let (Some(exp), Some(act)) = (expected.nodes.get(id), actual.nodes.get(id)) {
let _ = writeln!(out, " node {id}:");
diff_document_node(&mut out, exp, act);
}
}
}
}
if expected.scope_injections != actual.scope_injections {
let _ = writeln!(out, " scope_injections differ");
}
out
}
/// Field-level diff between two runtime `DocumentNode`s with the same ID, so the compare-on-open log
/// names *which* field diverged rather than just the node ID. `original_location` is `#[serde(skip)]`
/// and recomputed at load, so it's a likely culprit for a payload mismatch that doesn't affect behavior.
fn diff_document_node(out: &mut String, expected: &graph_craft::document::DocumentNode, actual: &graph_craft::document::DocumentNode) {
if expected.inputs != actual.inputs {
let _ = writeln!(out, " inputs differ (len expected={} actual={})", expected.inputs.len(), actual.inputs.len());
for (i, (e, a)) in expected.inputs.iter().zip(actual.inputs.iter()).enumerate() {
if e != a {
let _ = writeln!(out, " input[{i}]: expected={e:?}\n actual= {a:?}");
}
}
}
if expected.call_argument != actual.call_argument {
let _ = writeln!(out, " call_argument: expected={:?} actual={:?}", expected.call_argument, actual.call_argument);
}
if expected.implementation != actual.implementation {
let _ = writeln!(out, " implementation: expected={:?} actual={:?}", expected.implementation, actual.implementation);
}
if expected.visible != actual.visible {
let _ = writeln!(out, " visible: expected={} actual={}", expected.visible, actual.visible);
}
if expected.skip_deduplication != actual.skip_deduplication {
let _ = writeln!(out, " skip_deduplication: expected={} actual={}", expected.skip_deduplication, actual.skip_deduplication);
}
if expected.context_features != actual.context_features {
let _ = writeln!(out, " context_features: expected={:?} actual={:?}", expected.context_features, actual.context_features);
}
if expected.original_location != actual.original_location {
let _ = writeln!(out, " original_location differs (recomputed at load, not stored):");
let _ = writeln!(out, " expected={:?}\n actual= {:?}", expected.original_location, actual.original_location);
}
}
@@ -0,0 +1,267 @@
use std::collections::VecDeque;
use std::collections::{BTreeMap, HashSet};
use document_graph_storage::Registry;
use graph_craft::application_io::resource::{ResourceId, ResourceRegistry, ResourceStorage};
use super::utility_types::network_interface::NodeNetworkInterface;
use super::utility_types::network_interface::storage_metadata::{StorageMetadataView, collect_network_view_settings};
/// Per-document undo/redo state: the legacy snapshot stacks plus the `Gdd` working-copy cursor that is
/// becoming the authoritative history. Owns the dual-stack bookkeeping push/pop/clear and the cursor's stage/retire/move/verify
/// lifecycle, so the handler drives history through one surface rather than three loose fields.
///
/// Not serialized: the legacy stacks are runtime-only, and a clone shares the working-copy container by
/// `Arc`, so it keeps reading the live working copy.
#[derive(derivative::Derivative)]
#[derivative(Clone, Debug, Default)]
pub struct DocumentHistory {
/// Stack of document network snapshots for previous history states.
lagacy_undo_stack: VecDeque<NodeNetworkInterface>,
/// Stack of document network snapshots for future history states.
lagacy_redo_stack: VecDeque<NodeNetworkInterface>,
/// The `Gdd` working copy: owns the CRDT `Session` and mirrors edits to disk. `None` until the mount
/// future built by `load_document` resolves.
#[derivative(Debug = "ignore")]
storage: Option<document_format::GddV1>,
}
impl DocumentHistory {
// ===== Legacy snapshot stacks =====
/// Push a snapshot onto the undo stack, evicting the oldest entry past the history cap.
pub fn push_undo(&mut self, snapshot: NodeNetworkInterface) {
Self::push_capped(&mut self.lagacy_undo_stack, snapshot);
}
/// Push a snapshot onto the redo stack, evicting the oldest entry past the history cap.
pub fn push_redo(&mut self, snapshot: NodeNetworkInterface) {
Self::push_capped(&mut self.lagacy_redo_stack, snapshot);
}
/// Pop the most recent undo snapshot, or `None` when the stack is empty.
pub fn pop_undo(&mut self) -> Option<NodeNetworkInterface> {
self.lagacy_undo_stack.pop_back()
}
/// Pop the most recent redo snapshot, or `None` when the stack is empty.
pub fn pop_redo(&mut self) -> Option<NodeNetworkInterface> {
self.lagacy_redo_stack.pop_back()
}
/// Drop the most recently pushed undo snapshot (used to cancel a transaction that ended up unmodified).
pub fn discard_last_undo(&mut self) {
self.lagacy_undo_stack.pop_back();
}
/// Clear the redo stack, called when a fresh edit invalidates the redo future.
pub fn clear_redo(&mut self) {
self.lagacy_redo_stack.clear();
}
/// Add the resources referenced by every snapshot in both history stacks into `resources`, so
/// history-only resources stay alive for legacy undo/redo.
pub fn collect_used_resources(&self, resources: &mut HashSet<ResourceId>) {
for interface in self.lagacy_undo_stack.iter().chain(&self.lagacy_redo_stack) {
interface.collect_used_resources(resources);
}
}
// ===== Gdd working-copy cursor =====
/// The `Gdd` working copy, `None` until the mount future resolves.
pub fn storage(&self) -> Option<&document_format::GddV1> {
self.storage.as_ref()
}
/// Mutable access to the `Gdd` working copy.
pub fn storage_mut(&mut self) -> Option<&mut document_format::GddV1> {
self.storage.as_mut()
}
/// Attach (or clear) the `Gdd` working copy once the mount future resolves.
pub fn set_storage(&mut self, storage: Option<document_format::GddV1>) {
self.storage = storage;
}
/// Retire the pending staged hot ops into durable Gdd history as one undo unit. Called at each undo-step
/// boundary (a new `StartTransaction`) and before undo/redo, so the per-`CommitTransaction` staging
/// coalesces into one interaction aligned with the legacy step. No-op while unmounted.
pub fn retire_storage_interaction(&mut self) {
let Some(storage) = self.storage.as_mut() else { return };
if let Err(error) = storage.retire_pending_interaction() {
log::error!("Storage interaction retirement failed: {error}");
}
}
/// Stage the runtime snapshot into the `Gdd` working copy at each `CommitTransaction`. No-op while
/// unmounted. Proto-node declaration bytes go into `byte_store` (the app-global resource cache). The
/// staged hot ops are retired by [`retire_storage_interaction`](Self::retire_storage_interaction) at
/// undo-step boundaries. `validate` (the `validate_storage_round_trip` preference) gates the per-commit
/// round-trip check, off by default for its perf cost.
pub fn stage_snapshot(
&mut self,
interface: &NodeNetworkInterface,
registry: &ResourceRegistry,
view_settings: BTreeMap<String, serde_json::Value>,
legacy_document: &str,
byte_store: &dyn ResourceStorage,
) {
let Some(storage) = self.storage.as_mut() else { return };
let network = interface.document_network();
let metadata_view = StorageMetadataView::new(interface);
let network_view_settings = storage
.network_ids(network, &metadata_view)
.ok()
.map(|network_ids| collect_network_view_settings(interface, &network_ids));
// Stage without retiring: a tool drag fires several `CommitTransaction`s but is one legacy undo
// step, so the deltas accumulate as hot ops and coalesce at the next undo-step boundary.
if let Err(error) = storage.stage_runtime_snapshot(network, &metadata_view, registry, byte_store) {
log::error!("Storage snapshot staging failed: {error}");
return;
}
if let Err(error) = storage.set_view_settings(view_settings) {
log::error!("Persisting view settings failed: {error}");
}
if let Some(network_view_settings) = network_view_settings
&& let Err(error) = storage.set_network_view_settings(network_view_settings)
{
log::error!("Persisting per-network view settings failed: {error}");
}
// Dual-write soak: embed the legacy `.graphite` bytes so the new format
// can be validated against (and recovered from) the old one on open.
if let Err(error) = storage.store_legacy_document(legacy_document.as_bytes()) {
log::error!("Embedding legacy document into working copy failed: {error}");
}
}
/// Move the `Gdd` undo/redo cursor along the retired interaction chain, flushing any open interaction
/// first. Returns a clone of the post-move `Gdd` (`Arc`-shared) so a `'static` rebuild future can read
/// the rewound state while the live document keeps its cursor. `None` when there is nothing to move to,
/// unmounted, or the move failed.
pub fn move_cursor(&mut self, undo: bool) -> Option<document_format::GddV1> {
self.retire_storage_interaction();
let storage = self.storage.as_mut()?;
let moved = if undo {
if !storage.can_undo() {
return None;
}
storage.undo().map(|_| ())
} else {
if !storage.can_redo() {
return None;
}
storage.redo().map(|_| ())
};
if let Err(error) = moved {
log::error!("Storage undo/redo cursor move failed: {error}");
return None;
}
Some(storage.clone())
}
// Soak round-trip verification (runtime-gated by `validate_storage_round_trip`)
// These log drift rather than panicking, since the soak can run in release where a crash is
// unacceptable; tests still fail loud via the `#[cfg(test)]` panics.
/// Soak check: the stored registry should equal a fresh `from_runtime`, and a `to_runtime` of it should
/// equal the original network.
pub fn verify_round_trip(&self, interface: &NodeNetworkInterface, registry: &ResourceRegistry) {
use super::diff_networks;
use super::document_diff::diff_registries;
let Some(storage) = &self.storage else { return };
let peer = storage.session().peer();
let network = interface.document_network();
let metadata_view = StorageMetadataView::new(interface);
let conversion = match Registry::convert_from_runtime(network, &metadata_view, registry, peer) {
Ok(conversion) => conversion,
Err(error) => {
log::error!("storage round-trip: from_runtime failed: {error}");
return;
}
};
let target = &conversion.registry;
let declarations = match conversion.declarations() {
Ok(declarations) => declarations,
Err(error) => {
log::error!("storage round-trip: declaration rebuild failed: {error}");
return;
}
};
let stored = storage.registry();
if !stored.value_equal(target) {
log::error!("storage round-trip: registry value drift after commit\n{}", diff_registries(stored, target));
#[cfg(test)]
panic!("storage round-trip: registry value drift after commit");
}
if !stored.order_consistent(target) {
log::error!("storage round-trip: timestamp order inconsistent between stored and target");
#[cfg(test)]
panic!("storage round-trip: timestamp order inconsistent between stored and target");
}
let (round_tripped, _entries) = match stored.to_runtime_with_metadata(&declarations) {
Ok(result) => result,
Err(error) => {
log::error!("storage round-trip: to_runtime failed: {error}");
return;
}
};
if &round_tripped != network {
log::error!("storage round-trip: network drift after to_runtime\n{}", diff_networks(network, &round_tripped));
#[cfg(test)]
panic!("storage round-trip: network drift after to_runtime");
}
}
/// Soak check: after a cursor move, the cursor's registry should equal a fresh `from_runtime` of the
/// current (legacy-restored) interface. `current_resources` are the resources the live network
/// references; history-only resources the cursor dropped are expected, not drift.
pub fn verify_cursor_matches_runtime(&self, interface: &NodeNetworkInterface, registry: &ResourceRegistry, current_resources: &HashSet<ResourceId>) {
use super::document_diff::diff_registries;
let Some(storage) = &self.storage else { return };
let peer = storage.session().peer();
let network = interface.document_network();
let metadata_view = StorageMetadataView::new(interface);
let Ok(mut conversion) = Registry::convert_from_runtime(network, &metadata_view, registry, peer) else {
log::error!("undo/redo shadow: from_runtime failed");
return;
};
let stored = storage.registry();
// The cursor reverts the interaction's `AddResource` while the runtime keeps the resource alive for
// legacy redo, so a history-only resource the cursor dropped is expected. Drop those before comparing.
conversion.registry.resources.retain(|id, _| stored.resources.contains_key(id) || current_resources.contains(id));
if !stored.value_equal(&conversion.registry) {
log::error!(
"undo/redo shadow: cursor registry diverged from the restored interface\n{}",
diff_registries(stored, &conversion.registry)
);
}
}
fn push_capped(stack: &mut VecDeque<NodeNetworkInterface>, snapshot: NodeNetworkInterface) {
stack.push_back(snapshot);
if stack.len() > crate::consts::MAX_UNDO_HISTORY_LEN {
stack.pop_front();
}
}
}
@@ -0,0 +1,274 @@
use std::path::PathBuf;
use std::sync::Arc;
use super::utility_types::misc::{GroupFolderType, SnappingState};
use crate::messages::input_mapper::utility_types::input_keyboard::Key;
use crate::messages::portfolio::document::data_panel::DataPanelMessage;
use crate::messages::portfolio::document::overlays::utility_types::{OverlayContext, OverlaysType};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping};
use crate::messages::portfolio::utility_types::PanelType;
use crate::messages::prelude::*;
use glam::{DAffine2, IVec2};
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::raster::BlendMode;
use graphene_std::raster::Image;
use graphene_std::transform::Footprint;
use graphene_std::vector::Vector;
use graphene_std::vector::click_target::ClickTarget;
use graphene_std::vector::style::RenderMode;
#[impl_message(Message, PortfolioMessage, Document)]
#[derive(derivative::Derivative, Clone, serde::Serialize, serde::Deserialize)]
#[derivative(Debug, PartialEq)]
pub enum DocumentMessage {
Noop,
// Sub-messages
#[child]
GraphOperation(GraphOperationMessage),
#[child]
Navigation(NavigationMessage),
#[child]
NodeGraph(NodeGraphMessage),
#[child]
Overlays(OverlaysMessage),
#[child]
PropertiesPanel(PropertiesPanelMessage),
#[child]
DataPanel(DataPanelMessage),
#[child]
Resource(ResourceMessage),
// Messages
AlignSelectedLayers {
axis: AlignAxis,
aggregate: AlignAggregate,
},
RemoveArtboards,
ClearLayersPanel,
CreateEmptyFolder,
DeleteNode {
node_id: NodeId,
},
DeleteSelectedLayers,
DeselectAllLayers,
DocumentHistoryBackward,
DocumentHistoryForward,
DocumentStructureChanged,
DrawArtboardOverlays {
context: OverlayContext,
},
DuplicateSelectedLayers,
DuplicateSelectedLayersTo {
parent: LayerNodeIdentifier,
insert_index: usize,
},
EnterNestedNetwork {
node_id: NodeId,
},
Escape,
ExitNestedNetwork {
steps_back: usize,
},
FlipSelectedLayers {
flip_axis: FlipAxis,
},
RotateSelectedLayers {
degrees: f64,
},
GraphViewOverlay {
open: bool,
},
GraphViewOverlayToggle,
GridOptions {
options: GridSnapping,
},
GridOverlays {
context: OverlayContext,
},
GridVisibility {
visible: bool,
},
BlendSelectedLayers,
MorphSelectedLayers,
ExpandFillStrokeOnSelectedLayers,
ExpandFillStrokeOnSelectedLayersNoTransaction,
GroupSelectedLayers {
group_folder_type: GroupFolderType,
},
MoveSelectedLayersTo {
parent: LayerNodeIdentifier,
insert_index: usize,
},
ReorderPropertiesSection {
node_id: NodeId,
insert_index: usize,
},
MoveSelectedLayersToGroup {
parent: LayerNodeIdentifier,
},
NudgeSelectedLayers {
delta_x: f64,
delta_y: f64,
resize: Key,
resize_opposite: Key,
},
InsertImage {
name: Option<String>,
image: Image<Color>,
mouse: Option<(f64, f64)>,
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
/// When true (file-open flow), place the image at the document origin so `WrapContentInArtboard`
/// can wrap it without a content Transform node. When false, place at the cursor or viewport center.
place_at_origin: bool,
},
InsertSvg {
name: Option<String>,
svg: String,
mouse: Option<(f64, f64)>,
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
/// When true (file-open flow), place the SVG at the document origin so `WrapContentInArtboard`
/// can wrap it without a content Transform node. When false, place at the cursor or viewport center.
place_at_origin: bool,
},
Redo,
RenameDocument {
new_name: String,
},
RenderRulers,
RenderScrollbars,
SaveDocument,
SaveDocumentAs,
SavedDocument {
path: Option<PathBuf>,
},
MarkAsSaved,
SelectParentLayer,
SelectAllLayers,
SelectedLayersLower,
SelectedLayersLowerToBack,
SelectedLayersRaise,
SelectedLayersRaiseToFront,
SelectedLayersReverse,
SelectedLayersReorder {
relative_index_offset: isize,
},
ClipLayer {
id: NodeId,
},
SelectLayer {
id: NodeId,
ctrl: bool,
shift: bool,
},
SetActivePanel {
active_panel: PanelType,
},
SetBlendModeForSelectedLayers {
blend_mode: BlendMode,
},
SetGraphFadeArtwork {
percentage: f64,
},
SetNodePinned {
node_id: NodeId,
pinned: bool,
},
SetOpacityForSelectedLayers {
opacity: f64,
},
SetFillForSelectedLayers {
fill: f64,
},
SetOverlaysVisibility {
visible: bool,
overlays_type: Option<OverlaysType>,
},
SetRangeSelectionLayer {
new_layer: Option<LayerNodeIdentifier>,
},
SetSnapping {
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
closure: Option<for<'a> fn(&'a mut SnappingState) -> &'a mut bool>,
snapping_state: bool,
},
SetToNodeOrLayer {
node_id: NodeId,
is_layer: bool,
},
SetRenderMode {
render_mode: RenderMode,
},
AddTransaction,
StartTransaction,
EndTransaction,
CommitTransaction,
CancelTransaction,
AbortTransaction,
RepeatedAbortTransaction {
undo_count: usize,
},
ToggleLayerExpansion {
tree_path: Vec<NodeId>,
recursive: bool,
},
ToggleNodePropertiesSectionExpanded {
node_id: NodeId,
},
ToggleSelectedVisibility,
ToggleSelectedLocked,
ToggleGridVisibility,
ToggleOverlaysVisibility,
ToggleSnapping,
UpdateUpstreamTransforms {
upstream_footprints: HashMap<NodeId, Footprint>,
local_transforms: HashMap<NodeId, DAffine2>,
first_element_source_id: HashMap<NodeId, Option<NodeId>>,
},
UpdateClickTargets {
click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
},
UpdateOutlines {
outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
},
UpdateTextFrames {
text_frames: HashMap<NodeId, DAffine2>,
},
UpdateClipTargets {
clip_targets: HashSet<NodeId>,
},
UpdateVectorData {
vector_data: HashMap<NodeId, Arc<Vector>>,
},
// `Message` is only serialized at `editor_wrapper.rs`, and only inputs from JS pass through it.
// `UpdateFillAttributes` and `UpdateStrokeAttributes` are produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point.
#[serde(skip)]
UpdateFillAttributes {
fill_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
},
#[serde(skip)]
UpdateStrokeAttributes {
stroke_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
},
Undo,
UngroupSelectedLayers,
UngroupLayer {
layer: LayerNodeIdentifier,
},
PTZUpdate,
SelectionStepBack,
SelectionStepForward,
WrapContentInArtboard {
place_artboard_at_origin: bool,
/// When `Some`, use this canvas (origin, dimensions) for the artboard instead of measuring the content bounding box.
/// The origin comes from the SVG viewBox's min-x/min-y values and the dimensions from its width/height.
artboard_canvas: Option<(IVec2, IVec2)>,
},
ZoomCanvasTo100Percent,
ZoomCanvasTo200Percent,
ZoomCanvasToFitAll,
}

Some files were not shown because too many files have changed in this diff Show More