From d034fb5a6cfb9af0b4d2f25e85dc4aed5a0a0f98 Mon Sep 17 00:00:00 2001
From: Patrick Wuttke
Date: Fri, 27 Feb 2026 16:15:54 +0100
Subject: [PATCH] Implemented class-based data table and search components.
---
public/raid/components/component.hpp | 20 +
public/raid/components/data_table.hpp | 809 ++++++++++++++++++++++++++
public/raid/components/search.hpp | 75 +++
3 files changed, 904 insertions(+)
create mode 100644 public/raid/components/component.hpp
create mode 100644 public/raid/components/data_table.hpp
create mode 100644 public/raid/components/search.hpp
diff --git a/public/raid/components/component.hpp b/public/raid/components/component.hpp
new file mode 100644
index 0000000..e80bc1a
--- /dev/null
+++ b/public/raid/components/component.hpp
@@ -0,0 +1,20 @@
+
+#pragma once
+
+#if !defined(RAID_PUBLIC_RAID_COMPONENTS_COMPONENT_HPP_INCLUDED)
+#define RAID_PUBLIC_RAID_COMPONENTS_COMPONENT_HPP_INCLUDED 1
+
+namespace raid
+{
+template
+class Component
+{
+protected:
+ Component() noexcept = default;
+
+ TImpl& impl() noexcept { return *static_cast(this); }
+ const TImpl& impl() const noexcept { return *static_cast(this); }
+};
+} // namespace raid
+
+#endif // !defined(RAID_PUBLIC_RAID_COMPONENTS_COMPONENT_HPP_INCLUDED)
diff --git a/public/raid/components/data_table.hpp b/public/raid/components/data_table.hpp
new file mode 100644
index 0000000..055f670
--- /dev/null
+++ b/public/raid/components/data_table.hpp
@@ -0,0 +1,809 @@
+
+#pragma once
+
+#if !defined(RAID_PUBLIC_RAID_COMPONENTS_DATA_TABLE_HPP_INCLUDED)
+#define RAID_PUBLIC_RAID_COMPONENTS_DATA_TABLE_HPP_INCLUDED 1
+
+#include
+#include
+#include
+#include
+#include "./component.hpp"
+
+#include "../imraid.hpp"
+
+namespace raid
+{
+template
+class SimpleDataTableColumn
+{
+public:
+ using renderer_t = TRenderer;
+private:
+ const char* header;
+ TRenderer renderer;
+ ImGuiTableColumnFlags flags = ImGuiTableColumnFlags_None;
+ float initialWidthOrWeight = 0.f;
+public:
+
+};
+
+template
+concept data_table_simple_object_consumer = std::is_invocable_v;
+
+template
+concept data_table_self_object_consumer = std::is_invocable_v;
+
+template
+concept data_table_object_consumer = data_table_simple_object_consumer || data_table_self_object_consumer;
+
+template
+class DataTableColumn
+{
+public:
+ using renderer_t = TRenderer;
+private:
+ const char* mHeader;
+ renderer_t mRenderer;
+public:
+ constexpr DataTableColumn(const char* header, TRenderer renderer) noexcept
+ : mHeader(header), mRenderer(std::move(renderer)) {}
+ constexpr DataTableColumn(const DataTableColumn&) = default;
+ constexpr DataTableColumn(DataTableColumn&&) noexcept = default;
+
+ [[nodiscard]]
+ const char* getHeader() const noexcept { return mHeader; }
+
+ template
+ void render(TTable& table, const TObject& object) const noexcept
+ {
+ if constexpr (std::is_invocable_v) {
+ std::invoke(mRenderer, table, object);
+ }
+ else {
+ std::invoke(mRenderer, object);
+ }
+ }
+
+ template
+ constexpr auto withComparator(this TSelf&& self, TComparator&& comparator)
+ {
+ return ComparableDataTableColumn(std::forward(self), std::forward(comparator));
+ }
+};
+
+template
+class ComparableDataTableColumn : public TBase
+{
+public:
+ using comparator_t = std::remove_cvref_t;
+private:
+ comparator_t mComparator;
+public:
+ constexpr ComparableDataTableColumn(TBase from, comparator_t comp) noexcept
+ : TBase(std::move(from)), mComparator(std::move(comp)) {}
+ constexpr ComparableDataTableColumn(const ComparableDataTableColumn&) = default;
+ constexpr ComparableDataTableColumn(ComparableDataTableColumn&&) noexcept = default;
+
+ template
+ [[nodiscard]]
+ bool compare(TTable& table, const TObject& left, const TObject& right) const noexcept
+ {
+ if constexpr (std::is_invocable_v) {
+ return std::invoke(mComparator, table, left, right);
+ }
+ else {
+ return std::invoke(mComparator, left, right);
+ }
+ }
+};
+
+template
+constexpr auto makeMemberRenderer(std::format_string> format)
+{
+ using base_t = std::remove_cvref_t>;
+ return [format](const base_t& object) {
+ ImRaid::Text(format, object.*member);
+ };
+}
+
+template
+constexpr auto makeMemberRenderer()
+{
+ using member_t = std::remove_cvref_t>;
+ using base_t = std::remove_cvref_t>;
+
+ if constexpr (std::is_same_v) {
+ return [](const base_t& object) {
+ ImGui::TextUnformatted((object.*member).c_str());
+ };
+ }
+ else if constexpr (std::is_same_v) {
+ return [](const base_t& object) {
+ ImGui::TextUnformatted(object.*member);
+ };
+ }
+ else if constexpr (std::is_same_v) {
+ return [](const base_t& object) {
+ ImRaid::TextUnformatted(object.*member);
+ };
+ }
+ else {
+ return [](const base_t& object) {
+ ImRaid::Text("{}", object.*member);
+ };
+ }
+}
+
+template
+constexpr auto dataTableColumnFromMember(const char* header, TRendererArgs&&... rendererArgs)
+{
+ using member_t = std::remove_cvref_t>;
+ using base_t = std::remove_cvref_t>;
+ auto renderer = makeMemberRenderer(std::forward(rendererArgs)...);
+
+ if constexpr (requires (const member_t& value) { { value < value } -> mijin::implicitly_convertible; })
+ {
+ auto comparator = [](const base_t& left, const base_t& right) {
+ return left.*member < right.*member;
+ };
+ return DataTableColumn(header, std::move(renderer)).withComparator(comparator);
+ }
+ else {
+ return DataTableColumn(header, std::move(renderer));
+ }
+}
+
+template
+concept data_table_simple_object_comparator = requires(const T& comparator, const typename TTable::object_t& object)
+{
+ { std::invoke(comparator, object, object) } -> mijin::implicitly_convertible;
+};
+
+template
+concept data_table_self_object_comparator = requires(const T& comparator, const TTable& table, const typename TTable::object_t& object)
+{
+ { std::invoke(comparator, table, object, object) } -> mijin::implicitly_convertible;
+};
+
+template
+concept data_table_object_comparator = data_table_simple_object_comparator || data_table_self_object_comparator;
+
+template
+concept data_table_column_type = requires(const T& value, const typename TTable::object_t& object, TTable& table)
+{
+ { value.getHeader() } -> mijin::implicitly_convertible;
+ // { &T::render } -> mijin::any_of_type<
+ // mijin::bind_some, const TTable::object_t&>::template type, // static function
+ // mijin::bind_some, const T*, const TTable::object_t&>::template type, // member function
+ // mijin::bind_some, const TTable::object_t&, const TTable&>::template type, // static function, with table
+ // mijin::bind_some, const T*, const TTable::object_t&, const TTable&>::template type // member function, with table
+ // >;
+ { 0 } -> mijin::or_type<
+ requires { value.render(object); },
+ requires { value.render(table, object); }
+ >;
+};
+
+template
+concept data_table_comparable_column_type = data_table_column_type
+ && requires(const T& value, const typename TTable::object_t& object, TTable& table)
+{
+ { 0 } -> mijin::or_type<
+ requires { { value.compare(object, object) } -> mijin::implicitly_convertible; },
+ requires { { value.compare(table, object, object) } -> mijin::implicitly_convertible; }
+ >;
+};
+
+template
+using is_data_table_comparable_column = std::bool_constant>;
+
+struct DataTableFlags : mijin::BitFlags
+{
+ bool hoverable : 1 = false;
+ bool tree : 1 = false;
+ bool noHeaders : 1 = false;
+ bool nonUniformRows : 1 = false;
+};
+
+struct DataTableOptions
+{
+ DataTableFlags flags = {};
+ ImGuiTableFlags imguiFlags = ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | ImGuiTableFlags_Sortable
+ | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SortMulti;
+};
+
+template
+class DataTableComponent : public Component
+{
+protected:
+ struct SortColumn
+ {
+ std::size_t index;
+ bool sortDescending = false;
+ };
+
+ using object_t = typename TTraits::object_t;
+
+ std::vector mSortedIndices;
+ std::vector mSortColumns;
+ bool mDirty = true;
+
+ DataTableComponent() noexcept = default; // NOLINT(bugprone-crtp-constructor-accessibility) private constructor doesn't work if TImpl isn't a direct child
+
+ using Component::impl;
+public:
+ void render(ImVec2 outerSize = {})
+ {
+ if (outerSize.y <= 0.f) {
+ outerSize.y = ImGui::GetContentRegionAvail().y;
+ }
+
+ auto& self = impl();
+ using columns_t = std::remove_cvref_t;
+ if (!ImGui::BeginTable(raid::formatTemp("{}", static_cast(this)), static_cast(std::tuple_size_v), self.getOptions().imguiFlags, outerSize)) {
+ return;
+ }
+
+ mijin::tupleForEach([&](const auto& column) {
+ self.setupColumn(column);
+ }, self.getColumns());
+
+ self.renderHeadersRow();
+ self.updateSort();
+
+ self.renderContent();
+
+ ImGui::EndTable();
+ }
+protected:
+ static constexpr DataTableOptions getOptions() noexcept {
+ return {};
+ }
+
+ template TColumn>
+ void setupColumn(const TColumn& column)
+ {
+ ImGuiTableColumnFlags flags = ImGuiTableColumnFlags_None;
+ if constexpr (requires { column.getFlags(); }) {
+ flags = column.getFlags();
+ }
+ if constexpr (!data_table_comparable_column_type) {
+ flags |= ImGuiTableColumnFlags_NoSort;
+ }
+ float widthOrWeight = 0.f;
+ if constexpr (requires { column.getWidthOrWeight(); }) {
+ widthOrWeight = column.getWidthOrWeight();
+ }
+ ImGui::TableSetupColumn(column.getHeader(), flags, widthOrWeight);
+ }
+
+ void updateSort()
+ {
+ auto& self = impl();
+ decltype(auto) data = self.getData();
+
+ ImGuiTableSortSpecs* sortSpecs = ImGui::TableGetSortSpecs();
+ if (sortSpecs != nullptr && sortSpecs->SpecsDirty)
+ {
+ sortSpecs->SpecsDirty = false;
+
+ if (mSortColumns.size() != static_cast(sortSpecs->SpecsCount))
+ {
+ mDirty = true;
+ mSortColumns.resize(sortSpecs->SpecsCount);
+ }
+
+ for (int idx = 0; idx < sortSpecs->SpecsCount; ++idx)
+ {
+ const ImGuiTableColumnSortSpecs& specs = sortSpecs->Specs[idx];
+ SortColumn& sortColumn = mSortColumns[idx];
+ mDirty |= (static_cast(specs.ColumnIndex) != sortColumn.index);
+ mDirty |= sortColumn.sortDescending != (specs.SortDirection == ImGuiSortDirection_Descending);
+ sortColumn = {
+ .index = static_cast(specs.ColumnIndex),
+ .sortDescending = (specs.SortDirection == ImGuiSortDirection_Descending)
+ };
+ }
+ }
+
+ if (mSortedIndices.size() != data.size())
+ {
+ mDirty = true;
+ mSortedIndices.resize(data.size());
+ }
+
+ if (mDirty)
+ {
+ for (std::size_t idx = 0; idx < data.size(); ++idx) {
+ mSortedIndices[idx] = idx;
+ }
+ std::ranges::sort(mSortedIndices, [&](std::size_t leftIdx, std::size_t rightIdx)
+ {
+ enum Result
+ {
+ LESS,
+ GREATER,
+ EQUAL
+ };
+ Result result = EQUAL;
+ for (const SortColumn& sortColumn : mSortColumns)
+ {
+ withColumn(sortColumn.index, [&](const TColumn& column)
+ {
+ if constexpr (data_table_comparable_column_type)
+ {
+ const bool less = column.compare(self, data[leftIdx], data[rightIdx]);
+ if (less)
+ { // left < right
+ result = sortColumn.sortDescending ? LESS : GREATER;
+ }
+ if (column.compare(self, data[rightIdx], data[leftIdx]))
+ { // left > right
+ result = sortColumn.sortDescending ? GREATER : LESS;
+ }
+ }
+ else {
+ MIJIN_ERROR("Attempting to sort by non-sortable column.");
+ }
+ });
+ switch (result)
+ {
+ case LESS:
+ return true;
+ case GREATER:
+ return false;
+ case EQUAL: break; // next column
+ }
+ }
+ // left == right
+ return false;
+ });
+ mDirty = false;
+ }
+ }
+
+ void renderHeadersRow()
+ {
+ ImGui::TableSetupScrollFreeze(0, 1);
+ ImGui::TableHeadersRow();
+ }
+
+ template
+ using column_is_comparable = is_data_table_comparable_column;
+ static constexpr bool isSortable() noexcept
+ {
+ using columns_t = std::remove_cvref_t().getColumns())>; // tuple
+ using comparable_t = mijin::map_types_t; // tuple
+ return mijin::copy_args_t::value;
+ }
+
+ void renderContent()
+ {
+ auto& self = impl();
+ if (!self.getOptions().flags.nonUniformRows)
+ {
+ self.renderContentUniform();
+ return;
+ }
+ self.renderContentNonUniform();
+ }
+
+ static constexpr bool isRowVisible(const object_t&)
+ {
+ return true;
+ };
+
+ auto getSortedData()
+ {
+ auto& self = impl();
+ if constexpr (isSortable())
+ {
+ return mSortedIndices | std::views::transform([&](std::size_t idx) -> const object_t& {
+ return self.getData()[idx];
+ });
+ }
+ else
+ {
+ return self.getData();
+ }
+ }
+
+ void renderContentUniform()
+ {
+ auto& self = impl();
+ decltype(auto) data = self.getSortedData();
+ auto it = data.begin();
+ auto end = data.end();
+
+ // find the first non-hidden row
+ for (; it != end; ++it)
+ {
+ if (self.isRowVisible(*it)) {
+ break;
+ }
+ }
+
+ // nothing to render? fine
+ if (it == end) {
+ return;
+ }
+
+ // time to render the first row and measure its height
+ ImGui::TableNextRow();
+ const float startY = ImGui::GetCursorScreenPos().y;
+ self.renderRow(*it);
+ ++it;
+
+ // just one row? also fine
+ if (it == end) {
+ return;
+ }
+
+ // jump to the next row and measure the distance
+ ImGui::TableNextRow();
+ const float rowHeight = ImGui::GetCursorScreenPos().y - startY;
+ self.renderRow(*it);
+ ++it;
+
+ // skip over items until we reach the clip rect
+ const float clipStartY = ImGui::GetWindowDrawList()->GetClipRectMin().y;
+ const float clippedY = clipStartY - ImGui::GetCursorScreenPos().y;
+ if (clippedY >= rowHeight) // only if at least one item could be skipped
+ {
+ // number of items that can be skipped
+ const unsigned itemsToSkip = static_cast(clippedY / rowHeight);
+
+ // count the number of items we'll actually skip
+ unsigned skippedItems = 0;
+ for (; it != end && skippedItems < itemsToSkip; ++it)
+ {
+ if (self.isRowVisible(*it)) {
+ ++skippedItems;
+ }
+ }
+
+ if (skippedItems == 0)
+ {
+ // nothing to skip means everything was hidden -> we can stop here
+ return;
+ }
+
+ // create a fake row with the height of regular rows
+ const float skipHeight = static_cast(skippedItems) * rowHeight;
+ ImGui::TableNextRow(ImGuiTableRowFlags_None, skipHeight);
+ }
+
+ // calculate the number of rows within the clip region that can actually be rendered
+ const float clipEndY = ImGui::GetWindowDrawList()->GetClipRectMax().y;
+ const unsigned visibleRows = static_cast((clipEndY - clipStartY) / rowHeight) + 2; // always +2 for the (partially occluded) items at the top and bottom
+
+ // finally we can actually render the rows
+ unsigned rowsRendered = 0;
+ for (; it != end && rowsRendered < visibleRows; ++it)
+ {
+ if (!self.isRowVisible(*it)) {
+ continue;
+ }
+ ImGui::TableNextRow(ImGuiTableRowFlags_None, rowHeight);
+ self.renderRow(*it);
+ ++rowsRendered;
+ }
+
+ // finally, count the number of rows that could have been rendered, but are outside the clip region
+ unsigned itemsRemaining = 0;
+ for (; it != end; ++it)
+ {
+ if (self.isRowVisible(*it)) {
+ ++itemsRemaining;
+ }
+ }
+
+ // anything remaining? -> do another fake row
+ if (itemsRemaining > 0)
+ {
+ const float skipHeight = static_cast(itemsRemaining) * rowHeight;
+ ImGui::TableNextRow(ImGuiTableRowFlags_None, skipHeight);
+ }
+ };
+
+ void renderContentNonUniform()
+ {
+ auto& self = impl();
+ for (const object_t& object : self.getSortedData())
+ {
+ ImGui::TableNextRow();
+ self.renderRow(object);
+ }
+ }
+
+ void renderRow (const object_t& object)
+ {
+ auto& self = impl();
+ const DataTableOptions options = self.getOptions();
+ bool hoverNext = options.flags.hoverable || options.flags.tree; // should the next (first) column be "hovered" (depends on the table type)
+ // renderState.item = options.getState ? options.getState(object) : DataTableItemState{};
+
+ mijin::tupleForEach([&](const auto& column) {
+ self.renderCell(object, column, hoverNext);
+ }, self.getColumns());
+ }
+
+ [[nodiscard]]
+ static const char* const getID(const object_t& object)
+ {
+ static constexpr std::size_t kGetIdBufferSize = 19; // 16 digits + ## and \0
+ static std::array getIdBuffer = {0};
+ std::snprintf(getIdBuffer.data(), kGetIdBufferSize, "##%016" PRIXPTR, reinterpret_cast(&object));
+ return getIdBuffer.data();
+ }
+
+ template TColumn>
+ void renderCell(const object_t& object, const TColumn& column, bool& hoverNext)
+ {
+ auto& self = impl();
+ const DataTableOptions options = self.getOptions();
+
+ if (!ImGui::TableNextColumn()) {
+ return; // TODO: does options.nonUniformRows apply here? -> TableNextColumn() appears to return true, even if the column is completely clipped
+ }
+
+ if (hoverNext)
+ {
+ hoverNext = false;
+
+ bool clicked = false;
+#if 0
+ if (options.tree)
+ {
+ const DataTableTreeItem treeItem = getTreeItem(object);
+ MIJIN_ASSERT(treeItem.depth <= renderState.currentTreeDepth + 1, "Tree depth cannot increase by more than 1 per item.");
+
+ // close up to new depth
+ for (unsigned cnt = treeItem.depth; cnt < renderState.currentTreeDepth; ++cnt) {
+ ImGui::TreePop();
+ }
+
+ const ImGuiTreeNodeFlags flags = treeItem.flags
+ | (renderState.item.selected ? ImGuiTreeNodeFlags_Selected : ImGuiTreeNodeFlags_None);
+ const bool open = ImGui::TreeNodeEx(getId(object), flags, "");
+ renderState.currentTreeDepth = treeItem.depth + (open ? 1 : 0);
+ clicked = ImGui::IsItemClicked();
+ }
+ else
+#endif
+ {
+ const ImGuiSelectableFlags flags = ImGuiSelectableFlags_SpanAllColumns;
+ // | (renderState.item.hovered ? ImGuiSelectableFlags_Highlight : ImGuiSelectableFlags_None);
+ clicked = ImGui::Selectable(getID(object), false /* renderState.item.selected */, flags);
+ }
+
+ // if (clicked && options.selectHandler)
+ // {
+ // if (ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) {
+ // options.selectHandler(object, renderState.item.selected ? SelectAction::REMOVE : SelectAction::ADD);
+ // }
+ // else if (!renderState.item.selected) {
+ // options.selectHandler(object, SelectAction::SET);
+ // }
+ // }
+ // if (options.rightClickHandler && ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
+ // options.rightClickHandler(object);
+ // }
+ // if (options.hoverHandler && ImGui::IsItemHovered()) {
+ // options.hoverHandler(object);
+ // }
+ ImGui::SameLine();
+ }
+
+ if constexpr (requires { column.render(self, object); }) {
+ column.render(self, object);
+ }
+ else {
+ column.render(object);
+ }
+ }
+
+ template
+ bool withColumn(std::size_t idx, TFunc&& func)
+ {
+ decltype(auto) columns = impl().getColumns();
+ if (idx >= std::tuple_size_v>) {
+ return false;
+ }
+ mijin::tupleForEachWithIndex([&](std::integral_constant, const auto& column) {
+ if (idx == I) {
+ std::invoke(std::forward(func), column);
+ }
+ }, columns);
+ return true;
+ }
+};
+#if 0
+
+
+ static constexpr bool kSortable = std::ranges::random_access_range;
+
+ typename DataTableOptions::getid_t getId = options.getId;
+
+ static constexpr std::size_t kGetIdBufferSize = 19; // 16 digits + ## and \0
+ char getIdBuffer[kGetIdBufferSize] = {0};
+ if (!getId)
+ {
+ getId = [&](arg_t object)
+ {
+ std::snprintf(getIdBuffer, kGetIdBufferSize, "##%016" PRIXPTR, reinterpret_cast(&object));
+ return getIdBuffer;
+ };
+ }
+
+ typename DataTableOptions::gettreeitem_t getTreeItem = options.getTreeItem;
+ if (options.tree && !getTreeItem)
+ {
+ getTreeItem = [](arg_t) -> DataTableTreeItem {
+ return {};
+ };
+ }
+
+ renderState.rowIdx = 0;
+ renderState.currentTreeDepth = 0;
+
+ auto itemHidden = [&](arg_t object) -> bool
+ {
+ // when rendering a tree, skip any non-expanded items
+ if (options.tree && getTreeItem(object).depth > renderState.currentTreeDepth) {
+ return true;
+ }
+
+ // if there is a filter, apply it
+ if (options.filter && !options.filter(object)) {
+ return true;
+ }
+
+ return false;
+ };
+
+ auto renderRowsUniform = [&](auto&& range)
+ {
+ auto it = range.begin();
+ auto end = range.end();
+
+ // find the first non-hidden row
+ for (; it != end; ++it)
+ {
+ if (!itemHidden(*it)) {
+ break;
+ }
+ }
+
+ // nothing to render? fine
+ if (it == end) {
+ return;
+ }
+
+ // time to render the first row and measure its height
+ ImGui::TableNextRow();
+ const float startY = ImGui::GetCursorScreenPos().y;
+ renderRow(*it);
+ ++it;
+
+ // just one row? also fine
+ if (it == end) {
+ return;
+ }
+
+ // jump to the next row and measure the distance
+ ImGui::TableNextRow();
+ const float rowHeight = ImGui::GetCursorScreenPos().y - startY;
+ renderRow(*it);
+ ++it;
+
+ // skip over items until we reach the clip rect
+ const float clipStartY = ImGui::GetWindowDrawList()->GetClipRectMin().y;
+ const float clippedY = clipStartY - ImGui::GetCursorScreenPos().y;
+ if (clippedY >= rowHeight) // only if at least one item could be skipped
+ {
+ // number of items that can be skipped
+ const unsigned itemsToSkip = static_cast(clippedY / rowHeight);
+
+ // count the number of items we'll actually skip
+ unsigned skippedItems = 0;
+ for (; it != end && skippedItems < itemsToSkip; ++it)
+ {
+ if (!itemHidden(*it)) {
+ ++skippedItems;
+ }
+ }
+
+ if (skippedItems == 0)
+ {
+ // nothing to skip means everything was hidden -> we can stop here
+ return;
+ }
+
+ // create a fake row with the height of regular rows
+ const float skipHeight = static_cast(skippedItems) * rowHeight;
+ ImGui::TableNextRow(ImGuiTableRowFlags_None, skipHeight);
+ }
+
+ // calculate the number of rows within the clip region that can actually be rendered
+ const float clipEndY = ImGui::GetWindowDrawList()->GetClipRectMax().y;
+ const unsigned visibleRows = static_cast((clipEndY - clipStartY) / rowHeight) + 2; // always +2 for the (partially occluded) items at the top and bottom
+
+ // finally we can actually render the rows
+ unsigned rowsRendered = 0;
+ for (; it != end && rowsRendered < visibleRows; ++it)
+ {
+ if (itemHidden(*it)) {
+ continue;
+ }
+ ImGui::TableNextRow(ImGuiTableRowFlags_None, rowHeight);
+ renderRow(*it);
+ ++rowsRendered;
+ }
+
+ // finally, count the number of rows that could have been rendered, but are outside the clip region
+ unsigned itemsRemaining = 0;
+ for (; it != end; ++it)
+ {
+ if (!itemHidden(*it)) {
+ ++itemsRemaining;
+ }
+ }
+
+ // anything remaining? -> do another fake row
+ if (itemsRemaining > 0)
+ {
+ const float skipHeight = static_cast(itemsRemaining) * rowHeight;
+ ImGui::TableNextRow(ImGuiTableRowFlags_None, skipHeight);
+ }
+ };
+
+ auto renderRowsNonUniform = [&](auto&& range)
+ {
+ auto it = range.begin();
+ auto end = range.end();
+
+ // if rows are non-uniform, all of them need to be properly rendered to determine the table height
+ // simpler, but a lot less effective
+
+ for (; it != end; ++it)
+ {
+ if (!itemHidden(*it))
+ {
+ ImGui::TableNextRow();
+ renderRow(*it);
+ }
+ }
+ };
+
+ if constexpr (kSortable)
+ {
+
+ auto range = std::views::transform(state.sortedIndices, [&](std::size_t idx) -> decltype(auto) {
+ return data.at(idx);
+ });
+ if (!options.nonUniformRows && !options.tree) { // TODO: make this work with trees
+ renderRowsUniform(range);
+ }
+ else {
+ renderRowsNonUniform(range);
+ }
+ }
+ else // constexpr kSortable
+ {
+ if (!options.nonUniformRows && !options.tree) { // TODO: make this work with trees
+ renderRowsUniform(data);
+ }
+ else {
+ renderRowsNonUniform(data);
+ }
+ }
+
+ for (unsigned cnt = 0; cnt < renderState.currentTreeDepth; ++cnt) {
+ ImGui::TreePop();
+ }
+#endif
+} // namespace raid
+
+#endif // !defined(RAID_PUBLIC_RAID_COMPONENTS_DATA_TABLE_HPP_INCLUDED)
diff --git a/public/raid/components/search.hpp b/public/raid/components/search.hpp
new file mode 100644
index 0000000..3c65103
--- /dev/null
+++ b/public/raid/components/search.hpp
@@ -0,0 +1,75 @@
+
+#pragma once
+
+#if !defined(RAID_PUBLIC_RAID_COMPONENTS_SEARCH_HPP_INCLUDED)
+#define RAID_PUBLIC_RAID_COMPONENTS_SEARCH_HPP_INCLUDED 1
+
+#include "./component.hpp"
+#include "./data_table.hpp"
+
+namespace raid
+{
+template
+concept search_result_component_traits_type = requires()
+{
+ typename T::result_t;
+};
+
+template
+struct SearchResultComponentDataTableTraits
+{
+ using object_t = typename TTraits::result_t;
+};
+
+template
+class SearchResultComponent : public Component
+{
+protected:
+ using result_t = typename TTraits::result_t;
+ using datatable_traits_t = SearchResultComponentDataTableTraits;
+
+ class DataTableImpl : public DataTableComponent
+ {
+ public:
+ TImpl* mOwner;
+
+ explicit DataTableImpl(TImpl& owner) noexcept : mOwner(&owner) {}
+
+ constexpr decltype(auto) getColumns() noexcept { return mOwner->getColumns(); }
+ constexpr decltype(auto) getData() noexcept { return mOwner->getData(); }
+ };
+ DataTableImpl mDataTable;
+
+ SearchResultComponent() noexcept : mDataTable(impl()) {} // NOLINT(bugprone-crtp-constructor-accessibility) private constructor doesn't work if TImpl isn't a direct child
+
+ using Component::impl;
+public:
+ void render(ImVec2 size = {})
+ {
+ if constexpr (requires { impl().getProgress(); })
+ {
+ static_assert(std::is_convertible_v, "SearchResultComponent: TImpl has an invalid getProgress() method.");
+ const float progress = impl().getProgress();
+ if (progress >= 0.f)
+ {
+ ImGui::ProgressBar(progress, {size.x, 0.f});
+
+ if (size.y > 0.f)
+ {
+ size.y -= ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f + ImGui::GetStyle().ItemSpacing.y;
+ if (size.y < 0.f) {
+ return;
+ }
+ }
+ }
+ }
+
+ if (ImGui::BeginChild("##results", size)) {
+ mDataTable.render(size);
+ }
+ ImGui::EndChild();
+ }
+};
+} // namespace raid
+
+#endif // !defined(RAID_PUBLIC_RAID_COMPONENTS_SEARCH_HPP_INCLUDED)