graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: plate corner control — a menu trigger on every plate's top-right
Each pane that draws its own plate (network, params, spreadsheet, playbar —
not the viewport, whose plate is the window-spanning lip) now carries a small
circle on its top-right that opens a menu for that pane.
The control's geometry comes from the slot's LIVE rect rather than being
computed alongside positions[..], because rebuild_positions lays panes out
three different ways and a per-branch corner would be three things to keep in
step; the circular network pane is the one shape whose top-right is not a rect
corner, so it is placed on the arc. It is drawn with pc.circle + pc.arc, not
inset_plate: the trough prim clamps its radii below half the side, so a
fully-round inset_plate this small closes into a rounded SQUARE with a second
square wall inside it.
Collapse shrinks a plate to its title stub. apply_collapsed_panes is a
post-pass over positions[..] so collapse means one thing in all three layout
branches — keep the origin and width, take the height to the stub. In the
floating layout the main window uses, that reclaims the space outright: panes
float over a full-bleed viewport, so nothing reflows. Panes whose body is a
separate slot (the network plate owns the graph and breadcrumb) hide those too.
The stub is deliberately exempt from the minimum-span guard a full pane must
clear: applying it deleted the corner control from collapsed panes, leaving
nothing that could expand them again. test_plate_corner_menu_is_contextual
caught that and now pins it.
Detach is only offered for the network pane, the one pane that has a detached
window today; generalizing that spawn is separate work.
set_pane_collapsed joins the MCP surface so pane state is scriptable (and
drivable in a headless session) without steering the pointer.
CLAUDE.md | 10 +++
src/api.rs | 12 +++
src/app.rs | 35 ++++++++
src/main.rs | 87 +++++++++++++++++++
src/plate_corner.rs | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++
src/render.rs | 55 ++++++++++++
src/window.rs | 12 +++
7 files changed, 455 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2fa3655..a675453 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -87,6 +87,16 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
slot's concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too, and
`State` keeps one-line forwarders. `PassivePlate` and `Canvas`, the two app-owned
slot-only widgets, are also here.
+- `src/plate_corner.rs` — the plate corner control: a circular menu trigger on the
+ top-right of each pane that draws its own plate (`PLATE_SLOTS` — network, params,
+ spreadsheet, playbar; NOT the viewport, whose plate is the window-spanning lip).
+ Geometry is derived from the slot's live rect, so it holds across all three
+ `rebuild_positions` branches; the circular network pane is special-cased onto its
+ arc. The menu is a third `cce_ui::widget::context_menu` consumer alongside the node
+ and viewport right-click menus, with the same `*_menu_actions` + `handle_*_menu_click`
+ contract. Collapse shrinks a plate to its title stub via `apply_collapsed_panes`, a
+ post-pass over `positions[..]` (one place, all three branches); the stub is exempt
+ from the minimum-span guard or it would lose the control that expands it again.
- `src/application.rs` — the `Application` impl: translates engine hooks into
`WindowEvent`s, detached-window CSD, HTTP-server startup, exit autosave.
- `src/window.rs` — `WindowEvent` plus the post-event side-effect pass
diff --git a/src/api.rs b/src/api.rs
index 15cd9cf..a4eb687 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -174,6 +174,18 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
}),
),
tool("toggle_circular_pane", "Toggle the circular network pane.", no_args()),
+ tool(
+ "set_pane_collapsed",
+ "Collapse a pane to its title stub, or expand it back.",
+ json!({
+ "type": "object",
+ "properties": {
+ "pane": { "type": "string", "description": "network | parameters | spreadsheet | playbar" },
+ "collapsed": { "type": "boolean", "description": "true to collapse, false to expand" },
+ },
+ "required": ["pane", "collapsed"],
+ }),
+ ),
tool(
"menu_click",
"Click a menubar item by indices (widget_idx must be a menubar widget slot).",
diff --git a/src/app.rs b/src/app.rs
index 551c595..2f3c58c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -210,6 +210,9 @@ pub enum McpAction {
/// Execute a label-matched menu-pane action ("Show Spreadsheet Pane", "Save", ...)
/// — the items `menu_click`'s index-matched menubar dispatch cannot reach.
MenuAction { label: String },
+ /// Collapse a pane to its title stub, or restore it — the plate corner
+ /// menu's Collapse/Expand, reachable without driving the pointer.
+ SetPaneCollapsed { pane: String, collapsed: bool },
}
#[derive(Debug, Clone)]
@@ -657,6 +660,14 @@ pub struct State {
/// machinery as the node menu; this flag says the open menu is OURS).
pub viewport_menu_active: bool,
pub viewport_menu_actions: Vec<ViewportMenuAction>,
+ /// The plate corner menu — same `context_menu` thread-local again; the slot
+ /// says which plate's control opened it (and doubles as the pressed state
+ /// the corner control paints with).
+ pub plate_menu_slot: Option<usize>,
+ pub plate_menu_actions: Vec<crate::plate_corner::PlateMenuAction>,
+ /// Panes shrunk to their title stub, indexed by slot. Only the
+ /// `plate_corner::PLATE_SLOTS` entries are ever set.
+ pub collapsed_panes: [bool; WIDGET_COUNT],
pub drag_widget: Option<usize>,
/// Where the pointer pressed when `drag_widget` armed — the drag
@@ -2596,6 +2607,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
node_menu_actions: Vec::new(),
viewport_menu_active: false,
viewport_menu_actions: Vec::new(),
+ plate_menu_slot: None,
+ plate_menu_actions: Vec::new(),
+ collapsed_panes: [false; WIDGET_COUNT],
drag_widget: None,
drag_press_cursor: None,
focused_widget: None,
@@ -3369,6 +3383,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
self.slots.get_dyn_mut(idx).set_visible(false);
}
}
+
+ self.apply_collapsed_panes();
}
@@ -4108,6 +4124,25 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
}
self.close_node_menu();
}
+ // The plate corner menu, same contract as the node
+ // menu above: it took the click, or it is dismissed.
+ if self.plate_menu_open() {
+ if *button == MouseButton::Left && self.handle_plate_menu_click() {
+ return true;
+ }
+ self.close_plate_menu();
+ if *button == MouseButton::Left {
+ return true;
+ }
+ }
+ // A press ON a corner control opens (or re-closes) its
+ // menu and never reaches the pane underneath.
+ if *button == MouseButton::Left {
+ if let Some(idx) = self.plate_corner_at(self.cursor_x, self.cursor_y) {
+ self.open_plate_menu(idx);
+ return true;
+ }
+ }
if self.viewport_menu_open() {
if *button == MouseButton::Left && self.handle_viewport_menu_click() {
return true;
diff --git a/src/main.rs b/src/main.rs
index 8207346..cdb4a8a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,6 +5,7 @@ pub mod application;
// Root-level aliases some modules import via `crate::` paths.
#[allow(unused_imports)]
use app::{CustomEvent, McpAction, ModifiersState};
+pub mod plate_corner;
pub mod playbar;
pub mod viewport_3d;
pub mod api;
@@ -68,6 +69,92 @@ mod tests {
use crate::shortcut::{Shortcut, ShortcutManager, Action};
use crate::geometry::{GAttribute, GVertex, Geometry, line_vertices};
+ /// The corner control has to land ON its plate: derived from the slot's live
+ /// rect, an off-by-one in the inset would put the trigger outside the pane
+ /// (unclickable, and painted over the neighbour) with nothing to catch it —
+ /// the render pass draws wherever it is told.
+ #[test]
+ fn test_plate_corner_sits_inside_its_plate() {
+ use crate::plate_corner::{CORNER_R, PLATE_SLOTS};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+
+ let mut checked = 0;
+ for idx in PLATE_SLOTS {
+ let Some((cx, cy)) = state.plate_corner_center(idx) else { continue };
+ let (x, y, w, h) = state.slots.get_dyn(idx).rect();
+ checked += 1;
+
+ // Inside the plate, with the whole disc clear of every edge.
+ assert!(cx - CORNER_R >= x && cx + CORNER_R <= x + w,
+ "slot {idx}: corner x {cx} escapes plate {x}..{}", x + w);
+ assert!(cy - CORNER_R >= y && cy + CORNER_R <= y + h,
+ "slot {idx}: corner y {cy} escapes plate {y}..{}", y + h);
+ // ...and in the TOP-RIGHT quadrant of it, not merely somewhere inside.
+ assert!(cx > x + w / 2.0, "slot {idx}: corner is not on the right");
+ assert!(cy < y + h / 2.0, "slot {idx}: corner is not at the top");
+
+ // The hit test must agree with where it is painted.
+ assert_eq!(state.plate_corner_at(cx, cy), Some(idx), "slot {idx}: centre misses");
+ assert_eq!(state.plate_corner_at(cx + CORNER_R * 2.0, cy), None,
+ "slot {idx}: hit radius reaches past the control");
+ }
+ assert!(checked >= 2, "expected at least the network and params plates, checked {checked}");
+ }
+
+ /// Collapse must actually reclaim the plate AND take its body with it, and
+ /// expanding must put both back — a stub that still hosts a full-height
+ /// graph would paint the pane over the viewport it just freed.
+ #[test]
+ fn test_collapse_shrinks_the_plate_and_restores_it() {
+ use crate::plate_corner::STUB_H;
+ use crate::slots::{CONTENT_IDX, NETWORK_PANEL_IDX};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+
+ let (_, _, _, full_h) = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
+ assert!(full_h > STUB_H, "network plate starts taller than a stub");
+ assert!(state.slots.get_dyn(CONTENT_IDX).visible(), "graph starts visible");
+
+ state.set_pane_collapsed(NETWORK_PANEL_IDX, true);
+ let (_, _, _, stub_h) = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
+ assert_eq!(stub_h, STUB_H, "collapsed plate is not the stub height");
+ assert!(!state.slots.get_dyn(CONTENT_IDX).visible(), "graph survived the collapse");
+ // The control that expands it again must still be there.
+ assert!(state.plate_corner_center(NETWORK_PANEL_IDX).is_some(),
+ "collapsed plate lost its corner control — nothing can expand it");
+
+ state.set_pane_collapsed(NETWORK_PANEL_IDX, false);
+ let (_, _, _, back_h) = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
+ assert_eq!(back_h, full_h, "expanding did not restore the plate height");
+ assert!(state.slots.get_dyn(CONTENT_IDX).visible(), "graph did not come back");
+ }
+
+ /// The menu is contextual, and the two states are mutually exclusive: a
+ /// collapsed plate must offer Expand and NOT Collapse, or the item that
+ /// restores it is unreachable.
+ #[test]
+ fn test_plate_corner_menu_is_contextual() {
+ use crate::plate_corner::{PlateMenuAction, PLATE_SLOTS};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+
+ let idx = PLATE_SLOTS.iter().copied()
+ .find(|&i| state.plate_corner_center(i).is_some())
+ .expect("some plate carries a corner control at this size");
+
+ state.open_plate_menu(idx);
+ assert!(state.plate_menu_actions.contains(&PlateMenuAction::Collapse));
+ assert!(!state.plate_menu_actions.contains(&PlateMenuAction::Expand));
+ state.close_plate_menu();
+
+ state.set_pane_collapsed(idx, true);
+ state.open_plate_menu(idx);
+ assert!(state.plate_menu_actions.contains(&PlateMenuAction::Expand));
+ assert!(!state.plate_menu_actions.contains(&PlateMenuAction::Collapse));
+ state.close_plate_menu();
+ }
+
/// DE chrome is config-owned (`style.surface.relief.profile` /
/// `.edge_profile` / `style.surface.param.color`), so Main's retired Style
/// section must not come back from an older project file — while it did,
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
new file mode 100644
index 0000000..c77855a
--- /dev/null
+++ b/src/plate_corner.rs
@@ -0,0 +1,244 @@
+//! The plate corner control: a small circular menu trigger riding the top-right
+//! of every pane that draws a plate of its own, and the menu it opens.
+//!
+//! Geometry is derived from the slot's LIVE rect rather than computed alongside
+//! `positions[..]`, because `rebuild_positions` lays the panes out in three
+//! different branches (normal, circular network, detached window) and a corner
+//! computed per-branch would be three things to keep in step. The circular
+//! network pane is the one shape whose "top-right" is not a rect corner, so it
+//! is special-cased onto the arc.
+//!
+//! The control follows the DE's closed-menu-trigger language (see
+//! `cce-ui`'s popover conventions): a transparent face over an inset trough,
+//! here on a fully-round radius so the trough reads as a ring.
+
+use crate::app::State;
+use crate::slots::{NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX};
+
+/// Radius of the control itself.
+pub const CORNER_R: f32 = 8.0;
+
+/// Centre inset from the plate's top-right corner, on both axes. Clears the
+/// plate's own corner arc at the radii the DE ships.
+pub const CORNER_INSET: f32 = 14.0;
+
+/// A plate needs at least this much room before it earns a corner control —
+/// below it the trigger would cover the pane it belongs to.
+const MIN_PLATE_SPAN: f32 = 3.0 * CORNER_INSET;
+
+/// The plates that carry a corner control. The viewport is deliberately absent:
+/// its "plate" is the window-spanning lip, so a top-right control would sit on
+/// the window corner rather than on a pane.
+pub const PLATE_SLOTS: [usize; 4] = [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, PLAYBAR_IDX];
+
+/// What the corner menu can do to its plate.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PlateMenuAction {
+ /// Shrink the plate to its title stub (or restore it).
+ Collapse,
+ Expand,
+ /// Move the pane out into its own window.
+ Detach,
+}
+
+impl State {
+ /// Centre of `idx`'s corner control, or `None` when the plate is hidden or
+ /// too small to carry one.
+ pub fn plate_corner_center(&self, idx: usize) -> Option<(f32, f32)> {
+ if !PLATE_SLOTS.contains(&idx) {
+ return None;
+ }
+ let w = self.slots.get_dyn(idx);
+ if !w.visible() {
+ return None;
+ }
+
+ // The circular network pane: put the control where the plate's own
+ // top-right actually is — on the arc, at 45°.
+ if idx == NETWORK_PANEL_IDX && self.circular_network_pane {
+ let c = &self.circular_network_layout;
+ if c.r < MIN_PLATE_SPAN {
+ return None;
+ }
+ let d = std::f32::consts::FRAC_1_SQRT_2 * (c.r - CORNER_INSET);
+ return Some((c.x + d, c.y - d));
+ }
+
+ let (x, y, pw, ph) = w.rect();
+ if pw < MIN_PLATE_SPAN {
+ return None;
+ }
+ if self.collapsed_panes[idx] {
+ // The stub is BUILT to carry the control, and is shorter than the
+ // minimum span a full pane must clear — applying that guard here
+ // deleted the only control that can expand the pane again.
+ return Some((x + pw - CORNER_INSET, y + ph / 2.0));
+ }
+ if ph < MIN_PLATE_SPAN {
+ return None;
+ }
+ Some((x + pw - CORNER_INSET, y + CORNER_INSET))
+ }
+
+ /// The plate whose corner control is under `(px, py)`, if any. Searched in
+ /// reverse draw order so an overlapping pane's control wins, matching what
+ /// the user sees on top.
+ pub fn plate_corner_at(&self, px: f32, py: f32) -> Option<usize> {
+ PLATE_SLOTS.iter().rev().copied().find(|&idx| {
+ self.plate_corner_center(idx).is_some_and(|(cx, cy)| {
+ let (dx, dy) = (px - cx, py - cy);
+ dx * dx + dy * dy <= CORNER_R * CORNER_R
+ })
+ })
+ }
+}
+
+/// The pane's display name — the stub's label, and what the menu is "about".
+pub fn plate_title(idx: usize) -> &'static str {
+ match idx {
+ NETWORK_PANEL_IDX => "Network",
+ PARAM_IDX => "Parameters",
+ SPREADSHEET_IDX => "Spreadsheet",
+ PLAYBAR_IDX => "Playbar",
+ _ => "Pane",
+ }
+}
+
+impl State {
+ /// Open the corner menu for `idx`, anchored under its control. Items are
+ /// contextual: a collapsed plate offers Expand instead of Collapse, and
+ /// Detach only appears where a detached window exists for that pane.
+ pub fn open_plate_menu(&mut self, idx: usize) {
+ let Some((cx, cy)) = self.plate_corner_center(idx) else { return };
+
+ let mut options: Vec<String> = Vec::new();
+ let mut actions: Vec<PlateMenuAction> = Vec::new();
+
+ if self.collapsed_panes[idx] {
+ options.push("Expand".to_string());
+ actions.push(PlateMenuAction::Expand);
+ } else {
+ options.push("Collapse".to_string());
+ actions.push(PlateMenuAction::Collapse);
+ }
+
+ if self.plate_can_detach(idx) {
+ options.push("Detach".to_string());
+ actions.push(PlateMenuAction::Detach);
+ }
+
+ let target = self.slots.get_dyn(idx).base().id();
+ cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 0, target);
+ self.plate_menu_slot = Some(idx);
+ self.plate_menu_actions = actions;
+ }
+
+ pub fn plate_menu_open(&self) -> bool {
+ cce_ui::widget::context_menu::is_visible() && self.plate_menu_slot.is_some()
+ }
+
+ pub fn close_plate_menu(&mut self) {
+ cce_ui::widget::context_menu::hide();
+ self.plate_menu_slot = None;
+ self.plate_menu_actions.clear();
+ }
+
+ /// Route a left press while the corner menu is open — same contract as
+ /// `handle_node_menu_click`.
+ pub fn handle_plate_menu_click(&mut self) -> bool {
+ if !self.plate_menu_open() {
+ return false;
+ }
+ if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
+ let my = cce_ui::widget::context_menu::y();
+ let row = ((self.cursor_y - my) / 24.0).floor() as usize;
+ let picked = self.plate_menu_slot.zip(self.plate_menu_actions.get(row).copied());
+ self.close_plate_menu();
+ if let Some((idx, action)) = picked {
+ self.dispatch_plate_menu(idx, action);
+ }
+ return true;
+ }
+ self.close_plate_menu();
+ false
+ }
+
+ fn dispatch_plate_menu(&mut self, idx: usize, action: PlateMenuAction) {
+ match action {
+ PlateMenuAction::Collapse => self.set_pane_collapsed(idx, true),
+ PlateMenuAction::Expand => self.set_pane_collapsed(idx, false),
+ PlateMenuAction::Detach => self.detach_plate(idx),
+ }
+ }
+
+ pub fn set_pane_collapsed(&mut self, idx: usize, collapsed: bool) {
+ if !PLATE_SLOTS.contains(&idx) || self.collapsed_panes[idx] == collapsed {
+ return;
+ }
+ self.collapsed_panes[idx] = collapsed;
+ self.rebuild_positions();
+ self.apply_layout();
+ }
+}
+
+impl State {
+ /// Whether `idx` has somewhere to detach TO. Today only the network pane
+ /// has a detached-window mode (`--detached-network`); the others gain one
+ /// as that path is generalized, and until then they simply do not offer
+ /// the item rather than offering one that does nothing.
+ pub fn plate_can_detach(&self, idx: usize) -> bool {
+ idx == NETWORK_PANEL_IDX && !self.is_detached_network
+ }
+
+ fn detach_plate(&mut self, idx: usize) {
+ if idx == NETWORK_PANEL_IDX {
+ self.execute_action(crate::shortcut::Action::DetachCircularWindow);
+ }
+ }
+}
+
+/// Height of a collapsed plate: its title stub. Deep enough for the title text
+/// and the corner control that restores it, and no deeper.
+pub const STUB_H: f32 = 26.0;
+
+impl State {
+ /// Rewrite the collapsed plates' rects down to their stubs.
+ ///
+ /// A post-pass over `positions[..]` rather than a branch in each layout
+ /// arm: `rebuild_positions` lays panes out three different ways (floating,
+ /// circular network, detached window) and collapse means the same thing in
+ /// all of them — keep the plate's origin and width, take its height down to
+ /// the stub. In the floating layout, which is what the main window uses,
+ /// that reclaims the space outright: the panes float over a full-bleed
+ /// viewport, so nothing has to reflow around them.
+ ///
+ /// The panes whose body is a SEPARATE slot (the network plate owns the
+ /// graph and the breadcrumb) also hide those, since a stub has no room for
+ /// them and they would otherwise keep painting over the viewport.
+ pub(crate) fn apply_collapsed_panes(&mut self) {
+ for idx in PLATE_SLOTS {
+ if !self.collapsed_panes[idx] {
+ continue;
+ }
+ let (x, y, w, h) = self.positions[idx];
+ if w <= 0.0 || h <= 0.0 {
+ // Already laid out as hidden — collapse has nothing to say.
+ continue;
+ }
+ self.positions[idx] = (x, y, w, STUB_H.min(h));
+
+ if idx == NETWORK_PANEL_IDX {
+ for child in [crate::slots::CONTENT_IDX, crate::slots::BREADCRUMB_IDX] {
+ self.positions[child] = (0.0, 0.0, 0.0, 0.0);
+ self.slots.get_dyn_mut(child).set_visible(false);
+ }
+ }
+ }
+ }
+
+ /// Whether `idx` is currently drawn as a stub — the render pass asks before
+ /// painting a pane's body, and the title text only appears here.
+ pub fn pane_is_collapsed(&self, idx: usize) -> bool {
+ PLATE_SLOTS.contains(&idx) && self.collapsed_panes[idx]
+ }
+}
diff --git a/src/render.rs b/src/render.rs
index 86b5904..614fd51 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -172,6 +172,7 @@ impl State {
self.append_context_border(&mut pc);
self.append_frame_text(&mut pc);
self.append_popovers(&mut pc);
+ self.append_plate_corners(&mut pc);
// The node right-click context menu floats above everything (drawn last).
// Its labels carry bounds equal to the menu rect so the engine's text-
@@ -215,6 +216,30 @@ impl State {
return;
}
+ // A collapsed pane is its title stub and nothing else: the plate, the
+ // name, and (from the later corner pass) the control that restores it.
+ // Returning here is what suppresses the body — the params rows, the
+ // spreadsheet grid, the transport controls — rather than relying on
+ // each pane's own clip to hide content taller than the stub.
+ if self.pane_is_collapsed(idx) {
+ let (sx, sy, sw, sh) = w.rect();
+ append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(sx, sy, sw, sh));
+ let font_size = 12.0;
+ let ty = cce_ui::layout::align_text_y(sy, sh, font_size, 0.0);
+ // Bounds stop at the corner control so a long name cannot run under it.
+ let text_right = sx + sw - 2.0 * crate::plate_corner::CORNER_INSET;
+ pc.text_with(
+ crate::plate_corner::plate_title(idx),
+ sx + 12.0,
+ ty,
+ font_size,
+ [0xcc, 0xcc, 0xd4],
+ None,
+ Some([sx, sy, text_right, sy + sh]),
+ );
+ return;
+ }
+
let is_network_part = idx == CONTENT_IDX || idx == LEFT_MENUBAR_IDX || idx == BREADCRUMB_IDX || idx == NETWORK_PANEL_IDX;
let active_circle = if is_network_part { clip_circle } else { None };
if let Some(c) = active_circle {
@@ -690,6 +715,36 @@ impl State {
/// occludes the widget labels underneath it — the display list is drawn
/// strictly in order, so a popover background emitted in the geometry
/// pass would sit under every label.
+ /// The plates' corner menu triggers, drawn above pane content but below an
+ /// open context menu: a ring in the plate's own border color over a face
+ /// that stays transparent until hover, so the control reads as part of the
+ /// plate edge until it is reached for.
+ fn append_plate_corners(&self, pc: &mut PaintCtx) {
+ let hovered = self.plate_corner_at(self.cursor_x, self.cursor_y);
+ for idx in crate::plate_corner::PLATE_SLOTS {
+ let Some((cx, cy)) = self.plate_corner_center(idx) else { continue };
+ let r = crate::plate_corner::CORNER_R;
+ let face = if hovered == Some(idx) || self.plate_menu_slot == Some(idx) {
+ let mut c = cce_ui::colors::param_plate_fill();
+ // The plate fill carries the blur-behind marker as a NEGATIVE
+ // alpha; a control this small must not open its own blur tap.
+ c[3] = c[3].abs().max(0.35);
+ c
+ } else {
+ [0.0; 4]
+ };
+ // A real circle, not a fully-rounded rect: the trough prim clamps
+ // its radii below half the side, so an `inset_plate` this small
+ // closes into a rounded SQUARE with a second square wall inside it.
+ if face[3] > 0.001 {
+ pc.circle(cx, cy, r, face);
+ }
+ let ring = cce_ui::colors::plate_border_color()
+ .unwrap_or([0.55, 0.58, 0.66, 0.85]);
+ pc.arc(cx, cy, r, cce_ui::colors::plate_border_thickness().max(1.0), 0.0, TAU, ring);
+ }
+ }
+
fn append_popovers(&self, pc: &mut PaintCtx) {
for i in 0..WIDGET_COUNT {
let w = self.slots.get_dyn(i);
diff --git a/src/window.rs b/src/window.rs
index 4ceb939..765fa26 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -870,6 +870,18 @@ impl State {
Err("Slot out of bounds".to_string())
}
}
+ McpAction::SetPaneCollapsed { pane, collapsed } => {
+ let idx = match pane.to_ascii_lowercase().as_str() {
+ "network" => crate::slots::NETWORK_PANEL_IDX,
+ "parameters" | "params" => crate::slots::PARAM_IDX,
+ "spreadsheet" => crate::slots::SPREADSHEET_IDX,
+ "playbar" => crate::slots::PLAYBAR_IDX,
+ other => return Err(format!("unknown pane: {other}")),
+ };
+ state.set_pane_collapsed(idx, collapsed);
+ needs_redraw = true;
+ Ok(format!("{pane} collapsed={collapsed}"))
+ }
McpAction::ToggleCircularPane => {
state.circular_network_pane = !state.circular_network_pane;
let val = state.circular_network_pane;