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 }