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

src/application.rs (16K)

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