Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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())]);
    }
}