Code structure

Tech stack

  • rustc: Compiler for node graph generics and custom nodes
  • rust-gpu: Compiler backend to generate compute shaders from Rust source code
  • wgpu: Portable graphics API for running compute shaders on desktop and web
  • Tauri: lightweight desktop web UI shell while the backend runs natively (experimental)

Frontend/backend communication

The Graphite editor frontend is the web code which displays the user interface. It passes user interactions to the backend. The Graphite editor backend handles all the day-to-day logic and responsibilities of a user-facing interactive application. Some duties include: user input, GUI state management, viewport tool behavior, layer management and selection, and handling of multiple document tabs.

Frontend (TS) -> backend (Rust/wasm) communication is achieved through a thin Rust translation layer in /frontend/wasm/src/editor_api.rs which wraps the Editor backend's complex Rust data type API and provides the TS with a simpler API of callable functions. These wrapper functions are compiled by wasm-bindgen into autogenerated TS functions that serve as an entry point into the wasm.

Backend (Rust) -> frontend (TS) communication happens by sending a queue of messages to the frontend message dispatcher. After the TS has called any wrapper API function to get into backend (Rust) code execution, the Editor's business logic runs and queues up FrontendMessages (defined in /editor/src/messages/frontend/frontend_message.rs) which get mapped from Rust to TS-friendly data types in /frontend/src/wasm-communication/messages.ts. Various TS code subscribes to these messages by calling subscribeJsMessage(MessageName, (messageData) => { /* callback code */ });.

The message system

The Graphite editor backend is organized into a hierarchy of systems, called message handlers, which talk to one another through message passing. Messages are pushed to the front or back of a queue and each one is processed sequentially by the backend's dispatcher. The dispatcher lives at the root of the application hierarchy and it owns its message handlers. Thus, Rust's restrictions on mutable borrowing are satisfied because only the dispatcher mutably borrows its message handlers, one at a time, while each message is processed.

Messages

Messages are enum variants that are dispatched to perform some intended activity within their respective message handlers. Here are two DocumentMessage definitions:

pub enum DocumentMessage {
	...
	// A message that carries one named data field
	DeleteLayer {
		id: NodeId,
	}
	// A message that carries no data
	DeleteSelectedLayers,
	...
}

As shown above, additional data fields can be included with each message. But as a special case denoted by the #[child] attribute, that data can also be a sub-message, which enables us to nest message handler systems hierarchically. By convention, regular data must be written as struct-style named fields (shown above), while a sub-message must be written as an unnamed tuple/newtype-style field (shown below). The DocumentMessage enum of the previous example is defined as a child of PortfolioMessage which wraps it like this:

pub enum PortfolioMessage {
	...
	// A message that carries the `DocumentMessage` child enum as data
	#[child]
	Document(DocumentMessage),
	...
}

Likewise, the PortfolioMessage enum is wrapped by the top-level Message enum. The dispatcher operates on the queue of these base-level Message types.

So for example, the DeleteSelectedLayers message mentioned previously will look like this as a Message data type:

Message::Portfolio(
	PortfolioMessage::Document(
		DocumentMessage::DeleteSelectedLayers
	)
)

Writing out these nested message enum variants would be cumbersome, so that #[child] attribute shown earlier invokes a proc macro that automatically implements the From trait, letting you write this instead to get a Message data type:

DocumentMessage::DeleteSelectedLayers.into()

Most often, this is simplified even further because the .into() is called for you when pushing a message to the queue with .add() or .add_front(). So this becomes as simple as:

responses.add(DocumentMessage::DeleteSelectedLayers);

The responses message queue is composed of Message data types, and thanks to this system, child messages like DocumentMessage::DeleteSelectedLayers are automatically wrapped in their ancestor enum variants to become a Message, saving you from writing the verbose nested form.