Skip to main content

ferrum_flow/plugins/
zoom_controls.rs

1//! Bottom-left zoom controls (left to right: **+ − ↺ ⛶**): zoom in, zoom out, reset scale, fit entire graph.
2
3use gpui::{
4    Bounds, IntoElement as _, MouseButton, ParentElement as _, Pixels, Point, Size, Styled as _,
5    div, px, rgb,
6};
7
8/// Unicode minus sign (not ASCII hyphen).
9const LABEL_ZOOM_OUT: &str = "\u{2212}";
10const LABEL_ZOOM_IN: &str = "+";
11/// Anticlockwise open circle arrow — common “reset view” symbol.
12const LABEL_RESET_ZOOM: &str = "\u{21BA}";
13/// Square four corners — “frame / fit content” (same action as [`crate::plugins::FitAllGraphPlugin`]).
14const LABEL_FIT_ENTIRE_GRAPH: &str = "\u{26F6}";
15
16use crate::{
17    canvas::{Command, CommandContext},
18    plugin::{
19        EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer,
20    },
21};
22
23use super::fit_all::fit_entire_graph;
24use super::viewport_frame::{ZOOM_MAX, ZOOM_MIN};
25
26const MARGIN: f32 = 16.0;
27/// Square control size (width = height).
28const BTN: f32 = 36.0;
29const GAP: f32 = 6.0;
30/// Same step as [`crate::plugins::ViewportPlugin`] wheel zoom.
31const ZOOM_STEP: f32 = 1.1;
32
33struct ZoomControlsLayout {
34    zoom_in: Bounds<Pixels>,
35    zoom_out: Bounds<Pixels>,
36    reset: Bounds<Pixels>,
37    fit_entire_graph: Bounds<Pixels>,
38}
39
40impl ZoomControlsLayout {
41    fn hit(&self, p: Point<Pixels>) -> Option<Hit> {
42        if self.zoom_in.contains(&p) {
43            Some(Hit::ZoomIn)
44        } else if self.zoom_out.contains(&p) {
45            Some(Hit::ZoomOut)
46        } else if self.reset.contains(&p) {
47            Some(Hit::ResetZoom)
48        } else if self.fit_entire_graph.contains(&p) {
49            Some(Hit::FitEntireGraph)
50        } else {
51            None
52        }
53    }
54}
55
56#[derive(Copy, Clone)]
57enum Hit {
58    ZoomIn,
59    ZoomOut,
60    ResetZoom,
61    FitEntireGraph,
62}
63
64fn bar_outer_size() -> (f32, f32) {
65    let w = 4.0 * BTN + 3.0 * GAP;
66    (w, BTN)
67}
68
69fn build_layout(window_bounds: Bounds<Pixels>) -> ZoomControlsLayout {
70    let wh: f32 = window_bounds.size.height.into();
71    let (_, bar_h) = bar_outer_size();
72    let s = px(BTN);
73    let m = px(MARGIN);
74
75    let y0 = px(wh - MARGIN - bar_h);
76    let x0 = m;
77
78    let zoom_in = Bounds::new(Point::new(x0, y0), Size::new(s, s));
79    let zoom_out = Bounds::new(
80        Point::new(px(f32::from(x0) + BTN + GAP), y0),
81        Size::new(s, s),
82    );
83    let reset = Bounds::new(
84        Point::new(px(f32::from(x0) + 2.0 * (BTN + GAP)), y0),
85        Size::new(s, s),
86    );
87    let fit_entire_graph = Bounds::new(
88        Point::new(px(f32::from(x0) + 3.0 * (BTN + GAP)), y0),
89        Size::new(s, s),
90    );
91
92    ZoomControlsLayout {
93        zoom_in,
94        zoom_out,
95        reset,
96        fit_entire_graph,
97    }
98}
99
100struct ViewportZoomCommand {
101    from_zoom: f32,
102    from_offset: Point<Pixels>,
103    to_zoom: f32,
104    to_offset: Point<Pixels>,
105}
106
107impl Command for ViewportZoomCommand {
108    fn name(&self) -> &'static str {
109        "viewport_zoom"
110    }
111
112    fn execute(&mut self, ctx: &mut CommandContext) {
113        ctx.viewport.zoom = self.to_zoom;
114        ctx.viewport.offset.x = self.to_offset.x;
115        ctx.viewport.offset.y = self.to_offset.y;
116    }
117
118    fn undo(&mut self, ctx: &mut CommandContext) {
119        ctx.viewport.zoom = self.from_zoom;
120        ctx.viewport.offset.x = self.from_offset.x;
121        ctx.viewport.offset.y = self.from_offset.y;
122    }
123
124    fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec<crate::GraphOp> {
125        ctx.viewport.zoom = self.to_zoom;
126        ctx.viewport.offset.x = self.to_offset.x;
127        ctx.viewport.offset.y = self.to_offset.y;
128        vec![]
129    }
130}
131
132fn apply_zoom(ctx: &mut PluginContext, anchor_screen: Point<Pixels>, to_zoom: f32) {
133    let to_zoom = to_zoom.clamp(ZOOM_MIN, ZOOM_MAX);
134    let from_zoom = ctx.viewport.zoom;
135    let from_offset = ctx.viewport.offset;
136    if (from_zoom - to_zoom).abs() < 1e-5 {
137        return;
138    }
139    let anchor_world = ctx.screen_to_world(anchor_screen);
140    let wx: f32 = anchor_world.x.into();
141    let wy: f32 = anchor_world.y.into();
142    let ax: f32 = anchor_screen.x.into();
143    let ay: f32 = anchor_screen.y.into();
144    let to_offset = Point::new(px(ax - wx * to_zoom), px(ay - wy * to_zoom));
145    ctx.execute_command(ViewportZoomCommand {
146        from_zoom,
147        from_offset,
148        to_zoom,
149        to_offset,
150    });
151}
152
153fn window_center_screen(ctx: &PluginContext) -> Option<Point<Pixels>> {
154    let wb = ctx.viewport.window_bounds?;
155    let cx: f32 = (wb.size.width / 2.0).into();
156    let cy: f32 = (wb.size.height / 2.0).into();
157    Some(Point::new(px(cx), px(cy)))
158}
159
160fn zoom_by_factor(ctx: &mut PluginContext, factor: f32) {
161    let Some(center) = window_center_screen(ctx) else {
162        return;
163    };
164    apply_zoom(ctx, center, ctx.viewport.zoom * factor);
165}
166
167fn reset_zoom(ctx: &mut PluginContext) {
168    let Some(center) = window_center_screen(ctx) else {
169        return;
170    };
171    apply_zoom(ctx, center, 1.0);
172}
173
174/// Bottom-left **+** / **−** / **↺** / **⛶** (fit all); priority **128** so clicks beat canvas selection.
175pub struct ZoomControlsPlugin {
176    last_layout: Option<ZoomControlsLayout>,
177}
178
179impl ZoomControlsPlugin {
180    pub fn new() -> Self {
181        Self { last_layout: None }
182    }
183}
184
185impl Plugin for ZoomControlsPlugin {
186    fn name(&self) -> &'static str {
187        "zoom_controls"
188    }
189
190    fn setup(&mut self, _ctx: &mut crate::plugin::InitPluginContext) {}
191
192    fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult {
193        if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event {
194            if ev.button == MouseButton::Left {
195                if let Some(ref layout) = self.last_layout {
196                    if let Some(hit) = layout.hit(ev.position) {
197                        match hit {
198                            Hit::ZoomIn => zoom_by_factor(ctx, ZOOM_STEP),
199                            Hit::ZoomOut => zoom_by_factor(ctx, 1.0 / ZOOM_STEP),
200                            Hit::ResetZoom => reset_zoom(ctx),
201                            Hit::FitEntireGraph => fit_entire_graph(ctx),
202                        }
203                        ctx.notify();
204                        return EventResult::Stop;
205                    }
206                }
207            }
208        }
209        EventResult::Continue
210    }
211
212    fn priority(&self) -> i32 {
213        128
214    }
215
216    fn render_layer(&self) -> RenderLayer {
217        RenderLayer::Overlay
218    }
219
220    fn render(&mut self, ctx: &mut RenderContext) -> Option<gpui::AnyElement> {
221        let win = ctx.viewport.window_bounds.unwrap_or_else(|| {
222            let vs = ctx.window.viewport_size();
223            Bounds::new(Point::new(px(0.0), px(0.0)), Size::new(vs.width, vs.height))
224        });
225        let wh: f32 = win.size.height.into();
226        let (bar_w, bar_h) = bar_outer_size();
227        if wh < MARGIN + bar_h + 1.0 {
228            self.last_layout = None;
229            return None;
230        }
231
232        let layout = build_layout(win);
233        self.last_layout = Some(layout);
234
235        let bar_w_px = px(bar_w);
236
237        let btn_bg = ctx.theme.zoom_controls_background;
238        let btn_border = ctx.theme.zoom_controls_border;
239        let btn_text = ctx.theme.zoom_controls_text;
240
241        let mk_btn = move |label: &'static str| {
242            div()
243                .w(px(BTN))
244                .h(px(BTN))
245                .flex()
246                .items_center()
247                .justify_center()
248                .rounded(px(6.0))
249                .bg(rgb(btn_bg))
250                .border_1()
251                .border_color(rgb(btn_border))
252                .text_sm()
253                .font_weight(gpui::FontWeight::MEDIUM)
254                .text_color(rgb(btn_text))
255                .child(label)
256        };
257
258        Some(
259            div()
260                .absolute()
261                .size_full()
262                .child(
263                    div()
264                        .absolute()
265                        .bottom(px(MARGIN))
266                        .left(px(MARGIN))
267                        .w(bar_w_px)
268                        .h(px(bar_h))
269                        .flex()
270                        .flex_row()
271                        .gap(px(GAP))
272                        .items_center()
273                        .children(vec![
274                            mk_btn(LABEL_ZOOM_IN),
275                            mk_btn(LABEL_ZOOM_OUT),
276                            mk_btn(LABEL_RESET_ZOOM),
277                            mk_btn(LABEL_FIT_ENTIRE_GRAPH),
278                        ]),
279                )
280                .into_any_element(),
281        )
282    }
283}