facett-flow/src/view.rs
use crate::{Camera, FlowGraph, Msg};
use egui::{FontId, Rect, Sense, Stroke, pos2, vec2};
use facett_core::{
Elm,
a11y::{self, Semantics},
theme,
};
pub(crate) fn show(flow: &FlowGraph, ui: &mut egui::Ui) -> Vec<Msg> {
let m = flow.state();
let t = theme(ui);
let mut msgs = Vec::new();
let (rect, response) = ui.allocate_exact_size(
ui.available_size().max(vec2(64.0, 64.0)),
Sense::click_and_drag(),
);
let fit = ((rect.width() - 64.0).max(1.0) / flow.bounds().width().max(1.0))
.min((rect.height() - 64.0).max(1.0) / flow.bounds().height().max(1.0));
let scale = fit * m.camera.zoom;
let centre = pos2(m.camera.centre[0], m.camera.centre[1]);
let screen = |p: [f32; 2]| rect.center() + (pos2(p[0], p[1]) - centre) * scale;
let world = |p: egui::Pos2| centre + (p - rect.center()) / scale;
let mut camera = m.camera.clone();
if response.dragged() {
let d = response.drag_delta() / scale;
camera.centre[0] -= d.x;
camera.centre[1] -= d.y;
}
if response.hovered() {
let delta = ui.input(|i| i.smooth_scroll_delta.y);
if delta != 0.0 {
let zoom = (camera.zoom * (delta * 0.003).exp()).clamp(0.1, 100.0);
let p = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center());
let anchor = world(p);
let new_scale = fit * zoom;
let c = anchor - (p - rect.center()) / new_scale;
camera = Camera {
centre: [c.x, c.y],
zoom,
};
}
}
if camera != m.camera {
msgs.push(Msg::Camera(camera));
}
if response.double_clicked() {
msgs.push(Msg::Fit);
}
let geom = if m.clustered {
&flow.scene.clusters
} else {
&flow.scene.raw
};
let moving =
facett_core::look::effects_policy(ui).allows_decorative_motion() && !m.reduced_motion;
#[cfg(feature = "wgpu")]
let seconds = if moving { m.seconds as f32 } else { 0.0 };
ui.painter().rect_filled(rect, 0, t.bg);
#[cfg(feature = "host")]
let hosted = flow.host_scene.as_ref().map(|scene| {
crate::host::paint(
ui,
ui.id(),
rect,
scene,
&m.camera.view(rect, flow.bounds()),
&crate::host::PaintOpts {
seconds: m.seconds,
moving,
clustered: m.clustered,
cpu: crate::host::CpuStyle::Full {
max_edges: usize::MAX,
},
..Default::default()
},
)
});
#[cfg(not(feature = "host"))]
let hosted: Option<bool> = None;
#[cfg(feature = "wgpu")]
let gpu_drawn = if hosted.is_some() {
true
} else {
crate::gpu::draw(ui, rect, geom.clone(), centre, scale, seconds, moving)
};
#[cfg(not(feature = "wgpu"))]
let gpu_drawn = hosted.is_some();
#[cfg(feature = "host")]
if response.clicked()
&& !response.double_clicked()
&& !m.clustered
&& let Some(scene) = &flow.host_scene
&& let Some(p) = response.interact_pointer_pos()
{
let selected = match scene.hit(world(p), 4.0 / scale) {
Some(crate::Hit::Node(i)) => Some(m.graph.nodes[i].id.clone()),
_ => None,
};
msgs.push(Msg::Select(selected));
}
#[cfg(feature = "labels")]
if flow.labels && !m.clustered {
crate::labels::paint(ui, rect, &m.graph, &flow.scene, scale, centre);
}
let mut fallback = false;
if !gpu_drawn {
fallback = true;
// CPU fallback is intentionally a bounded preview, never reported as a full
// 10k GPU benchmark. The host should install_renderer for dense graphs.
for e in geom.links.iter().take(4000) {
let a = screen(geom.points[e.from as usize].pos);
let b = screen(geom.points[e.to as usize].pos);
if Rect::from_two_pos(a, b).intersects(rect) {
ui.painter()
.line_segment([a, b], Stroke::new(0.5, t.edge.gamma_multiply(0.2)));
}
}
for p in geom.points.iter().take(2000) {
// The hub is an outline, so incoming/outgoing flows stay visible inside it.
if p.kind > 2.5 {
continue;
}
let xy = screen(p.pos);
if rect.contains(xy) {
ui.painter()
.circle_filled(xy, (p.radius * scale).clamp(1.5, 12.0), t.accent);
}
}
}
let base = ui.id().with("nodes");
let _ = a11y::node(
ui,
base,
"summary",
Sense::hover(),
rect,
Semantics::image(format!(
"Flow graph: {} nodes, {} connections; {}",
m.graph.nodes.len(),
m.graph.edges.len(),
if m.clustered {
"cluster overview"
} else {
"individual nodes"
}
)),
);
if m.clustered {
for (p, (label, count)) in geom.points.iter().zip(&flow.scene.cluster_names) {
if p.kind > 2.5 {
continue;
}
let xy = screen(p.pos);
if rect.shrink(24.0).contains(xy) {
if flow.scene.hubs.is_empty() {
ui.painter().text(
xy + vec2(0.0, 16.0),
egui::Align2::CENTER_TOP,
format!("{label} · {count}"),
FontId::proportional(10.0),
t.text_dim,
);
} else {
let name = label.split_once(':').map_or(label.as_str(), |(_, s)| s);
let card = Rect::from_center_size(xy, vec2(64.0, 62.0));
let hit = a11y::node(
ui,
base,
label,
Sense::click(),
card,
Semantics::button(format!("{name}: {count} nodes; open group")),
);
ui.painter().circle_filled(xy, 30.0, t.node_fill);
ui.painter().circle_stroke(
xy,
30.0,
Stroke::new(1.0, t.node_stroke.gamma_multiply(0.65)),
);
let icon = Rect::from_center_size(xy - vec2(0.0, 6.0), vec2(31.0, 29.0));
mark(
ui,
icon.center(),
name,
if p.kind < 0.5 { t.point } else { t.accent },
);
ui.painter().text(
xy + vec2(0.0, 15.0),
egui::Align2::CENTER_CENTER,
name,
FontId::proportional(8.5),
t.text,
);
ui.painter().text(
xy + vec2(0.0, 38.0),
egui::Align2::CENTER_CENTER,
format!("{count}"),
FontId::proportional(8.0),
t.text_dim,
);
if hit.clicked() {
msgs.push(Msg::Clustered(false));
msgs.push(Msg::Camera(Camera {
centre: p.pos,
zoom: 12.0,
}));
}
}
}
}
} else {
let visible = Rect::from_min_max(world(rect.min), world(rect.max)).expand(24.0 / scale);
// Detailed chrome is bounded; all original points/edges remain in the GPU draw.
if scale > 1.0 {
let mut cards: Vec<Rect> = Vec::new();
for i in flow.scene.visible(visible, 120) {
let n = &m.graph.nodes[i];
if n.kind == 3 {
continue;
}
let xy = screen(n.position);
let card = Rect::from_center_size(xy, vec2(64.0, 68.0));
if !rect.contains_rect(card) || cards.iter().any(|r| r.expand(4.0).intersects(card))
{
continue;
}
cards.push(card);
let hit = a11y::node(
ui,
base,
&n.id,
Sense::click(),
card,
Semantics::button(n.label.clone()),
);
ui.painter().circle_filled(xy, 22.0, t.node_fill);
ui.painter().circle_stroke(
xy,
22.0,
Stroke::new(
1.0,
if m.selected.as_ref() == Some(&n.id) {
t.accent
} else {
t.node_stroke
},
),
);
let label = n.label.split(" · ").next().unwrap_or(&n.label);
mark(ui, xy, label, if n.kind == 0 { t.point } else { t.accent });
ui.painter().text(
xy + vec2(0.0, 29.0),
egui::Align2::CENTER_TOP,
label,
FontId::proportional(8.5),
t.text,
);
if hit.clicked() {
msgs.push(Msg::Select(Some(n.id.clone())));
}
}
}
if hosted.is_none()
&& response.clicked()
&& !response.double_clicked()
&& let Some(mouse) = response.interact_pointer_pos()
{
let p = world(mouse);
let tol = 8.0 / scale;
let nearest = flow
.scene
.visible(Rect::from_center_size(p, vec2(tol * 2.0, tol * 2.0)), 128)
.into_iter()
.min_by(|&a, &b| {
screen(m.graph.nodes[a].position)
.distance_sq(mouse)
.total_cmp(&screen(m.graph.nodes[b].position).distance_sq(mouse))
});
msgs.push(Msg::Select(
nearest
.filter(|&i| screen(m.graph.nodes[i].position).distance(mouse) <= 8.0)
.map(|i| m.graph.nodes[i].id.clone()),
));
}
if let Some(i) = m.selected.as_ref().and_then(|id| flow.scene.ids.get(id)) {
let p = screen(m.graph.nodes[*i].position);
if rect.contains(p) {
ui.painter()
.circle_stroke(p, 9.0, Stroke::new(2.0, t.accent));
}
}
}
for &i in &flow.scene.hubs {
let n = &m.graph.nodes[i];
let xy = screen(n.position);
if !rect.contains(xy) {
continue;
}
let radius = (180.0 * scale * ui.ctx().pixels_per_point()).clamp(60.0, 260.0)
/ ui.ctx().pixels_per_point();
let painter = ui.painter().with_clip_rect(rect);
let phase = if moving {
m.seconds as f32 * 0.035
} else {
0.0
};
for ring in [2.05, 2.8] {
let r = radius * ring;
for segment in (0..96).step_by(3) {
let a = phase + segment as f32 * std::f32::consts::TAU / 96.0;
let b = a + std::f32::consts::TAU / 192.0;
painter.line_segment(
[
xy + vec2(a.cos(), a.sin()) * r,
xy + vec2(b.cos(), b.sin()) * r,
],
Stroke::new(1.0, t.accent.gamma_multiply(0.12)),
);
}
}
painter.circle_stroke(
xy,
radius + 11.0,
Stroke::new(10.0, t.accent.gamma_multiply(0.08)),
);
painter.circle_stroke(
xy,
radius + 22.0,
Stroke::new(1.0, t.accent.gamma_multiply(0.17)),
);
// A light tint preserves the hub shape without hiding animated packets.
painter.circle_filled(xy, radius, t.node_fill.gamma_multiply(0.08));
painter.circle_stroke(xy, radius, Stroke::new(1.5, t.accent.gamma_multiply(0.4)));
let hit = a11y::node(
ui,
base,
&n.id,
Sense::click(),
Rect::from_center_size(xy, vec2(radius * 2.0, radius * 2.0)),
Semantics::button(format!("{} · central flow hub", n.label)),
);
if hit.clicked() {
msgs.push(Msg::Select(Some(n.id.clone())));
}
if m.clustered && scale < 1.0 {
painter.text(
xy + vec2(-radius * 2.2, -radius * 2.2),
egui::Align2::RIGHT_BOTTOM,
"INPUTS",
FontId::proportional(10.0),
t.text_dim,
);
painter.text(
xy + vec2(radius * 2.2, radius * 2.2),
egui::Align2::LEFT_TOP,
"OUTPUTS",
FontId::proportional(10.0),
t.text_dim,
);
let status = Rect::from_center_size(xy + vec2(0.0, radius * 2.75), vec2(220.0, 39.0));
painter.rect(
status,
7.0,
t.panel_bg,
Stroke::new(1.0, t.panel_stroke.gamma_multiply(0.5)),
egui::StrokeKind::Inside,
);
painter.text(
status.center() - vec2(0.0, 7.0),
egui::Align2::CENTER_CENTER,
format!("{} nodes · one shared hub", m.graph.nodes.len()),
FontId::monospace(10.0),
t.text_dim,
);
painter.text(
status.center() + vec2(0.0, 8.0),
egui::Align2::CENTER_CENTER,
if m.running && moving {
"> FLOW ACTIVE"
} else {
"> FLOW PAUSED"
},
FontId::monospace(10.0),
t.accent,
);
}
}
let note = if fallback {
"CPU preview · maximum 2,000 points · GPU host required"
} else {
"Drag to pan · scroll to zoom · double-click to fit"
};
ui.painter().text(
rect.left_bottom() + vec2(16.0, -14.0),
egui::Align2::LEFT_BOTTOM,
note,
FontId::proportional(11.0),
if fallback { t.accent } else { t.text_dim },
);
if moving && m.running {
ui.ctx().request_repaint();
}
msgs
}
fn mark(ui: &egui::Ui, p: egui::Pos2, label: &str, colour: egui::Color32) {
let stroke = Stroke::new(1.5, colour);
let painter = ui.painter();
let variant = label.bytes().fold(0usize, |a, b| a + usize::from(b)) % 4;
match variant {
0 => {
for y in [-5.0, 0.0, 5.0] {
painter.line_segment([p + vec2(-7.0, y), p + vec2(7.0, y)], stroke);
}
}
1 => {
for (a, b) in [
(vec2(-7.0, 5.0), vec2(0.0, -5.0)),
(vec2(0.0, -5.0), vec2(7.0, 3.0)),
] {
painter.line_segment([p + a, p + b], stroke);
}
for d in [vec2(-7.0, 5.0), vec2(0.0, -5.0), vec2(7.0, 3.0)] {
painter.circle_filled(p + d, 2.5, colour);
}
}
2 => {
for x in [-5.0, 5.0] {
for y in [-5.0, 5.0] {
painter.rect_filled(
Rect::from_center_size(p + vec2(x, y), vec2(6.0, 6.0)),
1.0,
colour,
);
}
}
}
_ => {
painter.line_segment([p + vec2(-6.0, -6.0), p + vec2(-9.0, 0.0)], stroke);
painter.line_segment([p + vec2(-9.0, 0.0), p + vec2(-6.0, 6.0)], stroke);
painter.line_segment([p + vec2(6.0, -6.0), p + vec2(9.0, 0.0)], stroke);
painter.line_segment([p + vec2(9.0, 0.0), p + vec2(6.0, 6.0)], stroke);
painter.line_segment([p + vec2(2.0, -7.0), p + vec2(-2.0, 7.0)], stroke);
}
}
}
#[cfg(all(test, feature = "boxes"))]
mod host_selection_tests {
use super::*;
use std::sync::Arc;
#[test]
fn box_click_far_from_centre_selects_once_and_fit_uses_extents() {
let mut flow = FlowGraph::new("boxes", crate::Graph::demo(1)).unwrap();
flow.update(Msg::Sizes(Arc::new(crate::attr::Sizes {
nodes: vec![[200.0, 100.0]],
})));
flow.update(Msg::Fit);
assert!(flow.bounds().width() >= 200.0 && flow.bounds().height() >= 100.0);
assert_eq!(
flow.state().camera.centre,
<[f32; 2]>::from(flow.bounds().center())
);
let ctx = egui::Context::default();
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 700.0));
let mut click = pos2(0.0, 0.0);
let mut selected = Vec::new();
for frame in 0..3 {
let events = match frame {
1 => vec![
egui::Event::PointerMoved(click),
egui::Event::PointerButton {
pos: click,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
},
],
2 => vec![egui::Event::PointerButton {
pos: click,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
}],
_ => vec![],
};
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(rect),
events,
..Default::default()
},
|ui| {
let pane = ui.available_rect_before_wrap();
let view = flow.state().camera.view(pane, flow.bounds());
let point =
egui::Pos2::from(flow.state().graph.nodes[0].position) + vec2(70.0, 20.0);
click = view.screen(point);
selected.extend(show(&flow, ui).into_iter().filter_map(|msg| match msg {
Msg::Select(id) => Some(id),
_ => None,
}));
},
);
output.textures_delta.clear();
}
assert_eq!(selected, vec![Some(flow.state().graph.nodes[0].id.clone())]);
}
}