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 }