Rust · 12938 bytes Raw Blame History
1 //! Canvas rendering for machine grid
2
3 use iced::widget::canvas::{self, Cache, Canvas, Event, Frame, Geometry, Path, Stroke, Text};
4 use iced::mouse;
5 use iced::{Color, Point, Rectangle, Renderer, Size, Theme};
6
7 use super::messages::{CanvasEvent, Message};
8 use super::state::{ConnectionStatus, DragState, GridPos, MachineNode};
9 use super::theme;
10
11 /// The machine grid canvas program
12 pub struct MachineGrid {
13 /// All machines to render
14 machines: Vec<MachineNode>,
15 /// Current drag state
16 drag: DragState,
17 /// Available snap positions
18 available_positions: Vec<GridPos>,
19 /// Render cache
20 cache: Cache,
21 }
22
23 impl MachineGrid {
24 pub fn new(
25 machines: Vec<MachineNode>,
26 drag: DragState,
27 available_positions: Vec<GridPos>,
28 ) -> Self {
29 Self {
30 machines,
31 drag,
32 available_positions,
33 cache: Cache::new(),
34 }
35 }
36
37 /// Convert grid position to canvas coordinates
38 fn grid_to_canvas(&self, pos: GridPos, bounds: &Rectangle) -> Point {
39 let center = Point::new(bounds.width / 2.0, bounds.height / 2.0);
40 Point::new(
41 center.x + (pos.x as f32 * theme::GRID_SPACING_H),
42 center.y + (pos.y as f32 * theme::GRID_SPACING_V),
43 )
44 }
45
46 /// Convert canvas coordinates to nearest grid position
47 fn canvas_to_grid(&self, point: Point, bounds: &Rectangle) -> GridPos {
48 let center = Point::new(bounds.width / 2.0, bounds.height / 2.0);
49 let relative = Point::new(point.x - center.x, point.y - center.y);
50
51 GridPos {
52 x: (relative.x / theme::GRID_SPACING_H).round() as i32,
53 y: (relative.y / theme::GRID_SPACING_V).round() as i32,
54 }
55 }
56
57 /// Find machine at canvas position
58 fn machine_at_position(&self, point: Point, bounds: &Rectangle) -> Option<usize> {
59 for (idx, machine) in self.machines.iter().enumerate() {
60 let machine_center = self.grid_to_canvas(machine.grid_pos, bounds);
61 let rect = Rectangle::new(
62 Point::new(
63 machine_center.x - theme::MACHINE_WIDTH / 2.0,
64 machine_center.y - theme::MACHINE_HEIGHT / 2.0,
65 ),
66 Size::new(theme::MACHINE_WIDTH, theme::MACHINE_HEIGHT),
67 );
68
69 if rect.contains(point) {
70 return Some(idx);
71 }
72 }
73 None
74 }
75
76 /// Find snap target near cursor position
77 fn find_snap_target(&self, point: Point, bounds: &Rectangle) -> Option<GridPos> {
78 for &pos in &self.available_positions {
79 let target = self.grid_to_canvas(pos, bounds);
80 let distance = ((point.x - target.x).powi(2) + (point.y - target.y).powi(2)).sqrt();
81
82 if distance < theme::SNAP_RADIUS {
83 return Some(pos);
84 }
85 }
86 None
87 }
88
89 /// Draw a machine rectangle
90 fn draw_machine(&self, frame: &mut Frame, machine: &MachineNode, center: Point, dragging: bool) {
91 let half_w = theme::MACHINE_WIDTH / 2.0;
92 let half_h = theme::MACHINE_HEIGHT / 2.0;
93
94 // Machine background
95 let bg_color = if machine.is_self {
96 theme::MACHINE_SELF
97 } else {
98 theme::MACHINE_NEIGHBOR
99 };
100
101 // Apply opacity if dragging
102 let bg_color = if dragging {
103 Color::from_rgba(bg_color.r, bg_color.g, bg_color.b, 0.6)
104 } else {
105 bg_color
106 };
107
108 let rect = Path::rectangle(
109 Point::new(center.x - half_w, center.y - half_h),
110 Size::new(theme::MACHINE_WIDTH, theme::MACHINE_HEIGHT),
111 );
112
113 frame.fill(&rect, bg_color);
114
115 // Status indicator (small circle in corner)
116 if !machine.is_self {
117 let status_color = match machine.status {
118 ConnectionStatus::Connected => theme::STATUS_CONNECTED,
119 ConnectionStatus::Connecting => theme::STATUS_CONNECTING,
120 ConnectionStatus::Disconnected => theme::STATUS_DISCONNECTED,
121 };
122
123 let status_pos = Point::new(center.x + half_w - 12.0, center.y - half_h + 12.0);
124 let status_circle = Path::circle(status_pos, 6.0);
125 frame.fill(&status_circle, status_color);
126 }
127
128 // Machine name
129 let name_text = Text {
130 content: machine.name.clone(),
131 position: Point::new(center.x, center.y - 10.0),
132 color: theme::TEXT_LIGHT,
133 size: iced::Pixels(16.0),
134 horizontal_alignment: iced::alignment::Horizontal::Center,
135 vertical_alignment: iced::alignment::Vertical::Center,
136 ..Default::default()
137 };
138 frame.fill_text(name_text);
139
140 // Address (for neighbors)
141 if !machine.is_self && !machine.address.is_empty() {
142 let addr_text = Text {
143 content: machine.address.clone(),
144 position: Point::new(center.x, center.y + 15.0),
145 color: Color::from_rgba(1.0, 1.0, 1.0, 0.7),
146 size: iced::Pixels(11.0),
147 horizontal_alignment: iced::alignment::Horizontal::Center,
148 vertical_alignment: iced::alignment::Vertical::Center,
149 ..Default::default()
150 };
151 frame.fill_text(addr_text);
152 }
153
154 // Self label
155 if machine.is_self {
156 let self_text = Text {
157 content: "(this machine)".to_string(),
158 position: Point::new(center.x, center.y + 15.0),
159 color: Color::from_rgba(1.0, 1.0, 1.0, 0.7),
160 size: iced::Pixels(11.0),
161 horizontal_alignment: iced::alignment::Horizontal::Center,
162 vertical_alignment: iced::alignment::Vertical::Center,
163 ..Default::default()
164 };
165 frame.fill_text(self_text);
166 }
167 }
168
169 /// Draw a snap target indicator
170 fn draw_snap_target(&self, frame: &mut Frame, center: Point, highlighted: bool) {
171 let half_w = theme::MACHINE_WIDTH / 2.0;
172 let half_h = theme::MACHINE_HEIGHT / 2.0;
173
174 let color = if highlighted {
175 theme::SNAP_HIGHLIGHT
176 } else {
177 Color::from_rgba(0.5, 0.5, 0.5, 0.15)
178 };
179
180 let rect = Path::rectangle(
181 Point::new(center.x - half_w, center.y - half_h),
182 Size::new(theme::MACHINE_WIDTH, theme::MACHINE_HEIGHT),
183 );
184
185 frame.fill(&rect, color);
186
187 // Dashed border
188 let stroke = Stroke::default()
189 .with_color(Color::from_rgba(0.5, 0.5, 0.5, 0.4))
190 .with_width(2.0);
191
192 frame.stroke(&rect, stroke);
193
194 // Direction label
195 if let Some(direction) = (GridPos {
196 x: ((center.x - frame.size().width / 2.0) / theme::GRID_SPACING_H).round() as i32,
197 y: ((center.y - frame.size().height / 2.0) / theme::GRID_SPACING_V).round() as i32,
198 })
199 .to_direction()
200 {
201 let label = Text {
202 content: format!("{}", direction),
203 position: center,
204 color: Color::from_rgba(0.4, 0.4, 0.4, 0.6),
205 size: iced::Pixels(14.0),
206 horizontal_alignment: iced::alignment::Horizontal::Center,
207 vertical_alignment: iced::alignment::Vertical::Center,
208 ..Default::default()
209 };
210 frame.fill_text(label);
211 }
212 }
213 }
214
215 impl canvas::Program<Message> for MachineGrid {
216 type State = ();
217
218 fn update(
219 &self,
220 _state: &mut Self::State,
221 event: Event,
222 bounds: Rectangle,
223 cursor: mouse::Cursor,
224 ) -> (canvas::event::Status, Option<Message>) {
225 let cursor_position = cursor.position_in(bounds);
226
227 match event {
228 Event::Mouse(mouse_event) => match mouse_event {
229 mouse::Event::ButtonPressed(mouse::Button::Left) => {
230 if let Some(pos) = cursor_position {
231 if let Some(idx) = self.machine_at_position(pos, &bounds) {
232 // Don't allow dragging self
233 if !self.machines[idx].is_self {
234 return (
235 canvas::event::Status::Captured,
236 Some(Message::CanvasEvent(CanvasEvent::MachinePressed(idx))),
237 );
238 }
239 }
240 }
241 }
242 mouse::Event::ButtonReleased(mouse::Button::Left) => {
243 if self.drag.is_dragging() {
244 return (
245 canvas::event::Status::Captured,
246 Some(Message::CanvasEvent(CanvasEvent::MouseReleased)),
247 );
248 }
249 }
250 mouse::Event::CursorMoved { .. } => {
251 if self.drag.is_dragging() {
252 if let Some(pos) = cursor_position {
253 // Calculate snap target
254 let snap_target = self.find_snap_target(pos, &bounds);
255 return (
256 canvas::event::Status::Captured,
257 Some(Message::CanvasEvent(CanvasEvent::MouseMoved(pos, snap_target))),
258 );
259 }
260 }
261 }
262 _ => {}
263 },
264 _ => {}
265 }
266
267 (canvas::event::Status::Ignored, None)
268 }
269
270 fn draw(
271 &self,
272 _state: &Self::State,
273 renderer: &Renderer,
274 _theme: &Theme,
275 bounds: Rectangle,
276 _cursor: mouse::Cursor,
277 ) -> Vec<Geometry> {
278 let geometry = self.cache.draw(renderer, bounds.size(), |frame| {
279 // Background
280 frame.fill_rectangle(
281 Point::ORIGIN,
282 frame.size(),
283 theme::BACKGROUND,
284 );
285
286 // Draw connection dots between self and each neighbor
287 let self_center = self.grid_to_canvas(GridPos::origin(), &bounds);
288 for machine in &self.machines {
289 if !machine.is_self {
290 let neighbor_center = self.grid_to_canvas(machine.grid_pos, &bounds);
291 // Calculate midpoint between self and neighbor
292 let mid = Point::new(
293 (self_center.x + neighbor_center.x) / 2.0,
294 (self_center.y + neighbor_center.y) / 2.0,
295 );
296 // Draw connection dot
297 let dot = Path::circle(mid, theme::CONNECTION_DOT_RADIUS);
298 frame.fill(&dot, theme::CONNECTION_DOT);
299 }
300 }
301
302 // Draw available snap positions
303 for &pos in &self.available_positions {
304 let target_center = self.grid_to_canvas(pos, &bounds);
305 let highlighted = self.drag.snap_target == Some(pos);
306 self.draw_snap_target(frame, target_center, highlighted);
307 }
308
309 // Draw machines (except the one being dragged)
310 for (idx, machine) in self.machines.iter().enumerate() {
311 let is_being_dragged = self.drag.dragging == Some(idx);
312 if !is_being_dragged {
313 let center = self.grid_to_canvas(machine.grid_pos, &bounds);
314 self.draw_machine(frame, machine, center, false);
315 }
316 }
317
318 // Draw dragged machine at cursor position
319 if let Some(drag_idx) = self.drag.dragging {
320 if let Some(cursor_pos) = self.drag.cursor_pos {
321 let machine = &self.machines[drag_idx];
322 self.draw_machine(frame, machine, cursor_pos, true);
323 }
324 }
325 });
326
327 vec![geometry]
328 }
329
330 fn mouse_interaction(
331 &self,
332 _state: &Self::State,
333 bounds: Rectangle,
334 cursor: mouse::Cursor,
335 ) -> mouse::Interaction {
336 if self.drag.is_dragging() {
337 return mouse::Interaction::Grabbing;
338 }
339
340 if let Some(pos) = cursor.position_in(bounds) {
341 if let Some(idx) = self.machine_at_position(pos, &bounds) {
342 if !self.machines[idx].is_self {
343 return mouse::Interaction::Grab;
344 }
345 }
346 }
347
348 mouse::Interaction::default()
349 }
350 }
351
352 /// Create the canvas widget
353 pub fn machine_canvas(
354 machines: Vec<MachineNode>,
355 drag: DragState,
356 available_positions: Vec<GridPos>,
357 ) -> Canvas<MachineGrid, Message> {
358 Canvas::new(MachineGrid::new(machines, drag, available_positions))
359 .width(iced::Length::Fill)
360 .height(iced::Length::Fill)
361 }
362