git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/application.rs (15.4K)

  1 //! The designer on the cce-ui engine: `impl Application for State`.
  2 //!
  3 //! The engine owns the Wayland plumbing, event loop, and renderer; these
  4 //! hooks translate its callbacks into the designer's `WindowEvent`s, stage
  5 //! the 3D scene each frame, and run the detached circular window's
  6 //! non-rectangular CSD (radial border resize, top-arc move).
  7 
  8 use std::time::{Duration, Instant};
  9 
 10 use cce_ui::engine::{
 11     xdg_toplevel::ResizeEdge, Application, CursorIcon, EngineState, LogicalPosition, LogicalSize,
 12     WindowAction, WindowSettings,
 13 };
 14 use cce_ui::vk::VkRenderer;
 15 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 16 use wayland_client::QueueHandle;
 17 
 18 use crate::api::start_mcp_server;
 19 use crate::app::{CustomEvent, PendingWindowDrag, State};
 20 use crate::slots::LEFT_MENUBAR_IDX;
 21 use crate::window::{LocalPosition, WindowEvent};
 22 
 23 /// Pointer travel (logical px) before a chrome press becomes an interactive
 24 /// move/resize, so a plain click on the border doesn't start a grab.
 25 const DRAG_THRESHOLD: f32 = 4.0;
 26 
 27 impl State {
 28     /// What the detached circular window's chrome at (lx, ly) would do:
 29     /// radial border band → resize, top menubar arc → move, an open menu
 30     /// always wins. `None` outside the chrome.
 31     fn circular_chrome_at(&self, lx: f32, ly: f32) -> Option<WindowAction> {
 32         let dx = lx - self.circular_network_layout.x;
 33         let dy = ly - self.circular_network_layout.y;
 34         let dist = (dx * dx + dy * dy).sqrt();
 35         if dist <= f32::EPSILON {
 36             return None;
 37         }
 38         let r = self.circular_network_layout.r;
 39         let on_border = dist >= r - 12.0 && dist <= r;
 40         let in_menubar_bg = dy < 0.0 && dist >= r - 35.0 && dist <= r;
 41         if !(on_border || in_menubar_bg)
 42             || self.menu(LEFT_MENUBAR_IDX).get_menu_items_at(lx, ly).is_some()
 43         {
 44             return None;
 45         }
 46         if on_border {
 47             let nx = dx / dist;
 48             let ny = dy / dist;
 49             let edge = if ny < -0.382 {
 50                 if nx < -0.382 {
 51                     ResizeEdge::TopLeft
 52                 } else if nx > 0.382 {
 53                     ResizeEdge::TopRight
 54                 } else {
 55                     ResizeEdge::Top
 56                 }
 57             } else if ny > 0.382 {
 58                 if nx < -0.382 {
 59                     ResizeEdge::BottomLeft
 60                 } else if nx > 0.382 {
 61                     ResizeEdge::BottomRight
 62                 } else {
 63                     ResizeEdge::Bottom
 64                 }
 65             } else if nx < -0.382 {
 66                 ResizeEdge::Left
 67             } else {
 68                 ResizeEdge::Right
 69             };
 70             Some(WindowAction::Resize(edge))
 71         } else {
 72             Some(WindowAction::Move)
 73         }
 74     }
 75 
 76     /// The engine syncs modifier state into the UiContext (on key and wheel
 77     /// events); mirror it into the designer's own `ModifiersState`.
 78     fn sync_modifiers_from_ctx(&mut self) {
 79         self.modifiers.ctrl = self.ui_context.ctrl_pressed;
 80         self.modifiers.shift = self.ui_context.shift_pressed;
 81         self.modifiers.alt = self.ui_context.alt_pressed;
 82         self.modifiers.logo = self.ui_context.logo_pressed;
 83     }
 84 
 85     fn default_project_path() -> std::path::PathBuf {
 86         std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json")
 87     }
 88 
 89     /// Detached-window sync (both directions): debounced autosave of the
 90     /// shared `default_project.json`, and mtime-polled reload when the other
 91     /// window wrote it. Returns true when a reload happened.
 92     fn poll_shared_project(&mut self) -> bool {
 93         let mut redraw = false;
 94         let syncing = self.is_detached_network
 95             || self.detached_circular_network
 96             || self.detached_pane.is_some()
 97             || self.detached_panes.iter().any(|d| *d);
 98         if !syncing {
 99             return false;
100         }
101 
102         if self.needs_autosave {
103             let now = Instant::now();
104             if now.duration_since(self.last_autosave_time) >= Duration::from_millis(200) {
105                 self.needs_autosave = false;
106                 self.last_autosave_time = now;
107                 let path = Self::default_project_path();
108                 if let Err(e) = self.save_to_file(&path) {
109                     eprintln!("Failed to auto-save default project: {:?}", e);
110                 } else if let Ok(m) = std::fs::metadata(&path) {
111                     if let Ok(mod_time) = m.modified() {
112                         self.last_project_mod_time = Some(mod_time);
113                     }
114                 }
115             }
116         }
117 
118         let now = Instant::now();
119         if now.duration_since(self.last_project_check) >= Duration::from_millis(100) {
120             self.last_project_check = now;
121             let path = Self::default_project_path();
122             if let Ok(m) = std::fs::metadata(&path) {
123                 if let Ok(mod_time) = m.modified() {
124                     if Some(mod_time) != self.last_project_mod_time {
125                         self.last_project_mod_time = Some(mod_time);
126                         if let Err(e) = self.load_from_file(&path) {
127                             eprintln!("Failed to auto-reload project: {:?}", e);
128                         } else {
129                             redraw = true;
130                         }
131                     }
132                 }
133             }
134         }
135         redraw
136     }
137 
138     pub(crate) fn autosave_on_exit(&mut self) {
139         if self.needs_autosave
140             && (self.is_detached_network
141                 || self.detached_circular_network
142                 || self.detached_pane.is_some()
143                 || self.detached_panes.iter().any(|d| *d))
144         {
145             let _ = self.save_to_file(&Self::default_project_path());
146         }
147     }
148 }
149 
150 impl Application for State {
151     type Message = CustomEvent;
152 
153     fn new(
154         _qh: &QueueHandle<EngineState<Self>>,
155         sender: calloop::channel::Sender<CustomEvent>,
156     ) -> Self {
157         let is_detached_network = std::env::args().any(|arg| arg == "--detached-network");
158         let detached_pane = std::env::args()
159             .find_map(|arg| crate::plate_corner::pane_from_detach_flag(&arg));
160         let mut state = State::new(is_detached_network);
161         if let Some(idx) = detached_pane {
162             // Set after construction, so the layout that `State::new` already
163             // ran has to be redone against the detached shape.
164             state.detached_pane = Some(idx);
165             state.rebuild_positions();
166             state.apply_layout();
167         }
168         state.event_sender = Some(sender.clone());
169         // One MCP server per project: the detached windows are satellites of the
170         // main one and would only collide on the port.
171         if !is_detached_network && detached_pane.is_none() {
172             // The user's configured startup project, over the bundled default
173             // State::new seeded. Main window only: the detached windows read
174             // default_project.json as their sync channel and must keep it.
175             state.load_default_project_setting();
176             start_mcp_server(sender);
177         }
178         state
179     }
180 
181     fn settings(&self) -> WindowSettings {
182         let (app_id, min_size) = if self.is_detached_network {
183             ("circular-network-pane", (200, 200))
184         } else if let Some(idx) = self.detached_pane {
185             (crate::plate_corner::pane_app_id(idx), (240, 160))
186         } else {
187             ("cce-designer", (480, 320))
188         };
189         WindowSettings {
190             title: self.title.clone(),
191             app_id: app_id.to_string(),
192             width: self.width as u32,
193             height: self.height as u32,
194             fullscreen: false,
195             min_size: Some(min_size),
196         }
197     }
198 
199     fn update(&mut self, msg: CustomEvent, needs_rebuild: &mut bool, exit: &mut bool) {
200         if matches!(msg, CustomEvent::Exit) {
201             self.autosave_on_exit();
202             *exit = true;
203             return;
204         }
205         if self.apply_custom_event(msg) {
206             *needs_rebuild = true;
207         }
208         // MCP can reach File > Exit through menu_action.
209         if self.exit_requested {
210             self.autosave_on_exit();
211             *exit = true;
212         }
213     }
214 
215     /// `poll_shared_project` watches the project file's mtime for a detached
216     /// pane's edits — nothing the runner can be woken by — so while a pane is
217     /// detached the loop may not sleep past this between ticks.
218     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
219         let syncing = self.is_detached_network
220             || self.detached_circular_network
221             || self.detached_pane.is_some()
222             || self.detached_panes.iter().any(|d| *d);
223         syncing.then(|| std::time::Duration::from_millis(250))
224     }
225 
226     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
227         if self.tick_frame(dt) {
228             *needs_rebuild = true;
229         }
230         if self.poll_shared_project() {
231             *needs_rebuild = true;
232         }
233     }
234 
235     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
236         Some(&self.ui_context)
237     }
238 
239     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
240         Some(&mut self.ui_context)
241     }
242 
243     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
244         self.sync_modifiers_from_ctx();
245         if let Some(pending) = self.pending_window_drag {
246             let dx = pos.x - pending.start_x;
247             let dy = pos.y - pending.start_y;
248             if (dx * dx + dy * dy).sqrt() > DRAG_THRESHOLD {
249                 self.window_action = Some(pending.action);
250                 self.pending_window_drag = None;
251             }
252         }
253         let ev = WindowEvent::CursorMoved {
254             position: LocalPosition { x: pos.x as f64, y: pos.y as f64 },
255         };
256         if self.process_window_event(ev) {
257             *needs_rebuild = true;
258         }
259     }
260 
261     fn handle_mouse_input(
262         &mut self,
263         button: MouseButton,
264         state: ElementState,
265         pos: LogicalPosition,
266         needs_rebuild: &mut bool,
267     ) -> Option<CustomEvent> {
268         self.sync_modifiers_from_ctx();
269         self.cursor_x = pos.x;
270         self.cursor_y = pos.y;
271         if self.is_detached_network && button == MouseButton::Left {
272             match state {
273                 ElementState::Pressed => {
274                     if let Some(action) = self.circular_chrome_at(pos.x, pos.y) {
275                         self.pending_window_drag = Some(PendingWindowDrag {
276                             start_x: pos.x,
277                             start_y: pos.y,
278                             action,
279                         });
280                         return None; // consumed by the window chrome
281                     }
282                 }
283                 ElementState::Released => {
284                     self.pending_window_drag = None;
285                 }
286             }
287         }
288         let ev = WindowEvent::MouseInput { state, button };
289         if self.process_window_event(ev) {
290             *needs_rebuild = true;
291         }
292         if self.exit_requested {
293             return Some(CustomEvent::Exit);
294         }
295         None
296     }
297 
298     fn handle_mouse_wheel(
299         &mut self,
300         delta: &MouseScrollDelta,
301         pos: LogicalPosition,
302         needs_rebuild: &mut bool,
303     ) {
304         self.sync_modifiers_from_ctx();
305         self.cursor_x = pos.x;
306         self.cursor_y = pos.y;
307         let ev = WindowEvent::MouseWheel { delta: delta.clone() };
308         if self.process_window_event(ev) {
309             *needs_rebuild = true;
310         }
311     }
312 
313     fn handle_pinch(&mut self, factor: f32, pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
314         self.cursor_x = pos.x;
315         self.cursor_y = pos.y;
316         // 1:1 camera zoom over the 3D viewport; anywhere else falls back to
317         // the engine's ctrl+wheel synthesis (which is what zooms the graph).
318         if self.is_detached_network || !self.cursor_in_viewport() {
319             return false;
320         }
321         self.viewport_mut().pinch_zoom(factor);
322         *needs_rebuild = true;
323         true
324     }
325 
326     /// The toolkit's undo/redo routing lands here once no focused text box
327     /// wanted the chord. Only the curve viewer state has a history today.
328     fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
329         // A code row being edited owns the chord: its typing is the thing to
330         // undo, ahead of a viewer tool that may also be active.
331         let taken = self.code_editor_action(cce_ui::widget::ContextAction::Undo) || self.viewer_tool_undo();
332         if taken {
333             *needs_rebuild = true;
334         }
335         taken
336     }
337 
338     fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
339         let taken = self.code_editor_action(cce_ui::widget::ContextAction::Redo) || self.viewer_tool_redo();
340         if taken {
341             *needs_rebuild = true;
342         }
343         taken
344     }
345 
346     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<CustomEvent> {
347         self.sync_modifiers_from_ctx();
348         let ev = WindowEvent::KeyboardInput { event: event.clone() };
349         if self.process_window_event(ev) {
350             *needs_rebuild = true;
351         }
352         if self.exit_requested {
353             return Some(CustomEvent::Exit);
354         }
355         None
356     }
357 
358     fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
359         // The single paint path: the whole 2D frame — geometry and text — rebuilt
360         // every drawn frame (the engine only draws on demand). The 3D scene / RT
361         // panes stay in stage_renderer.
362         Some(self.collect_display_list())
363     }
364 
365     fn display_list_text(&self) -> bool {
366         true
367     }
368 
369     fn renderer_init(&mut self, renderer: &mut VkRenderer) {
370         self.init_renderer(renderer);
371         // Everything that cannot survive a REPLACEMENT renderer, which this
372         // may be — see `renderer_handed_over`.
373         self.renderer_handed_over();
374     }
375 
376     fn stage_renderer(&mut self, renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
377         self.stage_frame(renderer)
378     }
379 
380     fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
381         self.resize(width, height, scale);
382     }
383 
384     fn standard_csd(&self) -> bool {
385         // The detached window's chrome is the circle, not the rect.
386         !self.is_detached_network
387     }
388 
389     fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
390         if !self.is_detached_network {
391             // Main window: resize cursor over the pane edge-resize hotspots and
392             // for the duration of a pane-edge drag; `None` elsewhere so the
393             // engine's standard CSD edge cursors still apply.
394             return self.pane_resize_cursor(x, y);
395         }
396         Some(match self.circular_chrome_at(x, y) {
397             Some(WindowAction::Resize(edge)) => match edge {
398                 ResizeEdge::TopLeft => CursorIcon::NwResize,
399                 ResizeEdge::Top => CursorIcon::NResize,
400                 ResizeEdge::TopRight => CursorIcon::NeResize,
401                 ResizeEdge::Left => CursorIcon::WResize,
402                 ResizeEdge::Right => CursorIcon::EResize,
403                 ResizeEdge::BottomLeft => CursorIcon::SwResize,
404                 ResizeEdge::Bottom => CursorIcon::SResize,
405                 ResizeEdge::BottomRight => CursorIcon::SeResize,
406                 _ => CursorIcon::Default,
407             },
408             _ => CursorIcon::Default,
409         })
410     }
411 
412     fn take_window_action(&mut self) -> Option<WindowAction> {
413         self.window_action.take()
414     }
415 
416     fn on_exit(&mut self) {
417         self.autosave_on_exit();
418     }
419 }