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

commita640310c4240c4bc5fce3d29d6885e3374a215cc
parent518f2935ee
authorLucas Galante <[email protected]>
date2026-09-08 22:27
feat(group): a lasso around registered widgets, fitted to its plate on request

`widget::Group` owns nothing and lays nothing out: it is a set of member ids,
and its frame is derived every paint from where the host's layout put those
members — the padded hull of their rects (label strips included), with a
title tab flush on the top edge when labelled. A host groups controls without
restructuring its tree or its layout code.

Fit to plate: given the plate it sits on, a side within `snap` of the plate's
inset seat takes it, moving outward only (a member already in the padding
zone keeps the frame outside itself, up to the plate's own edge); a snapped
top leaves the tab room inside the plate; a corner whose two sides snapped
follows the plate's corner concentrically. On a narrow pane a group is that
pane's inset lining, on a wide one a lasso.

The frame is the section's frame: `PaintCtx::section_well` — the settings
app's union carve (body recess, title tab carved with it, filleted throat),
lifted into the toolkit — under relief, the section outline otherwise; the
section style keys apply. The group never hits, and authors its title as its
own text so the paint walk carries it. `CCE_GROUP_DEBUG=1` prints each
paint's hull, plate and frame.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                     |   9 ++
 src/scene/paint.rs            |  78 ++++++++++
 src/widget/container/group.rs | 349 ++++++++++++++++++++++++++++++++++++++++++
 src/widget/container/mod.rs   |   2 +
 src/widget/mod.rs             |   1 +
 5 files changed, 439 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index b77745b..fc7b121 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -125,6 +125,15 @@ widget does not fit one of them, say so rather than stretching a word.
   the trackpad pane, the ColorSelector's recess. Things you press are plates;
   things you enter are wells. A well's floor can carry fills (a progress
   fill, a colour swatch) — those are segments of the floor, not plates.
+- **A group is a lasso.** `widget::Group` owns nothing: it is a set of member
+  ids, and its frame is the padded hull of wherever the host's layout put
+  them, with a title tab flush on the top edge — the section's frame
+  (`PaintCtx::section_well` under relief, the section outline otherwise), so
+  a group is a segment of the plate it sits on, parted by a section carve
+  rather than a seam. Given its plate (`with_plate`) and `with_fit`, sides
+  within `snap` of the plate's edge take the edge one padding in and corners
+  on the plate's corner follow it concentrically: on a narrow pane a group is
+  that pane's inset lining, on a wide one a lasso. A group never hits.
 - **Segments are plates or floors sharing one silhouette, parted by seams.**
   A seam is a `Groove` cut across the shared surface, dying into its rolled
   edge: Breadcrumb segments, ButtonStrip segments, the ColorSelector's
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 9430830..3d70cef 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -1073,6 +1073,84 @@ impl PaintCtx {
         }
     }
 
+    /// A section's well — the settings app's union carve, the ONE shape a
+    /// section or a [`crate::widget::Group`] is cut into the plate with: the
+    /// `body` carved as a recess with `radii` (TL, TR, BR, BL), and when there
+    /// is a title `tab` (flush on the body's top edge, at its left), the tab
+    /// carved WITH it as one shape — the tab bottom-open, one piece owning the
+    /// body's whole right run so its corners are real turns, a left piece
+    /// carrying the left wall, the pieces extending past their interior seam by
+    /// `depth` so the walls crossfade there instead of notching — and the
+    /// throat's inside corner rounded by a concave fillet.
+    pub fn section_well(&mut self, body: Rect, tab: Option<Rect>, radii: Radii, depth: f32) {
+        let (cx, cy, cw, ch) = (body.x, body.y, body.width, body.height);
+        let (tl, tr, br, bl) = radii;
+        let Some(t) = tab else {
+            self.recess_edges(body, radii, depth, (true, true, true, true));
+            return;
+        };
+        let (tx, ty, tw, th) = (t.x, t.y, t.width, t.height);
+        let rt = tl.max(tr).min(th * 0.45);
+        let throat_r = tx + tw;
+        // The designer's SECTION_FILLET_R.
+        let rho = 10.0f32;
+        let body_lr = |x_run: f32, pc: &mut Self| {
+            pc.recess_edges(
+                Rect { x: x_run, y: cy, width: cx + cw - x_run, height: ch },
+                (0.0, tr, br, 0.0),
+                depth,
+                (true, true, true, false),
+            );
+            pc.recess_edges(
+                Rect { x: cx, y: cy, width: x_run + depth - cx, height: ch },
+                (0.0, 0.0, 0.0, bl),
+                depth,
+                (false, false, true, true),
+            );
+        };
+        if cx + cw > throat_r + 2.0 * rho {
+            // Filleted throat: the tab's right wall ends at the fillet's vertical
+            // tangent, a left-only bridge carries the left wall across the span.
+            self.recess_edges(
+                Rect { x: tx, y: ty, width: tw, height: (cy - rho) - ty + depth },
+                (rt, rt, 0.0, 0.0),
+                depth,
+                (true, true, false, true),
+            );
+            self.recess_edges(
+                Rect { x: tx, y: cy - rho, width: tw, height: rho + depth },
+                (0.0, 0.0, 0.0, 0.0),
+                depth,
+                (false, false, false, true),
+            );
+            body_lr(throat_r + rho - depth, self);
+            self.concave_fillet(throat_r + rho, cy - rho, rho, depth, std::f32::consts::FRAC_PI_2, false);
+        } else if cx + cw > throat_r + 0.5 {
+            // Too narrow for the fillet: the plain square throat.
+            self.recess_edges(
+                Rect { x: tx, y: ty, width: tw, height: (cy - ty) + depth },
+                (rt, rt, 0.0, 0.0),
+                depth,
+                (true, true, false, true),
+            );
+            body_lr(throat_r - depth, self);
+        } else {
+            // The tab spans the body: no top wall at all.
+            self.recess_edges(
+                Rect { x: tx, y: ty, width: tw, height: (cy - ty) + depth },
+                (rt, rt, 0.0, 0.0),
+                depth,
+                (true, true, false, true),
+            );
+            self.recess_edges(
+                Rect { x: cx, y: cy, width: cw, height: ch },
+                (0.0, 0.0, br, bl),
+                depth,
+                (false, true, true, true),
+            );
+        }
+    }
+
     /// A flush inset control: `rect`'s plate sits SUNKEN into the surface with
     /// its face level with it — a valley seam runs the boundary, the surface
     /// falling into it on the way out and the control's own face rising back
diff --git a/src/widget/container/group.rs b/src/widget/container/group.rs
new file mode 100644
index 0000000..f05ed80
--- /dev/null
+++ b/src/widget/container/group.rs
@@ -0,0 +1,349 @@
+//! `Group` — a lasso around registered widgets. See "Plates, wells and seams"
+//! in `CLAUDE.md`: a group is a segment of the plate it sits on, parted from
+//! the rest by a section carve rather than a seam.
+//!
+//! Unlike a container it OWNS nothing and lays nothing out: it is defined by
+//! membership alone (widget ids), and its frame is derived every paint from
+//! where the host's own layout put those members — the padded hull of their
+//! rects, with a title tab flush on the top edge when it has a label. So a
+//! host groups controls without restructuring its tree or its layout code.
+//!
+//! **Fit to plate.** Given the plate it sits on ([`Adapted<Group>::with_plate`]
+//! and [`Adapted<Group>::with_fit`]), any side of the frame within `snap` of
+//! that plate's edge (or past it) extends to the edge, one padding in, and a
+//! corner whose two sides both snapped takes the plate's corner concentrically
+//! — the Dropdown's corner-frame rule. A group on a narrow pane becomes that
+//! pane's inset lining; on a wide one it stays a lasso around its members.
+//!
+//! **The frame is the section's frame.** Under `control_relief` it is the
+//! settings app's union well ([`PaintCtx::section_well`]): the body carved as
+//! a recess, the title tab carved with it as one shape, the throat between
+//! them filleted. Without relief it is the section outline: a hairline ring
+//! with a gap for the title. The section style keys
+//! (`style.container.section.{depth,font,padding}`) apply.
+//!
+//! The group is never hittable and draws only through `paint_ui` (it needs
+//! the context to find its members), so hosts on the legacy quad bridges see
+//! it only through the prim replay — which carries every prim it emits
+//! (recesses, fillets, vectors, text). `CCE_GROUP_DEBUG=1` prints each
+//! paint's hull, plate and frame.
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::{Cap, PaintCtx};
+use crate::widget::{Adapted, Input, Layout, Paint, UiContext, WidgetId};
+
+/// A group's frame for one paint: the body box, the title tab flush on its top
+/// edge (when labelled), and the body's corner radii (TL, TR, BR, BL — a
+/// fitted corner is the plate's, concentric).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct GroupFrame {
+    pub body: Rect,
+    pub tab: Option<Rect>,
+    pub radii: (f32, f32, f32, f32),
+}
+
+#[derive(Debug, Clone)]
+pub struct Group {
+    members: Vec<WidgetId>,
+    label: Option<String>,
+    /// Frame inset around the members' hull.
+    padding: f32,
+    /// The plate the group sits on, and its corner radius — the fit target.
+    plate: Option<(Rect, f32)>,
+    fit: bool,
+    /// How close (px) a side must be to the plate's edge to snap to it.
+    snap: f32,
+}
+
+impl Group {
+    /// A group of `members` (registered widget ids); no plate, no fit, the
+    /// section padding plus the relief width around the hull.
+    pub fn new(members: Vec<WidgetId>) -> Adapted<Group> {
+        Adapted::new(Group {
+            members,
+            label: None,
+            // The section padding plus the wall's outer half: the carve straddles
+            // the frame line, so this keeps its outer slope clear of the members.
+            padding: crate::layout::section_padding() + crate::layout::bevel_width() * 0.5,
+            plate: None,
+            fit: false,
+            snap: 12.0,
+        })
+    }
+
+    pub fn members(&self) -> &[WidgetId] {
+        &self.members
+    }
+
+    pub fn set_members(&mut self, members: Vec<WidgetId>) {
+        self.members = members;
+    }
+
+    /// The plate this group sits on (rect, corner radius) — what `fit` snaps to.
+    pub fn set_plate(&mut self, plate: Rect, corner_radius: f32) {
+        self.plate = Some((plate, corner_radius));
+    }
+
+    pub fn set_fit(&mut self, fit: bool) {
+        self.fit = fit;
+    }
+
+    /// The section carve's wall width: the DE relief scaled by the section depth
+    /// multiplier, capped against the frame height (the ParametersBg rule).
+    fn depth(&self, h: f32) -> f32 {
+        (crate::layout::bevel_width() * crate::layout::section_depth()).min(h * 0.2).max(0.5)
+    }
+
+    fn title_font(&self) -> (String, f32) {
+        let (fam, size) = crate::layout::parse_font_string(&crate::layout::section_label_font());
+        (fam, size.unwrap_or(14.0))
+    }
+
+    /// The members' hull: the union of the registered, visible, on-screen members' rects.
+    fn hull(&self, ui: &UiContext) -> Option<Rect> {
+        let mut hull: Option<(f32, f32, f32, f32)> = None;
+        for id in &self.members {
+            let Some(ptr) = ui.tree.get_ptr(*id) else { continue };
+            if ptr.is_null() {
+                continue;
+            }
+            let w = unsafe { &*ptr };
+            if !w.visible() {
+                continue;
+            }
+            let (x, mut y, ww, mut hh) = w.rect();
+            if ww <= 0.0 || hh <= 0.0 || x + ww <= 0.0 || y + hh <= 0.0 {
+                continue;
+            }
+            // A labelled control's detached label hangs in the strip above its
+            // rect: the lasso wraps the label too.
+            let strip = w.label_strip();
+            y -= strip;
+            hh += strip;
+            hull = Some(match hull {
+                None => (x, y, x + ww, y + hh),
+                Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + ww), y1.max(y + hh)),
+            });
+        }
+        hull.map(|(x0, y0, x1, y1)| Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
+    }
+
+    /// This paint's frame — `None` when no member is on screen.
+    pub fn frame(&self, ui: &UiContext) -> Option<GroupFrame> {
+        let hull = self.hull(ui)?;
+        let p = self.padding;
+        let mut body = Rect { x: hull.x - p, y: hull.y - p, width: hull.width + 2.0 * p, height: hull.height + 2.0 * p };
+        let r = crate::layout::plate_corner_radius();
+        let mut radii = (r, r, r, r);
+        let title = self.label.as_deref().filter(|l| !l.is_empty());
+        let (fam, size) = self.title_font();
+        let tab_h = size + 8.0;
+        if let (true, Some((plate, pr))) = (self.fit, self.plate) {
+            // The frame's natural seat is one padding inside the plate's edge. A
+            // side within `snap` of that seat — or past it — takes it, moving
+            // OUTWARD only: a member that already sits in the padding zone keeps
+            // the frame outside itself, up to the plate's own edge. A snapped
+            // top leaves the tab room inside the plate.
+            let (l, t) = (plate.x, plate.y);
+            let (rr, b) = (plate.x + plate.width, plate.y + plate.height);
+            let top_room = if title.is_some() { tab_h } else { 0.0 };
+            let (x0, y0, x1, y1) = (body.x, body.y, body.x + body.width, body.y + body.height);
+            let snap_l = x0 - (l + p) <= self.snap;
+            let snap_t = y0 - (t + p + top_room) <= self.snap;
+            let snap_r = (rr - p) - x1 <= self.snap;
+            let snap_b = (b - p) - y1 <= self.snap;
+            let nx0 = if snap_l { x0.min(l + p).max(l) } else { x0 };
+            let ny0 = if snap_t { y0.min(t + p + top_room).max(t + top_room) } else { y0 };
+            let nx1 = if snap_r { x1.max(rr - p).min(rr) } else { x1 };
+            let ny1 = if snap_b { y1.max(b - p).min(b) } else { y1 };
+            body = Rect { x: nx0, y: ny0, width: nx1 - nx0, height: ny1 - ny0 };
+            // A corner on the plate's corner follows its curve, concentrically —
+            // at the inset the two sides actually landed at.
+            let cr = |a: f32, bb: f32| (pr - a.max(bb)).max(0.0);
+            radii = (
+                if snap_l && snap_t { cr(nx0 - l, ny0 - top_room - t) } else { r },
+                if snap_r && snap_t { cr(rr - nx1, ny0 - top_room - t) } else { r },
+                if snap_r && snap_b { cr(rr - nx1, b - ny1) } else { r },
+                if snap_l && snap_b { cr(nx0 - l, b - ny1) } else { r },
+            );
+        }
+        let tab = title.map(|l| {
+            let tw = (crate::widget::display::measure_text_width(l, &fam, size) + 16.0).clamp(8.0, body.width.max(8.0));
+            // Flush on the body's top edge, at its left (the settings' tab).
+            Rect { x: body.x, y: body.y - tab_h, width: tw, height: tab_h }
+        });
+        if std::env::var_os("CCE_GROUP_DEBUG").is_some() {
+            eprintln!("[group] {:?} hull={:?} plate={:?} fit={} body={:?} tab={:?}", self.label, hull, self.plate, self.fit, body, tab);
+        }
+        Some(GroupFrame { body, tab, radii })
+    }
+}
+
+impl Adapted<Group> {
+    pub fn with_padding(mut self, padding: f32) -> Self {
+        self.padding = padding;
+        self
+    }
+
+    /// The plate this group sits on (rect, corner radius) — see [`Group::set_plate`].
+    pub fn with_plate(mut self, plate: Rect, corner_radius: f32) -> Self {
+        self.plate = Some((plate, corner_radius));
+        self
+    }
+
+    /// Fit to the plate: sides near its edges snap to them (see the module doc).
+    pub fn with_fit(mut self, fit: bool) -> Self {
+        self.fit = fit;
+        self
+    }
+
+    pub fn with_snap(mut self, snap: f32) -> Self {
+        self.snap = snap;
+        self
+    }
+}
+
+impl Layout for Group {
+    /// The title is part of the frame (the tab), not a detached control label.
+    fn inline_label(&self) -> bool {
+        true
+    }
+}
+
+impl Paint for Group {
+    fn color(&self) -> [f32; 4] {
+        [0.0; 4]
+    }
+
+    fn sync_label(&mut self, label: &str) {
+        self.label = Some(label.to_string());
+    }
+
+    /// The title is authored here, with its font, in `paint_ui` — it must pass
+    /// through the paint walk verbatim rather than be re-derived from `paint`
+    /// (which, without the context, has nothing to say).
+    fn paints_own_subtree(&self) -> bool {
+        true
+    }
+
+    /// Nothing without the context: the frame is where the members are.
+    fn paint(&self, _rect: Rect, _ctx: &mut PaintCtx) {}
+
+    fn paint_ui(&self, ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
+        let Some(f) = self.frame(ui) else { return };
+        let (fam, size) = self.title_font();
+        if crate::layout::control_relief() {
+            ctx.section_well(f.body, f.tab, f.radii, self.depth(f.body.height));
+        } else {
+            // The section outline: a hairline ring, the top edge parted for the title.
+            let c = [0.25, 0.25, 0.35, 1.0];
+            let (x0, y0) = (f.body.x, f.body.y);
+            let (x1, y1) = (f.body.x + f.body.width, f.body.y + f.body.height);
+            match f.tab {
+                Some(t) => {
+                    let (gap0, gap1) = (t.x + 2.0, t.x + t.width - 2.0);
+                    if gap0 > x0 {
+                        ctx.vector(x0, y0, gap0, y0, 1.0, c, Cap::Flat);
+                    }
+                    if x1 > gap1 {
+                        ctx.vector(gap1, y0, x1, y0, 1.0, c, Cap::Flat);
+                    }
+                }
+                None => ctx.vector(x0, y0, x1, y0, 1.0, c, Cap::Flat),
+            }
+            ctx.vector(x0, y1, x1, y1, 1.0, c, Cap::Flat);
+            ctx.vector(x0, y0, x0, y1, 1.0, c, Cap::Flat);
+            ctx.vector(x1, y0, x1, y1, 1.0, c, Cap::Flat);
+        }
+        if let (Some(label), Some(t)) = (self.label.as_deref(), f.tab) {
+            let color = crate::color::control_label_color_for_state(false, false);
+            let ty = if crate::layout::control_relief() { t.y + 4.0 } else { t.y + t.height - size - 2.0 };
+            ctx.text_with(label.to_string(), t.x + 8.0, ty, size, color, Some(format!("{fam} {size}")), None);
+        }
+    }
+}
+
+impl Input for Group {
+    /// Never hittable: the members underneath take the pointer.
+    fn hit(&self, _rect: Rect, _x: f32, _y: f32) -> bool {
+        false
+    }
+
+    fn blocks_root_plate_drag(&self) -> bool {
+        false
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::{Button, WidgetHost};
+
+    fn register(ctx: &mut UiContext, w: &mut dyn WidgetHost) -> WidgetId {
+        let id = w.base().id();
+        let ptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(w as *mut dyn WidgetHost) };
+        ctx.register_widget(id, ptr);
+        id
+    }
+
+    /// The frame is the members' hull plus the padding; a parked member is not in it.
+    #[test]
+    fn frame_is_the_padded_hull_of_the_members() {
+        let mut ctx = UiContext::new();
+        let mut a = Button::new(0.0, 0.0, 80.0, 24.0);
+        let mut b = Button::new(0.0, 0.0, 80.0, 24.0);
+        let mut parked = Button::new(0.0, 0.0, 1.0, 1.0);
+        WidgetHost::set_rect(&mut a, 100.0, 50.0, 80.0, 24.0);
+        WidgetHost::set_rect(&mut b, 200.0, 90.0, 60.0, 30.0);
+        WidgetHost::set_rect(&mut parked, -1000.0, -1000.0, 1.0, 1.0);
+        let ids = vec![register(&mut ctx, &mut a), register(&mut ctx, &mut b), register(&mut ctx, &mut parked)];
+        let g = Group::new(ids).with_padding(10.0);
+        let f = g.inner().frame(&ctx).expect("members on screen");
+        assert_eq!((f.body.x, f.body.y, f.body.width, f.body.height), (90.0, 40.0, 180.0, 90.0));
+        assert!(f.tab.is_none(), "no label, no tab");
+    }
+
+    /// Fit to plate: a side near the plate's edge takes it (one padding in), a
+    /// far side keeps the hull, and a corner on the plate's corner is concentric.
+    #[test]
+    fn fit_snaps_near_sides_to_the_plate() {
+        let mut ctx = UiContext::new();
+        let mut a = Button::new(0.0, 0.0, 80.0, 24.0);
+        WidgetHost::set_rect(&mut a, 20.0, 20.0, 80.0, 24.0);
+        let ids = vec![register(&mut ctx, &mut a)];
+        let plate = Rect { x: 0.0, y: 0.0, width: 400.0, height: 300.0 };
+        let g = Group::new(ids).with_padding(10.0).with_plate(plate, 16.0).with_fit(true).with_snap(24.0);
+        let f = g.inner().frame(&ctx).unwrap();
+        // Left/top hull edges (10, 10) sit on the plate's inset seat (10, 10): snapped.
+        assert_eq!((f.body.x, f.body.y), (10.0, 10.0));
+        // A member inside the padding zone keeps the frame outside itself, up to the edge.
+        let mut edge = Button::new(0.0, 0.0, 80.0, 24.0);
+        WidgetHost::set_rect(&mut edge, 4.0, 60.0, 80.0, 24.0);
+        let eid = register(&mut ctx, &mut edge);
+        let ge = Group::new(vec![eid]).with_padding(10.0).with_plate(plate, 16.0).with_fit(true).with_snap(24.0);
+        let fe = ge.inner().frame(&ctx).unwrap();
+        assert_eq!(fe.body.x, 0.0, "clamped to the plate's own edge, never inside the member");
+        // Right/bottom are far from the plate: the hull's own.
+        assert_eq!((f.body.x + f.body.width, f.body.y + f.body.height), (110.0, 54.0));
+        assert_eq!(f.radii.0, 6.0, "the top-left corner is the plate's, concentric (16 - 10)");
+        assert_eq!(f.radii.2, crate::layout::plate_corner_radius(), "the far corner keeps the section radius");
+        // Unfitted, the same group hugs its member.
+        let loose = Group::new(vec![a.base().id()]).with_padding(10.0).with_plate(plate, 16.0);
+        assert_eq!(loose.inner().frame(&ctx).unwrap().body.x, 10.0);
+        assert_eq!(loose.inner().frame(&ctx).unwrap().radii.0, crate::layout::plate_corner_radius());
+    }
+
+    /// A labelled group carries a title tab flush on the body's top edge.
+    #[test]
+    fn a_label_adds_a_tab_on_the_top_edge() {
+        let mut ctx = UiContext::new();
+        let mut a = Button::new(0.0, 0.0, 80.0, 24.0);
+        WidgetHost::set_rect(&mut a, 100.0, 100.0, 80.0, 24.0);
+        let ids = vec![register(&mut ctx, &mut a)];
+        let g = Group::new(ids).with_label("Group");
+        let f = g.inner().frame(&ctx).unwrap();
+        let t = f.tab.expect("a tab");
+        assert_eq!(t.x, f.body.x);
+        assert_eq!(t.y + t.height, f.body.y, "flush on the top edge");
+    }
+}
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index 7b02c32..e130e15 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -7,6 +7,7 @@ pub mod spreadsheet;
 pub mod scroll_box;
 pub mod paginator;
 pub mod treelist;
+pub mod group;
 
 pub use container_layout::{ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout, ColumnsLayout, MosaicLayout, ReverseMosaicLayout};
 pub use content_bg::ContentBg;
@@ -17,3 +18,4 @@ pub use spreadsheet::Spreadsheet;
 pub use scroll_box::ScrollBox;
 pub use paginator::Paginator;
 pub use treelist::{TreeList, TreeElement};
+pub use group::{Group, GroupFrame};
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index a2c20ff..fa939dc 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -565,6 +565,7 @@ pub use self::input::{
     format_ramp_spec, parse_ramp_spec
 };
 pub use self::container::{
+    Group, GroupFrame,
     ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
     ColumnsLayout, MosaicLayout, ReverseMosaicLayout,
     ContentBg, ParametersBg,