Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
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
6 changes: 6 additions & 0 deletions README.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ 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 a live-filter input above the tree with whitespace-
tokenized AND-narrowing. Filter is hierarchy-aware (vendor / category / sub-category
/ model), so typing "halloween" or a vendor name surfaces the whole sub-tree, and
"tree EFL" is two AND-narrowed terms — both must appear in the path. Empty
categories and vendors are pruned bottom-up; surviving branches auto-expand while
a filter is 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
223 changes: 213 additions & 10 deletions src-ui-wx/import_export/VendorModelDialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
#include <wx/msgdlg.h>
#include <wx/stopwatch.h>
#include <wx/progdlg.h>
#include <wx/srchctrl.h>
#include <wx/timer.h>
#include <wx/tokenzr.h>
#include "settings/XLightsConfigAdapter.h"
#include <wx/dir.h>
#include <wx/wfstream.h>
Expand Down Expand Up @@ -918,6 +921,35 @@ VendorModelDialog::VendorModelDialog(wxWindow* parent, const std::string& showFo
Connect(wxEVT_SIZE, (wxObjectEventFunction)&VendorModelDialog::OnResize);
//*)

// Experimental catalog filter input. Single live-filter box that
// tokenizes the typed text on whitespace — each token AND-narrows
// the tree (e.g. "tree EFL" only shows items whose ancestor path
// contains both "tree" and "efl"). Inserted at the top of Panel3's
// sizer so it sits above the tree.
{
TextCtrl_Filter = new wxSearchCtrl(Panel3, wxID_ANY, wxEmptyString,
wxDefaultPosition, wxDefaultSize,
wxTE_PROCESS_ENTER);
TextCtrl_Filter->SetDescriptiveText(_("Filter catalog (space-separated terms)..."));
TextCtrl_Filter->ShowCancelButton(true);
Comment thread
heffneil marked this conversation as resolved.
// 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, TextCtrl_Filter, 0, wxALL | wxEXPAND, 5);
FlexGridSizer2->RemoveGrowableRow(0);
FlexGridSizer2->AddGrowableRow(1);
TextCtrl_Filter->Bind(wxEVT_TEXT,
&VendorModelDialog::OnCatalogFilterText, this);
TextCtrl_Filter->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,7 +1094,28 @@ 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()
{
static bool rebuilding = false;
if (rebuilding) return;
rebuilding = true;
Comment thread
heffneil marked this conversation as resolved.
Outdated

Comment thread
heffneil marked this conversation as resolved.
Outdated
TreeCtrl_Navigator->Freeze();
TreeCtrl_Navigator->UnselectAll();

TreeCtrl_Navigator->DeleteAllItems();
wxTreeItemId root = TreeCtrl_Navigator->AddRoot("Vendors");
Comment thread
heffneil marked this conversation as resolved.
Outdated
Expand All @@ -1076,32 +1129,164 @@ 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);
}
}

if (first.IsOk() && first != root)
// Only scroll to first vendor on the initial build, not on every
// filter rebuild — otherwise typing in the filter keeps yanking
// the user back to the top of the tree.
if (_initialBuild && first.IsOk() && first != root)
{
TreeCtrl_Navigator->EnsureVisible(first);
}

_initialBuild = false;
}
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 the filter is clear.
if (!_filterTokens.empty()) {
TreeCtrl_Navigator->ExpandAll();
}

TreeCtrl_Navigator->Thaw();
rebuilding = false;
}

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.
{
// Suppressed vendors intentionally have no children (we skipped
// AddHierachy for them). When no filter is active, leave them
// in place so the user can still see and un-suppress them via
// the "Don't download this vendors list of models" checkbox.
// BUT under an active filter, suppressed vendors with no
// surviving descendants should be hidden along with everything
// else that doesn't match — otherwise typing a query that hits
// nothing still shows the suppressed-vendor row, which is
// confusing.
if (type == "Vendor" && _filterTokens.empty()) {
auto* vd = static_cast<MVendorTreeItemData*>(tid);
if (vd->GetVendor() != nullptr &&
IsVendorSuppressed(vd->GetVendor()->_name))
{
return false;
}
}
TreeCtrl_Navigator->Delete(parent);
return true;
Comment thread
heffneil marked this conversation as resolved.
}
return false;
}

bool VendorModelDialog::CatalogFilterMatchesPath(const std::string& pathSoFar,
const std::string& leafName) const
{
if (_filterTokens.empty()) {
return true;
}
// Build a single haystack: "vendor / category / sub / leaf" lower-cased.
// Every token must be present somewhere in the haystack (AND-narrow).
wxString haystack = wxString::FromUTF8(pathSoFar);
if (!haystack.IsEmpty()) {
haystack += " / ";
}
haystack += wxString::FromUTF8(leafName);
haystack.MakeLower();
for (const auto& token : _filterTokens) {
if (haystack.Find(token) == wxNOT_FOUND) {
Comment thread
heffneil marked this conversation as resolved.
return false;
}
}
return true;
}

void VendorModelDialog::OnCatalogFilterText(wxCommandEvent& /*event*/)
{
// Capture and tokenize 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.
_filterTokens.clear();
if (TextCtrl_Filter != nullptr) {
wxString text = TextCtrl_Filter->GetValue().Lower();
wxStringTokenizer tk(text, " \t");
while (tk.HasMoreTokens()) {
wxString tok = tk.GetNextToken();
if (!tok.IsEmpty()) {
_filterTokens.push_back(tok);
}
}
}
if (_filterDebounceTimer != nullptr) {
_filterDebounceTimer->Start(kCatalogFilterDebounceMs, wxTIMER_ONE_SHOT);
}
}

void VendorModelDialog::OnCatalogFilterCancel(wxCommandEvent& /*event*/)
{
if (TextCtrl_Filter != nullptr) {
TextCtrl_Filter->ChangeValue(wxEmptyString);
}
_filterTokens.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 +1310,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 +1369,12 @@ VendorModelDialog::~VendorModelDialog()
//(*Destroy(VendorModelDialog)
//*)

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

GetCache().Save();

for (const auto& it : _vendors) {
Expand Down
36 changes: 34 additions & 2 deletions src-ui-wx/import_export/VendorModelDialog.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

#include <pugixml.hpp>
#include <wx/filename.h>
#include <wx/timer.h>
#include <list>
#include <string>
#include <vector>
Expand Down Expand Up @@ -62,8 +63,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 +79,37 @@ class VendorModelDialog: public wxDialog
void DownloadSelectedModels();
[[nodiscard]] wxTreeItemId GetFocusedItem() const;

// ----- Catalog filter (experimental) -----
// Single live-filter input that narrows the tree to model nodes
// whose ancestor path (vendor / category / sub-category / model
// name) contains EVERY whitespace-separated token from the input
// (case-insensitive). e.g. "tree EFL" is two AND-narrowed terms,
// matching only items whose path contains both 'tree' and 'efl'.
// Categories and un-suppressed vendors with no surviving
// descendants are pruned bottom-up by PruneEmptyBranches. Lives
// outside wxSmith so the .wxs file does not need to know about it.
class wxSearchCtrl* TextCtrl_Filter = nullptr;
std::vector<wxString> _filterTokens; // already lower-cased
wxTimer* _filterDebounceTimer = nullptr;
bool _initialBuild = true;
static constexpr int kCatalogFilterDebounceMs = 200;
void OnCatalogFilterText(wxCommandEvent& event);
void OnCatalogFilterCancel(wxCommandEvent& event);
void OnCatalogFilterDebounce(wxTimerEvent& event);
// Returns true if pathSoFar + leafName contains every token in the
// current filter (case-insensitive substring match). pathSoFar is
// the ancestor breadcrumb "vendor / category / subcategory" so the
// user can filter on hierarchy text — typing "halloween" includes
// every descendant of the matching node. Empty filter matches
// anything.
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