Skip to main content

ferrum_flow/plugins/
minimap.rs

1//! Overview minimap: full-graph bounds in world space, current viewport indicator, click-to-center.
2
3use gpui::{Bounds, Element, MouseButton, PathBuilder, Pixels, Point, Size, canvas, px, rgb};
4
5use crate::{
6    Graph, Viewport,
7    canvas::{Command, CommandContext},
8    plugin::{
9        EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer,
10    },
11};
12
13const MAP_W: f32 = 200.0;
14const MAP_H: f32 = 140.0;
15const OUTER_MARGIN: f32 = 16.0;
16const INNER_INSET: f32 = 3.0;
17const WORLD_PAD: f32 = 96.0;
18
19/// Last-computed layout for hit-testing (updated each [`MinimapPlugin::render`]).
20#[derive(Clone)]
21struct MinimapLayout {
22    chrome: Bounds<Pixels>,
23    inner: Bounds<Pixels>,
24    world_x0: f32,
25    world_y0: f32,
26    world_w: f32,
27    world_h: f32,
28}
29
30impl MinimapLayout {
31    fn contains_chrome(&self, p: Point<Pixels>) -> bool {
32        self.chrome.contains(&p)
33    }
34
35    /// Maps a screen position inside the chrome to world coordinates (clamped to the mapped extent).
36    fn screen_to_world(&self, screen: Point<Pixels>) -> Point<Pixels> {
37        let ix: f32 = self.inner.origin.x.into();
38        let iy: f32 = self.inner.origin.y.into();
39        let iw: f32 = self.inner.size.width.into();
40        let ih: f32 = self.inner.size.height.into();
41        let sx: f32 = screen.x.into();
42        let sy: f32 = screen.y.into();
43        let u = ((sx - ix) / iw.max(1.0)).clamp(0.0, 1.0);
44        let v = ((sy - iy) / ih.max(1.0)).clamp(0.0, 1.0);
45        let wx = self.world_x0 + u * self.world_w;
46        let wy = self.world_y0 + v * self.world_h;
47        Point::new(px(wx), px(wy))
48    }
49}
50
51fn graph_world_extent(graph: &Graph, viewport: &Viewport) -> (f32, f32, f32, f32) {
52    let nodes: Vec<_> = graph
53        .nodes()
54        .values()
55        .filter(|n| viewport.is_node_visible(n))
56        .collect();
57    if nodes.is_empty() {
58        return (0.0, 0.0, 640.0, 480.0);
59    }
60    let mut min_x = f32::MAX;
61    let mut min_y = f32::MAX;
62    let mut max_x = f32::MIN;
63    let mut max_y = f32::MIN;
64    for n in nodes {
65        let x: f32 = n.x.into();
66        let y: f32 = n.y.into();
67        let w: f32 = n.size.width.into();
68        let h: f32 = n.size.height.into();
69        min_x = min_x.min(x);
70        min_y = min_y.min(y);
71        max_x = max_x.max(x + w);
72        max_y = max_y.max(y + h);
73    }
74    let w = (max_x - min_x + 2.0 * WORLD_PAD).max(120.0);
75    let h = (max_y - min_y + 2.0 * WORLD_PAD).max(120.0);
76    (min_x - WORLD_PAD, min_y - WORLD_PAD, w, h)
77}
78
79fn visible_world_aabb(viewport: &Viewport, win: &Bounds<Pixels>) -> (f32, f32, f32, f32) {
80    let w: f32 = win.size.width.into();
81    let h: f32 = win.size.height.into();
82    let corners = [
83        viewport.screen_to_world(Point::new(px(0.0), px(0.0))),
84        viewport.screen_to_world(Point::new(px(w), px(0.0))),
85        viewport.screen_to_world(Point::new(px(w), px(h))),
86        viewport.screen_to_world(Point::new(px(0.0), px(h))),
87    ];
88    let mut min_x = f32::MAX;
89    let mut min_y = f32::MAX;
90    let mut max_x = f32::MIN;
91    let mut max_y = f32::MIN;
92    for c in corners {
93        let x: f32 = c.x.into();
94        let y: f32 = c.y.into();
95        min_x = min_x.min(x);
96        min_y = min_y.min(y);
97        max_x = max_x.max(x);
98        max_y = max_y.max(y);
99    }
100    (
101        min_x,
102        min_y,
103        (max_x - min_x).max(1.0),
104        (max_y - min_y).max(1.0),
105    )
106}
107
108fn build_layout(viewport: &Viewport, graph: &Graph) -> Option<MinimapLayout> {
109    let win = viewport.window_bounds?;
110    let ww: f32 = win.size.width.into();
111    let wh: f32 = win.size.height.into();
112    if ww < MAP_W + OUTER_MARGIN || wh < MAP_H + OUTER_MARGIN {
113        return None;
114    }
115
116    let map_w = px(MAP_W);
117    let map_h = px(MAP_H);
118    let ox = win.size.width - map_w - px(OUTER_MARGIN);
119    let oy = win.size.height - map_h - px(OUTER_MARGIN);
120    let chrome = Bounds::new(Point::new(ox, oy), Size::new(map_w, map_h));
121
122    let inset = px(INNER_INSET);
123    let inner = Bounds::new(
124        chrome.origin + Point::new(inset, inset),
125        Size::new(
126            chrome.size.width - inset * 2.0,
127            chrome.size.height - inset * 2.0,
128        ),
129    );
130
131    let (wx0, wy0, ww, wh) = graph_world_extent(graph, viewport);
132
133    Some(MinimapLayout {
134        chrome,
135        inner,
136        world_x0: wx0,
137        world_y0: wy0,
138        world_w: ww.max(1.0),
139        world_h: wh.max(1.0),
140    })
141}
142
143fn world_to_inner_pt(wx: f32, wy: f32, layout: &MinimapLayout) -> Point<Pixels> {
144    let u = ((wx - layout.world_x0) / layout.world_w).clamp(0.0, 1.0);
145    let v = ((wy - layout.world_y0) / layout.world_h).clamp(0.0, 1.0);
146    let ix: f32 = layout.inner.origin.x.into();
147    let iy: f32 = layout.inner.origin.y.into();
148    let iw: f32 = layout.inner.size.width.into();
149    let ih: f32 = layout.inner.size.height.into();
150    Point::new(px(ix + u * iw), px(iy + v * ih))
151}
152
153fn center_viewport_on_world(ctx: &mut PluginContext, world: Point<Pixels>) {
154    let Some(wb) = ctx.viewport.window_bounds else {
155        return;
156    };
157    let cx: f32 = (wb.size.width / 2.0).into();
158    let cy: f32 = (wb.size.height / 2.0).into();
159    let z = ctx.viewport.zoom;
160    let wx: f32 = world.x.into();
161    let wy: f32 = world.y.into();
162    let from = ctx.viewport.offset;
163    ctx.viewport.offset.x = px(cx - wx * z);
164    ctx.viewport.offset.y = px(cy - wy * z);
165    let to = ctx.viewport.offset;
166    ctx.execute_command(MinimapPanCommand { from, to });
167}
168
169struct MinimapPanCommand {
170    from: Point<Pixels>,
171    to: Point<Pixels>,
172}
173
174impl Command for MinimapPanCommand {
175    fn name(&self) -> &'static str {
176        "minimap_pan"
177    }
178
179    fn execute(&mut self, ctx: &mut CommandContext) {
180        ctx.viewport.offset.x = self.to.x;
181        ctx.viewport.offset.y = self.to.y;
182    }
183
184    fn undo(&mut self, ctx: &mut CommandContext) {
185        ctx.viewport.offset.x = self.from.x;
186        ctx.viewport.offset.y = self.from.y;
187    }
188
189    fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec<crate::GraphOp> {
190        vec![]
191    }
192}
193
194/// Renders a bottom-right overview map and pans the viewport when the user clicks it.
195///
196/// Uses priority **135** so clicks hit the minimap before [`crate::plugins::SelectionPlugin`] (100)
197/// starts a canvas selection.
198pub struct MinimapPlugin {
199    last_layout: Option<MinimapLayout>,
200}
201
202impl MinimapPlugin {
203    pub fn new() -> Self {
204        Self { last_layout: None }
205    }
206}
207
208impl Plugin for MinimapPlugin {
209    fn name(&self) -> &'static str {
210        "minimap"
211    }
212
213    fn setup(&mut self, _ctx: &mut crate::plugin::InitPluginContext) {}
214
215    fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult {
216        if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event {
217            if let Some(ref layout) = self.last_layout {
218                if layout.contains_chrome(ev.position) {
219                    if ev.button == MouseButton::Right {
220                        return EventResult::Stop;
221                    }
222                    if ev.button == MouseButton::Left {
223                        let world = layout.screen_to_world(ev.position);
224                        center_viewport_on_world(ctx, world);
225                        ctx.notify();
226                        return EventResult::Stop;
227                    }
228                }
229            }
230        }
231        EventResult::Continue
232    }
233
234    fn priority(&self) -> i32 {
235        135
236    }
237
238    fn render_layer(&self) -> RenderLayer {
239        RenderLayer::Overlay
240    }
241
242    fn render(&mut self, ctx: &mut RenderContext) -> Option<gpui::AnyElement> {
243        let layout = build_layout(ctx.viewport, ctx.graph)?;
244        self.last_layout = Some(layout.clone());
245
246        let inner = layout.inner;
247
248        let nodes: Vec<_> = ctx
249            .graph
250            .nodes()
251            .values()
252            .filter(|n| ctx.is_node_visible(&n.id))
253            .map(|n| {
254                let x: f32 = n.x.into();
255                let y: f32 = n.y.into();
256                let w: f32 = n.size.width.into();
257                let h: f32 = n.size.height.into();
258                (x, y, w, h)
259            })
260            .collect();
261
262        let edges: Vec<_> = ctx
263            .graph
264            .edges
265            .values()
266            .filter(|e| ctx.is_edge_visible(e))
267            .filter_map(|e| {
268                let s = ctx.graph.ports.get(&e.source_port)?;
269                let t = ctx.graph.ports.get(&e.target_port)?;
270                let sn = ctx.graph.nodes.get(&s.node_id)?;
271                let tn = ctx.graph.nodes.get(&t.node_id)?;
272                let sx: f32 = f32::from(sn.x) + f32::from(sn.size.width) * 0.5;
273                let sy: f32 = f32::from(sn.y) + f32::from(sn.size.height) * 0.5;
274                let tx: f32 = f32::from(tn.x) + f32::from(tn.size.width) * 0.5;
275                let ty: f32 = f32::from(tn.y) + f32::from(tn.size.height) * 0.5;
276                Some((sx, sy, tx, ty))
277            })
278            .collect();
279
280        let win_bounds = ctx.viewport.window_bounds?;
281        let (vx0, vy0, vw, vh) = visible_world_aabb(ctx.viewport, &win_bounds);
282        let v_tl = world_to_inner_pt(vx0, vy0, &layout);
283        let v_br = world_to_inner_pt(vx0 + vw, vy0 + vh, &layout);
284
285        let minimap_background = ctx.theme.minimap_background;
286        let minimap_border = ctx.theme.minimap_border;
287        let minimap_edge = ctx.theme.minimap_edge;
288        let minimap_node_fill = ctx.theme.minimap_node_fill;
289        let minimap_node_stroke = ctx.theme.minimap_node_stroke;
290        let minimap_viewport_stroke = ctx.theme.minimap_viewport_stroke;
291
292        Some(
293            canvas(
294                move |_, _, _| (),
295                move |_, _, win, _| {
296                    // Inner background
297                    if let Ok(p) = rect_fill_path(inner) {
298                        win.paint_path(p, rgb(minimap_background));
299                    }
300                    if let Ok(p) = rect_stroke_path(inner, px(1.0)) {
301                        win.paint_path(p, rgb(minimap_border));
302                    }
303
304                    // Edges (straight segments between node centers)
305                    for (sx, sy, tx, ty) in edges {
306                        let a = world_to_inner_pt(sx, sy, &layout);
307                        let b = world_to_inner_pt(tx, ty, &layout);
308                        let mut line = PathBuilder::stroke(px(1.0));
309                        line.move_to(a);
310                        line.line_to(b);
311                        if let Ok(p) = line.build() {
312                            win.paint_path(p, rgb(minimap_edge));
313                        }
314                    }
315
316                    for (x, y, nw, nh) in nodes {
317                        let p0 = world_to_inner_pt(x, y, &layout);
318                        let p1 = world_to_inner_pt(x + nw, y + nh, &layout);
319                        let min_x = f32::min(f32::from(p0.x), f32::from(p1.x));
320                        let max_x = f32::max(f32::from(p0.x), f32::from(p1.x));
321                        let min_y = f32::min(f32::from(p0.y), f32::from(p1.y));
322                        let max_y = f32::max(f32::from(p0.y), f32::from(p1.y));
323                        let rw = (max_x - min_x).max(2.0);
324                        let rh = (max_y - min_y).max(2.0);
325                        let o = Point::new(px(min_x), px(min_y));
326                        let s = Size::new(px(rw), px(rh));
327                        if let Ok(p) = rect_fill_bounds(o, s) {
328                            win.paint_path(p, rgb(minimap_node_fill));
329                        }
330                        if let Ok(p) = rect_stroke_bounds(o, s, px(1.0)) {
331                            win.paint_path(p, rgb(minimap_node_stroke));
332                        }
333                    }
334
335                    // Viewport frame
336                    let min_x = f32::min(f32::from(v_tl.x), f32::from(v_br.x));
337                    let max_x = f32::max(f32::from(v_tl.x), f32::from(v_br.x));
338                    let min_y = f32::min(f32::from(v_tl.y), f32::from(v_br.y));
339                    let max_y = f32::max(f32::from(v_tl.y), f32::from(v_br.y));
340                    let vo = Point::new(px(min_x), px(min_y));
341                    let vs = Size::new(px((max_x - min_x).max(2.0)), px((max_y - min_y).max(2.0)));
342                    if let Ok(p) = rect_stroke_bounds(vo, vs, px(1.5)) {
343                        win.paint_path(p, rgb(minimap_viewport_stroke));
344                    }
345                },
346            )
347            .into_any(),
348        )
349    }
350}
351
352fn rect_fill_path(b: Bounds<Pixels>) -> Result<gpui::Path<Pixels>, anyhow::Error> {
353    rect_fill_bounds(b.origin, b.size)
354}
355
356fn rect_fill_bounds(
357    o: Point<Pixels>,
358    s: Size<Pixels>,
359) -> Result<gpui::Path<Pixels>, anyhow::Error> {
360    let x0: f32 = o.x.into();
361    let y0: f32 = o.y.into();
362    let w: f32 = s.width.into();
363    let h: f32 = s.height.into();
364    let pts = [
365        Point::new(px(x0), px(y0)),
366        Point::new(px(x0 + w), px(y0)),
367        Point::new(px(x0 + w), px(y0 + h)),
368        Point::new(px(x0), px(y0 + h)),
369    ];
370    let mut pb = PathBuilder::fill();
371    pb.add_polygon(&pts, true);
372    pb.build()
373}
374
375fn rect_stroke_path(b: Bounds<Pixels>, width: Pixels) -> Result<gpui::Path<Pixels>, anyhow::Error> {
376    rect_stroke_bounds(b.origin, b.size, width)
377}
378
379fn rect_stroke_bounds(
380    o: Point<Pixels>,
381    s: Size<Pixels>,
382    width: Pixels,
383) -> Result<gpui::Path<Pixels>, anyhow::Error> {
384    let x0: f32 = o.x.into();
385    let y0: f32 = o.y.into();
386    let w: f32 = s.width.into();
387    let h: f32 = s.height.into();
388    let mut line = PathBuilder::stroke(width);
389    line.move_to(Point::new(px(x0), px(y0)));
390    line.line_to(Point::new(px(x0 + w), px(y0)));
391    line.line_to(Point::new(px(x0 + w), px(y0 + h)));
392    line.line_to(Point::new(px(x0), px(y0 + h)));
393    line.close();
394    line.build()
395}