GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
7c-1: widget::plate_dock — the plate-corner affordance protocol
RFC Phase 7c, first cut: the app-agnostic core of cce-designer's plate
corner, lifted. Constants (control radius/inset, min span, stub height,
drag threshold), PlateDockState, corner_center (stub exemption included),
corner_hit, press_becomes_drag, and standard_menu (Reattach-only for
detached stubs, Collapse/Expand, Detach when allowed) — hosts append their
own rows and keep all policy: which plates, dock regions, layout, and the
detach process model. Deliberately lean — no container, no widget; a
second consumer (7c-2) decides those. PlateSpec::detached() is the 7c role
flip: all corners become window corners, radii snap to the silhouette, and
fill() swaps frost regimes — unit-tested alongside the protocol.
docs/rfc-core-rebuild.md | 21 +++++++
src/scene/paint.rs | 22 +++++++
src/widget/mod.rs | 1 +
src/widget/plate_dock.rs | 160 +++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 204 insertions(+)
diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index a94a5bc..8812c83 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -2510,6 +2510,27 @@ Constraint respected: **each crate still builds standalone** — the new core is
and sync channel (`default_project.json` polling) remain app policy — the toolkit provides
the plate-role flip (interior→window corners, blur regime swap, CSD hookup via the existing
`standard_csd`/`take_window_action` hooks), not the process management.
+ - **7c-1 DONE (2026-08-25).** `widget::plate_dock`: the app-agnostic PROTOCOL —
+ constants (control radius/inset, min plate span, stub height, drag threshold),
+ `PlateDockState { collapsed, detached }` with `stubbed()`, `corner_center(rect,
+ stubbed)` (rect placement incl. the stub exemption; the designer's circular-pane
+ arc placement stays app policy), `corner_hit`, `press_becomes_drag`, and
+ `standard_menu(state, can_detach)` → (label, `PlateDockAction`) rows the host
+ appends its own items after (the designer's spreadsheet span modes). Deliberately
+ LEAN: no container type, no widget — a second consumer decides those.
+ `PlateSpec::detached()` is the role flip (all corners→window, radii snap to the
+ silhouette, `fill()` swaps frost regimes), unit-tested. The designer delegates:
+ constants re-exported, rect placement/hit/threshold/standard-menu all
+ toolkit-calls; PLATE_SLOTS membership, visibility, dock regions, layout
+ application, and the detach process spawn stay designer policy. Verified live:
+ corner menus (Collapse/Detach; Reattach-only stubs), the collapse→stub→expand
+ cycle, and dock-drag arming+drop all behave identically through the delegation.
+ - **7c-2 — pending.** A second consumer adopts the affordance (cce-mail's
+ list/detail split or cce-files' panes are the candidates) — that adoption decides
+ whether a `PlateDock` container type earns its place, whether the corner control
+ becomes a drawable helper, and how `standard_csd`/`take_window_action` package the
+ detached-window CSD for hosts without the designer's custom chrome. The cce-cloud
+ overlay-silhouette question from 7b-2 also lands here.
Not started; no code moves before 7a's alias sweep is reviewed, and stages land in order —
7b is the value (one paint path, plates compositional), 7c is the payoff feature.
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 85ba269..ae6c1e1 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -95,6 +95,17 @@ impl PlateSpec {
Self::radii_for(self.window_corners)
}
+ /// This plate detached into its own window (RFC Phase 7c): every corner
+ /// becomes a window corner, and with the role the radii snap to the
+ /// silhouette curve and [`Self::fill`] flips frost regimes (the
+ /// compositor's blur-behind takes over from the in-app sentinel). The
+ /// reverse — reattaching — is the host assigning its computed
+ /// `window_corner_flags` back.
+ pub fn detached(mut self) -> Self {
+ self.window_corners = (true, true, true, true);
+ self
+ }
+
/// The fill with the role-correct frost encoding: root → alpha forced
/// non-negative (the compositor's frost, not ours), nested + `blur` →
/// the in-app frost pass's negative-alpha sentinel.
@@ -1247,6 +1258,17 @@ mod tests {
assert!(spec.fill()[3] < 0.0, "nested frost = negative-alpha sentinel");
spec.blur = false;
assert_eq!(spec.fill()[3], 0.8, "no frost, no encoding");
+
+ // The detach role flip (RFC 7c): a frosted nested pane becomes a
+ // root — silhouette corners, and the frost regime flips from the
+ // in-app sentinel to the compositor's (alpha back to positive).
+ spec.blur = true;
+ assert!(spec.fill()[3] < 0.0);
+ let det = spec.detached();
+ assert!(det.is_root());
+ assert!(det.fill()[3] > 0.0, "root frost is the compositor's again");
+ let wr = crate::layout::window_silhouette_radius();
+ assert_eq!(det.radii(), (wr, wr, wr, wr));
}
fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index b3c3540..60b7337 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -526,6 +526,7 @@ pub trait WidgetHost {
pub mod core;
pub mod input;
+pub mod plate_dock;
pub mod container;
pub mod display;
pub mod editor;
diff --git a/src/widget/plate_dock.rs b/src/widget/plate_dock.rs
new file mode 100644
index 0000000..5b9bb5c
--- /dev/null
+++ b/src/widget/plate_dock.rs
@@ -0,0 +1,160 @@
+//! The plate-corner dock affordance (RFC Phase 7c), generalized from
+//! cce-designer's plate corner: a small circular control on a pane plate's
+//! top-right that opens a menu (Collapse/Expand, Detach/Reattach, plus
+//! host-specific rows), collapses the plate to a title stub, and arms
+//! click-vs-drag for dock repositioning.
+//!
+//! This module owns the app-agnostic PROTOCOL: control geometry and hit
+//! testing, the per-plate state vocabulary, the standard menu assembly, and
+//! the press-becomes-drag threshold. The HOST keeps everything that is
+//! policy: which plates carry controls, where docks are and what dropping
+//! means, how a detached pane becomes a window (process model, sync channel),
+//! and how collapse reshapes its layout. The role flip a detach implies on
+//! the plate itself is [`crate::scene::paint::PlateSpec::detached`].
+
+/// Radius of the corner control's hit circle (and its drawn dot).
+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.
+pub const MIN_PLATE_SPAN: f32 = 3.0 * CORNER_INSET;
+
+/// 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;
+
+/// Pointer travel (Chebyshev) past which an armed corner press stops being a
+/// click and becomes a dock drag.
+pub const DRAG_THRESHOLD: f32 = 4.0;
+
+/// One plate's dock state. `collapsed` and `detached` are exclusive in
+/// practice (a detached pane's stub is not collapsible — the menu offers only
+/// Reattach), but the type does not enforce it; the host's dispatch does.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct PlateDockState {
+ /// Shrunk to its title stub in the host layout.
+ pub collapsed: bool,
+ /// Moved out into its own window; the host keeps a stub for reattaching.
+ pub detached: bool,
+}
+
+impl PlateDockState {
+ /// Whether the plate currently shows as a title stub (either way).
+ pub fn stubbed(&self) -> bool {
+ self.collapsed || self.detached
+ }
+}
+
+/// What the standard corner menu can do to its plate. Hosts append their own
+/// rows after these (the designer's spreadsheet span modes, for example) —
+/// the row protocol is (label, action) pairs in display order.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PlateDockAction {
+ Collapse,
+ Expand,
+ Detach,
+ Reattach,
+}
+
+/// Centre of the corner control for a plate occupying `rect`
+/// (`(x, y, w, h)`), or `None` when the plate is too small to carry one. A
+/// stub is BUILT to carry the control and is shorter than the minimum span a
+/// full pane must clear, so it centres vertically instead — that control is
+/// the only way to bring the pane back.
+pub fn corner_center(rect: (f32, f32, f32, f32), stubbed: bool) -> Option<(f32, f32)> {
+ let (x, y, w, h) = rect;
+ if w < MIN_PLATE_SPAN {
+ return None;
+ }
+ if stubbed {
+ return Some((x + w - CORNER_INSET, y + h / 2.0));
+ }
+ if h < MIN_PLATE_SPAN {
+ return None;
+ }
+ Some((x + w - CORNER_INSET, y + CORNER_INSET))
+}
+
+/// Whether `(px, py)` hits a control centred at `center`.
+pub fn corner_hit(center: (f32, f32), px: f32, py: f32) -> bool {
+ let (dx, dy) = (px - center.0, py - center.1);
+ dx * dx + dy * dy <= CORNER_R * CORNER_R
+}
+
+/// Whether an armed corner press at `press` has travelled far enough at
+/// `cursor` to become a dock drag rather than a click.
+pub fn press_becomes_drag(press: (f32, f32), cursor: (f32, f32)) -> bool {
+ (cursor.0 - press.0).abs().max((cursor.1 - press.1).abs()) > DRAG_THRESHOLD
+}
+
+/// The standard corner-menu rows for a plate in `state`. A detached pane's
+/// stub offers ONLY Reattach (collapsing it would mean nothing); otherwise
+/// Collapse/Expand per state, then Detach when the host allows it. The host
+/// appends its own rows after these and owns dispatch.
+pub fn standard_menu(state: PlateDockState, can_detach: bool) -> Vec<(String, PlateDockAction)> {
+ if state.detached {
+ return vec![("Reattach".to_string(), PlateDockAction::Reattach)];
+ }
+ let mut rows = Vec::new();
+ if state.collapsed {
+ rows.push(("Expand".to_string(), PlateDockAction::Expand));
+ } else {
+ rows.push(("Collapse".to_string(), PlateDockAction::Collapse));
+ }
+ if can_detach {
+ rows.push(("Detach".to_string(), PlateDockAction::Detach));
+ }
+ rows
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn corner_geometry_and_arming() {
+ // A full-size plate: control inset from the top-right.
+ let c = corner_center((100.0, 50.0, 300.0, 200.0), false).unwrap();
+ assert_eq!(c, (100.0 + 300.0 - CORNER_INSET, 50.0 + CORNER_INSET));
+ assert!(corner_hit(c, c.0 + CORNER_R - 0.1, c.1));
+ assert!(!corner_hit(c, c.0 + CORNER_R + 0.1, c.1));
+
+ // Too narrow, or full-height too short: no control.
+ assert!(corner_center((0.0, 0.0, MIN_PLATE_SPAN - 1.0, 200.0), false).is_none());
+ assert!(corner_center((0.0, 0.0, 300.0, MIN_PLATE_SPAN - 1.0), false).is_none());
+
+ // A stub is exempt from the height guard and centres vertically —
+ // its control is the only way back.
+ let s = corner_center((0.0, 0.0, 300.0, STUB_H), true).unwrap();
+ assert_eq!(s, (300.0 - CORNER_INSET, STUB_H / 2.0));
+
+ // Click-vs-drag threshold.
+ assert!(!press_becomes_drag((10.0, 10.0), (13.0, 13.0)));
+ assert!(press_becomes_drag((10.0, 10.0), (10.0, 15.0)));
+ }
+
+ #[test]
+ fn standard_menu_variants() {
+ let plain = PlateDockState::default();
+ let rows = standard_menu(plain, true);
+ assert_eq!(
+ rows.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
+ ["Collapse", "Detach"]
+ );
+ assert_eq!(standard_menu(plain, false).len(), 1);
+
+ let collapsed = PlateDockState { collapsed: true, detached: false };
+ assert_eq!(standard_menu(collapsed, true)[0].1, PlateDockAction::Expand);
+ assert!(collapsed.stubbed());
+
+ let detached = PlateDockState { collapsed: false, detached: true };
+ let rows = standard_menu(detached, true);
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].1, PlateDockAction::Reattach);
+ assert!(detached.stubbed());
+ }
+}