From 12002ace864d5b673d5a72986612b8462d7b59b6 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 12:21:11 +1000 Subject: [PATCH] properties: filter psets/quantities by name, property, or value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter field now actually filters. Typing shows only the sets whose name matches, or that contain a matching property name/value — and when it's a property/value match, only the matching rows are kept (neighbouring rows are dropped). Matching is case-insensitive. Set widgets live in a per-section container that's rebuilt from the raw data on each keystroke, so filtering never recreates the filter field (its focus and cursor are preserved). Placeholder reads "No properties/ quantities" with no data, "No matching properties/quantities" when the filter excludes everything. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/modules/properties/Panel.cpp | 110 +++++++++++++++--- src/bonsaiviewer/modules/properties/Panel.h | 17 +++ 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/bonsaiviewer/modules/properties/Panel.cpp b/src/bonsaiviewer/modules/properties/Panel.cpp index 7820c1b69e..3343da17e4 100644 --- a/src/bonsaiviewer/modules/properties/Panel.cpp +++ b/src/bonsaiviewer/modules/properties/Panel.cpp @@ -33,6 +33,8 @@ #include #include +#include + namespace { QWidget* makePropertySetPanel(const bonsaiviewer::modules::properties::PropertySet& property_set, QWidget* parent = nullptr) { @@ -50,6 +52,24 @@ QWidget* makePropertySetPanel(const bonsaiviewer::modules::properties::PropertyS return group; } +void clearLayout(QLayout* layout) { + if (!layout) return; + while (QLayoutItem* item = layout->takeAt(0)) { + if (QWidget* w = item->widget()) delete w; + delete item; + } +} + +// A container for a section's set widgets, laid out like the section body so it +// can be swapped/rebuilt in one place without touching the filter field. +QWidget* makeSetContainer(QWidget* parent) { + auto* container = new QWidget(parent); + auto* layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(bonsaiviewer::components::style::metrics::padding); + return container; +} + QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { QList table_rows; for (const auto& row : rows) { @@ -136,16 +156,12 @@ PropertiesPanel::PropertiesPanel(QWidget* parent) void PropertiesPanel::render(const PropertiesPanelState& state) { clearBodyWidgets(); - - QList property_set_widgets; - for (const auto& property_set : state.property_sets) { - property_set_widgets.append(makePropertySetPanel(property_set, this)); - } - - QList quantity_set_widgets; - for (const auto& property_set : state.quantity_sets) { - quantity_set_widgets.append(makePropertySetPanel(property_set, this)); - } + // The previous widgets were just deleted — drop the stale container pointers + // before rebuilding so a stray filter pass can't touch them. + property_sets_data_ = state.property_sets; + quantity_sets_data_ = state.quantity_sets; + properties_container_ = nullptr; + quantities_container_ = nullptr; auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, this); entity_section->addBodyWidget(makeEntityBox(state.entity, this)); @@ -179,12 +195,12 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { }); connect(properties_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) { properties_filter_text_ = text; + rebuildPropertyWidgets(); }); properties_section->addBodyWidget(properties_filter_wrapper); - for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); - if (property_set_widgets.isEmpty()) { - properties_section->addBodyWidget(makeEmptyStateLabel("No properties", this)); - } + properties_container_ = makeSetContainer(properties_section); + properties_section->addBodyWidget(properties_container_); + rebuildPropertyWidgets(); properties_section->setExpanded(properties_expanded_); properties_filter_toggle->setChecked(properties_filter_visible_); @@ -209,12 +225,12 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { }); connect(quantities_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) { quantities_filter_text_ = text; + rebuildQuantityWidgets(); }); quantities_section->addBodyWidget(quantities_filter_wrapper); - for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); - if (quantity_set_widgets.isEmpty()) { - quantities_section->addBodyWidget(makeEmptyStateLabel("No quantities", this)); - } + quantities_container_ = makeSetContainer(quantities_section); + quantities_section->addBodyWidget(quantities_container_); + rebuildQuantityWidgets(); quantities_section->setExpanded(quantities_expanded_); quantities_filter_toggle->setChecked(quantities_filter_visible_); @@ -246,4 +262,62 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { addBodyWidget(quantities_section); } +namespace { + +// Filter one set: keep it if the filter is empty, or its name matches (then all +// rows are kept), or some property name/value matches (then only those rows). +// Returns nullopt when nothing in the set matches. +std::optional filterSet(const PropertySet& set, const QString& text) { + if (text.isEmpty() || set.title.contains(text, Qt::CaseInsensitive)) { + return set; + } + PropertySet filtered; + filtered.title = set.title; + for (const auto& row : set.rows) { + if (row.key.contains(text, Qt::CaseInsensitive) || + row.value.contains(text, Qt::CaseInsensitive)) { + filtered.rows.append(row); + } + } + if (filtered.rows.isEmpty()) return std::nullopt; + return filtered; +} + +// Rebuild a set container's contents from raw data under the current filter, +// dropping non-matching rows, with a placeholder when nothing is shown. +void rebuildSetContainer(QWidget* container, + const QList& sets, + const QString& filter_text, + const QString& empty_text, + const QString& no_match_text) { + if (!container) return; + auto* layout = qobject_cast(container->layout()); + if (!layout) return; + clearLayout(layout); + + const QString text = filter_text.trimmed(); + int shown = 0; + for (const auto& set : sets) { + if (auto filtered = filterSet(set, text)) { + layout->addWidget(makePropertySetPanel(*filtered, container)); + ++shown; + } + } + if (shown == 0) { + layout->addWidget(makeEmptyStateLabel(sets.isEmpty() ? empty_text : no_match_text, container)); + } +} + +} // namespace + +void PropertiesPanel::rebuildPropertyWidgets() { + rebuildSetContainer(properties_container_, property_sets_data_, properties_filter_text_, + "No properties", "No matching properties"); +} + +void PropertiesPanel::rebuildQuantityWidgets() { + rebuildSetContainer(quantities_container_, quantity_sets_data_, quantities_filter_text_, + "No quantities", "No matching quantities"); +} + } // namespace bonsaiviewer::modules::properties diff --git a/src/bonsaiviewer/modules/properties/Panel.h b/src/bonsaiviewer/modules/properties/Panel.h index 1e5558be3a..32a72a9465 100644 --- a/src/bonsaiviewer/modules/properties/Panel.h +++ b/src/bonsaiviewer/modules/properties/Panel.h @@ -30,6 +30,7 @@ class QLabel; class QLineEdit; class QToolButton; +class QWidget; namespace bonsaiviewer::modules::properties { @@ -41,6 +42,14 @@ public: void render(const PropertiesPanelState& state); private: + // Rebuild the set widgets inside their container, applying the current + // (case-insensitive) filter text: a set is shown only if its name or one of + // its property names/values matches, and — when it's a property/value match + // rather than a set-name match — only the matching rows are kept. Toggles a + // "No properties" / "No matching properties" placeholder. + void rebuildPropertyWidgets(); + void rebuildQuantityWidgets(); + bool attributes_expanded_ = true; bool relationships_expanded_ = true; bool properties_expanded_ = true; @@ -49,6 +58,14 @@ private: bool quantities_filter_visible_ = false; QString properties_filter_text_; QString quantities_filter_text_; + + // Raw data + the container the set widgets live in, so a filter change can + // rebuild just the sets without disturbing the filter field. Recreated on + // each render(); the container is owned by its section. + QList property_sets_data_; + QList quantity_sets_data_; + QWidget* properties_container_ = nullptr; + QWidget* quantities_container_ = nullptr; }; } // namespace bonsaiviewer::modules::properties