Make a texture a registered primitive like any other

`write_texture` differed from `write` by one argument, which is what the
generic parameter was already for, so textures register as a primitive
with a `TexturePrimitive` holding the slot. `write_texture`,
`InstanceKind` and the layer's separate texture list are gone;
`DrawLayers` is back to `write` and `free`, with the kind carried in
`PrimitiveInst` as it carries everything else.

What differs between a texture and a rect is only what it samples, so
that is what registration says: `PrimitiveTexture::Atlas` binds the
shared atlas once for the layer, `PerInstance` binds the texture its own
data names and draws one instance at a time. One loop over a layer's
lists, one match on that.

`Pod` is back to being a supertrait of `Primitive` rather than the bound
itself. The guarantee is that a `PrimitiveKind<P>` is only minted by
`register::<P>` and `write` takes the kind and the value together, so a
primitive always has a list of its own to go in and the write does not
check anything: a list takes its stride from the type it was made for
instead of inferring it from the first write and asserting on the rest.

Also from reviewing this: a layer's lists and their buffers are created
only when that layer draws that primitive, so a primitive nobody uses no
longer costs two buffers in every layer -- which matters more now the set
is open-ended. `ListBuffers::update` takes the two things it uses rather
than the whole pipeline.

Verified again over all five cases: an image alone in a layer, three
images added and one deleted, the masked text-edit tab, the text-layout
tab and the default tab.
This commit is contained in:
iris committed 2026-09-13 14:46:08 -04:00
1 parent 89491a5949
commit 7b318e3271
5 files changed
+206 -184

No files matched your search

+4 -20
View File
@@ -1,8 +1,7 @@
use std::ops::{Index, IndexMut};
use crate::{
UiRegion, WidgetId,
render::{LayerDraws, MaskIdx, PrimitiveHandle, PrimitiveKind},
render::{LayerDraws, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
util::to_mut,
};
@@ -121,32 +120,17 @@ impl<T: Default> Layers<T> {
}
impl DrawLayers {
pub fn write<P: bytemuck::Pod>(
pub fn write<P: Primitive>(
&mut self,
layer: LayerId,
kind: PrimitiveKind<P>,
id: WidgetId,
primitive: P,
region: UiRegion,
mask_idx: MaskIdx,
info: PrimitiveInst<P>,
) -> PrimitiveHandle {
self[layer].write(layer, kind, id, primitive, region, mask_idx)
self[layer].write(layer, info)
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self[h.layer].free(h)
}
pub fn write_texture(
&mut self,
layer: LayerId,
id: WidgetId,
texture: u32,
region: UiRegion,
mask_idx: MaskIdx,
) -> PrimitiveHandle {
self[layer].write_texture(layer, id, texture, region, mask_idx)
}
}
impl<T: Default> Default for Layers<T> {
+53 -65
View File
@@ -26,19 +26,15 @@ pub use data::{Mask, MaskIdx};
pub use primitive::*;
const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
const TEXTURE_SHADER: &str = include_str!("./shader/texture.wgsl");
pub struct UiRenderNode {
shared_layout: BindGroupLayout,
shared_group: BindGroup,
texture_data_layout: BindGroupLayout,
texture_layout: BindGroupLayout,
format: TextureFormat,
/// One per registered primitive, in id order. The texture pipeline is
/// apart because a texture binds group 2 per instance, not per draw.
/// One per registered primitive, in id order.
primitives: Vec<PrimitivePipeline>,
texture_pipeline: RenderPipeline,
layers: HashMap<usize, RenderLayer>,
active: Vec<usize>,
@@ -50,11 +46,10 @@ pub struct UiRenderNode {
}
struct RenderLayer {
/// One per registered primitive, in id order, matching `LayerDraws`.
primitives: Vec<ListBuffers>,
textures: ListBuffers,
/// Which texture each entry of `textures` samples, in the same order.
texture_slots: Vec<u32>,
/// One per registered primitive, in id order, matching `LayerDraws` --
/// `None` where this layer draws none, so a primitive nobody uses costs no
/// buffers per layer.
primitives: Vec<Option<ListBuffers>>,
}
/// What draws one registered primitive. The group 1 layout is its own rather
@@ -62,6 +57,7 @@ struct RenderLayer {
struct PrimitivePipeline {
data_layout: BindGroupLayout,
pipeline: RenderPipeline,
texture: PrimitiveTexture,
}
/// One list's vertex buffer and the data its shader reads at group 1.
@@ -69,6 +65,8 @@ struct ListBuffers {
instance: ArrBuf<PrimitiveInstance>,
data: ArrBuf<u8>,
group: Option<BindGroup>,
/// For a `PerInstance` primitive, the texture each instance binds.
slots: Vec<u32>,
}
impl UiRenderNode {
@@ -80,36 +78,37 @@ impl UiRenderNode {
// under a texture. Ordering beyond that is what `Layers` is for --
// freeing an instance swaps another into its place.
for (id, list) in layer.primitives.iter().enumerate() {
let Some(list) = list else {
continue;
};
let Some(group) = &list.group else {
continue;
};
pass.set_pipeline(&self.primitives[id].pipeline);
// Both after the pipeline: each primitive has its own pipeline
// layout, and a change drops the groups from where they differ.
let primitive = &self.primitives[id];
pass.set_pipeline(&primitive.pipeline);
// Both groups after the pipeline: each primitive has its own
// pipeline layout, and a change drops the groups from where
// the two layouts differ.
pass.set_bind_group(1, group, &[]);
pass.set_bind_group(2, self.pages.group(), &[]);
pass.set_vertex_buffer(0, list.instance.buffer.slice(..));
match primitive.texture {
PrimitiveTexture::Atlas => {
pass.set_bind_group(2, self.pages.group(), &[]);
pass.draw(0..4, 0..list.instance.len() as u32);
}
if !layer.texture_slots.is_empty() {
// Group 1 too, unread as it is: a layer holding only an image
// never ran the loop above, so nothing is bound there.
let Some(data) = &layer.textures.group else {
PrimitiveTexture::PerInstance => {
for (i, &slot) in list.slots.iter().enumerate() {
let Some(texture) = self.textures.group(slot) else {
continue;
};
pass.set_pipeline(&self.texture_pipeline);
pass.set_bind_group(1, data, &[]);
pass.set_vertex_buffer(0, layer.textures.instance.buffer.slice(..));
for (i, &slot) in layer.texture_slots.iter().enumerate() {
let Some(group) = self.textures.group(slot) else {
continue;
};
pass.set_bind_group(2, group, &[]);
pass.set_bind_group(2, texture, &[]);
pass.draw(0..4, i as u32..i as u32 + 1);
}
}
}
}
}
}
pub fn update(
&mut self,
@@ -135,35 +134,33 @@ impl UiRenderNode {
}
}
}
let rlayer = self
.layers
.entry(i)
.or_insert_with(|| RenderLayer::new(device));
let rlayer = self.layers.entry(i).or_insert_with(RenderLayer::new);
if draws.updated {
rlayer
.primitives
.resize_with(draws.primitives().len(), || ListBuffers::new(device));
for (id, (list, draws)) in rlayer
.resize_with(draws.primitives().len(), || None);
for (id, (buffers, list)) in rlayer
.primitives
.iter_mut()
.zip(draws.primitives())
.enumerate()
{
let Some(list) = list else {
continue;
};
// Indexed, not zipped: a missing pipeline should say so
// rather than quietly leave the list unbuilt.
let layout = &self.primitives[id].data_layout;
list.update(device, queue, layout, draws);
let primitive = &self.primitives[id];
buffers
.get_or_insert_with(|| ListBuffers::new(device))
.update(
device,
queue,
primitive.texture,
&primitive.data_layout,
list,
);
}
rlayer
.textures
.update(device, queue, &self.texture_data_layout, &draws.textures);
rlayer.texture_slots.clear();
// Read rather than cast: the payload is a byte vec, so it
// carries no alignment a `u32` slice could borrow.
let slots = draws.textures.data().as_chunks::<4>().0;
rlayer
.texture_slots
.extend(slots.iter().copied().map(u32::from_ne_bytes));
draws.updated = false;
}
}
@@ -218,30 +215,12 @@ impl UiRenderNode {
let pages = GpuPages::new(device, queue, &texture_layout, &sampler);
let textures = GpuTextures::new(device, queue);
// A texture instance's data is the slot it binds, which its shader
// never reads -- but group 1 is in the layout, so it is bound anyway.
let texture_data_layout = Self::data_layout(device, size_of::<u32>() as u64);
let texture_pipeline = Self::pipeline(
device,
&Self::pipeline_layout(
device,
&shared_layout,
&texture_data_layout,
&texture_layout,
),
config.format,
TEXTURE_SHADER,
"texture",
);
Self {
shared_layout,
shared_group,
texture_data_layout,
texture_layout,
format: config.format,
primitives: Vec::new(),
texture_pipeline,
window_buffer,
layers: HashMap::default(),
active: Vec::new(),
@@ -267,6 +246,7 @@ impl UiRenderNode {
self.primitives.push(PrimitivePipeline {
data_layout,
pipeline,
texture: source.texture,
});
}
}
@@ -438,11 +418,9 @@ impl UiRenderNode {
}
impl RenderLayer {
fn new(device: &Device) -> Self {
fn new() -> Self {
Self {
primitives: Vec::new(),
textures: ListBuffers::new(device),
texture_slots: Vec::new(),
}
}
}
@@ -461,6 +439,7 @@ impl ListBuffers {
"primitive data",
),
group: None,
slots: Vec::new(),
}
}
@@ -468,9 +447,18 @@ impl ListBuffers {
&mut self,
device: &Device,
queue: &Queue,
texture: PrimitiveTexture,
layout: &BindGroupLayout,
list: &InstanceList,
) {
if texture == PrimitiveTexture::PerInstance {
self.slots.clear();
// Read rather than cast: the payload is a byte vec, so it carries
// no alignment a `u32` slice could borrow.
let slots = list.data().as_chunks::<4>().0;
self.slots
.extend(slots.iter().copied().map(u32::from_ne_bytes));
}
self.instance.update(device, queue, list.instances());
let resized = self.data.update(device, queue, list.data());
// An empty list has no buffer big enough for one entry, and nothing
+114 -81
View File
@@ -7,6 +7,14 @@ use crate::{
};
use bytemuck::Pod;
/// One instance of a registered primitive, laid out as the struct that
/// primitive's shader reads at `@group(1) @binding(0)`.
///
/// A `PrimitiveKind<P>` is only minted by `register::<P>`, and `write` takes
/// the kind and the value together, so holding one is the proof that `P` has a
/// list of its own to go in and a write needs no check.
pub trait Primitive: Pod {}
/// Which registered primitive an instance is, and so which list it lives in
/// and which pipeline draws it. The type ties a `write` to what its shader reads.
pub struct PrimitiveKind<P> {
@@ -37,6 +45,7 @@ impl<P> Copy for PrimitiveKind<P> {}
pub const RECT: PrimitiveKind<RectPrimitive> = PrimitiveKind::new(0);
pub const GLYPH: PrimitiveKind<GlyphPrimitive> = PrimitiveKind::new(1);
pub const TEXTURE: PrimitiveKind<TexturePrimitive> = PrimitiveKind::new(2);
/// Every primitive a ui can draw, in id order. Registering one is all the
/// wiring it needs: its list, buffers, free list and pipeline follow, and
@@ -44,6 +53,9 @@ pub const GLYPH: PrimitiveKind<GlyphPrimitive> = PrimitiveKind::new(1);
///
/// A source is compiled after `prelude.wgsl` and supplies its data at
/// `@group(1) @binding(0)` and an `fs_main` shading one instance.
///
/// Order is draw order within a layer, so a primitive registered later is
/// drawn over one registered earlier.
pub struct PrimitiveRegistry {
kinds: Vec<PrimitiveSource>,
}
@@ -54,30 +66,55 @@ pub struct PrimitiveSource {
/// Size of one instance's entry, which the renderer states as the group 1
/// binding's minimum rather than leaving it to be inferred.
pub stride: u64,
pub texture: PrimitiveTexture,
}
/// What a primitive samples at group 2, which is the whole of why some of them
/// cannot share one instanced draw.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrimitiveTexture {
/// The shared glyph atlas, bound once for the layer.
Atlas,
/// The `Textures` slot in the first four bytes of its own data, bound for
/// that instance alone -- so one draw call each.
PerInstance,
}
impl Default for PrimitiveRegistry {
fn default() -> Self {
use PrimitiveTexture::*;
let mut registry = Self { kinds: Vec::new() };
let rect = registry.register::<RectPrimitive>(include_str!("shader/rect.wgsl"), "rect");
let glyph = registry.register::<GlyphPrimitive>(include_str!("shader/glyph.wgsl"), "glyph");
let rect =
registry.register::<RectPrimitive>(include_str!("shader/rect.wgsl"), "rect", Atlas);
let glyph =
registry.register::<GlyphPrimitive>(include_str!("shader/glyph.wgsl"), "glyph", Atlas);
let texture = registry.register::<TexturePrimitive>(
include_str!("shader/texture.wgsl"),
"texture",
PerInstance,
);
// The built-ins have constant ids so a widget can name one without the
// registry; registering them first is what makes those constants true.
assert_eq!((rect.id(), glyph.id()), (RECT.id(), GLYPH.id()));
assert_eq!(
(rect.id(), glyph.id(), texture.id()),
(RECT.id(), GLYPH.id(), TEXTURE.id())
);
registry
}
}
impl PrimitiveRegistry {
pub fn register<P: Pod>(
pub fn register<P: Primitive>(
&mut self,
wgsl: &'static str,
label: &'static str,
texture: PrimitiveTexture,
) -> PrimitiveKind<P> {
self.kinds.push(PrimitiveSource {
wgsl,
label,
stride: size_of::<P>() as u64,
texture,
});
PrimitiveKind::new(self.kinds.len() as u32 - 1)
}
@@ -87,21 +124,13 @@ impl PrimitiveRegistry {
}
}
/// Which of a layer's lists an instance is in. They index independently, so a
/// handle or a renumbering naming only a position would be ambiguous.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstanceKind {
Primitive(u32),
Texture,
}
/// Instances, the widget each belongs to, the slots waiting to be reused, and
/// `stride` bytes of data per instance at the same index.
/// One registered primitive's instances in one layer: the instances, the
/// widget each belongs to, the slots waiting to be reused, and `stride` bytes
/// of that primitive's data per instance at the same index.
///
/// That data is the struct a primitive's shader reads, or the slot a texture
/// binds. Keeping both here is what holds them in step through a
/// `swap_remove`.
#[derive(Default)]
/// `stride` comes from the type the list was made for, so a write is never
/// checked against it. The data rides here rather than beside the list so the
/// two stay in step through a `swap_remove`.
pub struct InstanceList {
instances: Vec<PrimitiveInstance>,
assoc: Vec<WidgetId>,
@@ -111,6 +140,16 @@ pub struct InstanceList {
}
impl InstanceList {
fn new<P: Primitive>() -> Self {
Self {
instances: Vec::new(),
assoc: Vec::new(),
free: Vec::new(),
data: Vec::new(),
stride: size_of::<P>(),
}
}
pub fn instances(&self) -> &[PrimitiveInstance] {
&self.instances
}
@@ -120,14 +159,6 @@ impl InstanceList {
}
fn push(&mut self, id: WidgetId, inst: PrimitiveInstance, data: &[u8]) -> usize {
if self.instances.is_empty() {
self.stride = data.len();
}
debug_assert_eq!(
data.len(),
self.stride,
"primitive written to the wrong list"
);
if let Some(i) = self.free.pop() {
self.instances[i] = inst;
self.assoc[i] = id;
@@ -147,7 +178,7 @@ impl InstanceList {
self.instances[i].mask_idx
}
fn apply_free(&mut self, kind: InstanceKind) -> impl Iterator<Item = PrimitiveChange> {
fn apply_free(&mut self, kind: u32) -> impl Iterator<Item = PrimitiveChange> {
self.free.sort_by(|a, b| b.cmp(a));
let instances = &mut self.instances;
let assoc = &mut self.assoc;
@@ -173,11 +204,13 @@ impl InstanceList {
}
}
/// Everything one layer draws. A texture binds its own group 2, so it draws on
/// its own and cannot join the instanced draw a primitive gets.
/// Everything one layer draws, one list per registered primitive. They index
/// independently, so a handle or a renumbering naming only a position would be
/// ambiguous between them.
pub struct LayerDraws {
primitives: Vec<InstanceList>,
pub textures: InstanceList,
/// `None` until this layer draws that primitive, because only the write
/// knows the type the list is for.
primitives: Vec<Option<InstanceList>>,
pub updated: bool,
}
@@ -185,99 +218,85 @@ impl Default for LayerDraws {
fn default() -> Self {
Self {
primitives: Vec::new(),
textures: InstanceList::default(),
updated: true,
}
}
}
impl LayerDraws {
pub fn write<P: Pod>(
pub fn write<P: Primitive>(
&mut self,
layer: usize,
kind: PrimitiveKind<P>,
id: WidgetId,
primitive: P,
region: UiRegion,
mask_idx: MaskIdx,
PrimitiveInst {
kind,
id,
primitive,
region,
mask_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
self.updated = true;
// Grown on first use rather than sized from the registry, which a
// layer cannot see.
if self.primitives.len() <= kind.id as usize {
self.primitives
.resize_with(kind.id as usize + 1, Default::default);
self.primitives.resize_with(kind.id as usize + 1, || None);
}
let inst_idx = self.primitives[kind.id as usize].push(
let inst_idx = self.primitives[kind.id as usize]
.get_or_insert_with(InstanceList::new::<P>)
.push(
id,
PrimitiveInstance { region, mask_idx },
bytemuck::bytes_of(&primitive),
);
PrimitiveHandle {
layer,
kind: InstanceKind::Primitive(kind.id),
kind: kind.id,
inst_idx,
}
}
/// Writes an instance that samples the texture in slot `texture`, which is
/// bound for it alone rather than read from a buffer.
pub fn write_texture(
&mut self,
layer: usize,
id: WidgetId,
texture: u32,
region: UiRegion,
mask_idx: MaskIdx,
) -> PrimitiveHandle {
self.updated = true;
PrimitiveHandle {
layer,
kind: InstanceKind::Texture,
inst_idx: self.textures.push(
id,
PrimitiveInstance { region, mask_idx },
bytemuck::bytes_of(&texture),
),
}
}
pub fn primitives(&self) -> &[InstanceList] {
pub fn primitives(&self) -> &[Option<InstanceList>] {
&self.primitives
}
pub fn apply_free(&mut self) -> impl Iterator<Item = PrimitiveChange> {
let Self {
primitives,
textures,
..
} = self;
primitives
self.primitives
.iter_mut()
.enumerate()
.flat_map(|(i, list)| list.apply_free(InstanceKind::Primitive(i as u32)))
.chain(textures.apply_free(InstanceKind::Texture))
.filter_map(|(kind, list)| Some((kind as u32, list.as_mut()?)))
.flat_map(|(kind, list)| list.apply_free(kind))
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self.updated = true;
self.list_mut(h.kind).free(h.inst_idx)
self.list(h).free(h.inst_idx)
}
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
self.updated = true;
&mut self.list_mut(h.kind).instances[h.inst_idx].region
&mut self.list(h).instances[h.inst_idx].region
}
fn list_mut(&mut self, kind: InstanceKind) -> &mut InstanceList {
match kind {
InstanceKind::Primitive(i) => &mut self.primitives[i as usize],
InstanceKind::Texture => &mut self.textures,
/// A handle is only ever made by `write`, which is what created the list.
fn list(&mut self, h: &PrimitiveHandle) -> &mut InstanceList {
self.primitives[h.kind as usize]
.as_mut()
.expect("handle names a primitive this layer never drew")
}
}
pub struct PrimitiveInst<P> {
pub kind: PrimitiveKind<P>,
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
}
pub struct PrimitiveChange {
pub id: WidgetId,
pub kind: InstanceKind,
/// Which registered primitive's list moved, since they index separately.
pub kind: u32,
pub old: usize,
pub new: usize,
}
@@ -285,7 +304,7 @@ pub struct PrimitiveChange {
#[derive(Debug)]
pub struct PrimitiveHandle {
pub layer: usize,
pub kind: InstanceKind,
pub kind: u32,
pub inst_idx: usize,
}
@@ -300,6 +319,7 @@ pub struct RectPrimitive {
unsafe impl bytemuck::Pod for RectPrimitive {}
unsafe impl bytemuck::Zeroable for RectPrimitive {}
impl Primitive for RectPrimitive {}
impl RectPrimitive {
pub fn color(color: Color<u8>) -> Self {
@@ -327,3 +347,16 @@ pub struct GlyphPrimitive {
unsafe impl bytemuck::Pod for GlyphPrimitive {}
unsafe impl bytemuck::Zeroable for GlyphPrimitive {}
impl Primitive for GlyphPrimitive {}
/// One drawn image. Its shader reads nothing: the slot names the texture bound
/// for this instance alone, which is what `PrimitiveTexture::PerInstance` does.
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct TexturePrimitive {
pub slot: u32,
}
unsafe impl bytemuck::Pod for TexturePrimitive {}
unsafe impl bytemuck::Zeroable for TexturePrimitive {}
impl Primitive for TexturePrimitive {}
+29 -15
View File
@@ -1,9 +1,10 @@
use bytemuck::Pod;
use crate::{
Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, Widget, WidgetId,
render::{GLYPH, GlyphPrimitive, Mask, MaskIdx, PrimitiveHandle, PrimitiveKind},
render::{
GLYPH, GlyphPrimitive, Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst,
PrimitiveKind, TEXTURE, TexturePrimitive,
},
util::Vec2,
};
@@ -22,11 +23,22 @@ pub struct Painter<'a> {
}
impl<'a> Painter<'a> {
fn primitive_at<P: Pod>(&mut self, kind: PrimitiveKind<P>, primitive: P, region: UiRegion) {
let h = self
.state
.layers
.write(self.layer, kind, self.id, primitive, region, self.mask);
fn primitive_at<P: Primitive>(
&mut self,
kind: PrimitiveKind<P>,
primitive: P,
region: UiRegion,
) {
let h = self.state.layers.write(
self.layer,
PrimitiveInst {
kind,
id: self.id,
primitive,
region,
mask_idx: self.mask,
},
);
self.push_primitive(h);
}
@@ -39,11 +51,11 @@ impl<'a> Painter<'a> {
}
/// Writes a primitive to be rendered
pub fn primitive<P: Pod>(&mut self, kind: PrimitiveKind<P>, primitive: P) {
pub fn primitive<P: Primitive>(&mut self, kind: PrimitiveKind<P>, primitive: P) {
self.primitive_at(kind, primitive, self.region)
}
pub fn primitive_within<P: Pod>(
pub fn primitive_within<P: Primitive>(
&mut self,
kind: PrimitiveKind<P>,
primitive: P,
@@ -91,11 +103,13 @@ impl<'a> Painter<'a> {
pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) {
self.textures.push(handle.clone());
let h =
self.state
.layers
.write_texture(self.layer, self.id, handle.slot(), region, self.mask);
self.push_primitive(h);
self.primitive_at(
TEXTURE,
TexturePrimitive {
slot: handle.slot(),
},
region,
);
}
pub fn render_text(
+6 -3
View File
@@ -245,9 +245,12 @@ impl UiRenderState {
pub fn debug_layers(&self) {
for ((idx, depth), draws) in self.layers.iter_depth() {
let indent = " ".repeat(depth * 2);
let primitives: usize = draws.primitives().iter().map(|l| l.instances().len()).sum();
let textures = draws.textures.instances().len();
println!("{indent}{idx}: {primitives} primitives, {textures} textures");
let counts: Vec<String> = draws
.primitives()
.iter()
.map(|l| l.as_ref().map_or(0, |l| l.instances().len()).to_string())
.collect();
println!("{indent}{idx}: [{}]", counts.join(", "));
}
}