ClaFi Grid
Building grids was never that simple
Introduction
The structure you see in that picture — different column levels, visual cell spans and merges, groups — falls out of two hierarchies, a tree of columns and a tree of rows, plus one fact per cell: whether that row fills that column.
You describe all of it — columns, rows, and the contents of every cell — as a single nested declaration:
Grid& m_grid{ add<Grid>(
Columns{ /* the column tree */ },
Header{}, // The grid header is optional, this line creates one
Rows{ /* the rows, and the cells in them */ }
) };
The grid builds itself from that list and, for each row, connects a pair of handlers to that row: one answering which columns the row fills, one supplying the text of its cells. The grid asks the row while it lays out, paints and hit-tests, so there is no second place to keep in sync. The row owns those connections, so nothing outside the grid has to stay alive.
Quick Start
A grid with two columns and two rows. This is the whole thing:
import ClaFi.Controls.Grids_Dt;
import ClaFi.Controls.Grids;
import ClaFi.Controls.Stack;
import ClaFi.Core.TextEngine.Text;
import ClaFi.Core.Foundation;
import ClaFi.Core.System.UiTypes;
namespace MyApp
{
using namespace ::ClaFi;
using namespace ::ClaFi::Controls;
using namespace ::ClaFi::Controls::Grids::Dt;
enum class Col
{
Name,
Size
};
class FilesPage : public Stack
{
public:
explicit FilesPage(const CreateParams&);
private:
Grid& m_grid{ add<Grid>(
Columns{
Column{ Tag{ Col::Name }, Text{ L"Name" } },
Column{ Tag{ Col::Size }, Text{ L"Size" } }
},
Header{},
Rows{
Row{
Cell{ Tag{ Col::Name }, [](Grids::GetCellTextEvent& event) {
event.text() << L"readme.txt";
} },
Cell{ Tag{ Col::Size }, [](Grids::GetCellTextEvent& event) {
event.text() << L"1.2 KB";
} }
},
Row{
Cell{ Tag{ Col::Name }, [](Grids::GetCellTextEvent& event) {
event.text() << L"notes.md";
} },
Cell{ Tag{ Col::Size }, [](Grids::GetCellTextEvent& event) {
event.text() << L"640 B";
} }
}
}
) };
};
FilesPage::FilesPage(const CreateParams& params)
:
Stack{ params, Orientation::Vertical }
{
}
}
ClaFi::Controls::Grids — plural, so that Grid stays free
as a class name. The blueprint layer is ClaFi::Controls::Grids::Dt. The
machinery both of the framework's blueprint layers share — OnEvent,
Init, the node bases, the pack routing — lives in ClaFi::Dt,
and Grids::Dt re-exports the parts you name directly.
Pull the blueprint layer in once, in the namespace your page lives in, and write every node unqualified after that:
namespace MyApp
{
using namespace ::ClaFi;
using namespace ::ClaFi::Controls;
using namespace ::ClaFi::Controls::Grids::Dt;
Two nodes are written in full, Grids::Dt::Expander and
Grids::Dt::Divider. Wherever Controls::Expander or
Controls::Divider is visible, the bare name is ambiguous. The other nodes are
written bare.
The events a provider or a handler takes are not nodes, and they stay in
Grids:: -
Grids::GetCellTextEvent, Grids::CellContentEvent,
Grids::NewItemEvent. So do Grids::GridLines and
Grids::ViewMode. Spell a node Grids::Dt:: in full rather than
Dt::. Under using namespace ::ClaFi, Dt:: names
ClaFi::Dt, which holds none of them.
Grids_Dt also publishes Grids::Rt::, aliases for the runtime types a
blueprint mentions: Rt::Column, Rt::RowBase,
Rt::RowContainer. The same classes, spelled so that a blueprint reads visibly
differently from the live objects it describes.
import ClaFi.Controls.Grids_Dt; brings the
blueprint layer, with OnEvent and Init re-exported from
ClaFi::Dt. import ClaFi.Controls.Grids; brings the runtime classes and
the events. The properties come from their own modules: Tag from
ClaFi.Core.System.UiTypes, Text from
ClaFi.Core.TextEngine.Text, CreateParams from
ClaFi.Core.Foundation. clang wants each one imported by the file that names it,
even where MSVC lets the name through from another import.
Four things to notice, because they apply everywhere:
- The
Gridbuilds itself from its argument list in its constructor. Anything that list names - aCellSet, a member a row reads - has to be declared before the grid, because members are built in declaration order. - A column is named by its
Tag— an ordinary property, forwarded to the column like any other. Any enum or integer works. The values have to be unique among the columns of one grid, and a debug build says so if they are not. - A cell names its column with that same
Tag—Cell{ Tag{ Col::Name }, ... }. - A row has cells only where you give it cells. A row with no cell in
Col::Sizehas no cell there at all — not an empty one.
Text usually comes from your data rather than a literal. Point the cell at a member function and it will be called whenever that cell needs drawing:
void FilesPage::nameCell(Grids::GetCellTextEvent& event) const
{
event.text() << m_files[event.row().tag<std::size_t>()].name;
}
Row FilesPage::fileRow(std::size_t index) const
{
return Row{
Tag{ index },
Cell{ Tag{ Col::Name }, this, &FilesPage::nameCell }
};
}
Tag is yours to use however you like — here it carries the row's index
into the model. The grid never reads it.
Core Concepts
Columns
Columns form a tree. Any column can have sub-columns:
Column{ Tag{ Col::Range }, Text{ L"Range" },
Column{ Tag{ Col::RangeMode }, Text{ L"Mode" } },
Column{ Tag{ Col::RangeAmount }, Text{ L"Amount" } }
}
Anything in a Column that is not another Column is a property.
The useful ones:
| Property | Effect |
|---|---|
Text{ ... } | The header label, and the default text of the header cell. |
ShowInHeader::Yes / No | Whether the header row draws a cell here. Default Yes. |
ColumnWidthMode::FitContent | As wide as its widest cell. The default. |
ColumnWidthMode::Fixed | As wide as the float you also pass. |
ColumnWidthMode::Fill | Takes a share of the leftover width. With several Fill siblings the float acts as a ratio. |
TextAlign::... | Alignment for every cell in the column, the header's included. |
TextFormat{ ... } | What every data cell of the column is laid out over - a style, a colour, an alignment. The header takes the alignment alone. See Cells and content. |
Tag{ ... } | Names the column, so a cell can refer to it and columnByTag can find it afterwards. Unique within the grid. Reaches the column like any other property. |
Rows
Rows appear in the order you declare them. Besides plain Row there are
containers that hold other rows:
| Node | Produces |
|---|---|
Header{} | The header row. Draws a cell in every column whose ShowInHeader is Yes, using that column's Text. |
Row{ ... } | An ordinary row. Can hold text cells and live controls. |
Divider{} | A thin band between rows, closed by a grid line. |
NewItem{ PlaceHolderText{...} } | A muted line kept below the last row. Pressing it asks you for a new item - see Adding items from the grid. |
Expander{ Header{...}, rows... } | A collapsible header with rows beneath it. |
Group{ Span{...}, rows... } | A span row laid over its rows, so its cells span them vertically. |
Rows{ ... } | Grouping only — adds nesting in your source, not in the grid. |
Cells and content
Text handed to a provider is a stream the grid clears once per query, and
providers append to it. Several levels can contribute to one cell — the
cell's own provider, then a grid-wide OnGetCellText handler. One that wants to
replace what an earlier one wrote calls event.text().clear() first.
A row's cells are exactly the ones you declare. Four forms, in one row:
Row{
// The cell exists and is empty. It still draws a border and can be hovered.
Cell{ Tag{ Col::Amount } },
// Text from a lambda.
Cell{ Tag{ Col::Name }, [](Grids::GetCellTextEvent& event) {
event.text() << L"...";
} },
// Text from a member function - the usual choice.
Cell{ Tag{ Col::Channel }, this, &Page::channelCell },
// A live control living in the cell. Its constructor arguments follow the Tag.
CellWith<CheckBox>{ Tag{ Col::Mute } }
}
A text provider receives the grid's Grids::GetCellTextEvent - the same event a
grid-wide OnGetCellText handler takes, so one method serves at either level:
| Member | What it gives |
|---|---|
event.row() | const Rt::RowBase& - which row. Read its Tag to find your data. |
event.column() | const Rt::Column& - which column. |
event.text() | Text& - write here, appending. |
event.stopPropagation() | Keeps the handlers behind this one from seeing the cell. |
Cell{ Tag{ ... } } gives you a real, bordered,
hoverable cell that happens to be empty. Both are useful; pick deliberately.
A look every cell of a column shares belongs to the column, not to each provider. Stated as the
column's TextFormat - see the Text in Controls manual - it is laid out under the text every cell writes, and it reaches a
cell's tooltip and its in-place editor the way it reaches the paint:
Column{ Tag{ Reading::Last }, Text{ L"Last" }, TextAlign::Right, TextFormat{ TextStyleId::Code } }
void FpsPage::writeReading(Grids::GetCellTextEvent& event) const
{
// The value alone - the column states the style.
event.text() << Fmt{ L"{}[color muted]{}[/color]", number, unit };
}
A TextAlign the column takes goes in ahead of its TextFormat, so an
alignment the format states wins. The header cell is laid out over the alignment alone: it names the
column and holds none of its values. None of the format is written into the text a provider receives,
so a value accepted from the editor holds what was typed and nothing else.
Merging
Cells grow to cover their neighbours in two ways. Neither is configured — both follow from which cells you declared.
A column whose cell covers its sub-columns
If a row fills a column but none of that column's sub-columns, the cell spans downward over them. The result is always a plain rectangle, because sub-columns exactly cover their parent's width.
This is how one row shows a single wide label while its neighbour shows two separate values under the same parent column.
A group's span row, whose cell covers all of its rows
A Group's span row is stretched to the full height of the rows inside it
and drawn across them. They do not collide because the span row and its rows fill
different columns: the span row owns the columns that should read as one tall
cell, the rows own the rest.
Widths
Each column's width comes from its ColumnWidthMode:
- FitContent — wide enough for its widest cell across all rows.
- Fixed — the width you give it.
- Fill — shares out whatever is left after the others.
A parent column is at least as wide as its sub-columns combined, and the leftover width
is distributed among the Fill ones.
ColumnWidthMode::Fill. If none of
them can stretch, they keep their natural widths and may not reach their parent's right
edge, leaving a narrow unpainted strip there.
Tags
A Tag is the one identifier in play. On a column it is the name cells use to
find it; on a row or a control it is yours to use however you like.
| On a column | On a row or a control | |
|---|---|---|
| Read by | the blueprint, and columnByTag | nobody but you |
| Purpose | lets a cell name the column | whatever you want to attach |
| Unique? | yes, within one grid | no requirement at all |
| Reaches the object? | yes, forwarded like any property | yes, like any property |
Column{ Tag{ Col::Level }, // how cells name this column
Text{ L"Level" }
}
Row{ Tag{ Kind::Channel, channel }, // yours - the grid never reads it
Cell{ Tag{ Col::Level }, this, &Page::levelCell }
}
Tag packs several values into one, so a column name can be compound:
Tag{ SectionId::Audio, Col::Level }. Reading a segment back is
tag.get<index, count, Enum>(), or row.tag<index, count, Enum>()
on a row.
Tag drawn
from the enum your cells use, and keep that enum separate from your row tags.
Tag is legal — it simply cannot be named by a
cell. Note that Tag{} holding zero is a perfectly ordinary tag: the first
enumerator of a plain enum is zero, so absence is tracked by whether you passed the property
at all, not by its value.
Blueprint Reference
import ClaFi.Controls.Grids_Dt;
import ClaFi.Controls.Grids;
namespace MyApp
{
using namespace ::ClaFi;
using namespace ::ClaFi::Controls;
using namespace ::ClaFi::Controls::Grids::Dt;
}
Grid
template <typename... Args>
explicit Grid(const CreateParams& params, Args&&... args);
Grid is the grid itself: an Rt::Grid whose constructor builds the
columns, the header and the rows it is handed. The grid's own properties and handlers go in the
same list. Add it where any control is added:
Grid& m_grid{ add<Grid>(
Grids::GridLines::Horizontal,
OnEvent{ [this](Grids::GetCellTooltipEvent& event) {
writeHint(event);
} },
Columns{ /* ... */ },
Header{},
Rows{ /* ... */ }
) };
Header{} is added ahead of the first row wherever
it is written, and NewItem{} stays after the last one. The rows keep the order they
are written in.
std::logic_error if two columns share a tag, or
if a cell names a tag no column carries. It checks both only where _DEBUG is
defined, which is the Debug configuration under Visual Studio. Other builds skip them.
Column and Columns
Column{ Tag{ ... }, properties..., Column{ ... }, Column{ ... } }
The Tag names the column and may sit anywhere in the argument list — it
is an ordinary property, read by the blueprint on its way through. Everything that is not a
nested Column is a property, passed straight through to the column. Nested
Column nodes become sub-columns.
A column without a Tag is allowed — it simply cannot be named by a
cell:
Column{ Text{ L"decorative" }, ShowInHeader::Yes }
Columns{ ... } is a grouping node with no effect on the grid; use it as the
outer wrapper.
Row, Expander, Group, Header, Span, Divider, NewItem
Row{ properties..., Cell{ ... }, CellWith<T>{ ... }, aCellSet }
A Row takes no leading key. Properties — anything that is not a cell
— go to the row control, so this is where your Tag goes:
Row{
Tag{ Kind::Device, deviceIndex },
Cell{ Tag{ Col::Name }, this, &Page::nameCell },
m_valueCells
}
Every blueprint row can host controls, so nothing special is needed to put one in a cell.
| Node | Children |
|---|---|
Row{ props..., cells... } | cells |
Expander{ props..., rows... } | rows, inside the collapsible body |
Group{ props..., rows... } | rows, overlapped by the span row |
Header{ ... } | the header of the container it sits in — see below |
Span{ ... } | the span row of the group it sits in — see below |
Divider{} | — |
NewItem{ PlaceHolderText{...} } | — |
Rows{ rows... } | spliced into the enclosing grid |
Rows{
Grids::Dt::Expander{
Header{ Text{ TextStyleId::SubHeading, L"Outputs" } },
Group{
Span{ Tag{ Kind::Group, Device::Mixer },
Cell{ Tag{ Col::Name }, this, &Page::groupNameCell } },
channelRow(Channel::Input),
channelRow(Channel::Output)
}
}
}
Grids::Dt, Row, Cell and Header cannot be
mistaken for anything else, and GridRow would stutter.
CellWith<T> is a cell as well, named for what the cell holds rather than
Control<T>, which would collide with the framework's own control base.
Tag, Text, ShowInHeader and
ColumnWidthMode are not grid types at all. They are shared control properties,
understood by anything with a column or a header. Header itself is shared too: it
lives in ClaFi::Dt alongside OnEvent and Init, because
a container having a header is not a grid idea. Span is declared in
Grids::Dt, because a row spanning the rows beside it is.
Header
Header{ ... } is the header of the container it sits directly
inside. It is not a row and not a node of any hierarchy: a container looks for one in its own
argument list, so it can appear anywhere in that list, and a Header nested a level
deeper — inside Rows{}, a Row, a Column — is
a compile error rather than a silent no-op.
What it accepts depends on the container, because the two headers are different objects:
| In | The header is | Takes |
|---|---|---|
Grid | the column-title row | nothing — the columns already say what belongs in it, through ShowInHeader |
Expander | one text control | Text. A cell here is a compile error |
Span
Span{ ... } is a group's span row: a row of the group's own, sectioned by the
same columns as every other row and laid over the group's rows, so its cells span them. It is
a part of the container it sits directly inside, the way a Header
is, and only a Group takes one — a Span anywhere else, or a
Header in a Group, is a compile error. It takes a Tag,
cells and CellWiths, exactly as a Row does.
RowContainer, so a CellWith lands in
one of its cells the way it lands in any row's.
Tag goes inside Span{ ... } and an
expander's text inside its Header{ ... } — the nesting says whose each one
is. Through the direct API, grid.addGroup takes the span's tag as
SpanTag and grid.addExpander the header's text as
HeaderText. The standalone Controls::Expander is a different control
and takes HeaderText too.
NewItem
NewItem{ PlaceHolderText{ ... } } is the grid's new-item row. It takes the text
its one cell shows and nothing else. Put it in the grid's own list: wherever it is written among
the rows, it ends up last, and it stays last as rows are added. What pressing it does is in
Adding items from the grid.
Cell, CellWith, CellSet, Init
Cell{ Tag{ ... } } // empty but present
Cell{ Tag{ ... }, lambda } // void(Grids::GetCellTextEvent&)
Cell{ Tag{ ... }, this, &Page::method } // bound member function
CellWith<T>{ Tag{ ... }, ctorArgs... } // a live control
CellSet{ cells... } // a reusable bundle
The leading Tag on a cell is positional — unlike a column's,
it is consumed rather than forwarded, so it has to come first. Everything after it belongs to
the control.
CellWith<T> forwards its arguments to the control's constructor. Because
the call has to be replayed when the blueprint is applied, those arguments are
copied into the node:
CellWith<T> must be copy-constructible, and anything
large or shared will be duplicated. Wrap it: std::ref(items) passes a
reference through instead.
For settings that have no constructor property, use Init<T>. It runs
against the control immediately after it is created and can appear anywhere in the argument
list:
CellWith<Slider>{ Tag{ Col::Amount },
ScrollButtons::No,
Init<Slider>{ [](Slider& slider) {
slider.setMaxPosition(1.0f);
slider.setRelativePosition(0.5f, false);
} }
}
Finding things later
The grid answers for everything the blueprint built. The lookups are members of
GridBase, and a sub-grid answers them the same as its root:
| Member | Purpose |
|---|---|
columnByTag(Tag) | The Column& carrying that tag. Throws std::logic_error if there is none, in every build. |
findColumnByTag(Tag) | Same, but returns nullptr if absent. |
forEachRow(func) | Calls func(RowContainer&) for every row in the grid and its sub-grids. |
ComboBox& mode = row.controlAtColumnAs<ComboBox>(m_grid.columnByTag(Tag{ Col::Mode }));
m_grid.forEachRow([this](Grids::Rt::RowContainer& row) {
refresh(row);
});
Inside a lambda that runs later, capture the tag and resolve it when the event fires. The columns do not exist yet while the blueprint is being built:
OnEvent{ [this, amountTag](ComboBoxChangeEvent& event) {
auto& row = *static_cast<Grids::Rt::RowContainer*>(event.comboBox().parent());
Control* slider = row.controlAtColumn(m_grid.columnByTag(amountTag));
slider->invalidateState();
} }
columnByTag walks the column tree; there is no index. That is fine once per event,
and wrong in a handler the grid calls per row and per column — an
OnCellContent handler, for instance. Resolve the column once, keep the reference,
and compare by address there.
Recipes
A label that spans sub-columns
Some rows want one wide label where others want two separate values. Declare the parent column's cell and skip the children:
// Device has two sub-columns.
Column{ Tag{ Col::Device }, Text{ L"Device" },
Column{ Tag{ Col::Name }, ShowInHeader::No },
Column{ Tag{ Col::Channel }, ShowInHeader::No }
}
// This row fills the parent, so its cell spans both children: one wide label.
Row{ Cell{ Tag{ Col::Device }, this, &Page::deviceCell } }
// This one fills a child instead, so the two sit side by side.
Row{ Cell{ Tag{ Col::Channel }, this, &Page::channelCell } }
Grouping rows beside a span
A Group's span row stretches down its rows. It is a row, so it is described
as one:
// The rows fill Channel; the span row fills Name. Together they cover both.
Group{
Span{
Tag{ Kind::Group, Device::Mixer },
Cell{ Tag{ Col::Name }, this, &Page::deviceCell } },
channelRow(Channel::Input),
channelRow(Channel::Output)
}
That is the whole recipe. The span's cells connect to the span row exactly as any row's do, so it answers for itself and nothing at grid level is involved.
A control that belongs to the group rather than to a row
The span's cells are stretched over the rows beside them, so a control in one of them reads as the group's own. That is where a choice made once per group goes:
Group{
Span{
Tag{ Kind::Group, Device::Mixer },
Cell{ Tag{ Col::Name }, this, &Page::deviceCell },
CellWith<CheckBox>{ Tag{ Col::Mute } } },
channelRow(Channel::Input),
channelRow(Channel::Output)
}
The control is placed at the top of the merged cell, at the height of one row, and the column it names counts as the span's under the disjointness rule — no row of the group may declare it.
forEachRow walks the rows of a grid and its sub-grids. A group's span is not one
of them, so a control there is not reached that way. Reach it through the group, or keep a
reference to it from Init<T>.
Tag even where the grid never needs it. A provider shared by the
span and the rows receives either one as event.row(), and the tag is how it tells
the span from an element row.
Live controls in cells
A CellWith<T> puts a live control in a cell. The row keeps the keyboard
focus for it: the grid keeps the arrows, Home, End, the page keys and Tab, and every other key
goes to the control in the selected cell. A key held with Alt goes to the control too, and on to
the application's shortcuts when the control has no use for it: Alt+Down drops a combo box's
list, and Alt+Left in a browser goes back.
The first click picks the cell. A control that opens something over itself - a combo box's list, an editor typed in place - opens it only from a click on the cell that is already selected. The first click selects the cell and its row, and the next one opens. The dropdown arrow opens on the first click too, because opening the list is its one job. Picking a row to delete it opens nothing.
| Gesture | On a cell not selected | On the selected cell |
|---|---|---|
| Click on the control | Selects the cell and its row | Opens the list or the editor |
| Click on the dropdown arrow | Selects and opens | Opens |
| Double click | Selects and opens | Opens |
| Ctrl+click, Shift+click | In a SelectionMode::Multi grid, changes the selected rows and opens nothing, the arrow included | |
| Return, Space, F4, Alt+Down | - | Opens |
A check box or a button in a cell acts on the first click. It opens nothing over the cell, so the grid has nothing to hold back.
A click on a control in a cell reaches the grid as a click on the row holding it, which is
how the grid moves its selection. A click handler on the grid reads the row in
event.control, not the control.
A cell control of your own that drops a popup on a click asks first, and the question it asks says what dropping the popup is to that click. When the answer is no, it leaves the click alone - the click goes on to the row, which picks the cell.
| Question | Dropping the popup is | On the click that picks the cell |
|---|---|---|
mayDropPopupImplicitly | A meaning a click on the control is given - the list a combo box drops from its main area, an editor over a caption | Refused |
mayDropPopup | The one job of the part that was clicked - a dropdown arrow | Allowed |
Ctrl+click and Shift+click in a SelectionMode::Multi grid refuse both. A swatch
that opens a colour picker when clicked is given that meaning, so it asks the first question:
void SwatchControl::nestedClick(ClickEvent& event)
{
if (!mayDropPopupImplicitly(event))
return;
event.stopPropagation();
openSwatchPopup();
}
DropdownControlBase or WithInPlaceEdit asks
already and needs nothing more. Both questions walk nestedControlDroppingPopup from
the control up to the root, and the row holding the cell answers them for the control, reading
DropPopupEvent::implicit to tell them apart.
Rows that differ from their neighbours
Build rows with a function and let it choose:
Row Page::channelRow(Channel channel) const
{
const bool readOnly = channel == Channel::Monitor;
return Row{
Tag{ Kind::Channel, channel },
Cell{ Tag{ Col::Channel }, this, &Page::channelCell },
readOnly ? m_readOnlyCells : m_valueCells
};
}
A row that should leave a slot visibly blank rather than let a neighbour cover it declares the cell with no content:
const CellSet m_readOnlyCells{
Cell{ Tag{ Col::Mode } }, // present, empty, still bordered
Cell{ Tag{ Col::Amount } }
};
Reaching a control later
void Page::refreshAll()
{
Grids::Rt::Column& modeColumn = m_grid.columnByTag(Tag{ Col::Mode });
Grids::Rt::Column& amountColumn = m_grid.columnByTag(Tag{ Col::Amount });
m_grid.forEachRow([&](Grids::Rt::RowContainer& row) {
if (row.tag<0, 2, Kind>() != Kind::Channel)
return;
ComboBox& mode = row.controlAtColumnAs<ComboBox>(modeColumn);
Slider& amount = row.controlAtColumnAs<Slider>(amountColumn);
mode.invalidateState();
amount.invalidateState();
});
}
forEachRow visits every RowContainer standing as a row of the grid,
including ones inside expanders and groups. Expanders and groups are not containers themselves,
so they are stepped over and descended into. A group's span is a container but not a row of
the grid, so it is not visited. If some rows do not have the control you are asking for, check
the row's Tag first — controlAtColumnAs does not check.
Adding items from the grid
A NewItem row lets the grid ask for an item itself: a muted line under the last
row, led by a plus, that reads the text you give it. The grid does the asking and leaves the
adding to you:
Grid& m_grid{ add<Grid>(
Columns{ /* ... */ },
Header{},
NewItem{ PlaceHolderText{ L"Add rule" } }
) };
The handler goes in the constructor, once the grid is built:
m_grid.onNewItem([this](Grids::NewItemEvent&) {
m_rules.push_back({});
addRuleRow(m_rules.size() - 1);
});
The row's one cell spans every column and behaves like any other cell: the pointer lights
it and the arrow keys reach it. A click on it, or Return or Space while it is selected, raises
NewItemEvent on the grid. When your handler returns, the grid selects the first
cell of the row the handler added, gives it the focus, and scrolls it into view once the row
has been laid out. A handler that adds nothing leaves the selection where it was.
A row added after the new-item row is put ahead of it, so it stays last however the rows arrive - appended one at a time or rebuilt wholesale.
Delete handler that walks selection() never meets it. It opens no
in-place editor either, so F2 on it does nothing.
To ask for an item from somewhere else - a toolbar button, a shortcut - call
grid.requestNewItem(). It raises the same event and selects the same way.
Troubleshooting
Build errors
| Symptom | Cause | Fix |
|---|---|---|
| Unqualified grid names fail to resolve in an implementation unit | A module implementation unit does not inherit everything its interface unit had in scope. | Give the .cpp its own imports and using-directives:
import ClaFi.Controls.Grids_Dt;, import ClaFi.Controls.Grids;,
using namespace ::ClaFi;, using namespace ::ClaFi::Controls;,
using namespace ::ClaFi::Controls::Grids::Dt;. Spell a node
Grids::Dt:: in full - Dt:: names ClaFi::Dt here. |
"deleted function" or "no copy constructor", pointing into
std::function or std::tuple |
An argument to CellWith<T> or Column is move-only. The
call is stored so it can be replayed, which requires copyable arguments. |
Wrap it in std::ref. If it truly must be moved, create that control
through the direct API instead. |
No matching constructor for Cell, with a note that a constraint was not
satisfied |
The provider cannot be called with a Grids::GetCellTextEvent&. A handler
written for another event is the usual cause. |
Take Grids::GetCellTextEvent&, the parameter a grid-wide
OnGetCellText handler takes. |
text() called on a const event, inside a provider |
The provider takes const Grids::GetCellTextEvent&. That passes the
constraint, and text() is not const. |
Drop the const. |
A call through a member pointer drops const, inside Cell's
constructor instantiated for const Page |
The function building the row is const but the cell provider is not. |
Make the provider const too. |
Errors listing a control's constructor, from inside CellWith<T> |
The argument pack does not match that control's constructor. | Write one direct row.addControl<T>(column, args...) to confirm the
arguments, then move them into CellWith<T> unchanged. |
Nothing appears, or it looks wrong
| Symptom | Cause |
|---|---|
Debug build throws from Grid's constructor: "Grid design: cell Tags
matched no column" |
Exactly what it says, and the message lists the numeric tag values. Some cell names a
column that was never declared with that Tag. A build without
_DEBUG skips the check and drops those cells. |
| A row you declared never appears at all | None of its cells resolved, so the row has no content, therefore no height. Same cause as above — run a debug build. |
| A cell is missing but the row is fine | That one tag did not match a column. A debug build throws from the constructor and lists the numeric tag values. |
| A group's span row has no label | Its Span{ ... } declares no cell in that column. See
the recipe. |
| Every cell is filled in a row you expected to be sparse | That row declared no cells, so no handler was connected to it and the question fell
through to the grid's event, where hasContent defaults to true. The span of a
Group whose Span declares no cells does this too. |
| A parent cell is short, with a gap beneath it | Something in its sub-columns has content in that row, so it is not merging. Declare the
empty sibling as a bare Cell{ Tag{ ... } } if you want the space filled. |
| A narrow unpainted strip at the right edge of a column group | None of that group's sub-columns is ColumnWidthMode::Fill, so they do not
reach their parent's edge. |
| Controls appear in the wrong cells | A shared CellSet was built with the wrong tags, or a lambda captured a
column reference instead of a tag. |
| A handler that picks out one column is slow | It resolves a tag on every call. Resolve the column once with columnByTag
after the grid is built, keep the Rt::Column&, and compare by address:
&event.column() == &m_someColumn. |
A crash in Grid's constructor, or a row without the cells a shared set
holds |
A CellSet the grid's argument list names is declared after the grid. Members
are built in declaration order, so the grid read the set before it was built. |
Do / Don't
Do
- Declare cell sets, and anything else the grid's argument list names, before the grid.
- Give every column a cell names a unique
Tag. - Capture tags in lambdas and resolve them through the grid when the event fires.
- Resolve a column once and compare by address in anything the grid calls per cell.
- Use
std::reffor anything shared or expensive. - Use a bare
Cell{ Tag{ ... } }when you want a present-but-empty cell. - State a look every cell of a column shares as the column's
TextFormat, not in each provider. - Add an item's row inside the
NewItemEventhandler, so the grid can select it. - Ask
mayDropPopupImplicitlybefore a cell control of your own drops a popup on a click, ormayDropPopupfrom a part whose one job is that popup. - Give at least one sub-column in each group
Fill. - Run a debug build after changing a blueprint.
Don't
- Don't draw column tags from the same enum as your row tags — one type serves both, so nothing catches the mix-up at compile time.
- Don't capture a
Column&in a cell set — the columns do not exist yet. - Don't call
columnByTaginsideOnCellContent. - Don't leave a
Group'sSpanwithout cells. A span that declares none answers through the grid's handlers, where every cell exists. - Don't read a missing cell as an empty one; it means no cell at all.
- Don't rely on release builds to tell you a tag was wrong.
Appendix · Direct API
The blueprint is a convenience, not a wall. Everything below works on a grid built from a blueprint as well, which is useful when one row needs something the blueprint does not express.
| Call | Returns |
|---|---|
grid.columns() | the root ColumnCollection& |
columns.add(props...) | Column& |
column.createSubColumns() | ColumnCollection&, creating on first use |
column.subColumns() | ColumnCollection*, null if none |
column.cellTextFormat() | const TextFormat& - what the column's data cells are laid out over, its alignment included |
column.headerTextFormat() | const TextFormat& - the alignment alone, which the header cell is laid out over |
grid.addHeader() | Grids::GridHeader& |
grid.addRow(props...) | Rt::Row& |
grid.addControlRow(props...) | Rt::RowContainer& |
grid.addDivider(props...) | Grids::RowDivider& |
grid.addExpander(props...) | Rt::RowExpander&, body via .body() |
grid.addGroup(props...) | Rt::RowGroup&, span via .span(), body via .body() |
grid.addNewItemRow(PlaceHolderText) | Grids::RowNewItem&, kept after the last row |
grid.requestNewItem() | nothing - raises NewItemEvent the way pressing the new-item row does |
row.addControl<T>(column, args...) | T& |
row.controlAtColumn(column) | Control*, null if none |
row.controlAtColumnAs<T>(column) | T&, unchecked |
Without a blueprint, a grid answers two questions through events — which cells exist, and what they say:
m_grid.onCellContent([](Grids::CellContentEvent& event) {
event.hasContent = event.column().tag() != Tag{ Col::Mute }; // defaults to true
});
m_grid.onGetCellText([this](Grids::GetCellTextEvent& event) {
writeCell(event);
});
hasContent arrives as true, so without a blueprint every
cell exists unless you say otherwise. That is the opposite of a blueprint row, where a cell
exists only if you declared it.
The same two events exist on every row, which is how a blueprint answers for the rows it
builds. A row takes a handler for either in its argument list, as an OnEvent
like any other, and RowBase::onCellContent / onGetCellText connect one
later:
m_grid.addControlRow(Tag{ Kind::Channel, Channel::Input },
OnEvent{ [](Grids::CellContentEvent& event) {
event.hasContent = event.column().tag() == Tag{ Col::Channel };
event.stopPropagation(); // keep the grid handler out of it
} });
stopPropagation() the grid
handler never sees that cell — which is how a blueprint row stays authoritative about its
own content while rows it did not declare fall through to whatever the grid says.