git.lucas.co / cce-designer
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 }