Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e78dfc4
Add hierarchy-aware live filter to vendor model catalog
Apr 29, 2026
986bfe6
Address Copilot review on #6256
Apr 29, 2026
adb20f9
Catalog filter: collapse two boxes into one with whitespace tokens
Apr 29, 2026
a10a3d9
Catalog filter: hide suppressed vendors when filter is active
Apr 29, 2026
1144a06
Update README to reflect single-box whitespace-tokenized filter
Apr 29, 2026
49c270f
Address Copilot review on #6256
Apr 29, 2026
0e37510
RebuildTreeUI: UnselectAll before DeleteAllItems + re-entrancy guard
Apr 29, 2026
2f25c07
Catalog filter: avoid UI freeze on Windows under heavy filters
Apr 29, 2026
6191b08
RebuildTreeUI: disable tree during rebuild, RAII guard
Apr 29, 2026
3ec71bb
Catalog filter: drop tree Disable, expansion was suppressed
Apr 29, 2026
19f50d8
Hide bottom Search row; new live filter replaces it on all platforms
Apr 29, 2026
34f3626
AddHierachy: Expand after children populated, not before
Apr 29, 2026
b5342ed
Catalog filter: restore focus to filter ctrl after rebuild
Apr 29, 2026
07caeba
Expand multi-wiring model nodes when filter is active
Apr 29, 2026
9805b4a
Fix Windows filter losing typed text mid-word
Apr 29, 2026
835d332
Defer caret-to-end via CallAfter for Windows WM_SETFOCUS
Apr 29, 2026
37aaf86
Fix double-Thaw assert in RebuildTreeUI
Apr 30, 2026
58e6f58
Merge master and fix double-Thaw assert
Apr 30, 2026
fbe1873
Reset stale _lastSearchItem on filter rebuild + restore handler busy …
Apr 30, 2026
cfee5bf
Defer _treeRebuilding clear + hide masters bottom search
Apr 30, 2026
be30c8d
Guard UpdatePanelForItem with _treeRebuilding too
Apr 30, 2026
9eaefd6
Revert deferred flag clear, validate stale tree items instead
Apr 30, 2026
d8b9eec
Add spdlog tracing for filter rebuild diagnosis
Apr 30, 2026
0dc1499
Add per-step tracing to find rebuild hang point
Apr 30, 2026
97d205b
Remove per-vendor auto-Expand under filter (hangs Windows)
Apr 30, 2026
448867d
Filter UX: expand vendors but leave categories collapsed
Apr 30, 2026
b8505dc
Force Refresh+Update after rebuild for Windows paint sync
Apr 30, 2026
97bc228
Drop stale SEL_CHANGED events post-rebuild without GetFocusedItem fal…
Apr 30, 2026
947aefd
Defer Refresh+Update until after RebuildScope thaws
Apr 30, 2026
c1949da
Remove all auto-expand under filter to fix EFL hang on Windows
Apr 30, 2026
9edd3cc
Merge remote-tracking branch 'origin/master' into vendor-catalog-filt…
heffneil Jun 18, 2026
ef0dbb0
Vendor catalog: flat de-duplicated model list per vendor (#6256)
heffneil Jun 18, 2026
53dcf41
Vendor catalog: strictly flat results + auto-expand under filter
heffneil Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ Issue Tracker is found here: www.github.com/xLightsSequencer/xLights/issues
XLIGHTS/NUTCRACKER RELEASE NOTES
---------------------------------
2026.08 May ??, 2026
-enh (Neil) Vendor model catalog: add two live-filter inputs above the tree. Filters are
hierarchy-aware (vendor / category / sub-category / model), so typing "halloween"
or a vendor name surfaces the whole sub-tree, and the second box AND-narrows
further. Empty categories and vendors are pruned bottom-up; surviving branches
auto-expand while filters are active. Debounced 200ms so fast typing doesn't thrash.
Comment thread
heffneil marked this conversation as resolved.
Outdated
-enh (dkulp) Linux: text rendering switched from wxGraphicsContext (Cairo+Pango) to a portable
FreeType+HarfBuzz+Fontconfig backend in src-core/. Text and Shape effects can now
render on background threads on Linux (previously forced to the main thread because
Expand Down
206 changes: 199 additions & 7 deletions src-ui-wx/import_export/VendorModelDialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
#include <wx/msgdlg.h>
#include <wx/stopwatch.h>
#include <wx/progdlg.h>
#include <wx/srchctrl.h>
#include <wx/timer.h>
#include "settings/XLightsConfigAdapter.h"
#include <wx/dir.h>
#include <wx/wfstream.h>
Expand Down Expand Up @@ -918,6 +920,47 @@ VendorModelDialog::VendorModelDialog(wxWindow* parent, const std::string& showFo
Connect(wxEVT_SIZE, (wxObjectEventFunction)&VendorModelDialog::OnResize);
//*)

// Experimental catalog filter inputs. Two live-filter boxes that
// narrow the tree to models matching BOTH terms. Inserted at the
// top of Panel3's sizer so they sit above the tree.
{
auto filterRow = new wxFlexGridSizer(0, 2, 0, 0);
filterRow->AddGrowableCol(0);
filterRow->AddGrowableCol(1);
TextCtrl_Filter1 = new wxSearchCtrl(Panel3, wxID_ANY, wxEmptyString,
wxDefaultPosition, wxDefaultSize,
wxTE_PROCESS_ENTER);
TextCtrl_Filter1->SetDescriptiveText(_("Filter catalog..."));
TextCtrl_Filter1->ShowCancelButton(true);
filterRow->Add(TextCtrl_Filter1, 1, wxALL | wxEXPAND, 5);
TextCtrl_Filter2 = new wxSearchCtrl(Panel3, wxID_ANY, wxEmptyString,
wxDefaultPosition, wxDefaultSize,
wxTE_PROCESS_ENTER);
TextCtrl_Filter2->SetDescriptiveText(_("...and also..."));
TextCtrl_Filter2->ShowCancelButton(true);
filterRow->Add(TextCtrl_Filter2, 1, wxALL | wxEXPAND, 5);
// Insert at top of Panel3's vertical sizer. Panel3 uses
// FlexGridSizer2 with row 0 marked growable for the tree;
// after insertion the indices shift so the tree is at row 1.
// Move the growable-row marker accordingly so the tree keeps
// its vertical fill instead of the filter row absorbing it.
FlexGridSizer2->Insert(0, filterRow, 0, wxEXPAND, 0);
FlexGridSizer2->RemoveGrowableRow(0);
FlexGridSizer2->AddGrowableRow(1);
TextCtrl_Filter1->Bind(wxEVT_TEXT,
&VendorModelDialog::OnCatalogFilterText, this);
TextCtrl_Filter1->Bind(wxEVT_SEARCHCTRL_CANCEL_BTN,
&VendorModelDialog::OnCatalogFilterCancel, this);
TextCtrl_Filter2->Bind(wxEVT_TEXT,
&VendorModelDialog::OnCatalogFilterText, this);
TextCtrl_Filter2->Bind(wxEVT_SEARCHCTRL_CANCEL_BTN,
&VendorModelDialog::OnCatalogFilterCancel, this);
_filterDebounceTimer = new wxTimer(this);
Bind(wxEVT_TIMER, &VendorModelDialog::OnCatalogFilterDebounce,
this, _filterDebounceTimer->GetId());
Comment thread
heffneil marked this conversation as resolved.
Panel3->Layout();
}

SetSize(800, 600);

PopulateModelPanel((MModel*)nullptr);
Expand Down Expand Up @@ -1062,6 +1105,22 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high)
delete vd;
}

RebuildTreeUI();

if (_vendors.size() == 0)
{
DisplayError("Unable to retrieve any vendor information", this);
return false;
}

return true;
}

// Rebuilds the tree UI from the cached _vendors data, honouring the
// current catalog filter inputs. Called once at end of LoadTree() and
// whenever a filter input changes (debounced).
void VendorModelDialog::RebuildTreeUI()
{
TreeCtrl_Navigator->Freeze();

TreeCtrl_Navigator->DeleteAllItems();
Expand All @@ -1076,7 +1135,7 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high)
}
if (!IsVendorSuppressed(it->_name))
{
AddHierachy(v, it, it->_categories);
AddHierachy(v, it, it->_categories, it->_name);
}
}

Expand All @@ -1085,23 +1144,138 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high)
TreeCtrl_Navigator->EnsureVisible(first);
}
Comment thread
heffneil marked this conversation as resolved.
Outdated

// Two passes:
// 1) The original DeleteEmptyCategories only deletes leaf categories
// that were already empty when first visited. After it deletes a
// leaf, the parent may now be empty, but the recursion never
// re-checks it. With the experimental filter trimming model leaves,
// that leaves whole chains of empty parent categories on screen
// (e.g. "DayCor Printed Props -> Hats" when no model named Hats
// matches "pumpkin").
// 2) PruneEmptyBranches walks the tree bottom-up and also drops
// empty Vendor nodes, so the final tree shows only branches that
// actually have at least one matching model leaf.
wxTreeItemIdValue cookie;
for (auto l1 = TreeCtrl_Navigator->GetFirstChild(root, cookie); l1.IsOk(); l1 = TreeCtrl_Navigator->GetNextChild(root, cookie))
{
UNUSED(DeleteEmptyCategories(l1));
}
PruneEmptyBranches(root);
Comment on lines +540 to +544

// When a filter is active the user almost always wants to see every
// surviving leaf without having to re-expand each vendor on every
// keystroke. ExpandAll the tree while filtering; leave the original
// collapsed-vendor state alone when both filters are clear.
if (!_filter1.IsEmpty() || !_filter2.IsEmpty()) {
TreeCtrl_Navigator->ExpandAll();
}

TreeCtrl_Navigator->Thaw();
}

if (_vendors.size() == 0)
// Bottom-up walk: recurse into each child first, then if THIS node has
// type Category or Vendor and ended up with no surviving children, drop
// it. Leaves it (Models/Wirings) alone. The root itself is preserved.
//
// Snapshots the children before recursing because Delete() may
// invalidate the wxTreeCtrl cookie iterator on some platforms.
bool VendorModelDialog::PruneEmptyBranches(wxTreeItemId parent)
{
if (!parent.IsOk()) return false;
std::vector<wxTreeItemId> children;
{
DisplayError("Unable to retrieve any vendor information", this);
wxTreeItemIdValue cookie;
for (auto child = TreeCtrl_Navigator->GetFirstChild(parent, cookie);
child.IsOk();
child = TreeCtrl_Navigator->GetNextChild(parent, cookie))
{
children.push_back(child);
}
}
for (auto& child : children) {
PruneEmptyBranches(child);
}
if (parent == TreeCtrl_Navigator->GetRootItem()) {
return false;
}
auto* tid = static_cast<VendorBaseTreeItemData*>(
TreeCtrl_Navigator->GetItemData(parent));
if (tid == nullptr) return false;
const std::string type = tid->GetType();
if ((type == "Category" || type == "Vendor") &&
TreeCtrl_Navigator->GetChildrenCount(parent) == 0)
Comment thread
heffneil marked this conversation as resolved.
Comment thread
heffneil marked this conversation as resolved.
Comment thread
heffneil marked this conversation as resolved.
Comment thread
heffneil marked this conversation as resolved.
{
TreeCtrl_Navigator->Delete(parent);
return true;
Comment thread
heffneil marked this conversation as resolved.
}
return false;
}

bool VendorModelDialog::CatalogFilterMatches(const std::string& name) const
{
return CatalogFilterMatchesPath("", name);
}
Comment thread
heffneil marked this conversation as resolved.
Outdated

bool VendorModelDialog::CatalogFilterMatchesPath(const std::string& pathSoFar,
const std::string& leafName) const
{
if (_filter1.IsEmpty() && _filter2.IsEmpty()) {
return true;
}
// Build a single haystack: "vendor / category / sub / leaf" lower-cased.
// Substring match against each filter so any term anywhere in the
// path counts.
wxString haystack = wxString::FromUTF8(pathSoFar);
if (!haystack.IsEmpty()) {
haystack += " / ";
}
haystack += wxString::FromUTF8(leafName);
haystack.MakeLower();
if (!_filter1.IsEmpty() && haystack.Find(_filter1) == wxNOT_FOUND) {
return false;
}
if (!_filter2.IsEmpty() && haystack.Find(_filter2) == wxNOT_FOUND) {
return false;
}
return true;
}

void VendorModelDialog::OnCatalogFilterText(wxCommandEvent& /*event*/)
{
// Capture the filter text immediately so cached values stay in sync,
// but debounce the expensive RebuildTreeUI rebuild so fast typing
// doesn't repeatedly rebuild the entire vendor catalog.
if (TextCtrl_Filter1 != nullptr) {
_filter1 = TextCtrl_Filter1->GetValue().Lower();
}
if (TextCtrl_Filter2 != nullptr) {
_filter2 = TextCtrl_Filter2->GetValue().Lower();
}
if (_filterDebounceTimer != nullptr) {
_filterDebounceTimer->Start(kCatalogFilterDebounceMs, wxTIMER_ONE_SHOT);
}
}

void VendorModelDialog::OnCatalogFilterCancel(wxCommandEvent& event)
{
if (event.GetEventObject() == TextCtrl_Filter1) {
TextCtrl_Filter1->ChangeValue(wxEmptyString);
_filter1.Clear();
} else if (event.GetEventObject() == TextCtrl_Filter2) {
TextCtrl_Filter2->ChangeValue(wxEmptyString);
_filter2.Clear();
}
if (_filterDebounceTimer != nullptr) {
_filterDebounceTimer->Stop();
}
RebuildTreeUI();
}

void VendorModelDialog::OnCatalogFilterDebounce(wxTimerEvent& /*event*/)
{
RebuildTreeUI();
}

bool VendorModelDialog::DeleteEmptyCategories(wxTreeItemId& parent)
{
VendorBaseTreeItemData* tid = (VendorBaseTreeItemData*)TreeCtrl_Navigator->GetItemData(parent);
Expand All @@ -1125,23 +1299,35 @@ bool VendorModelDialog::DeleteEmptyCategories(wxTreeItemId& parent)
return false;
}

void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list<MVendorCategory*> categories)
void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list<MVendorCategory*> categories, const std::string& pathSoFar)
{
for (const auto& it : categories)
{
wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(id, it->_name, -1, -1, new MCategoryTreeItemData(it));
AddHierachy(tid, vendor, it->_categories);
// Extend the breadcrumb path so descendant filter checks can
// see the category names above them.
std::string nextPath = pathSoFar.empty()
? it->_name
: pathSoFar + " / " + it->_name;
AddHierachy(tid, vendor, it->_categories, nextPath);
TreeCtrl_Navigator->Expand(tid);
AddModels(tid, vendor, it->_id);
AddModels(tid, vendor, it->_id, nextPath);
}
}

void VendorModelDialog::AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId)
void VendorModelDialog::AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId, const std::string& pathSoFar)
{
auto models = vendor->GetModels(categoryId);

for (const auto& it : models)
{
// Catalog filter (experimental): drop models whose ancestor path
// + own name doesn't satisfy both filter inputs. Hierarchy is
// included so typing "halloween" or "boscoyo" surfaces every
// descendant of the matching node. Empty inputs match anything.
Comment thread
heffneil marked this conversation as resolved.
Outdated
Comment thread
heffneil marked this conversation as resolved.
Outdated
Comment thread
heffneil marked this conversation as resolved.
Outdated
Comment thread
heffneil marked this conversation as resolved.
Outdated
Comment thread
heffneil marked this conversation as resolved.
Outdated
if (!CatalogFilterMatchesPath(pathSoFar, it->_name)) {
continue;
}
if (it->_wiring.size() > 1)
{
wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(v, it->_name, -1, -1, new MModelTreeItemData(it));
Expand Down Expand Up @@ -1172,6 +1358,12 @@ VendorModelDialog::~VendorModelDialog()
//(*Destroy(VendorModelDialog)
//*)

if (_filterDebounceTimer != nullptr) {
_filterDebounceTimer->Stop();
delete _filterDebounceTimer;
_filterDebounceTimer = nullptr;
}

GetCache().Save();

for (const auto& it : _vendors) {
Expand Down
32 changes: 30 additions & 2 deletions src-ui-wx/import_export/VendorModelDialog.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ class VendorModelDialog: public wxDialog

[[nodiscard]] pugi::xml_document* GetXMLFromURL(wxURI url, std::string& filename, wxProgressDialog* prog, int low, int high, bool keepProgress) const;
[[nodiscard]] bool LoadTree(wxProgressDialog* prog, int low = 0, int high = 100);
void AddHierachy(wxTreeItemId v, MVendor* vendor, std::list<MVendorCategory*> categories);
void AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId);
void AddHierachy(wxTreeItemId v, MVendor* vendor, std::list<MVendorCategory*> categories, const std::string& pathSoFar = "");
Comment thread
heffneil marked this conversation as resolved.
Outdated
void AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId, const std::string& pathSoFar = "");
void ValidateWindow();
void PopulateVendorPanel(MVendor* vendor);
void PopulateModelPanel(MModel* vendor);
Expand All @@ -78,6 +78,34 @@ class VendorModelDialog: public wxDialog
void DownloadSelectedModels();
[[nodiscard]] wxTreeItemId GetFocusedItem() const;

// ----- Catalog filter (experimental) -----
// Two live-filter inputs that narrow the tree to model nodes whose
// names contain BOTH filter strings (case-insensitive). Categories
// and vendors with no surviving model leaves are pruned by the
// existing DeleteEmptyCategories pass. Lives outside wxSmith so
// the .wxs file does not need to know about it.
Comment thread
heffneil marked this conversation as resolved.
Outdated
class wxSearchCtrl* TextCtrl_Filter1 = nullptr;
class wxSearchCtrl* TextCtrl_Filter2 = nullptr;
wxString _filter1; // already lower-cased
wxString _filter2; // already lower-cased
wxTimer* _filterDebounceTimer = nullptr;
static constexpr int kCatalogFilterDebounceMs = 200;
void OnCatalogFilterText(wxCommandEvent& event);
void OnCatalogFilterCancel(wxCommandEvent& event);
void OnCatalogFilterDebounce(wxTimerEvent& event);
bool CatalogFilterMatches(const std::string& name) const;
Comment thread
heffneil marked this conversation as resolved.
Outdated
// Same as CatalogFilterMatches but also considers the ancestor path
// (vendor / category / subcategory). Lets the user filter on
// hierarchy text — typing "halloween" or "boscoyo" includes every
// descendant of the matching node.
bool CatalogFilterMatchesPath(const std::string& pathSoFar,
const std::string& leafName) const;
void RebuildTreeUI();
// Bottom-up prune that drops Category and Vendor nodes whose
// descendants were filtered away. Returns true if the node itself
// was deleted. Models and wirings (leaves) are never deleted here.
bool PruneEmptyBranches(wxTreeItemId parent);

public:

VendorModelDialog(wxWindow* parent, const std::string& showFolder, wxWindowID id=wxID_ANY, const wxPoint& pos=wxDefaultPosition,const wxSize& size=wxDefaultSize);
Expand Down
Loading