graphic design tool
git clone https://git.lucas.co/cce-designer.git
src/plate_menu.rs (20.8K)
1 //! The plate menu: what can be done to a pane's PLATE — collapse and
2 //! detach — as rows of that pane's right-click menu.
3 //!
4 //! Until 2026-10-01 these rows were a menu of their own, opened by a small
5 //! circular trigger on the top-right of every plate (and that trigger, dragged,
6 //! moved the pane to another dock). The trigger is gone: where the plate's
7 //! pane has a context menu of its own (the network editor's, the
8 //! playbar's) the rows are a PAGE of it, its **Plate** row turning the menu
9 //! into them (since 2026-10-06; they were appended inline until then), and
10 //! they make up the whole menu where it has none (the params pane off a
11 //! row, the spreadsheet, and a collapsed or detached plate's stub). Moving
12 //! a pane to another dock was a row too (`Move To …`) until the docks went,
13 //! 2026-10-07.
14 //!
15 //! [`State::plate_menu_rows`] is the one list; [`State::open_plate_menu_at`]
16 //! shows it alone and [`State::open_plate_page`] as another menu's page,
17 //! dispatching a pick through [`State::run_plate_menu_action`].
18
19 use crate::app::State;
20 use crate::slots::{
21 NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX, WIDGET_COUNT,
22 };
23 use cce_ui::widget::plate_dock::{self, PlateDockAction, PlateDockState};
24
25 /// The plates that carry a plate menu. The viewport is deliberately absent:
26 /// its "plate" is the window-spanning lip, not a pane.
27 pub const PLATE_SLOTS: [usize; 4] = [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, PLAYBAR_IDX];
28
29 /// What the plate menu can do to its plate.
30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 pub enum PlateMenuAction {
32 /// Shrink the plate to its title stub (or restore it).
33 Collapse,
34 Expand,
35 /// Move the pane out into its own window.
36 Detach,
37 /// Take a detached pane back, closing the window that held it.
38 Reattach,
39 /// A "-" row: engraved, inert — keeps the action list aligned with the
40 /// option rows so a click on the line dispatches nothing.
41 Separator,
42 }
43
44 /// The pane's display name — the stub's label, and what the menu is "about".
45 pub fn plate_title(idx: usize) -> &'static str {
46 match idx {
47 NETWORK_PANEL_IDX => "Network",
48 PARAM_IDX => "Parameters",
49 SPREADSHEET_IDX => "Spreadsheet",
50 PLAYBAR_IDX => "Playbar",
51 _ => "Pane",
52 }
53 }
54
55
56 impl State {
57 /// The plate whose rect holds (px, py), topmost first — the plate a
58 /// right press there is about.
59 pub fn plate_at(&self, px: f32, py: f32) -> Option<usize> {
60 PLATE_SLOTS.iter().rev().copied().find(|&idx| {
61 let w = self.slots.get_dyn(&self.ui_context, idx);
62 if !w.visible() {
63 return false;
64 }
65 // The network spans the window and has no plate: it is not the
66 // plate under anything.
67 if idx == NETWORK_PANEL_IDX && !self.pane_is_stubbed(idx) {
68 return false;
69 }
70 // The params HUD is under every plate and, without its own,
71 // only its rows: it is the plate there only where it claims.
72 if idx == PARAM_IDX && !self.pane_is_stubbed(idx) {
73 return self.params_claims(px, py);
74 }
75 let (x, y, ww, h) = w.rect();
76 ww > 0.0 && h > 0.0 && px >= x && px < x + ww && py >= y && py < y + h
77 })
78 }
79
80 /// The rows of `idx`'s plate menu, labels and actions in step. Contextual:
81 /// a collapsed plate offers Expand instead of Collapse, Detach appears only
82 /// where a detached window exists for that pane, and a detached pane's
83 /// stub offers Reattach alone.
84 pub fn plate_menu_rows(&self, idx: usize) -> (Vec<String>, Vec<PlateMenuAction>) {
85 // The standard rows come from the toolkit protocol (Reattach-only for
86 // a detached pane's stub, Collapse/Expand, Detach when allowed); the
87 // designer appends its app rows after.
88 let state = PlateDockState {
89 collapsed: self.collapsed_panes[idx],
90 detached: self.pane_is_detached(idx),
91 };
92 let mut options: Vec<String> = Vec::new();
93 let mut actions: Vec<PlateMenuAction> = Vec::new();
94 for (label, action) in plate_dock::standard_menu(state, self.plate_can_detach(idx)) {
95 // Neither the HUD nor the network collapses: neither is a plate
96 // with a stub to shrink to, and hiding one is its Show … Pane.
97 if (idx == PARAM_IDX || idx == NETWORK_PANEL_IDX)
98 && matches!(action, PlateDockAction::Collapse | PlateDockAction::Expand)
99 {
100 continue;
101 }
102 options.push(label);
103 actions.push(match action {
104 PlateDockAction::Collapse => PlateMenuAction::Collapse,
105 PlateDockAction::Expand => PlateMenuAction::Expand,
106 PlateDockAction::Detach => PlateMenuAction::Detach,
107 PlateDockAction::Reattach => PlateMenuAction::Reattach,
108 });
109 }
110 // The window actions are the whole menu: the spreadsheet's Full
111 // Width / Between Panes rows and every docked plate's Move To rows
112 // went with the docks, 2026-10-07.
113 (options, actions)
114 }
115
116 /// Show `idx`'s plate menu alone, at the pointer — a pane with no
117 /// context menu of its own, or a stub.
118 pub fn open_plate_menu(&mut self, idx: usize) {
119 self.open_plate_menu_at(idx, None);
120 }
121
122 /// Show `idx`'s plate menu alone: at the pointer, or with its top-left
123 /// at `at` in place of a page it is turned back to from.
124 pub fn open_plate_menu_at(&mut self, idx: usize, at: Option<(f32, f32)>) {
125 let (options, actions) = self.plate_menu_rows(idx);
126 if options.is_empty() {
127 return;
128 }
129 let target = self.slots.get_dyn(&self.ui_context, idx).base().id();
130 self.put_up_menu(at, None, options, 0, target);
131 self.plate_menu_slot = Some(idx);
132 self.plate_menu_actions = actions;
133 self.plate_page_from = None;
134 }
135
136 /// `idx`'s plate rows as a PAGE of another menu — the playbar's Plate
137 /// row turned into them — at `at`, under a back band to `from`.
138 pub fn open_plate_page(&mut self, idx: usize, at: (f32, f32), from: crate::menu_page::MenuOrigin) {
139 let (options, actions) = self.plate_menu_rows(idx);
140 if options.is_empty() {
141 return;
142 }
143 let target = self.slots.get_dyn(&self.ui_context, idx).base().id();
144 self.put_up_menu(Some(at), Some(from), options, 0, target);
145 self.plate_menu_slot = Some(idx);
146 self.plate_menu_actions = actions;
147 self.plate_page_from = Some(from);
148 }
149
150 pub fn plate_menu_open(&self) -> bool {
151 cce_ui::widget::context_menu::is_visible() && self.plate_menu_slot.is_some()
152 }
153
154 pub fn close_plate_menu(&mut self) {
155 cce_ui::widget::context_menu::hide();
156 self.plate_menu_slot = None;
157 self.plate_menu_actions.clear();
158 self.plate_page_from = None;
159 }
160
161 /// Route a left press while the plate menu is open — same contract as
162 /// `handle_node_menu_click`.
163 pub fn handle_plate_menu_click(&mut self) -> bool {
164 if !self.plate_menu_open() {
165 return false;
166 }
167 if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
168 let row = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
169 let picked = self.plate_menu_slot.zip(row.and_then(|r| self.plate_menu_actions.get(r).copied()));
170 self.close_plate_menu();
171 if let Some((idx, action)) = picked {
172 self.run_plate_menu_action(idx, action);
173 }
174 return true;
175 }
176 self.close_plate_menu();
177 false
178 }
179
180 /// Run a plate row for `idx`.
181 pub fn run_plate_menu_action(&mut self, idx: usize, action: PlateMenuAction) {
182 match action {
183 PlateMenuAction::Collapse => self.set_pane_collapsed(idx, true),
184 PlateMenuAction::Expand => self.set_pane_collapsed(idx, false),
185 PlateMenuAction::Detach => self.detach_plate(idx),
186 PlateMenuAction::Reattach => self.reattach_plate(idx),
187 PlateMenuAction::Separator => {}
188 }
189 }
190
191 pub fn set_pane_collapsed(&mut self, idx: usize, collapsed: bool) {
192 // The params HUD and the network never collapse (see
193 // `plate_menu_rows`).
194 let collapsed = collapsed && idx != PARAM_IDX && idx != NETWORK_PANEL_IDX;
195 if !PLATE_SLOTS.contains(&idx) || self.collapsed_panes[idx] == collapsed {
196 return;
197 }
198 self.collapsed_panes[idx] = collapsed;
199 self.rebuild_positions();
200 self.apply_layout();
201 }
202 }
203
204 impl State {
205 /// Whether `idx` has somewhere to detach TO. Today only the network pane
206 /// has a detached-window mode (`--detached-network`); the others gain one
207 /// as that path is generalized, and until then they simply do not offer
208 /// the item rather than offering one that does nothing.
209 pub fn plate_can_detach(&self, idx: usize) -> bool {
210 // A detached window never offers to detach its own pane again, and a
211 // pane already handed out cannot be handed out twice.
212 if self.is_detached_network || self.detached_pane.is_some() {
213 return false;
214 }
215 match idx {
216 NETWORK_PANEL_IDX => !self.detached_circular_network,
217 other => pane_detach_flag(other).is_some() && !self.detached_panes[other],
218 }
219 }
220
221 fn detach_plate(&mut self, idx: usize) {
222 if idx == NETWORK_PANEL_IDX {
223 self.execute_action(crate::shortcut::Action::DetachCircularWindow);
224 return;
225 }
226 let Some(flag) = pane_detach_flag(idx) else { return };
227
228 // The detached window reads the pane out of the shared project file and
229 // then syncs through it, exactly as the network window does — so it has
230 // to be on disk BEFORE the child starts.
231 let shared = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
232 if let Err(e) = self.save_to_file(&shared) {
233 eprintln!("Failed to save shared project before detaching: {e:?}");
234 return;
235 }
236
237 match std::env::current_exe() {
238 Ok(exe) => match std::process::Command::new(exe).arg(flag).spawn() {
239 Ok(child) => {
240 self.detached_children.insert(idx, child);
241 self.detached_panes[idx] = true;
242 self.rebuild_positions();
243 self.apply_layout();
244 }
245 // Leave the pane in place if the child never started, rather
246 // than hiding it into a window that does not exist.
247 Err(e) => eprintln!("Failed to spawn detached {}: {e:?}", plate_title(idx)),
248 },
249 Err(e) => eprintln!("Cannot locate own executable to detach: {e:?}"),
250 }
251 }
252 }
253
254 /// Height of a collapsed plate: its title stub (toolkit-owned since 7c).
255 pub use cce_ui::widget::plate_dock::STUB_H;
256
257 impl State {
258 /// Rewrite the collapsed plates' rects down to their stubs.
259 ///
260 /// A post-pass over `positions[..]` rather than a branch in each layout
261 /// arm: `rebuild_positions` lays panes out three different ways (floating,
262 /// circular network, detached window) and collapse means the same thing in
263 /// all of them — keep the plate's origin and width, take its height down to
264 /// the stub. In the floating layout, which is what the main window uses,
265 /// that reclaims the space outright: the panes float over a full-bleed
266 /// viewport, so nothing has to reflow around them.
267 ///
268 /// The panes whose body is a SEPARATE slot (the network plate owns the
269 /// graph and the breadcrumb) also hide those, since a stub has no room for
270 /// them and they would otherwise keep painting over the viewport.
271 pub(crate) fn apply_collapsed_panes(&mut self) {
272 for idx in PLATE_SLOTS {
273 if !self.collapsed_panes[idx] {
274 continue;
275 }
276 self.stub_slot(idx);
277 }
278 }
279
280 /// Whether `idx` is currently drawn as a stub — the render pass asks before
281 /// painting a pane's body, and the title text only appears here.
282 pub fn pane_is_collapsed(&self, idx: usize) -> bool {
283 PLATE_SLOTS.contains(&idx) && self.collapsed_panes[idx]
284 }
285 }
286
287 /// The CLI flag that runs this pane as its own window, e.g. `--detached-params`.
288 /// The network keeps `--detached-network`, handled separately: its detached
289 /// window is circular, not merely detached.
290 pub fn pane_detach_flag(idx: usize) -> Option<&'static str> {
291 match idx {
292 PARAM_IDX => Some("--detached-params"),
293 SPREADSHEET_IDX => Some("--detached-spreadsheet"),
294 PLAYBAR_IDX => Some("--detached-playbar"),
295 _ => None,
296 }
297 }
298
299 /// The stable external name of a plate pane — the identity used by the MCP
300 /// pane tools and by pane state persisted in project files.
301 pub fn pane_name_from_slot(idx: usize) -> Option<&'static str> {
302 match idx {
303 NETWORK_PANEL_IDX => Some("network"),
304 PARAM_IDX => Some("parameters"),
305 SPREADSHEET_IDX => Some("spreadsheet"),
306 PLAYBAR_IDX => Some("playbar"),
307 _ => None,
308 }
309 }
310
311 /// The inverse of [`pane_name_from_slot`], accepting the "params" shorthand.
312 pub fn pane_slot_from_name(name: &str) -> Option<usize> {
313 match name.to_ascii_lowercase().as_str() {
314 "network" => Some(NETWORK_PANEL_IDX),
315 "parameters" | "params" => Some(PARAM_IDX),
316 "spreadsheet" => Some(SPREADSHEET_IDX),
317 "playbar" => Some(PLAYBAR_IDX),
318 _ => None,
319 }
320 }
321
322 /// The pane an argv entry asks for, if any — the inverse of [`pane_detach_flag`].
323 pub fn pane_from_detach_flag(arg: &str) -> Option<usize> {
324 PLATE_SLOTS
325 .iter()
326 .copied()
327 .find(|&idx| pane_detach_flag(idx) == Some(arg))
328 }
329
330 /// The detached window's `app_id`, which the compositor keys window rules off.
331 pub fn pane_app_id(idx: usize) -> &'static str {
332 match idx {
333 PARAM_IDX => "cce-designer-params",
334 SPREADSHEET_IDX => "cce-designer-spreadsheet",
335 PLAYBAR_IDX => "cce-designer-playbar",
336 _ => "cce-designer",
337 }
338 }
339
340 /// Inset of a detached pane inside its own window, so the plate keeps a visible
341 /// edge of its own instead of fusing with the window border.
342 pub const DETACHED_MARGIN: f32 = 8.0;
343
344 impl State {
345 /// Resolve the detached-window arrangement, both sides of it.
346 ///
347 /// A post-pass for the same reason `apply_collapsed_panes` is one: detaching
348 /// means one thing regardless of which of the three layout branches just
349 /// ran. In the CHILD process the detached pane claims the whole window and
350 /// every other slot goes dark; in the PARENT the panes it has handed out
351 /// stop being laid out, so the space they held is released.
352 pub(crate) fn apply_detached_panes(&mut self) {
353 if let Some(idx) = self.detached_pane {
354 for i in 0..WIDGET_COUNT {
355 if i == idx {
356 continue;
357 }
358 self.positions[i] = (0.0, 0.0, 0.0, 0.0);
359 self.slots.get_dyn_mut(&mut self.ui_context, i).set_visible(false);
360 }
361 let m = DETACHED_MARGIN;
362 self.positions[idx] = (
363 m,
364 m,
365 (self.width - 2.0 * m).max(0.0),
366 (self.height - 2.0 * m).max(0.0),
367 );
368 self.slots.get_dyn_mut(&mut self.ui_context, idx).set_visible(true);
369 return;
370 }
371
372 // The parent keeps a STUB for each pane it handed out rather than
373 // dropping it: the stub carries the corner control, which is the only
374 // way back. Hiding the pane outright left no way to reattach it.
375 for idx in PLATE_SLOTS {
376 if self.detached_panes[idx] {
377 self.stub_slot(idx);
378 }
379 }
380 }
381
382 /// Shrink one slot to its title stub, taking any separate body slots with it.
383 /// Shared by collapse and by the parent side of a detach.
384 fn stub_slot(&mut self, idx: usize) {
385 let (x, y, w, h) = self.positions[idx];
386 if w <= 0.0 || h <= 0.0 {
387 // Already laid out as hidden — there is no stub to make.
388 return;
389 }
390 self.positions[idx] = (x, y, w, STUB_H.min(h));
391 if idx == NETWORK_PANEL_IDX {
392 for child in [crate::slots::CONTENT_IDX, crate::slots::BREADCRUMB_IDX] {
393 self.positions[child] = (0.0, 0.0, 0.0, 0.0);
394 self.slots.get_dyn_mut(&mut self.ui_context, child).set_visible(false);
395 }
396 }
397 }
398 }
399
400 impl State {
401 /// Is this pane currently living in a detached window? The network's flag
402 /// is separate because its detached window is the circular one.
403 pub fn pane_is_detached(&self, idx: usize) -> bool {
404 if idx == NETWORK_PANEL_IDX {
405 return self.detached_circular_network;
406 }
407 PLATE_SLOTS.contains(&idx) && self.detached_panes[idx]
408 }
409
410 /// Is this pane drawn as a stub rather than in full — collapsed, or left
411 /// behind by a detach?
412 pub fn pane_is_stubbed(&self, idx: usize) -> bool {
413 self.pane_is_detached(idx) || self.pane_is_collapsed(idx)
414 }
415
416 /// The label a stubbed pane shows, or `None` when the pane is drawn in full.
417 /// Collapsed and detached both stub, and they must not look alike: one is
418 /// one click from expanding, the other is somewhere else entirely.
419 pub fn pane_stub_label(&self, idx: usize) -> Option<String> {
420 if self.pane_is_detached(idx) {
421 Some(format!("{} — detached", plate_title(idx)))
422 } else if self.pane_is_collapsed(idx) {
423 Some(plate_title(idx).to_string())
424 } else {
425 None
426 }
427 }
428
429 /// Detach or reattach a pane — the corner menu's two window actions, also
430 /// the MCP surface's, so pane placement is scriptable like collapse is.
431 pub fn set_pane_detached(&mut self, idx: usize, detached: bool) {
432 if detached {
433 if self.plate_can_detach(idx) {
434 self.detach_plate(idx);
435 }
436 } else {
437 self.reattach_plate(idx);
438 }
439 }
440
441 /// Take a detached pane back and close the window that held it.
442 pub fn reattach_plate(&mut self, idx: usize) {
443 if !self.pane_is_detached(idx) {
444 return;
445 }
446 self.close_detached_child(idx);
447
448 if idx == NETWORK_PANEL_IDX {
449 // Toggling the action back off is the network's own reattach — it
450 // clears the flag and re-lays out without spawning anything.
451 self.execute_action(crate::shortcut::Action::DetachCircularWindow);
452 return;
453 }
454
455 self.detached_panes[idx] = false;
456 self.rebuild_positions();
457 self.apply_layout();
458 }
459
460 /// Close the detached child and reap it. Best-effort: a child the user
461 /// already closed is simply gone, and reattaching must work anyway.
462 fn close_detached_child(&mut self, idx: usize) {
463 if let Some(mut child) = self.detached_children.remove(&idx) {
464 let _ = child.kill();
465 // Reap it, or the process table keeps a zombie for the rest of the
466 // session — the same trap the liveness probe fell into.
467 let _ = child.wait();
468 }
469 }
470
471 /// Notice detached children the user closed themselves and take their panes
472 /// back, so a closed window does not strand its pane as a dead stub. Called
473 /// from the frame tick.
474 ///
475 /// `try_wait`, NOT `kill(pid, 0)`: the child is ours and unreaped, so once
476 /// it exits it is a zombie — still present in the process table, so the
477 /// signal probe reports it alive forever and the pane is never reclaimed.
478 pub(crate) fn poll_detached_children(&mut self) -> bool {
479 let mut reclaimed = false;
480 for idx in PLATE_SLOTS {
481 if !self.pane_is_detached(idx) {
482 continue;
483 }
484 let exited = match self.detached_children.get_mut(&idx) {
485 // `Ok(None)` is the only "still running" answer; an Err handle
486 // is no more useful than an exited one.
487 Some(child) => !matches!(child.try_wait(), Ok(None)),
488 None => continue,
489 };
490 if !exited {
491 continue;
492 }
493 self.detached_children.remove(&idx);
494 if idx == NETWORK_PANEL_IDX {
495 self.detached_circular_network = false;
496 let val = false;
497 self.menu_mut(crate::slots::LEFT_MENUBAR_IDX).set_item_checked(2, 3, val);
498 self.menu_mut(crate::slots::HEADER_IDX).set_item_checked(2, 3, val);
499 } else {
500 self.detached_panes[idx] = false;
501 }
502 reclaimed = true;
503 }
504 if reclaimed {
505 self.rebuild_positions();
506 self.apply_layout();
507 }
508 reclaimed
509 }
510 }