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 }