git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/plate_dock.rs (6.9K)

  1 //! The plate-corner dock affordance (RFC Phase 7c), generalized from
  2 //! cce-designer's plate corner: a small circular control on a pane plate's
  3 //! top-right that opens a menu (Collapse/Expand, Detach/Reattach, plus
  4 //! host-specific rows), collapses the plate to a title stub, and arms
  5 //! click-vs-drag for dock repositioning.
  6 //!
  7 //! This module owns the app-agnostic PROTOCOL: control geometry and hit
  8 //! testing, the per-plate state vocabulary, the standard menu assembly, and
  9 //! the press-becomes-drag threshold. The HOST keeps everything that is
 10 //! policy: which plates carry controls, where docks are and what dropping
 11 //! means, how a detached pane becomes a window (process model, sync channel),
 12 //! and how collapse reshapes its layout. The role flip a detach implies on
 13 //! the plate itself is [`crate::scene::paint::PlateSpec::detached`].
 14 
 15 /// Radius of the corner control's hit circle (and its drawn dot).
 16 pub const CORNER_R: f32 = 8.0;
 17 
 18 /// Centre inset from the plate's top-right corner, on both axes. Clears the
 19 /// plate's own corner arc at the radii the DE ships.
 20 pub const CORNER_INSET: f32 = 14.0;
 21 
 22 /// A plate needs at least this much room before it earns a corner control —
 23 /// below it the trigger would cover the pane it belongs to.
 24 pub const MIN_PLATE_SPAN: f32 = 3.0 * CORNER_INSET;
 25 
 26 /// Height of a collapsed plate: its title stub. Deep enough for the title
 27 /// text and the corner control that restores it, and no deeper.
 28 pub const STUB_H: f32 = 26.0;
 29 
 30 /// Pointer travel (Chebyshev) past which an armed corner press stops being a
 31 /// click and becomes a dock drag.
 32 pub const DRAG_THRESHOLD: f32 = 4.0;
 33 
 34 /// One plate's dock state. `collapsed` and `detached` are exclusive in
 35 /// practice (a detached pane's stub is not collapsible — the menu offers only
 36 /// Reattach), but the type does not enforce it; the host's dispatch does.
 37 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
 38 pub struct PlateDockState {
 39     /// Shrunk to its title stub in the host layout.
 40     pub collapsed: bool,
 41     /// Moved out into its own window; the host keeps a stub for reattaching.
 42     pub detached: bool,
 43 }
 44 
 45 impl PlateDockState {
 46     /// Whether the plate currently shows as a title stub (either way).
 47     pub fn stubbed(&self) -> bool {
 48         self.collapsed || self.detached
 49     }
 50 }
 51 
 52 /// What the standard corner menu can do to its plate. Hosts append their own
 53 /// rows after these (the designer's spreadsheet span modes, for example) —
 54 /// the row protocol is (label, action) pairs in display order.
 55 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 56 pub enum PlateDockAction {
 57     Collapse,
 58     Expand,
 59     Detach,
 60     Reattach,
 61 }
 62 
 63 /// Centre of the corner control for a plate occupying `rect`
 64 /// (`(x, y, w, h)`), or `None` when the plate is too small to carry one. A
 65 /// stub is BUILT to carry the control and is shorter than the minimum span a
 66 /// full pane must clear, so it centres vertically instead — that control is
 67 /// the only way to bring the pane back.
 68 pub fn corner_center(rect: (f32, f32, f32, f32), stubbed: bool) -> Option<(f32, f32)> {
 69     let (x, y, w, h) = rect;
 70     if w < MIN_PLATE_SPAN {
 71         return None;
 72     }
 73     if stubbed {
 74         return Some((x + w - CORNER_INSET, y + h / 2.0));
 75     }
 76     if h < MIN_PLATE_SPAN {
 77         return None;
 78     }
 79     Some((x + w - CORNER_INSET, y + CORNER_INSET))
 80 }
 81 
 82 /// Whether `(px, py)` hits a control centred at `center`.
 83 pub fn corner_hit(center: (f32, f32), px: f32, py: f32) -> bool {
 84     let (dx, dy) = (px - center.0, py - center.1);
 85     dx * dx + dy * dy <= CORNER_R * CORNER_R
 86 }
 87 
 88 /// Whether an armed corner press at `press` has travelled far enough at
 89 /// `cursor` to become a dock drag rather than a click.
 90 pub fn press_becomes_drag(press: (f32, f32), cursor: (f32, f32)) -> bool {
 91     (cursor.0 - press.0).abs().max((cursor.1 - press.1).abs()) > DRAG_THRESHOLD
 92 }
 93 
 94 /// Draw the corner control at `center`: the plate-border-colored dot,
 95 /// enlarged when `emphasized` (hovered, or its menu is open). Earned by the
 96 /// second consumer (RFC 7c-2) — both hosts drew the identical dot.
 97 pub fn draw_corner_dot(
 98     pc: &mut crate::scene::paint::PaintCtx,
 99     center: (f32, f32),
100     emphasized: bool,
101 ) {
102     let r = if emphasized { CORNER_R * 1.15 } else { CORNER_R };
103     let fill = crate::color::plate_border_color().unwrap_or([0.55, 0.58, 0.66, 0.85]);
104     pc.circle(center.0, center.1, r, fill);
105 }
106 
107 /// The standard corner-menu rows for a plate in `state`. A detached pane's
108 /// stub offers ONLY Reattach (collapsing it would mean nothing); otherwise
109 /// Collapse/Expand per state, then Detach when the host allows it. The host
110 /// appends its own rows after these and owns dispatch.
111 pub fn standard_menu(state: PlateDockState, can_detach: bool) -> Vec<(String, PlateDockAction)> {
112     if state.detached {
113         return vec![("Reattach".to_string(), PlateDockAction::Reattach)];
114     }
115     let mut rows = Vec::new();
116     if state.collapsed {
117         rows.push(("Expand".to_string(), PlateDockAction::Expand));
118     } else {
119         rows.push(("Collapse".to_string(), PlateDockAction::Collapse));
120     }
121     if can_detach {
122         rows.push(("Detach".to_string(), PlateDockAction::Detach));
123     }
124     rows
125 }
126 
127 #[cfg(test)]
128 mod tests {
129     use super::*;
130 
131     #[test]
132     fn corner_geometry_and_arming() {
133         // A full-size plate: control inset from the top-right.
134         let c = corner_center((100.0, 50.0, 300.0, 200.0), false).unwrap();
135         assert_eq!(c, (100.0 + 300.0 - CORNER_INSET, 50.0 + CORNER_INSET));
136         assert!(corner_hit(c, c.0 + CORNER_R - 0.1, c.1));
137         assert!(!corner_hit(c, c.0 + CORNER_R + 0.1, c.1));
138 
139         // Too narrow, or full-height too short: no control.
140         assert!(corner_center((0.0, 0.0, MIN_PLATE_SPAN - 1.0, 200.0), false).is_none());
141         assert!(corner_center((0.0, 0.0, 300.0, MIN_PLATE_SPAN - 1.0), false).is_none());
142 
143         // A stub is exempt from the height guard and centres vertically —
144         // its control is the only way back.
145         let s = corner_center((0.0, 0.0, 300.0, STUB_H), true).unwrap();
146         assert_eq!(s, (300.0 - CORNER_INSET, STUB_H / 2.0));
147 
148         // Click-vs-drag threshold.
149         assert!(!press_becomes_drag((10.0, 10.0), (13.0, 13.0)));
150         assert!(press_becomes_drag((10.0, 10.0), (10.0, 15.0)));
151     }
152 
153     #[test]
154     fn standard_menu_variants() {
155         let plain = PlateDockState::default();
156         let rows = standard_menu(plain, true);
157         assert_eq!(
158             rows.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
159             ["Collapse", "Detach"]
160         );
161         assert_eq!(standard_menu(plain, false).len(), 1);
162 
163         let collapsed = PlateDockState { collapsed: true, detached: false };
164         assert_eq!(standard_menu(collapsed, true)[0].1, PlateDockAction::Expand);
165         assert!(collapsed.stubbed());
166 
167         let detached = PlateDockState { collapsed: false, detached: true };
168         let rows = standard_menu(detached, true);
169         assert_eq!(rows.len(), 1);
170         assert_eq!(rows[0].1, PlateDockAction::Reattach);
171         assert!(detached.stubbed());
172     }
173 }