feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "llimphi-widget-nodegraph"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-widget-nodegraph — lienzo de nodos con pins y cables Bezier. Reusable por pluma (DAG), nakui (fórmulas yupay), tullpu (ajustes no destructivos), dominium (sistemas), takiy (cadena de audio)."
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "nodegraph_demo"
|
||||
path = "examples/nodegraph_demo.rs"
|
||||
@@ -0,0 +1,5 @@
|
||||
# llimphi-widget-nodegraph
|
||||
|
||||
> Lienzo de nodos + cables Bezier para [llimphi](../../README.md).
|
||||
|
||||
Cada nodo es `View<Msg>` libre con puertos in/out. Aristas curvas Bezier. Drag de nodos, pan/zoom del canvas, conectar puertos. Usado por `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`.
|
||||
@@ -0,0 +1,5 @@
|
||||
# llimphi-widget-nodegraph
|
||||
|
||||
> Node canvas + Bezier wires for [llimphi](../../README.md).
|
||||
|
||||
Each node is a free `View<Msg>` with in/out ports. Bezier curve edges. Node drag, canvas pan/zoom, port connect. Used by `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`.
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Showcase de `llimphi-widget-nodegraph`. Cuatro nodos pre-conectados
|
||||
//! representando una cadena de audio (`Source → Filter → Mixer →
|
||||
//! Output`) y un `LFO` huérfano para que el usuario lo conecte
|
||||
//! arrastrando desde su pin de salida hasta el `mod` del filtro.
|
||||
//!
|
||||
//! - Arrastrá la title bar de cualquier nodo para moverlo.
|
||||
//! - Arrastrá desde un pin de salida (lado derecho) y soltá sobre un
|
||||
//! pin de entrada (lado izquierdo) de otro nodo para conectar.
|
||||
//!
|
||||
//! Corré con: `cargo run -p llimphi-widget-nodegraph --example
|
||||
//! nodegraph_demo --release`.
|
||||
|
||||
use llimphi_theme::Theme;
|
||||
use llimphi_ui::{App, DragPhase, Handle, View};
|
||||
use llimphi_widget_nodegraph::{
|
||||
nodegraph_view, NodeId, NodeSpec, NodegraphMetrics, NodegraphPalette, PinIdx, Wire,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Msg {
|
||||
DragNode {
|
||||
id: NodeId,
|
||||
// El demo no diferencia Move/End; lo dejamos en el Msg por si
|
||||
// un caller real quiere persistir layout solo en End.
|
||||
#[allow(dead_code)]
|
||||
phase: DragPhase,
|
||||
dx: f32,
|
||||
dy: f32,
|
||||
},
|
||||
Connect {
|
||||
from_node: NodeId,
|
||||
from_pin: PinIdx,
|
||||
to_node: NodeId,
|
||||
to_pin: PinIdx,
|
||||
},
|
||||
}
|
||||
|
||||
struct Model {
|
||||
nodes: Vec<NodeSpec>,
|
||||
wires: Vec<Wire>,
|
||||
}
|
||||
|
||||
const ID_SOURCE: NodeId = 1;
|
||||
const ID_FILTER: NodeId = 2;
|
||||
const ID_MIXER: NodeId = 3;
|
||||
const ID_OUTPUT: NodeId = 4;
|
||||
const ID_LFO: NodeId = 5;
|
||||
|
||||
struct Showcase;
|
||||
|
||||
impl App for Showcase {
|
||||
type Model = Model;
|
||||
type Msg = Msg;
|
||||
|
||||
fn title() -> &'static str {
|
||||
"llimphi · nodegraph showcase (drag títulos, arrastrá pin → pin)"
|
||||
}
|
||||
|
||||
fn initial_size() -> (u32, u32) {
|
||||
(1100, 720)
|
||||
}
|
||||
|
||||
fn init(_: &Handle<Msg>) -> Model {
|
||||
Model {
|
||||
nodes: vec![
|
||||
NodeSpec {
|
||||
id: ID_SOURCE,
|
||||
label: "Source".into(),
|
||||
x: 60.0,
|
||||
y: 80.0,
|
||||
inputs: vec![],
|
||||
outputs: vec!["out".into()],
|
||||
},
|
||||
NodeSpec {
|
||||
id: ID_FILTER,
|
||||
label: "Filter".into(),
|
||||
x: 290.0,
|
||||
y: 80.0,
|
||||
inputs: vec!["in".into(), "mod".into()],
|
||||
outputs: vec!["out".into()],
|
||||
},
|
||||
NodeSpec {
|
||||
id: ID_MIXER,
|
||||
label: "Mixer".into(),
|
||||
x: 520.0,
|
||||
y: 80.0,
|
||||
inputs: vec!["a".into(), "b".into()],
|
||||
outputs: vec!["out".into()],
|
||||
},
|
||||
NodeSpec {
|
||||
id: ID_OUTPUT,
|
||||
label: "Output".into(),
|
||||
x: 750.0,
|
||||
y: 80.0,
|
||||
inputs: vec!["in".into()],
|
||||
outputs: vec![],
|
||||
},
|
||||
NodeSpec {
|
||||
id: ID_LFO,
|
||||
label: "LFO".into(),
|
||||
x: 290.0,
|
||||
y: 260.0,
|
||||
inputs: vec![],
|
||||
outputs: vec!["out".into()],
|
||||
},
|
||||
],
|
||||
wires: vec![
|
||||
Wire {
|
||||
from_node: ID_SOURCE,
|
||||
from_output: 0,
|
||||
to_node: ID_FILTER,
|
||||
to_input: 0,
|
||||
},
|
||||
Wire {
|
||||
from_node: ID_FILTER,
|
||||
from_output: 0,
|
||||
to_node: ID_MIXER,
|
||||
to_input: 0,
|
||||
},
|
||||
Wire {
|
||||
from_node: ID_MIXER,
|
||||
from_output: 0,
|
||||
to_node: ID_OUTPUT,
|
||||
to_input: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
|
||||
let mut m = model;
|
||||
match msg {
|
||||
Msg::DragNode { id, phase: _, dx, dy } => {
|
||||
if let Some(n) = m.nodes.iter_mut().find(|n| n.id == id) {
|
||||
n.x += dx;
|
||||
n.y += dy;
|
||||
if n.x < 0.0 {
|
||||
n.x = 0.0;
|
||||
}
|
||||
if n.y < 0.0 {
|
||||
n.y = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Msg::Connect {
|
||||
from_node,
|
||||
from_pin,
|
||||
to_node,
|
||||
to_pin,
|
||||
} => {
|
||||
if from_node == to_node {
|
||||
return m;
|
||||
}
|
||||
let exists = m.wires.iter().any(|w| {
|
||||
w.from_node == from_node
|
||||
&& w.from_output == from_pin
|
||||
&& w.to_node == to_node
|
||||
&& w.to_input == to_pin
|
||||
});
|
||||
if !exists {
|
||||
m.wires.push(Wire {
|
||||
from_node,
|
||||
from_output: from_pin,
|
||||
to_node,
|
||||
to_input: to_pin,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn view(model: &Model) -> View<Msg> {
|
||||
let theme = Theme::dark();
|
||||
let palette = NodegraphPalette::from_theme(&theme);
|
||||
let metrics = NodegraphMetrics::default();
|
||||
nodegraph_view(
|
||||
&model.nodes,
|
||||
&model.wires,
|
||||
&palette,
|
||||
&metrics,
|
||||
|id, phase, dx, dy| Some(Msg::DragNode { id, phase, dx, dy }),
|
||||
|from_node, from_pin, to_node, to_pin| {
|
||||
Some(Msg::Connect {
|
||||
from_node,
|
||||
from_pin,
|
||||
to_node,
|
||||
to_pin,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
llimphi_ui::run::<Showcase>();
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
//! `llimphi-widget-nodegraph` — lienzo de nodos con pins y cables
|
||||
//! Bezier sobre Llimphi.
|
||||
//!
|
||||
//! Modelo declarativo de un grafo dirigido: cada frame, el caller pasa
|
||||
//! la lista actual de [`NodeSpec`]s + [`Wire`]s y el widget pinta:
|
||||
//!
|
||||
//! - el lienzo (fondo lleno);
|
||||
//! - cada nodo como un rect con título arriba y pins a los lados
|
||||
//! (entradas a la izquierda, salidas a la derecha);
|
||||
//! - los cables entre `(node_a, output_pin_a)` y `(node_b, input_pin_b)`
|
||||
//! como Bezier cúbicas con tangentes horizontales (mismo look que
|
||||
//! `pluma-editor-llimphi::multilienzo_editor::carril_editor`).
|
||||
//!
|
||||
//! El widget no mantiene estado: el caller acumula posición de nodos +
|
||||
//! cables en su `Model` y le pasa handlers para los dos eventos
|
||||
//! interactivos:
|
||||
//!
|
||||
//! - **mover un nodo** — `on_drag_node(node_id, phase, dx, dy)` se
|
||||
//! invoca al arrastrar la title bar de un nodo. El handler suma el
|
||||
//! delta a la posición persistida.
|
||||
//! - **conectar dos pins** — al arrastrar desde un pin de salida y
|
||||
//! soltar sobre un pin de entrada, `on_connect(from_node, from_out,
|
||||
//! to_node, to_in)` se invoca para que el caller materialice el
|
||||
//! `Wire` en su modelo.
|
||||
//!
|
||||
//! Reusable por: pluma (visualizador DAG), nakui (fórmulas yupay),
|
||||
//! tullpu (ajustes no destructivos), dominium (sistemas), takiy
|
||||
//! (cadena de audio), pluma-notebook (kernel-DAG visual).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, Dimension, Position, Size, Style},
|
||||
Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::{DragPhase, View};
|
||||
|
||||
/// Identificador opaco de un nodo. El caller asigna estos valores; el
|
||||
/// widget los pasa de vuelta sin interpretarlos.
|
||||
pub type NodeId = u32;
|
||||
/// Índice del pin dentro de la lista `inputs` o `outputs` del nodo.
|
||||
pub type PinIdx = u16;
|
||||
|
||||
/// Especificación de un nodo del grafo. El caller construye uno por
|
||||
/// nodo en cada `view`. Las posiciones son en pixels relativas al rect
|
||||
/// del lienzo.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeSpec {
|
||||
pub id: NodeId,
|
||||
pub label: String,
|
||||
/// Esquina superior-izquierda del nodo, en coordenadas del lienzo.
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Labels de los pins de entrada. Cantidad = altura mínima del nodo.
|
||||
pub inputs: Vec<String>,
|
||||
/// Labels de los pins de salida.
|
||||
pub outputs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Cable entre el pin de salida de un nodo y el pin de entrada de otro.
|
||||
/// El widget no valida ciclos ni direcciones — esa política vive en el
|
||||
/// caller.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Wire {
|
||||
pub from_node: NodeId,
|
||||
pub from_output: PinIdx,
|
||||
pub to_node: NodeId,
|
||||
pub to_input: PinIdx,
|
||||
}
|
||||
|
||||
/// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe
|
||||
/// el color correspondiente de la paleta global para *ese* nodo; los
|
||||
/// `None` heredan la paleta. Sirve para que el caller marque un subgrafo
|
||||
/// (p.ej. el cono afectado por un morfismo) sin tocar el resto.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NodeTint {
|
||||
pub bg_node: Option<Color>,
|
||||
pub bg_title: Option<Color>,
|
||||
pub fg_title: Option<Color>,
|
||||
}
|
||||
|
||||
/// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct NodegraphPalette {
|
||||
pub bg_canvas: Color,
|
||||
pub bg_node: Color,
|
||||
pub bg_title: Color,
|
||||
pub fg_title: Color,
|
||||
pub fg_pin_label: Color,
|
||||
pub pin_input: Color,
|
||||
pub pin_output: Color,
|
||||
pub pin_drop_hover: Color,
|
||||
pub wire: Color,
|
||||
pub border: Color,
|
||||
}
|
||||
|
||||
impl Default for NodegraphPalette {
|
||||
fn default() -> Self {
|
||||
Self::from_theme(&llimphi_theme::Theme::dark())
|
||||
}
|
||||
}
|
||||
|
||||
impl NodegraphPalette {
|
||||
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
|
||||
Self {
|
||||
bg_canvas: t.bg_app,
|
||||
bg_node: t.bg_panel,
|
||||
bg_title: t.bg_panel_alt,
|
||||
fg_title: t.fg_text,
|
||||
fg_pin_label: t.fg_muted,
|
||||
pin_input: t.accent,
|
||||
pin_output: t.accent,
|
||||
pin_drop_hover: t.bg_selected,
|
||||
wire: t.accent,
|
||||
border: t.border,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometría del nodo y de los pins.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct NodegraphMetrics {
|
||||
pub node_width: f32,
|
||||
pub title_height: f32,
|
||||
pub pin_row_height: f32,
|
||||
pub pin_radius: f32,
|
||||
pub pin_label_size: f32,
|
||||
pub title_text_size: f32,
|
||||
pub wire_stroke: f32,
|
||||
pub node_radius: f64,
|
||||
}
|
||||
|
||||
impl Default for NodegraphMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
node_width: 160.0,
|
||||
title_height: 22.0,
|
||||
pin_row_height: 18.0,
|
||||
pin_radius: 5.0,
|
||||
pin_label_size: 10.0,
|
||||
title_text_size: 11.0,
|
||||
wire_stroke: 1.6,
|
||||
node_radius: 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodegraphMetrics {
|
||||
/// Alto total del rect que ocupa un nodo con `n_in` entradas y
|
||||
/// `n_out` salidas. El cuerpo crece con el lado que tenga más pins.
|
||||
pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 {
|
||||
let rows = n_in.max(n_out).max(1) as f32;
|
||||
self.title_height + rows * self.pin_row_height + 6.0
|
||||
}
|
||||
|
||||
/// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es
|
||||
/// `(_x, node_y)`. Sirve también para outputs (misma alineación).
|
||||
pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
|
||||
node_y
|
||||
+ self.title_height
|
||||
+ 3.0
|
||||
+ (pin as f32 + 0.5) * self.pin_row_height
|
||||
}
|
||||
|
||||
pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
|
||||
self.input_pin_y(node_y, pin)
|
||||
}
|
||||
}
|
||||
|
||||
type DragNodeFn<Msg> =
|
||||
Arc<dyn Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
|
||||
type ConnectFn<Msg> = Arc<
|
||||
dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync,
|
||||
>;
|
||||
|
||||
/// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del
|
||||
/// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores =
|
||||
/// pin_idx.
|
||||
#[inline]
|
||||
fn encode_payload(node: NodeId, pin: PinIdx) -> u64 {
|
||||
((node as u64) << 32) | (pin as u64)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn decode_payload(payload: u64) -> (NodeId, PinIdx) {
|
||||
let node = (payload >> 32) as NodeId;
|
||||
let pin = (payload & 0xFFFF) as PinIdx;
|
||||
(node, pin)
|
||||
}
|
||||
|
||||
/// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta
|
||||
/// del cursor cuando el usuario arrastra la title bar de un nodo (las
|
||||
/// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca
|
||||
/// cuando el usuario suelta un cable iniciado en un pin de salida
|
||||
/// sobre un pin de entrada de otro nodo.
|
||||
pub fn nodegraph_view<Msg, FDrag, FConnect>(
|
||||
nodes: &[NodeSpec],
|
||||
wires: &[Wire],
|
||||
palette: &NodegraphPalette,
|
||||
metrics: &NodegraphMetrics,
|
||||
on_drag_node: FDrag,
|
||||
on_connect: FConnect,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + Send + Sync + 'static,
|
||||
FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
FConnect:
|
||||
Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
nodegraph_view_ex::<Msg, FDrag, FConnect, fn(NodeId) -> Option<Msg>>(
|
||||
nodes,
|
||||
wires,
|
||||
palette,
|
||||
metrics,
|
||||
on_drag_node,
|
||||
on_connect,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Variante extendida con un handler opcional de click derecho sobre
|
||||
/// la title bar de cada nodo. Permite a la app montar acciones por-nodo
|
||||
/// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar
|
||||
/// este nodo" en un editor de cadena de audio) sin esperar a que el
|
||||
/// widget tenga un menú contextual propio.
|
||||
///
|
||||
/// `on_right_click_node` se evalúa una vez por nodo al construir la
|
||||
/// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer
|
||||
/// right-click sobre la title bar; `None` deja al nodo sin acción
|
||||
/// contextual.
|
||||
pub fn nodegraph_view_ex<Msg, FDrag, FConnect, FRight>(
|
||||
nodes: &[NodeSpec],
|
||||
wires: &[Wire],
|
||||
palette: &NodegraphPalette,
|
||||
metrics: &NodegraphMetrics,
|
||||
on_drag_node: FDrag,
|
||||
on_connect: FConnect,
|
||||
on_right_click_node: Option<FRight>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + Send + Sync + 'static,
|
||||
FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
FConnect:
|
||||
Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
|
||||
FRight: Fn(NodeId) -> Option<Msg>,
|
||||
{
|
||||
nodegraph_view_styled(
|
||||
nodes,
|
||||
wires,
|
||||
palette,
|
||||
metrics,
|
||||
on_drag_node,
|
||||
on_connect,
|
||||
on_right_click_node,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Variante con realce: además de los handlers, acepta dos closures de
|
||||
/// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales
|
||||
/// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un
|
||||
/// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la
|
||||
/// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn nodegraph_view_styled<Msg, FDrag, FConnect, FRight>(
|
||||
nodes: &[NodeSpec],
|
||||
wires: &[Wire],
|
||||
palette: &NodegraphPalette,
|
||||
metrics: &NodegraphMetrics,
|
||||
on_drag_node: FDrag,
|
||||
on_connect: FConnect,
|
||||
on_right_click_node: Option<FRight>,
|
||||
node_tint: Option<&dyn Fn(NodeId) -> Option<NodeTint>>,
|
||||
wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + Send + Sync + 'static,
|
||||
FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
FConnect:
|
||||
Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
|
||||
FRight: Fn(NodeId) -> Option<Msg>,
|
||||
{
|
||||
let on_drag: DragNodeFn<Msg> = Arc::new(on_drag_node);
|
||||
let on_connect: ConnectFn<Msg> = Arc::new(on_connect);
|
||||
|
||||
let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint);
|
||||
let stroke_px = metrics.wire_stroke;
|
||||
|
||||
let mut children: Vec<View<Msg>> = Vec::with_capacity(nodes.len() + 1);
|
||||
|
||||
// Capa 0 — cables (van detrás de los nodos).
|
||||
children.push(wires_layer(painted, stroke_px));
|
||||
|
||||
// Capa 1..N — nodos.
|
||||
for node in nodes {
|
||||
let right_click_msg = on_right_click_node
|
||||
.as_ref()
|
||||
.and_then(|f| f(node.id));
|
||||
let tint = node_tint.and_then(|f| f(node.id));
|
||||
children.push(node_view(
|
||||
node,
|
||||
palette,
|
||||
metrics,
|
||||
&on_drag,
|
||||
&on_connect,
|
||||
right_click_msg,
|
||||
tint,
|
||||
));
|
||||
}
|
||||
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_canvas)
|
||||
.clip(true)
|
||||
.children(children)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct WirePainted {
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
y2: f32,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
fn precompute_wires(
|
||||
nodes: &[NodeSpec],
|
||||
wires: &[Wire],
|
||||
metrics: &NodegraphMetrics,
|
||||
default_color: Color,
|
||||
wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
|
||||
) -> Vec<WirePainted> {
|
||||
let mut out = Vec::with_capacity(wires.len());
|
||||
for w in wires {
|
||||
let from = nodes.iter().find(|n| n.id == w.from_node);
|
||||
let to = nodes.iter().find(|n| n.id == w.to_node);
|
||||
if let (Some(a), Some(b)) = (from, to) {
|
||||
let x1 = a.x + metrics.node_width;
|
||||
let y1 = metrics.output_pin_y(a.y, w.from_output);
|
||||
let x2 = b.x;
|
||||
let y2 = metrics.input_pin_y(b.y, w.to_input);
|
||||
let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color);
|
||||
out.push(WirePainted {
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn wires_layer<Msg>(wires: Vec<WirePainted>, stroke_px: f32) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
{
|
||||
let nodo = View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(0.0_f32),
|
||||
top: length(0.0_f32),
|
||||
right: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
if wires.is_empty() {
|
||||
return nodo;
|
||||
}
|
||||
nodo.paint_with(move |scene, _ts, rect| {
|
||||
let stroke = Stroke::new(stroke_px as f64);
|
||||
for w in &wires {
|
||||
// Bezier cúbica con tangentes horizontales — mismo patrón
|
||||
// que las hebras de pluma-editor-llimphi.
|
||||
let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64;
|
||||
let x1 = (rect.x + w.x1) as f64;
|
||||
let y1 = (rect.y + w.y1) as f64;
|
||||
let x2 = (rect.x + w.x2) as f64;
|
||||
let y2 = (rect.y + w.y2) as f64;
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((x1, y1));
|
||||
path.curve_to((x1 + dx, y1), (x2 - dx, y2), (x2, y2));
|
||||
scene.stroke(&stroke, Affine::IDENTITY, w.color, None, &path);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn node_view<Msg>(
|
||||
node: &NodeSpec,
|
||||
palette: &NodegraphPalette,
|
||||
metrics: &NodegraphMetrics,
|
||||
on_drag: &DragNodeFn<Msg>,
|
||||
on_connect: &ConnectFn<Msg>,
|
||||
on_right_click_msg: Option<Msg>,
|
||||
tint: Option<NodeTint>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let n_in = node.inputs.len();
|
||||
let n_out = node.outputs.len();
|
||||
let height = metrics.node_height(n_in, n_out);
|
||||
|
||||
// Colores efectivos: el tinte sobrescribe la paleta por-campo.
|
||||
let tint = tint.unwrap_or_default();
|
||||
let bg_node = tint.bg_node.unwrap_or(palette.bg_node);
|
||||
let bg_title = tint.bg_title.unwrap_or(palette.bg_title);
|
||||
let fg_title = tint.fg_title.unwrap_or(palette.fg_title);
|
||||
|
||||
let node_id = node.id;
|
||||
let drag = on_drag.clone();
|
||||
let mut title_bar = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(metrics.title_height),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
padding: Rect {
|
||||
left: length(8.0_f32),
|
||||
right: length(8.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg_title)
|
||||
.text_aligned(
|
||||
node.label.clone(),
|
||||
metrics.title_text_size,
|
||||
fg_title,
|
||||
Alignment::Start,
|
||||
)
|
||||
.draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy));
|
||||
|
||||
if let Some(msg) = on_right_click_msg {
|
||||
title_bar = title_bar.on_right_click(msg);
|
||||
}
|
||||
|
||||
let mut pin_layer_children: Vec<View<Msg>> = Vec::with_capacity(n_in + n_out);
|
||||
for (i, label) in node.inputs.iter().enumerate() {
|
||||
pin_layer_children.push(pin_view(
|
||||
node_id,
|
||||
i as PinIdx,
|
||||
PinKind::Input,
|
||||
label,
|
||||
palette,
|
||||
metrics,
|
||||
on_connect.clone(),
|
||||
));
|
||||
}
|
||||
for (i, label) in node.outputs.iter().enumerate() {
|
||||
pin_layer_children.push(pin_view(
|
||||
node_id,
|
||||
i as PinIdx,
|
||||
PinKind::Output,
|
||||
label,
|
||||
palette,
|
||||
metrics,
|
||||
on_connect.clone(),
|
||||
));
|
||||
}
|
||||
let pin_layer = View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(0.0_f32),
|
||||
top: length(metrics.title_height),
|
||||
right: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: Dimension::auto(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(pin_layer_children);
|
||||
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(node.x),
|
||||
top: length(node.y),
|
||||
right: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
size: Size {
|
||||
width: length(metrics.node_width),
|
||||
height: length(height),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg_node)
|
||||
.radius(metrics.node_radius)
|
||||
.children(vec![title_bar, pin_layer])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum PinKind {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
fn pin_view<Msg>(
|
||||
node_id: NodeId,
|
||||
pin_idx: PinIdx,
|
||||
kind: PinKind,
|
||||
label: &str,
|
||||
palette: &NodegraphPalette,
|
||||
metrics: &NodegraphMetrics,
|
||||
on_connect: ConnectFn<Msg>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let y_top = pin_idx as f32 * metrics.pin_row_height;
|
||||
let row_h = metrics.pin_row_height;
|
||||
let r = metrics.pin_radius;
|
||||
let diam = r * 2.0;
|
||||
|
||||
let (pin_left, pin_right, dot_color, label_align) = match kind {
|
||||
PinKind::Input => (
|
||||
Some(length(-r)),
|
||||
None,
|
||||
palette.pin_input,
|
||||
Alignment::Start,
|
||||
),
|
||||
PinKind::Output => (
|
||||
None,
|
||||
Some(length(-r)),
|
||||
palette.pin_output,
|
||||
Alignment::End,
|
||||
),
|
||||
};
|
||||
|
||||
let mut dot = View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: pin_left.unwrap_or_else(|| length(0.0_f32)),
|
||||
top: length((row_h - diam) * 0.5),
|
||||
right: pin_right.unwrap_or_else(|| length(0.0_f32)),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
size: Size {
|
||||
width: length(diam),
|
||||
height: length(diam),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(dot_color)
|
||||
.radius(r as f64);
|
||||
|
||||
match kind {
|
||||
PinKind::Output => {
|
||||
dot = dot
|
||||
.draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
|
||||
.drag_payload(encode_payload(node_id, pin_idx));
|
||||
}
|
||||
PinKind::Input => {
|
||||
let to_node = node_id;
|
||||
let to_pin = pin_idx;
|
||||
let cb = on_connect.clone();
|
||||
dot = dot
|
||||
.on_drop(move |payload: u64| {
|
||||
let (from_node, from_pin) = decode_payload(payload);
|
||||
(cb)(from_node, from_pin, to_node, to_pin)
|
||||
})
|
||||
.drop_hover_fill(palette.pin_drop_hover);
|
||||
}
|
||||
}
|
||||
|
||||
let label_view = View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(diam + 4.0),
|
||||
top: length(0.0),
|
||||
right: length(diam + 4.0),
|
||||
bottom: length(0.0),
|
||||
},
|
||||
size: Size {
|
||||
width: Dimension::auto(),
|
||||
height: length(row_h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(
|
||||
label.to_string(),
|
||||
metrics.pin_label_size,
|
||||
palette.fg_pin_label,
|
||||
label_align,
|
||||
);
|
||||
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(0.0),
|
||||
top: length(y_top),
|
||||
right: length(0.0),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(row_h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![label_view, dot])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn payload_roundtrip() {
|
||||
for (n, p) in [
|
||||
(0u32, 0u16),
|
||||
(1, 0),
|
||||
(0, 1),
|
||||
(42, 7),
|
||||
(u32::MAX, u16::MAX),
|
||||
(123_456, 65_535),
|
||||
] {
|
||||
let enc = encode_payload(n, p);
|
||||
let (n2, p2) = decode_payload(enc);
|
||||
assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_node_height_grows_with_max_side() {
|
||||
let m = NodegraphMetrics::default();
|
||||
assert_eq!(m.node_height(3, 1), m.node_height(1, 3));
|
||||
let min = m.title_height + m.pin_row_height + 6.0;
|
||||
assert_eq!(m.node_height(0, 0), min);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_y_progression() {
|
||||
let m = NodegraphMetrics::default();
|
||||
let y0 = m.input_pin_y(100.0, 0);
|
||||
let y1 = m.input_pin_y(100.0, 1);
|
||||
let y2 = m.input_pin_y(100.0, 2);
|
||||
assert!(y1 - y0 > 0.0, "pins crecen hacia abajo");
|
||||
assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precompute_skips_dangling_wires() {
|
||||
let nodes = vec![NodeSpec {
|
||||
id: 1,
|
||||
label: "solo".into(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
inputs: vec!["in".into()],
|
||||
outputs: vec!["out".into()],
|
||||
}];
|
||||
let wires = vec![Wire {
|
||||
from_node: 99,
|
||||
from_output: 0,
|
||||
to_node: 1,
|
||||
to_input: 0,
|
||||
}];
|
||||
let m = NodegraphMetrics::default();
|
||||
let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
|
||||
assert!(pre.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precompute_resolves_existing_wires() {
|
||||
let nodes = vec![
|
||||
NodeSpec {
|
||||
id: 1,
|
||||
label: "a".into(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
inputs: vec![],
|
||||
outputs: vec!["out".into()],
|
||||
},
|
||||
NodeSpec {
|
||||
id: 2,
|
||||
label: "b".into(),
|
||||
x: 200.0,
|
||||
y: 50.0,
|
||||
inputs: vec!["in".into()],
|
||||
outputs: vec![],
|
||||
},
|
||||
];
|
||||
let wires = vec![Wire {
|
||||
from_node: 1,
|
||||
from_output: 0,
|
||||
to_node: 2,
|
||||
to_input: 0,
|
||||
}];
|
||||
let m = NodegraphMetrics::default();
|
||||
let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
|
||||
assert_eq!(pre.len(), 1);
|
||||
assert!((pre[0].x1 - m.node_width).abs() < 1e-3);
|
||||
assert!((pre[0].x2 - 200.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user