patch
自分で WGSL シェーダを生成し、検証し、プレビューするノードエディタ。
隅の絵はエディタが描いているのではありません。その上のパッチ そのもの であるフラグメントシェーダが描いています。ノードを 2 つつなぐと、新しい WGSL のプログラムが生成され、CPU 上で naga が検証し、コンパイルされます。スライダーを動かしたときは、何も生成されません。4 つの float が uniform バッファに届き、同じプログラムが別の絵を描きます。
この違いがこのサンプルのすべてで、それは 1 か所での 1 つの決定です。どのメッセージも topology_rev(プログラムが変わりうる)か param_rev(変わりうるのは数値だけ)のどちらかを進め、その 2 つのカウンタにuse_memo が 1 つずつぶら下がります。片方が WGSL を、もう片方が uniform ブロックを作ります。「ソースが変わったか」を見張っているものはありません。それぞれの memo の deps が何かということから、自然にそうなります。そしてテストがそれを固定しています。検証が memo の中で起きることにも意味があります。不正なグラフは GPU ではなく、エディタの中で報告されます。
ノードは絶対座標に置かれます。ItemStyle にそのための属性はありませんし、必要でもありません。キャンバスは 1 つの葉で、自分の矩形を確保し、ノードごとにその座標で子の Ui を開き、その周りに Cx を組み立てます。エスケープハッチ の 1 段上です。ノードの中に入れば、また普通の <View> の flexbox です。ノードには cx.scope(node.id, ..) で手で鍵を付けています。key= がしているのとまったく同じことなので、別のノードを消したり並べ替えたりしても、そのノード自身の状態は残ります。
絵は背景です。キャンバスは出力シェーダを自分いっぱいに、切り取らずに収めて描き、パッチはその上に乗ります。パラメータはどれも、それを持つノードの中で編集されるので、選択状態と歩調を合わせておくべきものはありません。<Overlay> でキャンバスの上に浮いているのは、どのノードにも属さないものだけです。片隅にノード数、もう片隅に、あれば naga の苦情。ビューは最初に描かれるときパッチに合わせて収まり、Recenter がそこへ戻します。メニューバーは素の egui の葉が 1 つです。メニューは自分のレイヤーとして開き、レイアウトの場所を取らないからです。そこには時計、再生ボタン、それに絵が描かれる 1 分ループのタイムラインも入っています。絵そのものは egui_wgpuの描画コールバックです。対応しているブラウザでは WebGPU、そうでなければeframe の WebGL フォールバックです。
自分で動かす
cargo run -p patch
trunk serve --config examples/patch/Trunk.tomlソースは examples/patch/src/lib.rs です。
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use board::hooks::{Dnd, Undoable, use_dnd, use_undoable};
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
pub mod codegen;
pub mod gpu;
pub mod graph;
pub mod preset;
use codegen::{GenError, Generated};
use graph::{Graph, GrayMethod, Kind, MixMode, Msg, Node, NodeId, reduce};
/// One end of a wire: a node and one of its sockets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PortRef {
pub node: NodeId,
pub port: Port,
}
/// Which socket. Inputs are numbered; there is only ever one output.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Port {
In(usize),
Out,
}
/// Which edge of a node a socket sits on: inputs on the left, the output on
/// the right, the way the wires run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Left,
Right,
}
/// The drag session, `board`'s hook with a port at both ends: a port is picked
/// up and another port is where it lands.
pub type PortDnd = Dnd<PortRef, PortRef>;
/// What everything on screen sends to the reducer.
///
/// `Dispatch` is `Clone + Send + 'static`, which is what lets it travel by
/// context while a `State` guard could not (ARCHITECTURE 3.5).
pub type Actions = Dispatch<Undoable<Msg>>;
/// Where every port was drawn this frame.
///
/// A node's ports are laid out by flexbox, so the middle of a port circle is
/// not known until the node has been drawn — and the wires need all of them.
/// The nodes write into this as they go and the canvas reads it afterwards,
/// painting the wires into a shape slot it reserved before drawing anything.
///
/// The `Rc<RefCell<_>>` is not decoration. This is written on every frame, and
/// every write on `Handle` (`set`, `update`) asks for a repaint, which would
/// mean an app that never goes idle (ARCHITECTURE 5.6). `Handle::with` hands
/// out a `&T` without dirtying anything, so the scribbling happens inside the
/// value — the same shape, and for the same reason, as `board`'s `Dnd`.
#[derive(Clone, Default)]
pub struct Ports {
seen: Rc<RefCell<HashMap<PortRef, egui::Pos2>>>,
}
impl Ports {
/// Forget the last frame's positions.
fn begin_frame(&self) {
self.seen.borrow_mut().clear();
}
/// Report where a port is, from the node that just drew it.
pub fn put(&self, port: PortRef, at: egui::Pos2) {
self.seen.borrow_mut().insert(port, at);
}
/// Where a port is, or `None` if it has not been drawn yet.
pub fn at(&self, port: PortRef) -> Option<egui::Pos2> {
self.seen.borrow().get(&port).copied()
}
}
/// The port map for the whole tree, cleared once per frame.
#[hook]
fn use_ports<'s>(cx: &mut Cx<'s, '_>) -> Handle<'s, Ports> {
let ports = use_handle(cx, Ports::default);
// `with`, not `update`: this runs every frame and `update` would ask for a
// repaint every frame with it.
ports.with(Ports::begin_frame);
ports
}
/// The port map, or an unattached one outside a provider.
#[hook]
fn use_port_map(cx: &mut Cx) -> Ports {
use_context::<Ports>(cx).map_or_else(Ports::default, |ports| ports.get())
}
/// The drag session, or an unattached one outside a provider.
#[hook]
fn use_drag(cx: &mut Cx) -> PortDnd {
use_context::<PortDnd>(cx).map_or_else(PortDnd::new, |dnd| dnd.get())
}
/// The dispatcher, if this is drawn under one.
#[hook]
fn use_actions(cx: &mut Cx) -> Option<Actions> {
use_context::<Actions>(cx).map(|actions| actions.get())
}
/// Send one message to the patch's reducer.
fn send(actions: &Option<Actions>, msg: Msg) {
if let Some(actions) = actions {
actions.send(Undoable::Do(msg));
}
}
/// A generated program, kept until a better one arrives.
///
/// The last one that compiled, not the last one that was asked for: a cycle or
/// a mistyped expression leaves this alone, so the preview keeps drawing and
/// the `wgsl` tab keeps showing what is actually on the GPU (test P-4).
#[derive(Clone)]
struct Program {
wgsl: Arc<str>,
hash: u64,
slots: Vec<NodeId>,
}
/// What a node's header drag is doing.
#[derive(Clone, Copy, Debug)]
enum Drag {
Start,
By(egui::Vec2),
End,
}
/// The width of a node box, in points. Fixed, so the canvas can place a node
/// before the node has drawn itself.
const NODE_W: f32 = 168.0;
/// How much room a node's child `Ui` is given. The node draws its own frame
/// and takes only the height it needs; this is the ceiling.
const NODE_MAX_H: f32 = 300.0;
/// The diameter of a port circle.
const PORT: f32 = 12.0;
/// How much bigger than the circle the drop target is, so a port can be aimed
/// at without precision.
const PORT_PAD: f32 = 5.0;
/// How wide the validation message may be before it wraps.
const MESSAGE_W: f32 = 320.0;
/// How long the clock runs before it starts again, in seconds. The picture is
/// driven by `t`, so a loop is what makes it a loop.
const LOOP: f32 = 60.0;
/// How wide the timeline in the menu bar is.
const CLOCK_W: f32 = 160.0;
/// How far the node count and the validation message sit from the canvas's
/// corners.
const COUNT_GAP: f32 = 14.0;
/// The play and pause glyphs, from egui's own icon font.
const PLAY: &str = "⏵";
const PAUSE: &str = "⏸";
/// The undo and redo glyphs, from egui's own icon font.
const UNDO: &str = "⟲";
const REDO: &str = "⟳";
/// How much room `Recenter` leaves around the nodes, in patch units.
const FIT_MARGIN: f32 = 24.0;
/// Where the canvas is looking. A patch point `p` is drawn at
/// `canvas.min + zoom * (pan + p)`.
///
/// `pan` is in patch units, so at `zoom == 1` the nodes are drawn exactly
/// where they would be with no layer transform at all, and none is set. That
/// is the state a test looks at: kittest reads widget rectangles in layer
/// coordinates, and at zoom 1 those are screen coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub pan: egui::Vec2,
pub zoom: f32,
}
impl Default for Camera {
fn default() -> Self {
Self {
pan: egui::Vec2::ZERO,
zoom: 1.0,
}
}
}
impl Camera {
pub const ZOOM: std::ops::RangeInclusive<f32> = 0.25..=3.0;
/// The layer transform: scale about the canvas's corner, so that at zoom
/// 1 it is the identity whatever the pan.
fn to_global(self, corner: egui::Pos2) -> egui::emath::TSTransform {
use egui::emath::TSTransform;
TSTransform::from_translation(corner.to_vec2())
* TSTransform::from_scaling(self.zoom)
* TSTransform::from_translation(-corner.to_vec2())
}
/// The patch point under a screen point.
fn patch_at(self, corner: egui::Pos2, screen: egui::Pos2) -> egui::Pos2 {
((screen - corner) / self.zoom - self.pan).to_pos2()
}
/// Zoom by `factor` about a screen point, which stays where it is.
fn zoomed(self, factor: f32, corner: egui::Pos2, about: egui::Pos2) -> Self {
let zoom = (self.zoom * factor).clamp(*Self::ZOOM.start(), *Self::ZOOM.end());
let under = self.patch_at(corner, about);
Self {
pan: (about - corner) / zoom - under.to_vec2(),
zoom,
}
}
/// Every node on a canvas of `size`: zoomed out until `bounds` fits,
/// with [`FIT_MARGIN`] around it, and centred. Never zoomed *in* past 1 —
/// a small patch is not blown up — and an empty patch looks at its origin.
fn fit(bounds: egui::Rect, size: egui::Vec2) -> Self {
if !bounds.is_positive() || size.x <= 0.0 || size.y <= 0.0 {
return Self::default();
}
let padded = bounds.expand(FIT_MARGIN);
let zoom = (size.x / padded.width())
.min(size.y / padded.height())
.clamp(*Self::ZOOM.start(), 1.0);
// A patch point `p` lands at `zoom * (pan + p)`, so the middle of the
// canvas is the middle of the bounds when `pan` is this.
let pan = size / (2.0 * zoom) - padded.center().to_vec2();
Self { pan, zoom }
}
}
#[component]
pub fn App(cx: &mut Cx) {
rsx! {
<PatchProvider>
<PatchView/>
</PatchProvider>
}
}
/// Owns the drag session and the port map and publishes both.
///
/// A provider has to make what it provides: `provide_context` takes a `Handle`,
/// which borrows the store, and a prop may not name that lifetime
/// (ARCHITECTURE 6). The same shape as the `theme` example.
#[component(shares_ui)]
fn PatchProvider(cx: &mut Cx, children: impl View) {
// One session for the whole tree: the port that is picked up and the port
// it lands on are drawn by two different nodes.
let dnd = use_dnd::<PortRef, PortRef>(cx);
let ports = use_ports(cx);
provide_context(cx, dnd, |cx| {
provide_context(cx, ports, |cx| children.show(cx))
});
}
/// The patch: the menu bar, the reducer behind it, and the two panes that
/// wait for the preset.
#[component]
fn PatchView(cx: &mut Cx) {
// The reducer owns the patch and the persisted slot mirrors it, the way
// `showcase` and `board` do. `use_persisted` first, so its value is there
// to seed the history on the very first frame.
let mut saved = use_persisted(cx, "patch/graph", Graph::empty);
let (history, dispatch) = use_undoable(cx, reduce, || saved.clone());
if *saved != history.present {
*saved = history.present.clone();
}
// The `Dispatch` is what goes into the context; see [`Actions`].
let actions = use_handle(cx, || dispatch.clone());
// How many times the history has been walked, which is the one number
// here that never goes backwards.
//
// A revision counter does: undo restores an older patch and its older
// counter with it, so two different patches can carry the same
// `topology_rev` — undo a rewire, then rewire differently, and the number
// is the one it already was. A memo compares its deps with the *previous*
// value, and in practice a frame is drawn between any two messages, so it
// would see the intermediate revision and recompute anyway. That is a fact
// about timing rather than a property of the deps, and it stops being true
// the moment two messages land in one visit to the reducer. `(rev, epoch)`
// is monotonic, so the question does not arise.
let mut epoch = use_state(cx, || 0u64);
let mut camera = use_state(cx, Camera::default);
// Where the canvas is, and how much of the patch the nodes cover, both
// reported by the canvas itself and written only when they change.
// `Recenter` is decided here, next to the camera, and needs two things
// this component does not have: the rectangle the layout engine gave the
// canvas, and the height the nodes turned out to be once drawn.
let mut canvas = use_state(cx, || egui::Rect::NOTHING);
let mut bounds = use_state(cx, || egui::Rect::NOTHING);
// Whether the view has been fitted to the patch once. The first frame
// knows neither number — the canvas has not been laid out and the nodes
// have not been drawn — so it happens on the frame both arrive, which is
// also the frame the preset lands.
let mut fitted = use_state(cx, || false);
// The clock the picture is drawn at, and whether it is running. It lives
// here, next to the menu bar that shows it, rather than in the canvas
// that draws with it: the bar can scrub it, and a paused patch is still
// a patch to edit.
let mut playing = use_state(cx, || true);
let mut clock = use_state(cx, || 0.0f32);
if *playing {
// egui's frame time, and the clock wraps at the end of the loop. A
// write is a repaint (5.6), which is what keeps the picture moving;
// paused, nothing is written and the app goes idle.
let dt = cx.ui().input(|i| i.stable_dt).min(0.1);
*clock = (*clock + dt).rem_euclid(LOOP);
}
let now = *clock;
let running = *playing;
// Read once: an element may not hold a shared borrow of a state *and* a
// handler that writes it (ARCHITECTURE 3.7).
let steps = *epoch;
let view_at = *camera;
let graph = &history.present;
let full = graph.nodes.len() >= graph::MAX_NODES;
let home = Camera::fit(*bounds, canvas.size());
// Fit the view to the patch once, on the first frame that knows both
// numbers: the canvas has to have been laid out, and the nodes have to
// have been drawn once to have a height. `is_untouched` keeps it from
// firing on the empty patch that exists for the frame before the preset
// lands — that would centre the one output node and leave the preset off
// the screen.
if !*fitted && bounds.is_positive() && canvas.is_positive() && !graph.is_untouched() {
*fitted = true;
*camera = home;
}
// Where a new node lands: the first free place near the canvas's top-left
// corner, in patch coordinates.
let next_pos = graph.free_pos([
-view_at.pan.x + 40.0 / view_at.zoom,
-view_at.pan.y + 40.0 / view_at.zoom,
]);
let view = rsx! {
<View direction="column" grow={1.0} w="100%" h="100%" gap={8} p={8}>
<MenuBar
full={full}
can_undo={history.can_undo()}
can_redo={history.can_redo()}
playing={running}
time={now}
on_add={|kind: Kind| {
dispatch.send(Undoable::Do(Msg::AddNode { kind, pos: next_pos }));
}}
on_undo={|| {
*epoch += 1;
dispatch.send(Undoable::Undo);
}}
on_redo={|| {
*epoch += 1;
dispatch.send(Undoable::Redo);
}}
on_home={|| *camera = home}
on_play={|| *playing = !running}
on_seek={|to: f32| *clock = to}
/>
// Only the canvas and the bottom bar wait for the preset; the menu
// bar above is drawn and usable while the future is pending.
<Suspense fallback={view(|cx| {
cx.leaf(&ItemStyle::default().grow(1.0), |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
ui.label("loading the preset");
});
})}>
<Stage
graph={graph}
epoch={steps}
camera={view_at}
time={now}
on_camera={|next: Camera| *camera = next}
on_size={|rect: egui::Rect| {
// Every frame, so only a change may write.
if *canvas != rect {
*canvas = rect;
}
}}
on_bounds={|covered: egui::Rect| {
if *bounds != covered {
*bounds = covered;
}
}}
/>
</Suspense>
</View>
};
provide_context(cx, actions, |cx| view.show(cx));
}
/// The menu bar: the node menu, the history buttons, and the clock.
///
/// Everything here belongs to the patch rather than to the bar, so the bar
/// owns none of it: it is handed what to show and reports what was pressed.
///
/// One leaf, because the node menu is an `egui::Popup`, a container of egui's
/// own that opens as a layer of its own and takes no room in the layout. The
/// escape hatch goes one level deeper than usual — the row itself is egui's —
/// and everything the bar reports comes back out through events, the same as
/// a `<Button>` would.
#[component]
#[allow(clippy::too_many_arguments)]
fn MenuBar(
cx: &mut Cx,
#[prop(default)] style: ItemStyle,
full: bool,
can_undo: bool,
can_redo: bool,
playing: bool,
time: f32,
#[event] on_add: Kind,
#[event] on_undo: (),
#[event] on_redo: (),
#[event] on_home: (),
#[event] on_play: (),
#[event] on_seek: f32,
) {
let accent = look(cx.ctx()).accent;
cx.leaf(&style.w("100%"), |ui| {
// A hand-written leaf sets the wrap mode itself: measured in the
// zero-width `Ui` of its first draw, a wrapping row would report
// one word wide (ARCHITECTURE 6).
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("patch")
.size(20.0)
.strong()
.color(accent),
);
ui.add_space(4.0);
// The palette is a menu: one button in the bar, one entry per
// kind under it. A click on an entry closes the menu. The button
// shows the arrow; the tree says the name.
let button = ui.add_enabled(!full, egui::Button::new("Add node ⏷"));
button.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, !full, "Add node")
});
egui::Popup::menu(&button)
.close_behavior(egui::PopupCloseBehavior::CloseOnClick)
.show(|ui| {
for kind in Kind::palette() {
if ui.button(kind.name()).clicked() {
on_add.emit(kind);
}
}
});
if icon_button(ui, UNDO, "undo", can_undo) {
on_undo.emit(());
}
if icon_button(ui, REDO, "redo", can_redo) {
on_redo.emit(());
}
if ui.button("Recenter").clicked() {
on_home.emit(());
}
// The clock goes at the far end of the row. Right to left, so
// the timeline is added first and ends up rightmost.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut at = time;
ui.spacing_mut().slider_width = CLOCK_W;
let slider = ui.add(
egui::Slider::new(&mut at, 0.0..=LOOP)
.show_value(false)
.suffix("s"),
);
slider.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), "t")
});
if slider.changed() {
on_seek.emit(at);
}
// A fixed width, so the row does not shuffle sideways every
// tenth of a second as the number grows a digit.
ui.add_sized(
egui::vec2(44.0, ui.spacing().interact_size.y),
egui::Label::new(format!("t {time:.1}")).selectable(false),
);
let (glyph, name) = if playing {
(PAUSE, "pause")
} else {
(PLAY, "play")
};
if icon_button(ui, glyph, name, true) {
on_play.emit(());
}
});
});
});
}
/// A button in the menu bar that shows a glyph and is named in words: the
/// name is what a screen reader, and the test, call it.
fn icon_button(ui: &mut egui::Ui, glyph: &str, name: &str, enabled: bool) -> bool {
let response = ui.add_enabled(enabled, egui::Button::new(glyph));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name));
response.clicked()
}
/// The canvas and the preview: everything that needs the patch to exist.
///
/// The preset arrives through `use_future`, so this component returns early
/// until it is here and the `<Suspense>` above draws its fallback meanwhile.
/// The file is `include_str!`, so the wait is one frame — in a real editor it
/// would be a request to a patch library, and nothing else here would change.
#[component]
fn Stage(
cx: &mut Cx,
graph: &Graph,
epoch: u64,
camera: Camera,
// The clock the picture is drawn at; the menu bar owns it.
time: f32,
#[event] on_camera: Camera,
#[event] on_size: egui::Rect,
#[event] on_bounds: egui::Rect,
) {
let loaded = use_future(cx, (), || async { preset::parse(preset::STARTER) });
let Poll::Ready(preset) = loaded else {
// Nothing to draw, and nothing to say about it: the nearest boundary
// is what shows the fallback (ARCHITECTURE 5.8).
return;
};
let actions = use_actions(cx);
// Seeded once per mount, and only into a patch nobody has touched, so a
// saved patch is never overwritten by the preset.
let mut seeded = use_state(cx, || false);
if !*seeded {
*seeded = true;
if graph.is_untouched() {
send(&actions, Msg::Load(Box::new(preset.clone())));
}
}
// Stage one: the program. Rebuilt when the topology changed, and not when
// a parameter did — `generate` also runs the result through naga, so this
// is where a bad expression is caught, on the CPU, with no device around.
let generated: &Result<Generated, GenError> =
use_memo(cx, (graph.topology_rev, epoch), || codegen::generate(graph));
// The last program that compiled. A memo cannot do this job: it recomputes
// when its deps change and has no notion of keeping the previous value
// when the new one is an error.
let mut program = use_state(cx, || None::<Program>);
if let Ok(next) = generated
&& program.as_ref().map(|p| p.hash) != Some(next.hash)
{
*program = Some(Program {
wgsl: Arc::from(next.wgsl.as_str()),
hash: next.hash,
slots: next.slots.clone(),
});
}
let current = program.clone();
let slots = current
.as_ref()
.map(|p| p.slots.clone())
.unwrap_or_default();
// Stage two: the numbers. This is the memo a slider moves, and it makes no
// string and compiles nothing.
let params: &[[f32; 4]; codegen::SLOTS] =
use_memo(cx, (graph.param_rev, graph.topology_rev, epoch), || {
codegen::pack_params(graph, &slots)
});
let error = generated.as_ref().err().map(|err| err.to_string());
let mut selected = use_state(cx, || None::<NodeId>);
let picked = *selected;
// Whether the picture is moving. egui's clock only advances while
// something asks for a repaint, which is what makes this work.
rsx! {
// The canvas takes what the bar leaves. `h={0}` beside `grow`: a
// canvas reports the whole window as the size it could fill, and
// without it the bar would be pushed off the bottom (plan.md
// section 8).
<PatchCanvas
grow={1.0}
h={0.0}
w="100%"
graph={graph}
camera={camera}
selected={&picked}
program={¤t}
params={params}
time={time}
on_select={|node: NodeId| *selected = Some(node)}
on_camera={|next: Camera| on_camera.emit(next)}
on_size={|rect: egui::Rect| on_size.emit(rect)}
on_bounds={|covered: egui::Rect| on_bounds.emit(covered)}
/>
// The knobs, floating over the picture in the corner. An `<Overlay>`
// rather than a column of the layout: the canvas is the whole screen
// and the panel is on top of it, which is what a shader editor looks
// like and what leaves the picture uncropped.
// Why the picture stopped following the patch, if it has. Over the
// canvas rather than beside it: everything a node needs is drawn in
// the node, and this is the one thing that belongs to no node.
<Message text={&error}/>
// How full the patch is, small and unbacked in the other corner: a
// number to glance at, not a control.
<NodeCount count={graph.nodes.len()}/>
}
}
/// The patch canvas: the one place this example leaves the layout engine.
///
/// A leaf, because absolute positions are what a node graph is and taffy has
/// no opinion about them. Inside, for every node: a child `Ui` clipped to that
/// node's rectangle, a `Cx` built around it, and `cx.scope(node.id, ..)` to key
/// the node's hooks by the node rather than by where it is in the list.
#[component]
fn PatchCanvas(
cx: &mut Cx,
#[prop(default)] style: ItemStyle,
graph: &Graph,
camera: Camera,
// A real `Option`, so `&Option<T>`: a bare `Option<T>` prop is the
// *optional* kind, whose setter takes the inner value (board 8.5).
selected: &Option<NodeId>,
// The picture drawn behind the patch, and what it is drawn with.
program: &Option<Program>,
params: &[[f32; 4]; codegen::SLOTS],
time: f32,
#[event] on_select: NodeId,
#[event] on_camera: Camera,
#[event] on_size: egui::Rect,
// The rectangle the nodes cover, in patch units, with the heights they
// were drawn at. Reported after every frame, for `Recenter`.
#[event] on_bounds: egui::Rect,
) {
let dnd = use_drag(cx);
let ports = use_port_map(cx);
let actions = use_actions(cx);
let look = look(cx.ctx());
let points_to_pixels = cx.ctx().pixels_per_point();
let program = program.clone();
let params = *params;
// What the tests read, and what a screen reader is told the picture is:
// a painted picture has nothing to say for itself, and the program that
// draws it is the honest description.
let wgsl = program
.as_ref()
.map(|program| String::from(&*program.wgsl))
.unwrap_or_default();
// A drag that ended on a port becomes exactly one message, which is what
// makes it exactly one step of the undo history.
if let Some((from, to)) = dnd.take_drop() {
match (from.port, to.port) {
(Port::Out, Port::In(port)) => send(
&actions,
Msg::Connect {
from: from.node,
to: to.node,
port,
},
),
// The same wire, drawn backwards.
(Port::In(port), Port::Out) => send(
&actions,
Msg::Connect {
from: to.node,
to: from.node,
port,
},
),
_ => {}
}
}
// Where a node is while it is being dragged. This one piece of node state
// belongs to the canvas and not to the node: the rectangle a node is drawn
// in has to be decided *before* the node is drawn, and the offset is part
// of it. The node reports the gesture; the canvas keeps the position, and
// sends one `MoveNode` when the pointer is let go rather than one per
// frame (which would be one undo step per pixel).
let mut drag = use_state(cx, || None::<(NodeId, egui::Vec2)>);
let live = *drag;
let (store, scope) = (cx.store, cx.scope_id());
let carrying = dnd.carrying();
let chosen = *selected;
cx.leaf_fill(&style, move |ui| {
let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
// A `leaf_fill` is measured in a `Ui` with all the room in the world,
// so a rectangle bigger than the window is a measurement rather than
// a canvas, and "fit the view to the patch" would fit the view to it.
let window = ui.ctx().content_rect().size();
if rect.width() <= window.x && rect.height() <= window.y {
on_size.emit(rect);
}
let painter = ui.painter();
painter.rect_filled(rect, 4.0, look.canvas);
// The picture, behind everything: contained, not cropped. `uv` runs
// 0..1 on both axes, so the picture is square and it is drawn in the
// largest square the canvas holds, centred.
let side = rect.width().min(rect.height());
let picture = egui::Rect::from_center_size(rect.center(), egui::Vec2::splat(side));
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "preview")
});
ui.ctx()
.accesskit_node_builder(response.id, |node| node.set_description(wgsl));
if let Some(program) = program {
let resolution = picture.size() * points_to_pixels;
painter.add(egui_wgpu::Callback::new_paint_callback(
picture,
gpu::PatchCallback {
wgsl: program.wgsl.clone(),
source_hash: program.hash,
uniforms: gpu::Uniforms {
time,
pad: 0.0,
resolution: [resolution.x, resolution.y],
params,
},
},
));
}
let painter = ui.painter();
grid(painter, rect, camera, look);
// The nodes go in a layer of their own, right above this one, and the
// layer is what zooms: egui scales its shapes and maps the pointer
// back, so nothing inside a node knows. The same shape as
// `egui::Scene`, by hand, because the zoom here is driven by the
// scroll wheel as well as the pinch and `Scene` reads the wheel as a
// pan.
let to_global = camera.to_global(rect.min);
let layer = egui::LayerId::new(ui.layer_id().order, ui.id().with("patch"));
ui.ctx().set_sublayer(ui.layer_id(), layer);
let mut local = ui.new_child(
egui::UiBuilder::new()
.layer_id(layer)
.max_rect(to_global.inverse() * rect)
.sense(egui::Sense::click_and_drag()),
);
// The clip rect is the canvas, in layer coordinates: it is what keeps
// a node panned past the edge from painting over the canvas's edge.
local.set_clip_rect(to_global.inverse() * rect);
// egui warns in a debug build about a `Ui` whose rectangle does not
// land on whole points, by drawing an orange "Unaligned" over it.
// Inside this layer that is every node at any zoom but 1: the whole
// point of the layer is that patch coordinates are not screen
// coordinates. The warning is off for the layer, and stays on
// everywhere else, because everywhere else it is worth reading. The
// style is inherited, so the nodes below get it too. `Style::debug`
// only exists in a debug build, which is the only build that draws it.
#[cfg(debug_assertions)]
{
local.style_mut().debug.show_unaligned = false;
}
ui.ctx().set_transform_layer(layer, to_global);
let background = local.response();
// The background is the only thing that pans. A node's header is a
// widget on top of it and therefore wins the pointer. `drag_delta` is
// already divided by the zoom, so this is in patch units.
if background.dragged() {
on_camera.emit(Camera {
pan: camera.pan + background.drag_delta(),
..camera
});
}
// The wheel and the pinch both zoom, about the pointer. Only while the
// pointer is over the canvas: the inspector has a scroll area of its
// own.
if let Some(pointer) = ui.input(|i| i.pointer.latest_pos())
&& rect.contains(pointer)
{
let (pinch, scroll) = ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta()));
let factor = pinch * (scroll.y * 0.002).exp();
if factor != 1.0 {
on_camera.emit(camera.zoomed(factor, rect.min, pointer));
}
}
// Everything below draws into the zoomed layer.
let ui = &mut local;
let painter = ui.painter();
// Reserved now, filled at the end: the wires are drawn from positions
// the nodes themselves report, and they belong *under* the nodes.
let wires = painter.add(egui::Shape::Noop);
let origin = rect.min + camera.pan;
let mut covered = egui::Rect::NOTHING;
for node in &graph.nodes {
let mut at = origin + egui::vec2(node.pos[0], node.pos[1]);
if let Some((id, offset)) = live
&& id == node.id
{
at += offset;
}
// The box a node is given. It draws its own frame inside and
// takes only the height it needs; the clip rect above cuts off
// whatever falls outside the canvas. Nothing is culled: a node that
// is not drawn is a node whose hooks are swept, and panning a node
// off the edge should not forget that it was collapsed.
let node_rect = egui::Rect::from_min_size(at, egui::vec2(NODE_W, NODE_MAX_H));
// `id_salt` by the node, so the child `Ui`'s id does not depend on
// where the node is in `graph.nodes`. That is only half of it:
// egui's *auto* ids inside still count from the parent's counter,
// so `Raise` — sent by the first frame of a header drag — would
// still move them. Anything in a node that has to keep its id
// across a reorder (the header, the sockets) names its id itself.
let builder = egui::UiBuilder::new().id_salt(node.id).max_rect(node_rect);
let drawn = ui.scope_builder(builder, |ui| {
let mut cx = Cx::new(store, ui, scope);
// `key={node.id}`, written out. The scope chain of a node's
// hooks is canvas → node id → component, with nothing about
// *where* the node is in it, which is what makes a node's own
// state stay with the node.
cx.scope(node.id, |cx| {
let view = rsx! {
<NodeView
node={node}
selected={chosen == Some(node.id)}
// Whether anything reads this node, so a dangling
// one looks dangling. The node cannot know; the
// canvas has the whole patch in front of it.
used={graph.nodes.iter().any(|other| {
other.inputs.contains(&Some(node.id))
})}
on_select={|| on_select.emit(node.id)}
on_drag={|phase: Drag| match phase {
Drag::Start => {
*drag = Some((node.id, egui::Vec2::ZERO));
send(&actions, Msg::Raise(node.id));
on_select.emit(node.id);
}
Drag::By(delta) => {
if let Some((id, offset)) = drag.as_mut()
&& *id == node.id
{
*offset += delta;
}
}
Drag::End => {
if let Some((id, offset)) = *drag
&& id == node.id
{
send(&actions, Msg::MoveNode {
node: id,
pos: [node.pos[0] + offset.x, node.pos[1] + offset.y],
});
}
*drag = None;
}
}}
/>
};
view.show(cx);
});
});
// What the node took, at its saved position: a drag in progress
// does not move the bounds until it is let go.
covered = covered.union(egui::Rect::from_min_size(
egui::pos2(node.pos[0], node.pos[1]),
egui::vec2(NODE_W, drawn.response.rect.height()),
));
}
on_bounds.emit(covered);
// Every node has now said where its ports are.
let mut shapes = Vec::new();
for node in &graph.nodes {
for (port, from) in node.inputs.iter().enumerate() {
let Some(from) = from else { continue };
let ends = (
ports.at(PortRef {
node: *from,
port: Port::Out,
}),
ports.at(PortRef {
node: node.id,
port: Port::In(port),
}),
);
if let (Some(a), Some(b)) = ends {
shapes.push(wire(a, b, look.wire));
}
}
}
ui.painter().set(wires, egui::Shape::Vec(shapes));
// The wire being dragged is drawn last, on top of everything. The
// pointer is a screen position and the wire is drawn in the layer.
if let (Some(held), Some(pointer)) = (carrying, dnd.pointer())
&& let Some(from) = ports.at(held)
{
ui.painter()
.add(wire(from, to_global.inverse() * pointer, look.accent));
}
// The background answers to the pointer over the whole canvas, not
// just where the nodes happen to be.
ui.expand_to_include_rect(to_global.inverse() * rect);
});
}
/// One node: a header that is the drag handle, a row of sockets on the node's
/// two edges, and the controls for whatever kind of node it is.
///
/// `collapsed` and `hovered` belong to *this node*. Nothing above it knows
/// they exist, nothing has to make room for them when a node is added, and
/// nothing has to clean up after them when one is deleted — the pass-end sweep
/// does that.
#[component]
fn NodeView(
cx: &mut Cx,
node: &Node,
selected: bool,
used: bool,
#[event] on_select: (),
#[event] on_drag: Drag,
) {
let actions = use_actions(cx);
let look = look(cx.ctx());
let mut collapsed = use_state(cx, || false);
let mut hovered = use_state(cx, || None::<Port>);
let open = !*collapsed;
// The drag handle is one line of text tall, plus a little air. Taffy needs
// the number before the strip is drawn, so it is asked for here.
let head_h = cx.ui().text_style_height(&egui::TextStyle::Body) + 4.0;
// What the pointer is over, in words. Node-local state doing a job: the
// node is the only thing that knows which of its own ports is hot.
let hint = match *hovered {
Some(Port::Out) => String::from("out: drag to an input"),
Some(Port::In(port)) if node.inputs[port].is_some() => {
format!("{}: click to unplug", node.kind.input_label(port))
}
Some(Port::In(port)) => format!("{}: black", node.kind.input_label(port)),
None => String::new(),
};
rsx! {
// The node's box is the view's own paint: no painted wrapper and no
// leaf between the tree and the rows. Only `py` here — the sockets
// have to reach the node's edges, so the horizontal padding goes on
// the rows that are not the port row.
<View
direction="column"
w="100%"
gap={4}
py={6}
bg={look.node}
border={egui::Stroke::new(1.0, if selected { look.accent } else { look.edge })}
radius={6.0}
>
<View direction="row" w="100%" gap={4} align="center" px={6}>
// The header strip is the drag handle, and one leaf is
// all a drag needs. `<Text>` would draw the same thing
// and hand back no `Response` (board 8.4).
//
// `leaf_fill`, so the strip is the whole width taffy
// gives it rather than the width of the name: a node is
// grabbed by its header, not by its title.
{view(|cx| {
let response = cx.leaf_fill(
&ItemStyle::default().grow(1.0).min_w(0.0).h(head_h),
|ui| {
// A named id, not an auto one: a drag lives
// as long as the widget keeps its id, and the
// first frame of this drag reorders the nodes
// (see `PatchCanvas`).
let (rect, _) = ui.allocate_exact_size(
ui.available_size(),
egui::Sense::hover(),
);
let response = ui.interact(
rect,
ui.id().with("header"),
egui::Sense::click_and_drag(),
);
ui.painter().rect_filled(
rect,
3.0,
look.edge.gamma_multiply(0.35),
);
let galley = egui::WidgetText::from(
egui::RichText::new(node.name.as_str()).strong(),
)
.into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
(rect.width() - 8.0).max(0.0),
egui::TextStyle::Body,
);
let at = egui::pos2(
rect.left() + 4.0,
rect.center().y - galley.size().y * 0.5,
);
ui.painter().galley(at, galley, ui.visuals().text_color());
// A painted title is not a widget, so the
// name has to be said out loud — it is what
// a screen reader, and every test here,
// looks the node up by.
response.widget_info(|| {
egui::WidgetInfo::labeled(
egui::WidgetType::Button,
ui.is_enabled(),
node.name.as_str(),
)
});
// What the pointer says it can do, and then
// that it is doing it.
if response.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
response
} else {
response.on_hover_cursor(egui::CursorIcon::Grab)
}
},
);
if response.drag_started() {
on_drag.emit(Drag::Start);
}
// Only when it really moved: a write on every
// frame of a held pointer is a repaint on every
// frame of it.
if response.dragged() && response.drag_delta() != egui::Vec2::ZERO {
on_drag.emit(Drag::By(response.drag_delta()));
}
if response.drag_stopped() {
on_drag.emit(Drag::End);
}
if response.clicked() {
on_select.emit(());
}
})}
<SmallButton
label={format!("collapse {}", node.name).as_str()}
on_click={|| *collapsed = !*collapsed}
>
{if open { "-" } else { "+" }}
</SmallButton>
</View>
// The sockets sit on the node's own edges, where the wires
// meet them: inputs down the left, the output on the right.
<View direction="row" w="100%" justify="space-between" align="start">
// Enough room between the rows that two sockets are two
// targets: the area that answers to the pointer is wider
// than the circle.
<View direction="column" gap={6}>
for port in 0..node.kind.inputs() {
<View key={port} direction="row" gap={4} align="center">
<PortDot
node={node.id}
port={Port::In(port)}
side={Side::Left}
label={format!("{} in {port}", node.name).as_str()}
connected={node.inputs[port].is_some()}
on_hover={|over: bool| {
set_hover(&mut hovered, Port::In(port), over)
}}
/>
<Text size={10.0}>{node.kind.input_label(port)}</Text>
</View>
}
</View>
// The output has none: it is the end of the chain.
if node.kind != Kind::Output {
<View direction="row" gap={4} align="center">
<Text size={10.0}>"out"</Text>
<PortDot
node={node.id}
port={Port::Out}
side={Side::Right}
label={format!("{} out", node.name).as_str()}
connected={used}
on_hover={|over: bool| set_hover(&mut hovered, Port::Out, over)}
/>
</View>
}
</View>
if open {
<View direction="column" w="100%" gap={4} px={6}>
<NodeBody node={node}/>
<View direction="row" w="100%" gap={4} align="center">
<Text grow={1.0} size={10.0}>{node.kind.name()}</Text>
if node.kind != Kind::Output {
<SmallButton
label={format!("delete {}", node.name).as_str()}
on_click={|| send(&actions, Msg::RemoveNode(node.id))}
>"x"</SmallButton>
}
</View>
</View>
}
if !hint.is_empty() {
<Text size={10.0} px={6} color={look.accent}>{hint.as_str()}</Text>
}
</View>
}
}
/// Remember which port the pointer is over, and only when the answer changed.
///
/// Every port reports on every frame, so writing unconditionally would dirty
/// the node's state sixty times a second (ARCHITECTURE 5.6).
fn set_hover(hovered: &mut State<'_, Option<Port>>, port: Port, over: bool) {
if over {
if **hovered != Some(port) {
**hovered = Some(port);
}
} else if **hovered == Some(port) {
**hovered = None;
}
}
/// One socket: a circle to paint, a rectangle to drag from, and a rectangle to
/// drop on.
///
/// It reads the drag session and the port map out of the context rather than
/// being handed them, because every node has two or three of these and the
/// answer is the same for all of them.
#[component]
fn PortDot(
cx: &mut Cx,
node: NodeId,
port: Port,
// Which edge the circle straddles, which is what makes a wire arrive at
// the node instead of somewhere inside it.
side: Side,
// What a screen reader — and a test — calls this socket. A painted circle
// has no name of its own, so it is given one.
label: &str,
connected: bool,
#[event] on_hover: bool,
) {
let dnd = use_drag(cx);
let ports = use_port_map(cx);
let actions = use_actions(cx);
let look = look(cx.ctx());
let dragging = dnd.carrying().is_some();
let (centre, hit, response, layer) = cx.leaf(&ItemStyle::default().shrink(0.0), |ui| {
// The layout keeps `PORT` square of room, but the circle is drawn half
// outside it: its middle is on the node's edge, so a wire ends where
// the node does. Only the canvas clips, so painting past the node's
// own rectangle is allowed.
let (rect, _) = ui.allocate_exact_size(egui::Vec2::splat(PORT), egui::Sense::hover());
let centre = match side {
Side::Left => rect.left_center(),
Side::Right => rect.right_center(),
};
// The circle is small and a pointer is not precise, so what answers to
// the pointer is a square around the circle rather than the space the
// layout gave it.
let hit = egui::Rect::from_center_size(centre, egui::Vec2::splat(PORT)).expand(PORT_PAD);
// Named for the same reason as the header: the socket's id must not
// depend on where its node is in the list.
let response = ui.interact(hit, ui.id().with(port), egui::Sense::click_and_drag());
let hot = response.hovered() || (dragging && response.contains_pointer());
let fill = match (connected, hot) {
(_, true) => look.accent,
(true, false) => look.wire,
(false, false) => look.canvas,
};
ui.painter()
.circle(centre, PORT * 0.35, fill, egui::Stroke::new(1.0, look.wire));
// Nothing about a painted circle reaches the accessibility tree unless
// it is said out loud.
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)
});
(centre, hit, response, ui.layer_id())
});
// Where the wires are drawn from, this frame.
ports.put(PortRef { node, port }, centre);
// Only worth offering during a drag; `slot` checks that for itself. The
// session compares against the screen pointer, and this rectangle is in
// the zoomed layer, so it is mapped out first.
let to_global = cx
.ctx()
.layer_transform_to_global(layer)
.unwrap_or(egui::emath::TSTransform::IDENTITY);
dnd.slot(to_global * hit, PortRef { node, port });
if response.drag_started() {
dnd.pick_up(PortRef { node, port });
}
if response.clicked()
&& let Port::In(index) = port
&& connected
{
send(
&actions,
Msg::Disconnect {
to: node,
port: index,
},
);
}
on_hover.emit(response.hovered());
}
/// The controls inside a node, by kind.
///
/// The `match` is the polymorphism: every kind has its own component, and a
/// node's box is where its parameters are edited. There is no panel of them
/// somewhere else to keep in step.
#[component]
fn NodeBody(cx: &mut Cx, node: &Node) {
rsx! {
match &node.kind {
Kind::Shader { src } => { <ShaderParams node={node} src={src.as_str()}/> }
Kind::Level => { <LevelParams node={node}/> }
Kind::Hsv => { <HsvParams node={node}/> }
Kind::Transform => { <TransformParams node={node}/> }
Kind::Mix { mode } => { <MixParams node={node} mode={*mode}/> }
Kind::Invert => { <InvertParams node={node}/> }
Kind::Posterize => { <PosterizeParams node={node}/> }
Kind::Pixelate => { <PixelateParams node={node}/> }
Kind::Tile => { <TileParams node={node}/> }
// Nothing to slide: the method is the whole node.
Kind::Grayscale { method } => { <GrayParams node={node} method={*method}/> }
Kind::Output => { <Text size={10.0}>"the picture"</Text> }
}
}
}
#[component]
fn ShaderParams(cx: &mut Cx, node: &Node, src: &str) {
let actions = use_actions(cx);
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<SourceEdit
src={src}
rows={3}
on_change={|next: String| send(&actions, Msg::SetShaderSrc { node: id, src: next })}
/>
<Text size={10.0}>"uv, t, p0, p1, p2, res"</Text>
<Knob node={id} owner={node.name.as_str()} index={0} label="p0" value={node.params[0]} range={0.0..=4.0}/>
<Knob node={id} owner={node.name.as_str()} index={1} label="p1" value={node.params[1]} range={0.0..=4.0}/>
<Knob node={id} owner={node.name.as_str()} index={2} label="p2" value={node.params[2]} range={0.0..=1.0}/>
</View>
}
}
#[component]
fn LevelParams(cx: &mut Cx, node: &Node) {
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<Knob node={id} owner={node.name.as_str()} index={0} label="bright" value={node.params[0]} range={-1.0..=1.0}/>
<Knob node={id} owner={node.name.as_str()} index={1} label="contrast" value={node.params[1]} range={0.0..=4.0}/>
<Knob node={id} owner={node.name.as_str()} index={2} label="gamma" value={node.params[2]} range={0.1..=4.0}/>
</View>
}
}
#[component]
fn HsvParams(cx: &mut Cx, node: &Node) {
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<Knob node={id} owner={node.name.as_str()} index={0} label="hue" value={node.params[0]} range={0.0..=1.0}/>
<Knob node={id} owner={node.name.as_str()} index={1} label="sat" value={node.params[1]} range={0.0..=2.0}/>
<Knob node={id} owner={node.name.as_str()} index={2} label="val" value={node.params[2]} range={0.0..=2.0}/>
</View>
}
}
#[component]
fn TransformParams(cx: &mut Cx, node: &Node) {
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<Knob node={id} owner={node.name.as_str()} index={0} label="x" value={node.params[0]} range={-1.0..=1.0}/>
<Knob node={id} owner={node.name.as_str()} index={1} label="y" value={node.params[1]} range={-1.0..=1.0}/>
<Knob node={id} owner={node.name.as_str()} index={2} label="turn" value={node.params[2]} range={-3.15..=3.15}/>
<Knob node={id} owner={node.name.as_str()} index={3} label="scale" value={node.params[3]} range={0.1..=4.0}/>
</View>
}
}
#[component]
fn MixParams(cx: &mut Cx, node: &Node, mode: MixMode) {
let actions = use_actions(cx);
let id = node.id;
let names: Vec<&str> = MixMode::ALL.iter().map(|mode| mode.name()).collect();
let mut index = MixMode::ALL.iter().position(|m| *m == mode).unwrap_or(0);
rsx! {
<View direction="column" w="100%" gap={2}>
// The mode is baked into the shader, so picking another one is a
// recompile — the same message class as moving a wire.
<ComboBox
bind={&mut index}
options={&names}
on_change={|picked: usize| {
send(&actions, Msg::SetMode { node: id, mode: MixMode::ALL[picked] });
}}
/>
<Knob node={id} owner={node.name.as_str()} index={0} label="amount" value={node.params[0]} range={0.0..=1.0}/>
</View>
}
}
#[component]
fn InvertParams(cx: &mut Cx, node: &Node) {
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<Knob node={id} owner={node.name.as_str()} index={0} label="amount" value={node.params[0]} range={0.0..=1.0}/>
</View>
}
}
#[component]
fn PosterizeParams(cx: &mut Cx, node: &Node) {
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<Knob node={id} owner={node.name.as_str()} index={0} label="levels" value={node.params[0]} range={2.0..=16.0}/>
</View>
}
}
#[component]
fn PixelateParams(cx: &mut Cx, node: &Node) {
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<Knob node={id} owner={node.name.as_str()} index={0} label="cells" value={node.params[0]} range={2.0..=128.0}/>
</View>
}
}
#[component]
fn TileParams(cx: &mut Cx, node: &Node) {
let id = node.id;
rsx! {
<View direction="column" w="100%" gap={2}>
<Knob node={id} owner={node.name.as_str()} index={0} label="x" value={node.params[0]} range={1.0..=8.0}/>
<Knob node={id} owner={node.name.as_str()} index={1} label="y" value={node.params[1]} range={1.0..=8.0}/>
</View>
}
}
#[component]
fn GrayParams(cx: &mut Cx, node: &Node, method: GrayMethod) {
let actions = use_actions(cx);
let id = node.id;
let names: Vec<&str> = GrayMethod::ALL.iter().map(|method| method.name()).collect();
let mut index = GrayMethod::ALL
.iter()
.position(|m| *m == method)
.unwrap_or(0);
rsx! {
<ComboBox
bind={&mut index}
options={&names}
on_change={|picked: usize| {
send(&actions, Msg::SetGray { node: id, method: GrayMethod::ALL[picked] });
}}
/>
}
}
/// One parameter: a drag value, and a `SetParam` when it moved.
///
/// The escape hatch rather than `<Slider>`, because the value being edited
/// lives in the reducer: a bound element would need a `&mut f32` that no one
/// here owns. The widget writes into a copy and the copy becomes a message.
#[component]
#[allow(clippy::too_many_arguments)]
fn Knob(
cx: &mut Cx,
node: NodeId,
// The node's name, which is what tells eight "bright" knobs apart for
// anyone who cannot see which box this one is in.
owner: &str,
index: usize,
label: &str,
value: f32,
range: std::ops::RangeInclusive<f32>,
) {
let actions = use_actions(cx);
let mut current = value;
let changed = cx.leaf(&ItemStyle::default().w("100%"), |ui| {
// A taffy leaf is measured from its first draw, which happens in a
// zero-width `Ui`; a widget left to wrap would report one character
// wide and stay that way (ARCHITECTURE 6).
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
let response = ui.add(
egui::DragValue::new(&mut current)
.range(range)
.speed(0.01)
.prefix(format!("{label} ")),
);
// The name on screen is the prefix, which is not a label; the tree is
// told the node's name as well, because every level node has a
// "bright".
response.widget_info(|| {
egui::WidgetInfo::labeled(
egui::WidgetType::DragValue,
ui.is_enabled(),
format!("{owner} {label}"),
)
});
response.changed()
});
if changed {
send(
&actions,
Msg::SetParam {
node,
index,
value: current,
},
);
}
}
/// The WGSL expression of a `Shader` node.
///
/// Hand-written rather than `<TextEdit>` because this one has to *report* the
/// text: the element's `bind` holds the only `&mut` to the string, so a
/// handler on the same element could not read it as well (ARCHITECTURE 3.7).
/// The buffer is a copy made each frame, which is what an immediate-mode text
/// field is happy with.
#[component]
fn SourceEdit(cx: &mut Cx, src: &str, rows: usize, #[event] on_change: String) {
let mut text = src.to_owned();
let changed = cx.leaf(&ItemStyle::default().w("100%"), |ui| {
ui.add(
egui::TextEdit::multiline(&mut text)
.desired_rows(rows)
.desired_width(ui.available_width())
.font(egui::TextStyle::Monospace),
)
.changed()
});
if changed {
// Every keystroke regenerates and revalidates the program, which is
// the point: type a bad expression and the message appears at once,
// fix it and the picture comes back.
on_change.emit(text);
}
}
/// The validation message, over the canvas's top left corner.
///
/// naga's complaint about the generated program, or the editor's about a
/// cycle. It belongs to the patch as a whole rather than to any node, so it
/// is written on the canvas rather than in a box beside it, and it is not
/// drawn at all when there is nothing wrong.
#[component]
fn Message(cx: &mut Cx, text: &Option<String>) {
let Some(text) = text else {
return;
};
let look = look(cx.ctx());
rsx! {
<Overlay anchor="top-left" offset={(COUNT_GAP, COUNT_GAP)}>
<Text w={MESSAGE_W} wrap size={11.0} color={look.warn}>{text.as_str()}</Text>
</Overlay>
}
}
/// How many nodes the patch has, over the canvas's bottom right corner.
///
/// An `<Overlay>` with no fill: it is written on the picture rather than in a
/// box of its own, and it takes no room from the canvas. Anchored to the
/// window, whose bottom right corner is the canvas's own — the canvas is the
/// last thing in the column and the root's padding is the gap.
#[component]
fn NodeCount(cx: &mut Cx, count: usize) {
rsx! {
<Overlay anchor="bottom-right" offset={(-COUNT_GAP, -COUNT_GAP)}>
<Text size={11.0}>{format!("{count} / {} nodes", graph::MAX_NODES)}</Text>
</Overlay>
}
}
/// A small flat button, for the things a node does to itself.
#[component]
fn SmallButton(
cx: &mut Cx,
#[prop(default)] style: ItemStyle,
// The name of the button, which is not the glyph on it: every node has a
// "-" and an "x", and "x" tells a screen reader nothing.
label: &str,
#[event] on_click: (),
children: impl Into<egui::WidgetText>,
) {
let clicked = cx.leaf(&style.shrink(0.0), |ui| {
let button = egui::Button::new(children)
.small()
.frame(false)
.wrap_mode(egui::TextWrapMode::Extend);
let response = ui.add(button);
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)
});
response.clicked()
});
if clicked {
on_click.emit(());
}
}
/// The colours the canvas paints with, taken from egui's own visuals so the
/// example follows whatever theme the gallery is in.
#[derive(Clone, Copy)]
struct Look {
accent: egui::Color32,
canvas: egui::Color32,
node: egui::Color32,
edge: egui::Color32,
wire: egui::Color32,
warn: egui::Color32,
}
fn look(ctx: &egui::Context) -> Look {
let visuals = &ctx.style_of(ctx.theme()).visuals;
Look {
accent: if visuals.dark_mode {
egui::Color32::from_rgb(0x7f, 0xd1, 0xb9)
} else {
egui::Color32::from_rgb(0x0f, 0x7a, 0x63)
},
canvas: visuals.extreme_bg_color,
node: visuals.widgets.inactive.bg_fill,
edge: visuals.widgets.noninteractive.bg_stroke.color,
wire: visuals.widgets.active.bg_fill,
warn: visuals.error_fg_color,
}
}
/// The dots behind the patch, so panning is visible.
fn grid(painter: &egui::Painter, rect: egui::Rect, camera: Camera, look: Look) {
// Drawn on the canvas, not in the zoomed layer, so the lines stay one
// pixel wide; the spacing and the offset are what zoom.
let step = 32.0 * camera.zoom;
let stroke = egui::Stroke::new(1.0, look.edge.gamma_multiply(0.5));
let start = |offset: f32| (offset * camera.zoom).rem_euclid(step);
let mut x = rect.left() + start(camera.pan.x);
while x < rect.right() {
painter.vline(x, rect.y_range(), stroke);
x += step;
}
let mut y = rect.top() + start(camera.pan.y);
while y < rect.bottom() {
painter.hline(rect.x_range(), y, stroke);
y += step;
}
}
/// A wire, as a cubic curve that leaves an output to the right and arrives at
/// an input from the left.
fn wire(from: egui::Pos2, to: egui::Pos2, color: egui::Color32) -> egui::Shape {
let reach = ((to.x - from.x).abs() * 0.5).clamp(24.0, 90.0);
egui::Shape::CubicBezier(egui::epaint::CubicBezierShape::from_points_stroke(
[
from,
from + egui::vec2(reach, 0.0),
to - egui::vec2(reach, 0.0),
to,
],
false,
egui::Color32::TRANSPARENT,
egui::Stroke::new(1.5, color),
))
}