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

src/plate_corner.rs (29.6K)

  1 //! The plate corner control: a small circular menu trigger riding the top-right
  2 //! of every pane that draws a plate of its own, and the menu it opens.
  3 //!
  4 //! Geometry is derived from the slot's LIVE rect rather than computed alongside
  5 //! `positions[..]`, because `rebuild_positions` lays the panes out in three
  6 //! different branches (normal, circular network, detached window) and a corner
  7 //! computed per-branch would be three things to keep in step. The circular
  8 //! network pane is the one shape whose "top-right" is not a rect corner, so it
  9 //! is special-cased onto the arc.
 10 //!
 11 //! The control follows the DE's closed-menu-trigger language (see
 12 //! `cce-ui`'s popover conventions): a transparent face over an inset trough,
 13 //! here on a fully-round radius so the trough reads as a ring.
 14 
 15 use crate::app::State;
 16 use crate::slots::{
 17     NETWORK_PANEL2_IDX, NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX, WIDGET_COUNT,
 18 };
 19 
 20 /// Radius of the control itself.
 21 // The affordance's geometry and protocol are toolkit-owned since cce-ui RFC
 22 // Phase 7c generalized this file's machinery (`cce_ui::widget::plate_dock`);
 23 // the constants re-export so the designer's draw/hit code keeps its names.
 24 pub use cce_ui::widget::plate_dock::{CORNER_INSET, CORNER_R, MIN_PLATE_SPAN};
 25 use cce_ui::widget::plate_dock::{self, PlateDockAction, PlateDockState};
 26 
 27 /// The plates that carry a corner control. The viewport is deliberately absent:
 28 /// its "plate" is the window-spanning lip, so a top-right control would sit on
 29 /// the window corner rather than on a pane.
 30 pub const PLATE_SLOTS: [usize; 5] =
 31     [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, PLAYBAR_IDX, NETWORK_PANEL2_IDX];
 32 
 33 /// The panes the tab rows offer — the dockable set. The playbar's strip is
 34 /// not a dock, and the second network editor joins as the first CLOSABLE
 35 /// pane: unplaced it simply does not exist.
 36 pub const TAB_CANDIDATES: [usize; 4] =
 37     [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, NETWORK_PANEL2_IDX];
 38 
 39 /// What the corner menu can do to its plate.
 40 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 41 pub enum PlateMenuAction {
 42     /// Shrink the plate to its title stub (or restore it).
 43     Collapse,
 44     Expand,
 45     /// Move the pane out into its own window.
 46     Detach,
 47     /// Take a detached pane back, closing the window that held it.
 48     Reattach,
 49     /// Spreadsheet: span the full window width, tucking under both neighbors
 50     /// (whose bottoms the layout raises to make room — the playbar treatment).
 51     FullWidth,
 52     /// Spreadsheet: back to the strip between the network and params panes.
 53     BetweenPanes,
 54     /// Bring this dock's named tab to the front.
 55     ShowTab(usize),
 56     /// Pull the named pane out of its dock and tab it into this one, active.
 57     AddTab(usize),
 58     /// Swap the menu for the Add Tab page — the list of panes that can be
 59     /// pulled in ([`State::open_plate_add_tab_menu`]).
 60     AddTabMenu,
 61     /// The Add Tab page's Back row: swap the main menu page back in.
 62     BackToMain,
 63     /// Move this pane out of its shared dock into the first empty one.
 64     SplitTab,
 65     /// Remove a closable pane (the second network editor) from the docks.
 66     CloseTab,
 67     /// Bind this pane to whichever editor takes the last node click.
 68     PinFollow,
 69     /// Bind this pane to one editor (CONTENT_IDX / CONTENT2_IDX).
 70     PinTo(usize),
 71     /// A "-" row: engraved, inert — keeps `plate_menu_actions` aligned with
 72     /// the option rows so a click on the line dispatches nothing.
 73     Separator,
 74 }
 75 
 76 impl State {
 77     /// Centre of `idx`'s corner control, or `None` when the plate is hidden or
 78     /// too small to carry one.
 79     pub fn plate_corner_center(&self, idx: usize) -> Option<(f32, f32)> {
 80         if !PLATE_SLOTS.contains(&idx) {
 81             return None;
 82         }
 83         let w = self.slots.get_dyn(idx);
 84         if !w.visible() {
 85             return None;
 86         }
 87 
 88         // The circular network pane: put the control where the plate's own
 89         // top-right actually is — on the arc, at 45°.
 90         if idx == NETWORK_PANEL_IDX && self.circular_network_pane {
 91             let c = &self.circular_network_layout;
 92             if c.r < MIN_PLATE_SPAN {
 93                 return None;
 94             }
 95             let d = std::f32::consts::FRAC_1_SQRT_2 * (c.r - CORNER_INSET);
 96             return Some((c.x + d, c.y - d));
 97         }
 98 
 99         // Rect-based placement is toolkit-owned (stub exemption included);
100         // only the circular-pane arc above stays designer policy.
101         plate_dock::corner_center(w.rect(), self.pane_is_stubbed(idx))
102     }
103 
104     /// The plate whose corner control is under `(px, py)`, if any. Searched in
105     /// reverse draw order so an overlapping pane's control wins, matching what
106     /// the user sees on top.
107     pub fn plate_corner_at(&self, px: f32, py: f32) -> Option<usize> {
108         PLATE_SLOTS.iter().rev().copied().find(|&idx| {
109             self.plate_corner_center(idx)
110                 .is_some_and(|c| plate_dock::corner_hit(c, px, py))
111         })
112     }
113 }
114 
115 /// The pane's display name — the stub's label, and what the menu is "about".
116 pub fn plate_title(idx: usize) -> &'static str {
117     match idx {
118         NETWORK_PANEL_IDX => "Network",
119         PARAM_IDX => "Parameters",
120         SPREADSHEET_IDX => "Spreadsheet",
121         PLAYBAR_IDX => "Playbar",
122         NETWORK_PANEL2_IDX => "Network 2",
123         _ => "Pane",
124     }
125 }
126 
127 impl State {
128     /// Open the corner menu for `idx`, anchored under its control. Items are
129     /// contextual: a collapsed plate offers Expand instead of Collapse, and
130     /// Detach only appears where a detached window exists for that pane.
131     pub fn open_plate_menu(&mut self, idx: usize) {
132         let Some((cx, cy)) = self.plate_corner_center(idx) else { return };
133 
134         // The standard rows come from the toolkit protocol (Reattach-only for
135         // a detached pane's stub, Collapse/Expand, Detach when allowed); the
136         // designer appends its app rows after.
137         let state = PlateDockState {
138             collapsed: self.collapsed_panes[idx],
139             detached: self.pane_is_detached(idx),
140         };
141         let mut options: Vec<String> = Vec::new();
142         let mut actions: Vec<PlateMenuAction> = Vec::new();
143         for (label, action) in plate_dock::standard_menu(state, self.plate_can_detach(idx)) {
144             options.push(label);
145             actions.push(match action {
146                 PlateDockAction::Collapse => PlateMenuAction::Collapse,
147                 PlateDockAction::Expand => PlateMenuAction::Expand,
148                 PlateDockAction::Detach => PlateMenuAction::Detach,
149                 PlateDockAction::Reattach => PlateMenuAction::Reattach,
150             });
151         }
152         if state.detached {
153             let target = self.slots.get_dyn(idx).base().id();
154             cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 0, target);
155             self.plate_menu_slot = Some(idx);
156             self.plate_menu_actions = actions;
157             return;
158         }
159 
160         // Group boundaries are engraved separators ("-" rows — the toolkit
161         // convention): window actions | layout spans | tab switching | tab
162         // management. Pushed lazily so a group that contributes nothing
163         // leaves no orphaned line.
164         let mut separate = |options: &mut Vec<String>, actions: &mut Vec<PlateMenuAction>| {
165             if !options.is_empty() && options.last().map(String::as_str) != Some("-") {
166                 options.push("-".to_string());
167                 actions.push(PlateMenuAction::Separator);
168             }
169         };
170 
171         if idx == SPREADSHEET_IDX && !self.collapsed_panes[idx] {
172             separate(&mut options, &mut actions);
173             // Layout spans: full-width is the playbar treatment; the neighbors'
174             // bottoms rise to make room via the tuck interlock.
175             if !(self.spreadsheet_tucks_left() && self.spreadsheet_tucks_right()) {
176                 options.push("Full Width".to_string());
177                 actions.push(PlateMenuAction::FullWidth);
178             }
179             if self.spreadsheet_tucks_left() || self.spreadsheet_tucks_right() {
180                 options.push("Between Panes".to_string());
181                 actions.push(PlateMenuAction::BetweenPanes);
182             }
183         }
184 
185         // Selection binding — the params pane and spreadsheet can pin to
186         // one editor (the viewport's right-click radio, on the plates that
187         // follow selection). Only while a second editor exists: with one,
188         // following IS pinned.
189         if (idx == PARAM_IDX || idx == SPREADSHEET_IDX)
190             && self.tab_dock_of_pane(NETWORK_PANEL2_IDX).is_some()
191         {
192             separate(&mut options, &mut actions);
193             let pin = if idx == PARAM_IDX { self.params_pin } else { self.spreadsheet_pin };
194             let mark = |on: bool| if on { "●" } else { "○" };
195             options.push(format!("{} Follow Active Editor", mark(pin.is_none())));
196             actions.push(PlateMenuAction::PinFollow);
197             options.push(format!(
198                 "{} Pin: Network",
199                 mark(pin == Some(crate::slots::CONTENT_IDX))
200             ));
201             actions.push(PlateMenuAction::PinTo(crate::slots::CONTENT_IDX));
202             options.push(format!(
203                 "{} Pin: Network 2",
204                 mark(pin == Some(crate::slots::CONTENT2_IDX))
205             ));
206             actions.push(PlateMenuAction::PinTo(crate::slots::CONTENT2_IDX));
207         }
208 
209         // Tabs — only on docked plates (the playbar's strip is not a dock).
210         // The dock's other tabs switch to the front; panes docked elsewhere
211         // can be pulled in as tabs; a pane sharing its dock can move back
212         // out to the empty dock its arrival left behind.
213         if let Some(d) = self.dock_of_pane(idx) {
214             // The dock's tabs as a RADIO group: every tab listed, the front
215             // one marked. Clicking the marked row is a no-op (show_dock_tab
216             // declines the already-active slot), so the list reads as state,
217             // not just as actions.
218             separate(&mut options, &mut actions);
219             for &t in &self.dock_tabs[d as usize] {
220                 let mark = if t == idx { "●" } else { "○" };
221                 options.push(format!("{mark} {}", plate_title(t)));
222                 actions.push(PlateMenuAction::ShowTab(t));
223             }
224             let mut managed = false;
225             let mut manage_row = |options: &mut Vec<String>, actions: &mut Vec<PlateMenuAction>| {
226                 if !managed {
227                     separate(options, actions);
228                     managed = true;
229                 }
230             };
231             // ONE "Add Tab" row: clicking it swaps the menu for the page of
232             // addable panes, instead of one row per candidate here.
233             if !self.plate_add_tab_candidates(idx, d).is_empty() {
234                 manage_row(&mut options, &mut actions);
235                 options.push("Add Tab".to_string());
236                 actions.push(PlateMenuAction::AddTabMenu);
237             }
238             if self.dock_tabs[d as usize].len() > 1 {
239                 manage_row(&mut options, &mut actions);
240                 options.push("Move To Own Plate".to_string());
241                 actions.push(PlateMenuAction::SplitTab);
242             }
243             if idx == NETWORK_PANEL2_IDX {
244                 manage_row(&mut options, &mut actions);
245                 options.push("Close Tab".to_string());
246                 actions.push(PlateMenuAction::CloseTab);
247             }
248         }
249 
250         let target = self.slots.get_dyn(idx).base().id();
251         cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 0, target);
252         self.plate_menu_slot = Some(idx);
253         self.plate_menu_actions = actions;
254     }
255 
256     /// The panes a plate's Add Tab page can offer: docked (or dockable)
257     /// elsewhere, not already in this dock's list, not detached.
258     fn plate_add_tab_candidates(&self, idx: usize, d: crate::app::Dock) -> Vec<usize> {
259         TAB_CANDIDATES
260             .into_iter()
261             .filter(|&other| {
262                 other != idx
263                     && !self.dock_tabs[d as usize].contains(&other)
264                     && !self.pane_is_detached(other)
265             })
266             .collect()
267     }
268 
269     /// The Add Tab page: swaps the corner menu in place for the list of
270     /// addable panes, under a dimmed header row. Same anchor, same click
271     /// contract — a second PAGE of the one menu, not a second menu.
272     pub fn open_plate_add_tab_menu(&mut self, idx: usize) {
273         let Some((cx, cy)) = self.plate_corner_center(idx) else { return };
274         let Some(d) = self.dock_of_pane(idx) else { return };
275         let candidates = self.plate_add_tab_candidates(idx, d);
276         if candidates.is_empty() {
277             return;
278         }
279         let mut options = vec!["Add Tab".to_string()];
280         let mut actions = vec![PlateMenuAction::Separator];
281         for other in candidates {
282             options.push(plate_title(other).to_string());
283             actions.push(PlateMenuAction::AddTab(other));
284         }
285         options.push("-".to_string());
286         actions.push(PlateMenuAction::Separator);
287         options.push("‹ Back".to_string());
288         actions.push(PlateMenuAction::BackToMain);
289         let target = self.slots.get_dyn(idx).base().id();
290         cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 1, target);
291         self.plate_menu_slot = Some(idx);
292         self.plate_menu_actions = actions;
293     }
294 
295     pub fn plate_menu_open(&self) -> bool {
296         cce_ui::widget::context_menu::is_visible() && self.plate_menu_slot.is_some()
297     }
298 
299     pub fn close_plate_menu(&mut self) {
300         cce_ui::widget::context_menu::hide();
301         self.plate_menu_slot = None;
302         self.plate_menu_actions.clear();
303     }
304 
305     /// Route a left press while the corner menu is open — same contract as
306     /// `handle_node_menu_click`.
307     pub fn handle_plate_menu_click(&mut self) -> bool {
308         if !self.plate_menu_open() {
309             return false;
310         }
311         if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
312             let row = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
313             let picked = self.plate_menu_slot.zip(row.and_then(|r| self.plate_menu_actions.get(r).copied()));
314             self.close_plate_menu();
315             if let Some((idx, action)) = picked {
316                 self.dispatch_plate_menu(idx, action);
317             }
318             return true;
319         }
320         self.close_plate_menu();
321         false
322     }
323 
324     fn dispatch_plate_menu(&mut self, idx: usize, action: PlateMenuAction) {
325         match action {
326             PlateMenuAction::Collapse => self.set_pane_collapsed(idx, true),
327             PlateMenuAction::Expand => self.set_pane_collapsed(idx, false),
328             PlateMenuAction::Detach => self.detach_plate(idx),
329             PlateMenuAction::Reattach => self.reattach_plate(idx),
330             PlateMenuAction::FullWidth => self.set_spreadsheet_full_width(true),
331             PlateMenuAction::BetweenPanes => self.set_spreadsheet_full_width(false),
332             PlateMenuAction::ShowTab(t) => {
333                 if let Some(d) = self.dock_of_pane(idx) {
334                     self.show_dock_tab(d, t);
335                 }
336             }
337             PlateMenuAction::AddTab(o) => {
338                 if let Some(d) = self.dock_of_pane(idx) {
339                     self.add_dock_tab(d, o);
340                 }
341             }
342             PlateMenuAction::AddTabMenu => self.open_plate_add_tab_menu(idx),
343             PlateMenuAction::BackToMain => self.open_plate_menu(idx),
344             PlateMenuAction::SplitTab => self.split_dock_tab(idx),
345             PlateMenuAction::CloseTab => self.close_dock_tab(idx),
346             PlateMenuAction::PinFollow => self.set_pane_pin(idx, None),
347             PlateMenuAction::PinTo(e) => self.set_pane_pin(idx, Some(e)),
348             PlateMenuAction::Separator => {}
349         }
350     }
351 
352     /// Apply a plate's selection-binding pick and refresh what it feeds.
353     fn set_pane_pin(&mut self, idx: usize, pin: Option<usize>) {
354         match idx {
355             PARAM_IDX => {
356                 self.params_pin = pin;
357                 self.sync_parameters_pane();
358             }
359             SPREADSHEET_IDX => {
360                 self.spreadsheet_pin = pin;
361                 // The spreadsheet refresh lives in sync_nodes, keyed by the
362                 // bound selection's node id — rebinding changes the key.
363                 self.sync_nodes();
364             }
365             _ => {}
366         }
367     }
368 
369     /// Spreadsheet span: full width sets both tuck insets to their maxima
370     /// (the rect derivation clamps to the usable span), between-panes clears
371     /// them. The neighbors' heights follow through the existing tuck interlock.
372     pub fn set_spreadsheet_full_width(&mut self, full: bool) {
373         let v = if full { self.width.max(1.0) } else { 0.0 };
374         self.floating_spreadsheet_inset_left = v;
375         self.floating_spreadsheet_inset_right = v;
376         self.rebuild_positions();
377         self.apply_layout();
378         self.read_panel_offsets();
379     }
380 
381     pub fn set_pane_collapsed(&mut self, idx: usize, collapsed: bool) {
382         if !PLATE_SLOTS.contains(&idx) || self.collapsed_panes[idx] == collapsed {
383             return;
384         }
385         self.collapsed_panes[idx] = collapsed;
386         self.rebuild_positions();
387         self.apply_layout();
388     }
389 }
390 
391 impl State {
392     /// Whether `idx` has somewhere to detach TO. Today only the network pane
393     /// has a detached-window mode (`--detached-network`); the others gain one
394     /// as that path is generalized, and until then they simply do not offer
395     /// the item rather than offering one that does nothing.
396     pub fn plate_can_detach(&self, idx: usize) -> bool {
397         // A detached window never offers to detach its own pane again, and a
398         // pane already handed out cannot be handed out twice.
399         if self.is_detached_network || self.detached_pane.is_some() {
400             return false;
401         }
402         match idx {
403             NETWORK_PANEL_IDX => !self.detached_circular_network,
404             other => pane_detach_flag(other).is_some() && !self.detached_panes[other],
405         }
406     }
407 
408     fn detach_plate(&mut self, idx: usize) {
409         if idx == NETWORK_PANEL_IDX {
410             self.execute_action(crate::shortcut::Action::DetachCircularWindow);
411             return;
412         }
413         let Some(flag) = pane_detach_flag(idx) else { return };
414 
415         // The detached window reads the pane out of the shared project file and
416         // then syncs through it, exactly as the network window does — so it has
417         // to be on disk BEFORE the child starts.
418         let shared = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
419         if let Err(e) = self.save_to_file(&shared) {
420             eprintln!("Failed to save shared project before detaching: {e:?}");
421             return;
422         }
423 
424         match std::env::current_exe() {
425             Ok(exe) => match std::process::Command::new(exe).arg(flag).spawn() {
426                 Ok(child) => {
427                     self.detached_children.insert(idx, child);
428                     self.detached_panes[idx] = true;
429                     self.rebuild_positions();
430                     self.apply_layout();
431                 }
432                 // Leave the pane in place if the child never started, rather
433                 // than hiding it into a window that does not exist.
434                 Err(e) => eprintln!("Failed to spawn detached {}: {e:?}", plate_title(idx)),
435             },
436             Err(e) => eprintln!("Cannot locate own executable to detach: {e:?}"),
437         }
438     }
439 }
440 
441 /// Height of a collapsed plate: its title stub (toolkit-owned since 7c).
442 pub use cce_ui::widget::plate_dock::STUB_H;
443 
444 impl State {
445     /// Rewrite the collapsed plates' rects down to their stubs.
446     ///
447     /// A post-pass over `positions[..]` rather than a branch in each layout
448     /// arm: `rebuild_positions` lays panes out three different ways (floating,
449     /// circular network, detached window) and collapse means the same thing in
450     /// all of them — keep the plate's origin and width, take its height down to
451     /// the stub. In the floating layout, which is what the main window uses,
452     /// that reclaims the space outright: the panes float over a full-bleed
453     /// viewport, so nothing has to reflow around them.
454     ///
455     /// The panes whose body is a SEPARATE slot (the network plate owns the
456     /// graph and the breadcrumb) also hide those, since a stub has no room for
457     /// them and they would otherwise keep painting over the viewport.
458     pub(crate) fn apply_collapsed_panes(&mut self) {
459         for idx in PLATE_SLOTS {
460             if !self.collapsed_panes[idx] {
461                 continue;
462             }
463             self.stub_slot(idx);
464         }
465     }
466 
467     /// Whether `idx` is currently drawn as a stub — the render pass asks before
468     /// painting a pane's body, and the title text only appears here.
469     pub fn pane_is_collapsed(&self, idx: usize) -> bool {
470         PLATE_SLOTS.contains(&idx) && self.collapsed_panes[idx]
471     }
472 }
473 
474 /// The CLI flag that runs this pane as its own window, e.g. `--detached-params`.
475 /// The network keeps `--detached-network`, handled separately: its detached
476 /// window is circular, not merely detached.
477 pub fn pane_detach_flag(idx: usize) -> Option<&'static str> {
478     match idx {
479         PARAM_IDX => Some("--detached-params"),
480         SPREADSHEET_IDX => Some("--detached-spreadsheet"),
481         PLAYBAR_IDX => Some("--detached-playbar"),
482         _ => None,
483     }
484 }
485 
486 /// The stable external name of a plate pane — the identity used by the MCP
487 /// pane tools and by pane state persisted in project files.
488 pub fn pane_name_from_slot(idx: usize) -> Option<&'static str> {
489     match idx {
490         NETWORK_PANEL_IDX => Some("network"),
491         PARAM_IDX => Some("parameters"),
492         SPREADSHEET_IDX => Some("spreadsheet"),
493         PLAYBAR_IDX => Some("playbar"),
494         NETWORK_PANEL2_IDX => Some("network2"),
495         _ => None,
496     }
497 }
498 
499 /// The inverse of [`pane_name_from_slot`], accepting the "params" shorthand.
500 pub fn pane_slot_from_name(name: &str) -> Option<usize> {
501     match name.to_ascii_lowercase().as_str() {
502         "network" => Some(NETWORK_PANEL_IDX),
503         "parameters" | "params" => Some(PARAM_IDX),
504         "spreadsheet" => Some(SPREADSHEET_IDX),
505         "playbar" => Some(PLAYBAR_IDX),
506         "network2" => Some(NETWORK_PANEL2_IDX),
507         _ => None,
508     }
509 }
510 
511 /// The pane an argv entry asks for, if any — the inverse of [`pane_detach_flag`].
512 pub fn pane_from_detach_flag(arg: &str) -> Option<usize> {
513     PLATE_SLOTS
514         .iter()
515         .copied()
516         .find(|&idx| pane_detach_flag(idx) == Some(arg))
517 }
518 
519 /// The detached window's `app_id`, which the compositor keys window rules off.
520 pub fn pane_app_id(idx: usize) -> &'static str {
521     match idx {
522         PARAM_IDX => "cce-designer-params",
523         SPREADSHEET_IDX => "cce-designer-spreadsheet",
524         PLAYBAR_IDX => "cce-designer-playbar",
525         _ => "cce-designer",
526     }
527 }
528 
529 /// Inset of a detached pane inside its own window, so the plate keeps a visible
530 /// edge of its own instead of fusing with the window border.
531 pub const DETACHED_MARGIN: f32 = 8.0;
532 
533 impl State {
534     /// Resolve the detached-window arrangement, both sides of it.
535     ///
536     /// A post-pass for the same reason `apply_collapsed_panes` is one: detaching
537     /// means one thing regardless of which of the three layout branches just
538     /// ran. In the CHILD process the detached pane claims the whole window and
539     /// every other slot goes dark; in the PARENT the panes it has handed out
540     /// stop being laid out, so the space they held is released.
541     pub(crate) fn apply_detached_panes(&mut self) {
542         if let Some(idx) = self.detached_pane {
543             for i in 0..WIDGET_COUNT {
544                 if i == idx {
545                     continue;
546                 }
547                 self.positions[i] = (0.0, 0.0, 0.0, 0.0);
548                 self.slots.get_dyn_mut(i).set_visible(false);
549             }
550             let m = DETACHED_MARGIN;
551             self.positions[idx] = (
552                 m,
553                 m,
554                 (self.width - 2.0 * m).max(0.0),
555                 (self.height - 2.0 * m).max(0.0),
556             );
557             self.slots.get_dyn_mut(idx).set_visible(true);
558             return;
559         }
560 
561         // The parent keeps a STUB for each pane it handed out rather than
562         // dropping it: the stub carries the corner control, which is the only
563         // way back. Hiding the pane outright left no way to reattach it.
564         for idx in PLATE_SLOTS {
565             if self.detached_panes[idx] {
566                 self.stub_slot(idx);
567             }
568         }
569     }
570 
571     /// Shrink one slot to its title stub, taking any separate body slots with it.
572     /// Shared by collapse and by the parent side of a detach.
573     fn stub_slot(&mut self, idx: usize) {
574         let (x, y, w, h) = self.positions[idx];
575         if w <= 0.0 || h <= 0.0 {
576             // Already laid out as hidden — there is no stub to make.
577             return;
578         }
579         self.positions[idx] = (x, y, w, STUB_H.min(h));
580         if idx == NETWORK_PANEL_IDX {
581             for child in [crate::slots::CONTENT_IDX, crate::slots::BREADCRUMB_IDX] {
582                 self.positions[child] = (0.0, 0.0, 0.0, 0.0);
583                 self.slots.get_dyn_mut(child).set_visible(false);
584             }
585         }
586         if idx == NETWORK_PANEL2_IDX {
587             for child in [crate::slots::CONTENT2_IDX, crate::slots::BREADCRUMB2_IDX] {
588                 self.positions[child] = (0.0, 0.0, 0.0, 0.0);
589                 self.slots.get_dyn_mut(child).set_visible(false);
590             }
591         }
592     }
593 }
594 
595 impl State {
596     /// Is this pane currently living in a detached window? The network's flag
597     /// is separate because its detached window is the circular one.
598     pub fn pane_is_detached(&self, idx: usize) -> bool {
599         if idx == NETWORK_PANEL_IDX {
600             return self.detached_circular_network;
601         }
602         PLATE_SLOTS.contains(&idx) && self.detached_panes[idx]
603     }
604 
605     /// Is this pane drawn as a stub rather than in full — collapsed, or left
606     /// behind by a detach?
607     pub fn pane_is_stubbed(&self, idx: usize) -> bool {
608         self.pane_is_detached(idx) || self.pane_is_collapsed(idx)
609     }
610 
611     /// The label a stubbed pane shows, or `None` when the pane is drawn in full.
612     /// Collapsed and detached both stub, and they must not look alike: one is
613     /// one click from expanding, the other is somewhere else entirely.
614     pub fn pane_stub_label(&self, idx: usize) -> Option<String> {
615         if self.pane_is_detached(idx) {
616             Some(format!("{} — detached", plate_title(idx)))
617         } else if self.pane_is_collapsed(idx) {
618             Some(plate_title(idx).to_string())
619         } else {
620             None
621         }
622     }
623 
624     /// Detach or reattach a pane — the corner menu's two window actions, also
625     /// the MCP surface's, so pane placement is scriptable like collapse is.
626     pub fn set_pane_detached(&mut self, idx: usize, detached: bool) {
627         if detached {
628             if self.plate_can_detach(idx) {
629                 self.detach_plate(idx);
630             }
631         } else {
632             self.reattach_plate(idx);
633         }
634     }
635 
636     /// Take a detached pane back and close the window that held it.
637     pub fn reattach_plate(&mut self, idx: usize) {
638         if !self.pane_is_detached(idx) {
639             return;
640         }
641         self.close_detached_child(idx);
642 
643         if idx == NETWORK_PANEL_IDX {
644             // Toggling the action back off is the network's own reattach — it
645             // clears the flag and re-lays out without spawning anything.
646             self.execute_action(crate::shortcut::Action::DetachCircularWindow);
647             return;
648         }
649 
650         self.detached_panes[idx] = false;
651         self.rebuild_positions();
652         self.apply_layout();
653     }
654 
655     /// Close the detached child and reap it. Best-effort: a child the user
656     /// already closed is simply gone, and reattaching must work anyway.
657     fn close_detached_child(&mut self, idx: usize) {
658         if let Some(mut child) = self.detached_children.remove(&idx) {
659             let _ = child.kill();
660             // Reap it, or the process table keeps a zombie for the rest of the
661             // session — the same trap the liveness probe fell into.
662             let _ = child.wait();
663         }
664     }
665 
666     /// Notice detached children the user closed themselves and take their panes
667     /// back, so a closed window does not strand its pane as a dead stub. Called
668     /// from the frame tick.
669     ///
670     /// `try_wait`, NOT `kill(pid, 0)`: the child is ours and unreaped, so once
671     /// it exits it is a zombie — still present in the process table, so the
672     /// signal probe reports it alive forever and the pane is never reclaimed.
673     pub(crate) fn poll_detached_children(&mut self) -> bool {
674         let mut reclaimed = false;
675         for idx in PLATE_SLOTS {
676             if !self.pane_is_detached(idx) {
677                 continue;
678             }
679             let exited = match self.detached_children.get_mut(&idx) {
680                 // `Ok(None)` is the only "still running" answer; an Err handle
681                 // is no more useful than an exited one.
682                 Some(child) => !matches!(child.try_wait(), Ok(None)),
683                 None => continue,
684             };
685             if !exited {
686                 continue;
687             }
688             self.detached_children.remove(&idx);
689             if idx == NETWORK_PANEL_IDX {
690                 self.detached_circular_network = false;
691                 let val = false;
692                 self.menu_mut(crate::slots::LEFT_MENUBAR_IDX).set_item_checked(2, 3, val);
693                 self.menu_mut(crate::slots::HEADER_IDX).set_item_checked(2, 3, val);
694             } else {
695                 self.detached_panes[idx] = false;
696             }
697             reclaimed = true;
698         }
699         if reclaimed {
700             self.rebuild_positions();
701             self.apply_layout();
702         }
703         reclaimed
704     }
705 }