1use gpui::{
4 Bounds, IntoElement as _, MouseButton, ParentElement as _, Pixels, Point, Size, Styled as _,
5 div, px, rgb,
6};
7
8const LABEL_ZOOM_OUT: &str = "\u{2212}";
10const LABEL_ZOOM_IN: &str = "+";
11const LABEL_RESET_ZOOM: &str = "\u{21BA}";
13const 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;
27const BTN: f32 = 36.0;
29const GAP: f32 = 6.0;
30const 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
174pub 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}