From e78dfc4aeb901a9f88861055d78f18528da1a199 Mon Sep 17 00:00:00 2001 From: heffneil Date: Tue, 28 Apr 2026 20:30:23 -0400 Subject: [PATCH 01/31] Add hierarchy-aware live filter to vendor model catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendor model catalog tree (Layout > Import models from xlights.org) can have hundreds of nodes spread across a dozen vendors. The existing Search button steps through matches one at a time but doesn't narrow the tree; users still scroll past every non-matching vendor and category to find what they want. Add two live wxSearchCtrl filter inputs above the tree: - AND-narrowing across both boxes (typing 'halloween' in box 1 then 'pumpkin' in box 2 shows only Halloween's pumpkins). - Hierarchy-aware match: each model's haystack is its full breadcrumb path 'Vendor / Category / Sub-category / ModelName' lower-cased, so typing a vendor or category name surfaces the whole sub-tree. - Empty branches pruned bottom-up by a new PruneEmptyBranches pass — the original DeleteEmptyCategories only caught leaf categories that were already empty when first visited and never deleted vendors, so filtered results would be cluttered with empty parent rows. - ExpandAll while a filter is active so the user sees every survivor without re-expanding each vendor on every keystroke. When both filters clear, the tree returns to its original collapsed-vendor default. - 200 ms debounce — full tree rebuild is O(N+M) and re-allocates a ModelElement per visible node, so collapsing fast keystrokes into one rebuild matters on big catalogs. - All UI lives outside the wxSmith block; the .wxs file does not need to know about it. The original step-through Search button row at the bottom is kept unchanged — power users who want next-match navigation still have it. --- README.txt | 5 + src-ui-wx/import_export/VendorModelDialog.cpp | 206 +++++++++++++++++- src-ui-wx/import_export/VendorModelDialog.h | 32 ++- 3 files changed, 234 insertions(+), 9 deletions(-) diff --git a/README.txt b/README.txt index a3edce917b..a585dd88bf 100644 --- a/README.txt +++ b/README.txt @@ -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. -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 diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 00a80d65f1..5a386c49ba 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include "settings/XLightsConfigAdapter.h" #include #include @@ -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()); + Panel3->Layout(); + } + SetSize(800, 600); PopulateModelPanel((MModel*)nullptr); @@ -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(); @@ -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); } } @@ -1085,23 +1144,138 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high) TreeCtrl_Navigator->EnsureVisible(first); } + // 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); + + // 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 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( + 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) + { + TreeCtrl_Navigator->Delete(parent); + return true; + } + return false; +} + +bool VendorModelDialog::CatalogFilterMatches(const std::string& name) const +{ + return CatalogFilterMatchesPath("", name); +} +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); @@ -1125,23 +1299,35 @@ bool VendorModelDialog::DeleteEmptyCategories(wxTreeItemId& parent) return false; } -void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list categories) +void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list 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. + if (!CatalogFilterMatchesPath(pathSoFar, it->_name)) { + continue; + } if (it->_wiring.size() > 1) { wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(v, it->_name, -1, -1, new MModelTreeItemData(it)); @@ -1172,6 +1358,12 @@ VendorModelDialog::~VendorModelDialog() //(*Destroy(VendorModelDialog) //*) + if (_filterDebounceTimer != nullptr) { + _filterDebounceTimer->Stop(); + delete _filterDebounceTimer; + _filterDebounceTimer = nullptr; + } + GetCache().Save(); for (const auto& it : _vendors) { diff --git a/src-ui-wx/import_export/VendorModelDialog.h b/src-ui-wx/import_export/VendorModelDialog.h index 86cae02f33..dd0bff19c3 100644 --- a/src-ui-wx/import_export/VendorModelDialog.h +++ b/src-ui-wx/import_export/VendorModelDialog.h @@ -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 categories); - void AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId); + void AddHierachy(wxTreeItemId v, MVendor* vendor, std::list categories, const std::string& pathSoFar = ""); + void AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId, const std::string& pathSoFar = ""); void ValidateWindow(); void PopulateVendorPanel(MVendor* vendor); void PopulateModelPanel(MModel* vendor); @@ -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. + 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; + // 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); From 986bfe6be97eeb7c88b54bb2467cc947cd7a4354 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 08:41:20 -0400 Subject: [PATCH 02/31] Address Copilot review on #6256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four points raised, all valid: 1. PruneEmptyBranches deleted suppressed vendors. Suppressed vendors intentionally have zero children (we skip AddHierachy for them in the tree build) so the bottom-up prune was treating them as orphans and dropping them — meaning users couldn't see them in the tree and therefore couldn't un-suppress via the 'Don't download this vendors list of models' checkbox panel. Skip pruning when the empty Vendor node is suppressed; empty non-suppressed vendors (only happens with a filter active) still get pruned as intended. 2. VendorModelDialog.h was using wxTimer* and wxTimerEvent in member declarations and handler signatures without including . Made the header self-contained. 3. The header-comment block above the catalog filter members still referred to the old 'pruned by DeleteEmptyCategories' path and 'matches model names', neither of which is true after the PruneEmptyBranches + path-aware-match work. Updated the comment. 4. CatalogFilterMatches() became dead code once AddModels was refactored to call CatalogFilterMatchesPath directly. Removed the wrapper and updated the path-aware function's doc comment to stand alone. --- src-ui-wx/import_export/VendorModelDialog.cpp | 20 +++++++++++++----- src-ui-wx/import_export/VendorModelDialog.h | 21 +++++++++++-------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 5a386c49ba..b4e33c87af 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1205,17 +1205,27 @@ bool VendorModelDialog::PruneEmptyBranches(wxTreeItemId parent) if ((type == "Category" || type == "Vendor") && TreeCtrl_Navigator->GetChildrenCount(parent) == 0) { + // Suppressed vendors intentionally have no children (we skipped + // AddHierachy for them). Pruning them would hide them from the + // tree and prevent the user from un-suppressing via the + // "Don't download this vendors list of models" checkbox panel, + // so leave them in place. Empty Vendor nodes that aren't + // suppressed only happen under an active filter (no descendant + // matched), and pruning those is the desired behaviour. + if (type == "Vendor") { + auto* vd = static_cast(tid); + if (vd->GetVendor() != nullptr && + IsVendorSuppressed(vd->GetVendor()->_name)) + { + return false; + } + } TreeCtrl_Navigator->Delete(parent); return true; } return false; } -bool VendorModelDialog::CatalogFilterMatches(const std::string& name) const -{ - return CatalogFilterMatchesPath("", name); -} - bool VendorModelDialog::CatalogFilterMatchesPath(const std::string& pathSoFar, const std::string& leafName) const { diff --git a/src-ui-wx/import_export/VendorModelDialog.h b/src-ui-wx/import_export/VendorModelDialog.h index dd0bff19c3..794a499837 100644 --- a/src-ui-wx/import_export/VendorModelDialog.h +++ b/src-ui-wx/import_export/VendorModelDialog.h @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -80,10 +81,11 @@ class VendorModelDialog: public wxDialog // ----- 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. + // ancestor path (vendor / category / sub-category / model name) + // contains BOTH filter strings (case-insensitive). 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_Filter1 = nullptr; class wxSearchCtrl* TextCtrl_Filter2 = nullptr; wxString _filter1; // already lower-cased @@ -93,11 +95,12 @@ class VendorModelDialog: public wxDialog void OnCatalogFilterText(wxCommandEvent& event); void OnCatalogFilterCancel(wxCommandEvent& event); void OnCatalogFilterDebounce(wxTimerEvent& event); - bool CatalogFilterMatches(const std::string& name) const; - // 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. + // Returns true if pathSoFar + leafName satisfies BOTH filter + // inputs (case-insensitive substring match). pathSoFar is the + // ancestor breadcrumb "vendor / category / subcategory" so users + // can filter on hierarchy text — typing "halloween" or "boscoyo" + // includes every descendant of the matching node. Empty filters + // match anything. bool CatalogFilterMatchesPath(const std::string& pathSoFar, const std::string& leafName) const; void RebuildTreeUI(); From adb20f935811a5a99b4325e840fbbe95a09a59ad Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 08:53:04 -0400 Subject: [PATCH 03/31] Catalog filter: collapse two boxes into one with whitespace tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dual TextCtrl_Filter1/Filter2 inputs with a single TextCtrl_Filter where the user types space-separated terms and each is AND-narrowed against the breadcrumb path. e.g. typing 'tree EFL' is now equivalent to what 'tree' in box 1 + 'EFL' in box 2 used to do, but in one input — much closer to Spotlight / VS Code search behaviour. Mechanically: - Single wxSearchCtrl with placeholder 'Filter catalog (space-separated terms)...' - Filter state moves from two wxString members to a std::vector _filterTokens lower-cased on capture - CatalogFilterMatchesPath loops the tokens and requires every token to appear somewhere in the path haystack - OnCatalogFilterText tokenizes via wxStringTokenizer on space/tab - OnCatalogFilterCancel just clears the input and the token vector, no per-control event-source disambiguation needed - ExpandAll-while-filtering check now keys off !_filterTokens.empty() rather than two IsEmpty() checks Net: 91 lines of cpp + 32 lines of header churn for a feature that feels measurably nicer to use. --- src-ui-wx/import_export/VendorModelDialog.cpp | 91 ++++++++----------- src-ui-wx/import_export/VendorModelDialog.h | 32 +++---- 2 files changed, 56 insertions(+), 67 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index b4e33c87af..b727c7f07a 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include "settings/XLightsConfigAdapter.h" #include #include @@ -920,40 +921,28 @@ 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); + // 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); // 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->Insert(0, TextCtrl_Filter, 0, wxALL | wxEXPAND, 5); FlexGridSizer2->RemoveGrowableRow(0); FlexGridSizer2->AddGrowableRow(1); - TextCtrl_Filter1->Bind(wxEVT_TEXT, + TextCtrl_Filter->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, + TextCtrl_Filter->Bind(wxEVT_SEARCHCTRL_CANCEL_BTN, &VendorModelDialog::OnCatalogFilterCancel, this); _filterDebounceTimer = new wxTimer(this); Bind(wxEVT_TIMER, &VendorModelDialog::OnCatalogFilterDebounce, @@ -1165,8 +1154,8 @@ void VendorModelDialog::RebuildTreeUI() // 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()) { + // collapsed-vendor state alone when the filter is clear. + if (!_filterTokens.empty()) { TreeCtrl_Navigator->ExpandAll(); } @@ -1229,52 +1218,52 @@ bool VendorModelDialog::PruneEmptyBranches(wxTreeItemId parent) bool VendorModelDialog::CatalogFilterMatchesPath(const std::string& pathSoFar, const std::string& leafName) const { - if (_filter1.IsEmpty() && _filter2.IsEmpty()) { + if (_filterTokens.empty()) { 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. + // 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(); - if (!_filter1.IsEmpty() && haystack.Find(_filter1) == wxNOT_FOUND) { - return false; - } - if (!_filter2.IsEmpty() && haystack.Find(_filter2) == wxNOT_FOUND) { - return false; + for (const auto& token : _filterTokens) { + if (haystack.Find(token) == 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(); + // 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) +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 (TextCtrl_Filter != nullptr) { + TextCtrl_Filter->ChangeValue(wxEmptyString); } + _filterTokens.clear(); if (_filterDebounceTimer != nullptr) { _filterDebounceTimer->Stop(); } diff --git a/src-ui-wx/import_export/VendorModelDialog.h b/src-ui-wx/import_export/VendorModelDialog.h index 794a499837..973d87bdce 100644 --- a/src-ui-wx/import_export/VendorModelDialog.h +++ b/src-ui-wx/import_export/VendorModelDialog.h @@ -80,27 +80,27 @@ class VendorModelDialog: public wxDialog [[nodiscard]] wxTreeItemId GetFocusedItem() const; // ----- Catalog filter (experimental) ----- - // Two live-filter inputs that narrow the tree to model nodes whose - // ancestor path (vendor / category / sub-category / model name) - // contains BOTH filter strings (case-insensitive). 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_Filter1 = nullptr; - class wxSearchCtrl* TextCtrl_Filter2 = nullptr; - wxString _filter1; // already lower-cased - wxString _filter2; // already lower-cased + // 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 _filterTokens; // already lower-cased wxTimer* _filterDebounceTimer = nullptr; static constexpr int kCatalogFilterDebounceMs = 200; void OnCatalogFilterText(wxCommandEvent& event); void OnCatalogFilterCancel(wxCommandEvent& event); void OnCatalogFilterDebounce(wxTimerEvent& event); - // Returns true if pathSoFar + leafName satisfies BOTH filter - // inputs (case-insensitive substring match). pathSoFar is the - // ancestor breadcrumb "vendor / category / subcategory" so users - // can filter on hierarchy text — typing "halloween" or "boscoyo" - // includes every descendant of the matching node. Empty filters - // match anything. + // 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(); From a10a3d95a52bb56e49c4851faf6bca2603042e38 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 09:59:41 -0400 Subject: [PATCH 04/31] Catalog filter: hide suppressed vendors when filter is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of the suppressed-vendor handling in PruneEmptyBranches. The previous round protected suppressed vendors from being pruned period — but that meant 'DMX Fixture Library' (suppressed by default) stayed visible even when the user typed a filter that matched nothing under it, which is confusing. Refine: only protect suppressed vendors from pruning when there's NO active filter. Under an active filter, suppressed vendors with no surviving descendants get pruned along with everything else that doesn't match. Without a filter, they still show so the user can un-suppress via the 'Don't download this vendors list of models' checkbox panel. --- src-ui-wx/import_export/VendorModelDialog.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index b727c7f07a..f7971211c1 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1195,13 +1195,15 @@ bool VendorModelDialog::PruneEmptyBranches(wxTreeItemId parent) TreeCtrl_Navigator->GetChildrenCount(parent) == 0) { // Suppressed vendors intentionally have no children (we skipped - // AddHierachy for them). Pruning them would hide them from the - // tree and prevent the user from un-suppressing via the - // "Don't download this vendors list of models" checkbox panel, - // so leave them in place. Empty Vendor nodes that aren't - // suppressed only happen under an active filter (no descendant - // matched), and pruning those is the desired behaviour. - if (type == "Vendor") { + // 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(tid); if (vd->GetVendor() != nullptr && IsVendorSuppressed(vd->GetVendor()->_name)) From 1144a06d342d0dcf3c39418f39f9fdd8136f4015 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 10:01:21 -0400 Subject: [PATCH 05/31] Update README to reflect single-box whitespace-tokenized filter Copilot caught the inconsistency: code now has ONE filter input that AND-narrows on whitespace-separated tokens (typing 'tree EFL' matches both terms in the path), but README still described 'two live-filter inputs' and a 'second box'. Realign the release note text with what shipped. --- README.txt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.txt b/README.txt index a585dd88bf..762c4299d3 100644 --- a/README.txt +++ b/README.txt @@ -11,11 +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 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. + -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. -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 From 49c270f391a936ce78dc1a50ff6cbefc34fe4f09 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 13:28:37 -0400 Subject: [PATCH 06/31] Address Copilot review on #6256 EnsureVisible(first) ran on every filter rebuild, scrolling the tree back to the top on every keystroke. Gate it on a _initialBuild flag so it only runs the first time. --- src-ui-wx/import_export/VendorModelDialog.cpp | 6 +++++- src-ui-wx/import_export/VendorModelDialog.h | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index f7971211c1..1574824ac1 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1128,9 +1128,13 @@ void VendorModelDialog::RebuildTreeUI() } } - 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; } // Two passes: diff --git a/src-ui-wx/import_export/VendorModelDialog.h b/src-ui-wx/import_export/VendorModelDialog.h index 973d87bdce..f00a144193 100644 --- a/src-ui-wx/import_export/VendorModelDialog.h +++ b/src-ui-wx/import_export/VendorModelDialog.h @@ -91,6 +91,7 @@ class VendorModelDialog: public wxDialog class wxSearchCtrl* TextCtrl_Filter = nullptr; std::vector _filterTokens; // already lower-cased wxTimer* _filterDebounceTimer = nullptr; + bool _initialBuild = true; static constexpr int kCatalogFilterDebounceMs = 200; void OnCatalogFilterText(wxCommandEvent& event); void OnCatalogFilterCancel(wxCommandEvent& event); From 0e3751090dfc983d18c76b1989366d24f0d4cf46 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 14:43:47 -0400 Subject: [PATCH 07/31] RebuildTreeUI: UnselectAll before DeleteAllItems + re-entrancy guard Windows tester reported a hang/crash typing 'efl' into the catalog filter. Most likely cause: DeleteAllItems on a tree with an active selection fires selection-changed events that race with the rebuild. UnselectAll first to silence them. Also guard against re-entrant rebuilds. --- src-ui-wx/import_export/VendorModelDialog.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 1574824ac1..6d032ad054 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1110,7 +1110,12 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high) // whenever a filter input changes (debounced). void VendorModelDialog::RebuildTreeUI() { + static bool rebuilding = false; + if (rebuilding) return; + rebuilding = true; + TreeCtrl_Navigator->Freeze(); + TreeCtrl_Navigator->UnselectAll(); TreeCtrl_Navigator->DeleteAllItems(); wxTreeItemId root = TreeCtrl_Navigator->AddRoot("Vendors"); @@ -1164,6 +1169,7 @@ void VendorModelDialog::RebuildTreeUI() } TreeCtrl_Navigator->Thaw(); + rebuilding = false; } // Bottom-up walk: recurse into each child first, then if THIS node has From 2f25c077a2253bdab3c339c11429a4f961080f7e Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 16:52:36 -0400 Subject: [PATCH 08/31] Catalog filter: avoid UI freeze on Windows under heavy filters - Move rebuild guard from static-local to class member so OnTreeCtrl_NavigatorSelectionChanged can also short-circuit while a rebuild is in flight (the handler does network IO via PopulateVendorPanel for the vendor logo, which was blocking the UI thread when triggered by the rebuild's UnselectAll). - Replace ExpandAll with a targeted 'expand surviving vendor nodes only' loop. Categories are already auto-expanded by AddHierachy during the build, so ExpandAll was redundantly walking the entire tree on Windows native wxTreeCtrl which is expensive at scale. --- src-ui-wx/import_export/VendorModelDialog.cpp | 23 ++++++++++--------- src-ui-wx/import_export/VendorModelDialog.h | 1 + 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 6d032ad054..b5226346d2 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1110,9 +1110,8 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high) // whenever a filter input changes (debounced). void VendorModelDialog::RebuildTreeUI() { - static bool rebuilding = false; - if (rebuilding) return; - rebuilding = true; + if (_treeRebuilding) return; + _treeRebuilding = true; TreeCtrl_Navigator->Freeze(); TreeCtrl_Navigator->UnselectAll(); @@ -1160,16 +1159,19 @@ void VendorModelDialog::RebuildTreeUI() } PruneEmptyBranches(root); - // 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(); + wxTreeItemIdValue cookie; + for (auto vendor = TreeCtrl_Navigator->GetFirstChild(root, cookie); + vendor.IsOk(); + vendor = TreeCtrl_Navigator->GetNextChild(root, cookie)) { + if (TreeCtrl_Navigator->GetChildrenCount(vendor, false) > 0) { + TreeCtrl_Navigator->Expand(vendor); + } + } } TreeCtrl_Navigator->Thaw(); - rebuilding = false; + _treeRebuilding = false; } // Bottom-up walk: recurse into each child first, then if THIS node has @@ -1667,12 +1669,11 @@ void VendorModelDialog::OnTreeCtrl_NavigatorSelectionChanged(wxTreeEvent& event) { static bool busy = false; - if (busy) + if (busy || _treeRebuilding) { return; } - // Because this code triggers a web download this function can be re-entered and this is not good busy = true; wxTreeItemId startid = GetFocusedItem(); diff --git a/src-ui-wx/import_export/VendorModelDialog.h b/src-ui-wx/import_export/VendorModelDialog.h index f00a144193..d5f1af0069 100644 --- a/src-ui-wx/import_export/VendorModelDialog.h +++ b/src-ui-wx/import_export/VendorModelDialog.h @@ -92,6 +92,7 @@ class VendorModelDialog: public wxDialog std::vector _filterTokens; // already lower-cased wxTimer* _filterDebounceTimer = nullptr; bool _initialBuild = true; + bool _treeRebuilding = false; static constexpr int kCatalogFilterDebounceMs = 200; void OnCatalogFilterText(wxCommandEvent& event); void OnCatalogFilterCancel(wxCommandEvent& event); From 6191b0869f3437453f2bc4ef370c10c7c37a2f57 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 17:12:40 -0400 Subject: [PATCH 09/31] RebuildTreeUI: disable tree during rebuild, RAII guard User reported on Windows that typing 'EF' froze the UI, clicking the tree, then typing 'L' crashed. Hypothesis: the click queued an event that fired after rebuild deleted the clicked item, leaving a stale wxTreeItemId in SelectionChanged. - Disable/Enable the tree across the rebuild so clicks/keys can't reach it during the (potentially slow) rebuild. - RAII guard so Thaw + Enable + flag reset always run, even if a later step throws or returns early. --- src-ui-wx/import_export/VendorModelDialog.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index b5226346d2..f69f4ccdf2 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1111,11 +1111,22 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high) void VendorModelDialog::RebuildTreeUI() { if (_treeRebuilding) return; - _treeRebuilding = true; + struct RebuildScope { + bool& flag; + wxTreeCtrl* tree; + explicit RebuildScope(bool& f, wxTreeCtrl* t) : flag(f), tree(t) { + flag = true; + tree->Disable(); + tree->Freeze(); + } + ~RebuildScope() { + tree->Thaw(); + tree->Enable(); + flag = false; + } + } guard(_treeRebuilding, TreeCtrl_Navigator); - TreeCtrl_Navigator->Freeze(); TreeCtrl_Navigator->UnselectAll(); - TreeCtrl_Navigator->DeleteAllItems(); wxTreeItemId root = TreeCtrl_Navigator->AddRoot("Vendors"); wxTreeItemId first = root; From 3ec71bbe2c4c5a92a7da017f47cf884cdd5d81da Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 17:50:18 -0400 Subject: [PATCH 10/31] Catalog filter: drop tree Disable, expansion was suppressed Tree was Disable()d during rebuild for click-protection but that prevented Expand() calls from taking effect on Windows native wxTreeCtrl, leaving filter results all collapsed. Drop the Disable; rely on _treeRebuilding flag in click handlers instead (SelectionChanged already had it; added it to ItemActivated). --- src-ui-wx/import_export/VendorModelDialog.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index f69f4ccdf2..a0fd179d96 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1116,12 +1116,10 @@ void VendorModelDialog::RebuildTreeUI() wxTreeCtrl* tree; explicit RebuildScope(bool& f, wxTreeCtrl* t) : flag(f), tree(t) { flag = true; - tree->Disable(); tree->Freeze(); } ~RebuildScope() { tree->Thaw(); - tree->Enable(); flag = false; } } guard(_treeRebuilding, TreeCtrl_Navigator); @@ -1641,6 +1639,7 @@ void VendorModelDialog::OnNotebookPanelsPageChanged(wxNotebookEvent& event) void VendorModelDialog::OnTreeCtrl_NavigatorItemActivated(wxTreeEvent& event) { + if (_treeRebuilding) return; wxTreeItemId startid = event.GetItem(); SetCursor(wxCURSOR_WAIT); From 19f50d8d2c03422475254776ee4629175710fb10 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 18:26:51 -0400 Subject: [PATCH 11/31] Hide bottom Search row; new live filter replaces it on all platforms --- src-ui-wx/import_export/VendorModelDialog.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index a0fd179d96..4dcbf44789 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -947,6 +947,11 @@ VendorModelDialog::VendorModelDialog(wxWindow* parent, const std::string& showFo _filterDebounceTimer = new wxTimer(this); Bind(wxEVT_TIMER, &VendorModelDialog::OnCatalogFilterDebounce, this, _filterDebounceTimer->GetId()); + + if (TextCtrl_Search != nullptr) TextCtrl_Search->Hide(); + if (Button_Search != nullptr) Button_Search->Hide(); + if (FlexGridSizer9 != nullptr) FlexGridSizer2->Hide(FlexGridSizer9, true); + Panel3->Layout(); } From 34f3626cf03c6e9b893a9c2834b7f3b809244211 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 18:55:42 -0400 Subject: [PATCH 12/31] AddHierachy: Expand after children populated, not before On Windows native wxTreeCtrl, Expand on an item with no children yet is a no-op, so sub-categories were rendering collapsed even when filter narrowed the tree. Move the Expand call past both the recursive AddHierachy and the AddModels call so children exist when the expand state is set. --- src-ui-wx/import_export/VendorModelDialog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 4dcbf44789..cb587ab0f4 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1337,8 +1337,8 @@ void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list< ? it->_name : pathSoFar + " / " + it->_name; AddHierachy(tid, vendor, it->_categories, nextPath); - TreeCtrl_Navigator->Expand(tid); AddModels(tid, vendor, it->_id, nextPath); + TreeCtrl_Navigator->Expand(tid); } } From b5342ed36181d47a2dcd1524452fe941e6ec17b7 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 19:17:52 -0400 Subject: [PATCH 13/31] Catalog filter: restore focus to filter ctrl after rebuild --- src-ui-wx/import_export/VendorModelDialog.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index cb587ab0f4..a088309b16 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1301,6 +1301,9 @@ void VendorModelDialog::OnCatalogFilterCancel(wxCommandEvent& /*event*/) void VendorModelDialog::OnCatalogFilterDebounce(wxTimerEvent& /*event*/) { RebuildTreeUI(); + if (TextCtrl_Filter != nullptr) { + TextCtrl_Filter->SetFocus(); + } } bool VendorModelDialog::DeleteEmptyCategories(wxTreeItemId& parent) From 07caebaefebe385db17004d021a3549932ada776 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 19:20:14 -0400 Subject: [PATCH 14/31] Expand multi-wiring model nodes when filter is active --- src-ui-wx/import_export/VendorModelDialog.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index a088309b16..f63cc5c97d 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1366,6 +1366,9 @@ void VendorModelDialog::AddModels(wxTreeItemId v, MVendor* vendor, std::string c wxTreeItemId id = TreeCtrl_Navigator->AppendItem(tid, it2->_name, -1, -1, new MWiringTreeItemData(it2)); TreeCtrl_Navigator->SetItemTextColour(id, it->GetColour()); } + if (!_filterTokens.empty()) { + TreeCtrl_Navigator->Expand(tid); + } } else { From 9805b4a578025d7aa3c7092f5ddc639a4b04f882 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 19:48:09 -0400 Subject: [PATCH 15/31] Fix Windows filter losing typed text mid-word Windows native wxSearchCtrl::SetFocus() selects all existing text, so the next keystroke replaces what the user already typed (typing arch became ch because the rebuilds focus restore selected ar). Move the caret to the end with no selection after the restore. --- src-ui-wx/import_export/VendorModelDialog.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index f63cc5c97d..d74089b409 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1302,7 +1302,13 @@ void VendorModelDialog::OnCatalogFilterDebounce(wxTimerEvent& /*event*/) { RebuildTreeUI(); if (TextCtrl_Filter != nullptr) { + // Windows native wxSearchCtrl::SetFocus() selects all text, so the + // next keystroke would replace what the user already typed. Restore + // focus then move the caret to the end with no selection. TextCtrl_Filter->SetFocus(); + long pos = TextCtrl_Filter->GetLastPosition(); + TextCtrl_Filter->SetSelection(pos, pos); + TextCtrl_Filter->SetInsertionPointEnd(); } } From 835d332d80a590cb68e16ea5aaf01919712cd091 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 19:49:19 -0400 Subject: [PATCH 16/31] Defer caret-to-end via CallAfter for Windows WM_SETFOCUS Windows fires WM_SETFOCUS asynchronously after SetFocus returns and selects all text in the underlying edit control. The previous SetSelection ran before Windows clobbered it, so the next keystroke still replaced the whole string. Defer via CallAfter so we run after Windows native selection. --- src-ui-wx/import_export/VendorModelDialog.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index d74089b409..acf2a2708f 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1302,13 +1302,18 @@ void VendorModelDialog::OnCatalogFilterDebounce(wxTimerEvent& /*event*/) { RebuildTreeUI(); if (TextCtrl_Filter != nullptr) { - // Windows native wxSearchCtrl::SetFocus() selects all text, so the - // next keystroke would replace what the user already typed. Restore - // focus then move the caret to the end with no selection. - TextCtrl_Filter->SetFocus(); - long pos = TextCtrl_Filter->GetLastPosition(); - TextCtrl_Filter->SetSelection(pos, pos); - TextCtrl_Filter->SetInsertionPointEnd(); + // Windows fires WM_SETFOCUS asynchronously and selects all text in + // the underlying edit control AFTER SetFocus() returns, so we must + // defer the caret-to-end fix via CallAfter — otherwise our + // SetSelection runs first and Windows immediately overwrites it, + // causing the next keystroke to replace what the user already typed. + wxSearchCtrl* ctrl = TextCtrl_Filter; + ctrl->SetFocus(); + CallAfter([ctrl]() { + long pos = ctrl->GetLastPosition(); + ctrl->SetSelection(pos, pos); + ctrl->SetInsertionPointEnd(); + }); } } From 37aaf86f7f7de0fd7124dbcbb7c8a3304aa69629 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 20:44:29 -0400 Subject: [PATCH 17/31] Fix double-Thaw assert in RebuildTreeUI RebuildScope's destructor already calls Thaw() and resets _treeRebuilding when the function returns; the manual calls at the end of RebuildTreeUI ran them a second time, tripping the wx m_freezeCount assert (Thaw without matching Freeze). --- src-ui-wx/import_export/VendorModelDialog.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index acf2a2708f..d6cad1862a 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1184,8 +1184,7 @@ void VendorModelDialog::RebuildTreeUI() } } - TreeCtrl_Navigator->Thaw(); - _treeRebuilding = false; + // RebuildScope's destructor handles Thaw() and resets _treeRebuilding. } // Bottom-up walk: recurse into each child first, then if THIS node has From fbe1873b25c028329d123e3c4443efeb2e18dfec Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 21:36:21 -0400 Subject: [PATCH 18/31] Reset stale _lastSearchItem on filter rebuild + restore handler busy guard Fixes Windows hang when filter rebuild deletes the tree containing _lastSearchItem from masters F3 search: subsequent SetItemBold on a stale wxTreeItemId hung the UI. Clear _lastSearchItem at the top of RebuildTreeUI, validate it has live data before SetItemBold, and restore the handler-level busy guard the master split removed. --- src-ui-wx/import_export/VendorModelDialog.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index cc84adf204..1cee6c0299 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1120,6 +1120,12 @@ void VendorModelDialog::RebuildTreeUI() } } guard(_treeRebuilding, TreeCtrl_Navigator); + // The tree about to be deleted may contain _lastSearchItem (from + // master's F3 search). Drop it now — leaving a stale wxTreeItemId + // around makes SetItemBold on the next selection-change hang on + // Windows. + _lastSearchItem = wxTreeItemId(); + TreeCtrl_Navigator->UnselectAll(); TreeCtrl_Navigator->DeleteAllItems(); wxTreeItemId root = TreeCtrl_Navigator->AddRoot("Vendors"); @@ -1690,16 +1696,25 @@ void VendorModelDialog::OnTreeCtrl_NavigatorItemActivated(wxTreeEvent& event) void VendorModelDialog::OnTreeCtrl_NavigatorSelectionChanged(wxTreeEvent& event) { if (_treeRebuilding) return; + static bool busy = false; + if (busy) return; + busy = true; wxTreeItemId startid = event.GetItem(); if (!startid.IsOk()) { startid = GetFocusedItem(); } // User manually selected a different item — clear the search bold/cursor. + // Guard against _lastSearchItem pointing at a deleted item from a prior + // tree generation (filter rebuilds delete the entire tree). if (_lastSearchItem.IsOk() && startid != _lastSearchItem) { - TreeCtrl_Navigator->SetItemBold(_lastSearchItem, false); + wxTreeItemData* lastData = TreeCtrl_Navigator->GetItemData(_lastSearchItem); + if (lastData != nullptr) { + TreeCtrl_Navigator->SetItemBold(_lastSearchItem, false); + } _lastSearchItem = wxTreeItemId(); } UpdatePanelForItem(startid); + busy = false; } void VendorModelDialog::UpdatePanelForItem(wxTreeItemId item) From cfee5bfe290863af085894d2b77ecd6f6cdec240 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 21:54:36 -0400 Subject: [PATCH 19/31] Defer _treeRebuilding clear + hide masters bottom search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EFL filter hung Windows because programmatic Expand during the rebuild queues SEL_CHANGED events that fire AFTER RebuildTreeUI returns and the RAII guards destructor clears _treeRebuilding. Each of those events called PopulateModelPanel and triggered slow image loading, which on Windows looked like a permanent hang. Defer the flag-clear via CallAfter so the queued events see the flag still set and bail. Also hide masters new TextCtrl_Search wxSearchCtrl from #6267 — the top filter supersedes it, having both is confusing. --- src-ui-wx/import_export/VendorModelDialog.cpp | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 1cee6c0299..c5d80ad68e 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -945,6 +945,13 @@ VendorModelDialog::VendorModelDialog(wxWindow* parent, const std::string& showFo Bind(wxEVT_TIMER, &VendorModelDialog::OnCatalogFilterDebounce, this, _filterDebounceTimer->GetId()); + // Master's #6267 added a wxSearchCtrl at the bottom of Panel3 + // (TextCtrl_Search). Our top filter supersedes it; hide so + // the user only sees one search box. + if (TextCtrl_Search != nullptr) { + TextCtrl_Search->Hide(); + } + Panel3->Layout(); } @@ -1116,7 +1123,14 @@ void VendorModelDialog::RebuildTreeUI() } ~RebuildScope() { tree->Thaw(); - flag = false; + // Don't clear flag here — programmatic Expand() and + // selection changes during the rebuild queue SEL_CHANGED + // events that fire AFTER this destructor runs. Clearing + // synchronously lets those stale events through, each one + // calling PopulateModelPanel (slow image load) and + // appearing as a Windows spinning-wheel hang on large + // filters like "EFL". Defer the clear so the queued events + // see the flag still set and bail. } } guard(_treeRebuilding, TreeCtrl_Navigator); @@ -1181,7 +1195,9 @@ void VendorModelDialog::RebuildTreeUI() } } - // RebuildScope's destructor handles Thaw() and resets _treeRebuilding. + // Defer clearing _treeRebuilding so SEL_CHANGED events queued by + // the Expand calls above still see the flag set when they fire. + CallAfter([this]() { _treeRebuilding = false; }); } // Bottom-up walk: recurse into each child first, then if THIS node has From be30c8d5742cfa97160d0307aea56f183523ec87 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 22:09:27 -0400 Subject: [PATCH 20/31] Guard UpdatePanelForItem with _treeRebuilding too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PopulateModelPanel calls model->DownloadImages() synchronously, which on Windows blocks the UI thread on network/disk for many seconds when a filter like EFL matches lots of models. The handler already bails on _treeRebuilding, but UpdatePanelForItem can also be reached from OnButton_SearchClick and from queued events after the rebuild returns — guard it directly so no path triggers slow image loading while the filter is mid-flight. --- src-ui-wx/import_export/VendorModelDialog.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index c5d80ad68e..3817dfe7a0 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1735,6 +1735,11 @@ void VendorModelDialog::OnTreeCtrl_NavigatorSelectionChanged(wxTreeEvent& event) void VendorModelDialog::UpdatePanelForItem(wxTreeItemId item) { + // Skip while the filter is rebuilding the tree — PopulateModelPanel + // calls model->DownloadImages() synchronously, which on Windows + // blocks the UI thread on network/disk for many seconds when a + // filter like "EFL" matches lots of models. + if (_treeRebuilding) return; static bool busy = false; if (busy) return; busy = true; From 9eaefd6d44c659af44cd4eeae9a021dd95c28ae4 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 22:13:10 -0400 Subject: [PATCH 21/31] Revert deferred flag clear, validate stale tree items instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CallAfter strategy to keep _treeRebuilding=true past RebuildTreeUI return failed: when the initial build queues many events, the CallAfter doesnt fire fast enough, and the next user-triggered filter rebuild bails at the entry guard. Result: typing efl produced no filter (tree still showed all vendors) — the screenshot from testing showed exactly this. Restore the synchronous clear in RebuildScopes destructor. Handle stale events from a prior tree generation by validating GetItemData isnt null in OnTreeCtrl_NavigatorSelectionChanged — deleted items return null on Windows, so we can drop those events without touching them. --- src-ui-wx/import_export/VendorModelDialog.cpp | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 3817dfe7a0..f8b0d75bda 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1123,14 +1123,7 @@ void VendorModelDialog::RebuildTreeUI() } ~RebuildScope() { tree->Thaw(); - // Don't clear flag here — programmatic Expand() and - // selection changes during the rebuild queue SEL_CHANGED - // events that fire AFTER this destructor runs. Clearing - // synchronously lets those stale events through, each one - // calling PopulateModelPanel (slow image load) and - // appearing as a Windows spinning-wheel hang on large - // filters like "EFL". Defer the clear so the queued events - // see the flag still set and bail. + flag = false; } } guard(_treeRebuilding, TreeCtrl_Navigator); @@ -1195,9 +1188,6 @@ void VendorModelDialog::RebuildTreeUI() } } - // Defer clearing _treeRebuilding so SEL_CHANGED events queued by - // the Expand calls above still see the flag set when they fire. - CallAfter([this]() { _treeRebuilding = false; }); } // Bottom-up walk: recurse into each child first, then if THIS node has @@ -1716,8 +1706,17 @@ void VendorModelDialog::OnTreeCtrl_NavigatorSelectionChanged(wxTreeEvent& event) if (busy) return; busy = true; wxTreeItemId startid = event.GetItem(); + // Drop events whose item refers to a tree node deleted by a prior + // filter rebuild — GetItemData returns null for those on Windows + // and PopulateModelPanel would otherwise dereference a stale ptr. + if (startid.IsOk() && TreeCtrl_Navigator->GetItemData(startid) == nullptr) { + startid = wxTreeItemId(); + } if (!startid.IsOk()) { startid = GetFocusedItem(); + if (startid.IsOk() && TreeCtrl_Navigator->GetItemData(startid) == nullptr) { + startid = wxTreeItemId(); + } } // User manually selected a different item — clear the search bold/cursor. // Guard against _lastSearchItem pointing at a deleted item from a prior From d8b9eec14c2ed9aa7a58a114cb12bc5acc4cc72e Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 22:31:36 -0400 Subject: [PATCH 22/31] Add spdlog tracing for filter rebuild diagnosis Logs every step of the filter pipeline: - OnCatalogFilterText fires (text, token count, rebuilding state) - timer started - OnCatalogFilterDebounce fires - RebuildTreeUI ENTER and EXIT (token count, root children count) - Bail at entry guard if _treeRebuilding stuck Lets us see on Windows whether events fire, whether the rebuild runs, and whether tokens make it into _filterTokens. --- src-ui-wx/import_export/VendorModelDialog.cpp | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index f8b0d75bda..a222a906e5 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1113,7 +1113,12 @@ bool VendorModelDialog::LoadTree(wxProgressDialog* prog, int low, int high) // whenever a filter input changes (debounced). void VendorModelDialog::RebuildTreeUI() { - if (_treeRebuilding) return; + spdlog::info("VMD::RebuildTreeUI ENTER tokens={} rebuilding={} vendors={}", + _filterTokens.size(), _treeRebuilding, _vendors.size()); + if (_treeRebuilding) { + spdlog::warn("VMD::RebuildTreeUI BAILED at entry guard"); + return; + } struct RebuildScope { bool& flag; wxTreeCtrl* tree; @@ -1188,6 +1193,9 @@ void VendorModelDialog::RebuildTreeUI() } } + int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); + spdlog::info("VMD::RebuildTreeUI EXIT root_children={} tokens={}", + rootKids, _filterTokens.size()); } // Bottom-up walk: recurse into each child first, then if THIS node has @@ -1269,12 +1277,11 @@ bool VendorModelDialog::CatalogFilterMatchesPath(const std::string& pathSoFar, 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(); + spdlog::info("VMD::OnCatalogFilterText fired text='{}' rebuilding={}", + text.ToStdString(), _treeRebuilding); wxStringTokenizer tk(text, " \t"); while (tk.HasMoreTokens()) { wxString tok = tk.GetNextToken(); @@ -1282,9 +1289,13 @@ void VendorModelDialog::OnCatalogFilterText(wxCommandEvent& /*event*/) _filterTokens.push_back(tok); } } + spdlog::info("VMD::OnCatalogFilterText tokens.size={}", _filterTokens.size()); } if (_filterDebounceTimer != nullptr) { _filterDebounceTimer->Start(kCatalogFilterDebounceMs, wxTIMER_ONE_SHOT); + spdlog::info("VMD::OnCatalogFilterText timer started ({}ms)", kCatalogFilterDebounceMs); + } else { + spdlog::warn("VMD::OnCatalogFilterText timer is null!"); } } @@ -1302,7 +1313,11 @@ void VendorModelDialog::OnCatalogFilterCancel(wxCommandEvent& /*event*/) void VendorModelDialog::OnCatalogFilterDebounce(wxTimerEvent& /*event*/) { + spdlog::info("VMD::OnCatalogFilterDebounce fired tokens={} rebuilding={}", + _filterTokens.size(), _treeRebuilding); RebuildTreeUI(); + spdlog::info("VMD::OnCatalogFilterDebounce after RebuildTreeUI rebuilding={}", + _treeRebuilding); if (TextCtrl_Filter != nullptr) { // Windows fires WM_SETFOCUS asynchronously and selects all text in // the underlying edit control AFTER SetFocus() returns, so we must From 0dc1499c6ac25c3c25cb08b6409087617ac1f83c Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 22:47:28 -0400 Subject: [PATCH 23/31] Add per-step tracing to find rebuild hang point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs each major step inside RebuildTreeUI body: - UnselectAll, DeleteAllItems, AddRoot - per-vendor begin/end - DeleteEmptyCategories pass per vendor - PruneEmptyBranches start/end - per-vendor Expand under filter - per-category in AddHierachy The v15 log showed RebuildTreeUI ENTER but no EXIT — it hung inside. This pinpoints which step hangs. --- src-ui-wx/import_export/VendorModelDialog.cpp | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index a222a906e5..1f89401521 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1138,12 +1138,18 @@ void VendorModelDialog::RebuildTreeUI() // Windows. _lastSearchItem = wxTreeItemId(); + spdlog::info("VMD::RTUI step 1: UnselectAll"); TreeCtrl_Navigator->UnselectAll(); + spdlog::info("VMD::RTUI step 2: DeleteAllItems"); TreeCtrl_Navigator->DeleteAllItems(); + spdlog::info("VMD::RTUI step 3: AddRoot"); wxTreeItemId root = TreeCtrl_Navigator->AddRoot("Vendors"); wxTreeItemId first = root; + spdlog::info("VMD::RTUI step 4: vendor loop start"); + int vIdx = 0; for (const auto& it : _vendors) { + spdlog::info("VMD::RTUI vendor[{}] '{}' begin", vIdx, it->_name); wxTreeItemId v = TreeCtrl_Navigator->AppendItem(root, it->_name, -1, -1, new MVendorTreeItemData(it)); if (first == root) { @@ -1153,7 +1159,10 @@ void VendorModelDialog::RebuildTreeUI() { AddHierachy(v, it, it->_categories, it->_name); } + spdlog::info("VMD::RTUI vendor[{}] '{}' end", vIdx, it->_name); + vIdx++; } + spdlog::info("VMD::RTUI step 5: vendor loop done"); // Only scroll to first vendor on the initial build, not on every // filter rebuild — otherwise typing in the filter keeps yanking @@ -1175,22 +1184,36 @@ void VendorModelDialog::RebuildTreeUI() // 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. + spdlog::info("VMD::RTUI step 6: DeleteEmptyCategories pass start"); wxTreeItemIdValue cookie; + int dec = 0; for (auto l1 = TreeCtrl_Navigator->GetFirstChild(root, cookie); l1.IsOk(); l1 = TreeCtrl_Navigator->GetNextChild(root, cookie)) { + spdlog::info("VMD::RTUI DEC vendor {} begin", dec); UNUSED(DeleteEmptyCategories(l1)); + spdlog::info("VMD::RTUI DEC vendor {} end", dec); + dec++; } + spdlog::info("VMD::RTUI step 7: DeleteEmptyCategories pass done"); + spdlog::info("VMD::RTUI step 8: PruneEmptyBranches start"); PruneEmptyBranches(root); + spdlog::info("VMD::RTUI step 9: PruneEmptyBranches done"); if (!_filterTokens.empty()) { + spdlog::info("VMD::RTUI step 10: vendor expand start"); wxTreeItemIdValue cookie; + int eIdx = 0; for (auto vendor = TreeCtrl_Navigator->GetFirstChild(root, cookie); vendor.IsOk(); vendor = TreeCtrl_Navigator->GetNextChild(root, cookie)) { - if (TreeCtrl_Navigator->GetChildrenCount(vendor, false) > 0) { + int nc = TreeCtrl_Navigator->GetChildrenCount(vendor, false); + spdlog::info("VMD::RTUI expand[{}] children={}", eIdx, nc); + if (nc > 0) { TreeCtrl_Navigator->Expand(vendor); } + eIdx++; } + spdlog::info("VMD::RTUI step 11: vendor expand done"); } int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); @@ -1359,17 +1382,19 @@ bool VendorModelDialog::DeleteEmptyCategories(wxTreeItemId& parent) void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list categories, const std::string& pathSoFar) { + int cIdx = 0; for (const auto& it : categories) { + spdlog::info("VMD::AddHierachy '{}' cat[{}]='{}'", pathSoFar, cIdx, it->_name); wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(id, it->_name, -1, -1, new MCategoryTreeItemData(it)); - // 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); AddModels(tid, vendor, it->_id, nextPath); + spdlog::info("VMD::AddHierachy '{}' cat[{}]='{}' Expand", pathSoFar, cIdx, it->_name); TreeCtrl_Navigator->Expand(tid); + cIdx++; } } From 97d205b21fab320c68f6c3d36243ef86f738bbf1 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 23:03:34 -0400 Subject: [PATCH 24/31] Remove per-vendor auto-Expand under filter (hangs Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-step spdlog tracing on Windows confirmed the rebuild hung in the post-prune per-vendor Expand call: log stopped at 'expand[0] children=8' and never wrote the matching done line. Each vendor that survived the filter has many already-expanded categories underneath. When we Expand the vendor itself, the visible-item count explodes and the Win32 TreeView_Expand call during Freeze never returns. Leave matched vendors collapsed — users click to drill in, same UX as the unfiltered tree. --- src-ui-wx/import_export/VendorModelDialog.cpp | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 1f89401521..1f13330cc8 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1199,22 +1199,13 @@ void VendorModelDialog::RebuildTreeUI() PruneEmptyBranches(root); spdlog::info("VMD::RTUI step 9: PruneEmptyBranches done"); - if (!_filterTokens.empty()) { - spdlog::info("VMD::RTUI step 10: vendor expand start"); - wxTreeItemIdValue cookie; - int eIdx = 0; - for (auto vendor = TreeCtrl_Navigator->GetFirstChild(root, cookie); - vendor.IsOk(); - vendor = TreeCtrl_Navigator->GetNextChild(root, cookie)) { - int nc = TreeCtrl_Navigator->GetChildrenCount(vendor, false); - spdlog::info("VMD::RTUI expand[{}] children={}", eIdx, nc); - if (nc > 0) { - TreeCtrl_Navigator->Expand(vendor); - } - eIdx++; - } - spdlog::info("VMD::RTUI step 11: vendor expand done"); - } + // Per-vendor Expand under filter caused a permanent UI hang on + // Windows native wxTreeCtrl: each vendor has many already-expanded + // categories so the visible-item count explodes the moment the + // vendor opens, and the Win32 TreeView_Expand call never returns + // (confirmed via per-step spdlog tracing — log stopped at + // "expand[0] children=8"). Leave matched vendors collapsed; users + // click to drill in, same as the unfiltered tree. int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); spdlog::info("VMD::RebuildTreeUI EXIT root_children={} tokens={}", From 448867dc01d174070c9b3465aa3a12925353ac79 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 23:04:30 -0400 Subject: [PATCH 25/31] Filter UX: expand vendors but leave categories collapsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the auto-expand of matched vendors under the filter so users see results without clicking. Avoids the Windows hang by skipping the per-category Expand inside AddHierachy when a filter is active — expanding the vendor then only reveals collapsed first-level categories instead of every nested grandchild, keeping the visible- item count small enough that Win32 TreeView_Expand returns. --- src-ui-wx/import_export/VendorModelDialog.cpp | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 1f13330cc8..f9275e9128 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1199,13 +1199,20 @@ void VendorModelDialog::RebuildTreeUI() PruneEmptyBranches(root); spdlog::info("VMD::RTUI step 9: PruneEmptyBranches done"); - // Per-vendor Expand under filter caused a permanent UI hang on - // Windows native wxTreeCtrl: each vendor has many already-expanded - // categories so the visible-item count explodes the moment the - // vendor opens, and the Win32 TreeView_Expand call never returns - // (confirmed via per-step spdlog tracing — log stopped at - // "expand[0] children=8"). Leave matched vendors collapsed; users - // click to drill in, same as the unfiltered tree. + // Under filter, categories were left collapsed by AddHierachy so + // expanding the vendor here only reveals first-level categories, + // not every grandchild. Without that adjustment the visible-item + // count exploded and Win32 TreeView_Expand hung. + if (!_filterTokens.empty()) { + wxTreeItemIdValue cookie; + for (auto vendor = TreeCtrl_Navigator->GetFirstChild(root, cookie); + vendor.IsOk(); + vendor = TreeCtrl_Navigator->GetNextChild(root, cookie)) { + if (TreeCtrl_Navigator->GetChildrenCount(vendor, false) > 0) { + TreeCtrl_Navigator->Expand(vendor); + } + } + } int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); spdlog::info("VMD::RebuildTreeUI EXIT root_children={} tokens={}", @@ -1373,19 +1380,20 @@ bool VendorModelDialog::DeleteEmptyCategories(wxTreeItemId& parent) void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list categories, const std::string& pathSoFar) { - int cIdx = 0; for (const auto& it : categories) { - spdlog::info("VMD::AddHierachy '{}' cat[{}]='{}'", pathSoFar, cIdx, it->_name); wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(id, it->_name, -1, -1, new MCategoryTreeItemData(it)); std::string nextPath = pathSoFar.empty() ? it->_name : pathSoFar + " / " + it->_name; AddHierachy(tid, vendor, it->_categories, nextPath); AddModels(tid, vendor, it->_id, nextPath); - spdlog::info("VMD::AddHierachy '{}' cat[{}]='{}' Expand", pathSoFar, cIdx, it->_name); - TreeCtrl_Navigator->Expand(tid); - cIdx++; + // Under an active filter, leave categories collapsed so the + // vendor Expand below doesn't trigger a Win32 TreeView hang + // from the resulting visible-item explosion. + if (_filterTokens.empty()) { + TreeCtrl_Navigator->Expand(tid); + } } } From b8505dce5169f5e9d9bdb0bd9244ac2aeaeacc4a Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 23:23:15 -0400 Subject: [PATCH 26/31] Force Refresh+Update after rebuild for Windows paint sync v18 log proved the rebuild produced the right internal state (root_children=1 for an efl filter that should leave only EFL Designs) but the screenshot still showed all 10 vendors. Windows native wxTreeCtrl can leave the pre-Freeze frame on screen after Thaw if many items were added/removed during the freeze. Explicitly invalidate and update so the user sees the filtered tree. --- src-ui-wx/import_export/VendorModelDialog.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index f9275e9128..209a806be3 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1217,6 +1217,12 @@ void VendorModelDialog::RebuildTreeUI() int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); spdlog::info("VMD::RebuildTreeUI EXIT root_children={} tokens={}", rootKids, _filterTokens.size()); + // Windows native wxTreeCtrl sometimes leaves the prior frame on + // screen after Thaw if many items were added/removed during the + // freeze. Force an explicit invalidation so the user actually + // sees the filtered tree. + TreeCtrl_Navigator->Refresh(); + TreeCtrl_Navigator->Update(); } // Bottom-up walk: recurse into each child first, then if THIS node has From 97bc228c94e7275257522657b8551ac8837ffd63 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 23:29:40 -0400 Subject: [PATCH 27/31] Drop stale SEL_CHANGED events post-rebuild without GetFocusedItem fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queued SEL_CHANGED events fired AFTER RebuildTreeUI returns: the events item id was deleted by the rebuild so GetItemData returned null, my previous validation then fell back to GetFocusedItem which picked up a freshly-built item from the NEW tree. PopulateModelPanel then ran on that item and called synchronous model->DownloadImages(), freezing the UI for many seconds when GitHub rate-limited us. Drop the fallback. If the events item is stale, treat the event as having no selection — the panel hides until the user actually clicks something. --- src-ui-wx/import_export/VendorModelDialog.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 209a806be3..5454a5117c 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1751,18 +1751,16 @@ void VendorModelDialog::OnTreeCtrl_NavigatorSelectionChanged(wxTreeEvent& event) if (busy) return; busy = true; wxTreeItemId startid = event.GetItem(); - // Drop events whose item refers to a tree node deleted by a prior - // filter rebuild — GetItemData returns null for those on Windows - // and PopulateModelPanel would otherwise dereference a stale ptr. + // Drop events whose item refers to a node deleted by a prior + // rebuild. GetItemData returns null for those on Windows. We + // intentionally do NOT fall back to GetFocusedItem here: a stale + // event firing after a filter rebuild would otherwise pick up a + // freshly-built item and run PopulateModelPanel on it, which calls + // synchronous DownloadImages and freezes the UI for many seconds + // when GitHub rate-limits us. if (startid.IsOk() && TreeCtrl_Navigator->GetItemData(startid) == nullptr) { startid = wxTreeItemId(); } - if (!startid.IsOk()) { - startid = GetFocusedItem(); - if (startid.IsOk() && TreeCtrl_Navigator->GetItemData(startid) == nullptr) { - startid = wxTreeItemId(); - } - } // User manually selected a different item — clear the search bold/cursor. // Guard against _lastSearchItem pointing at a deleted item from a prior // tree generation (filter rebuilds delete the entire tree). From 947aefd035d16e67a6f6e2af58f677e87321edc9 Mon Sep 17 00:00:00 2001 From: heffneil Date: Wed, 29 Apr 2026 23:45:38 -0400 Subject: [PATCH 28/31] Defer Refresh+Update until after RebuildScope thaws Bug in v19: Refresh+Update ran inside the function body, but the RebuildScopes destructor that calls Thaw only fires when the function returns. Calling Refresh while still frozen left the tree visible area painted with an empty frame. Use CallAfter so the post-rebuild paint happens after the scope guard has thawed and the timer event has unwound. --- src-ui-wx/import_export/VendorModelDialog.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 5454a5117c..96d8089a1b 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1217,12 +1217,16 @@ void VendorModelDialog::RebuildTreeUI() int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); spdlog::info("VMD::RebuildTreeUI EXIT root_children={} tokens={}", rootKids, _filterTokens.size()); - // Windows native wxTreeCtrl sometimes leaves the prior frame on - // screen after Thaw if many items were added/removed during the - // freeze. Force an explicit invalidation so the user actually - // sees the filtered tree. - TreeCtrl_Navigator->Refresh(); - TreeCtrl_Navigator->Update(); + // Refresh after the RebuildScope destructor runs Thaw — calling + // these inside the freeze leaves the visible area painted with a + // stale or empty frame. CallAfter posts to the event queue, which + // runs after this function returns and the scope guard has thawed. + CallAfter([this]() { + if (TreeCtrl_Navigator != nullptr) { + TreeCtrl_Navigator->Refresh(); + TreeCtrl_Navigator->Update(); + } + }); } // Bottom-up walk: recurse into each child first, then if THIS node has From c1949da7be1a3e774511ba634350ab7b5479e0bf Mon Sep 17 00:00:00 2001 From: heffneil Date: Thu, 30 Apr 2026 00:04:58 -0400 Subject: [PATCH 29/31] Remove all auto-expand under filter to fix EFL hang on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-vendor and multi-wiring Expand calls under filter consistently hang Windows on certain vendor data shapes (EFL Designs). User confirmed boscoyo, tree, arches all work but EFL locks every time. Removing both auto-expands keeps the rebuild simple and stable — matched vendors render collapsed, user clicks to drill in, same UX as the unfiltered tree. --- src-ui-wx/import_export/VendorModelDialog.cpp | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 96d8089a1b..da4c250073 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -1199,20 +1199,10 @@ void VendorModelDialog::RebuildTreeUI() PruneEmptyBranches(root); spdlog::info("VMD::RTUI step 9: PruneEmptyBranches done"); - // Under filter, categories were left collapsed by AddHierachy so - // expanding the vendor here only reveals first-level categories, - // not every grandchild. Without that adjustment the visible-item - // count exploded and Win32 TreeView_Expand hung. - if (!_filterTokens.empty()) { - wxTreeItemIdValue cookie; - for (auto vendor = TreeCtrl_Navigator->GetFirstChild(root, cookie); - vendor.IsOk(); - vendor = TreeCtrl_Navigator->GetNextChild(root, cookie)) { - if (TreeCtrl_Navigator->GetChildrenCount(vendor, false) > 0) { - TreeCtrl_Navigator->Expand(vendor); - } - } - } + // Per-vendor auto-expand removed: even with collapsed categories + // it hung on Windows for some vendors (EFL Designs). Tree shows + // the matched vendors, user clicks to drill in — same UX as the + // unfiltered tree. int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); spdlog::info("VMD::RebuildTreeUI EXIT root_children={} tokens={}", @@ -1428,9 +1418,10 @@ void VendorModelDialog::AddModels(wxTreeItemId v, MVendor* vendor, std::string c wxTreeItemId id = TreeCtrl_Navigator->AppendItem(tid, it2->_name, -1, -1, new MWiringTreeItemData(it2)); TreeCtrl_Navigator->SetItemTextColour(id, it->GetColour()); } - if (!_filterTokens.empty()) { - TreeCtrl_Navigator->Expand(tid); - } + // Multi-wiring auto-expand under filter removed: cumulative + // Expand calls during AddModels could chain into a Windows + // hang when a vendor like EFL Designs has many multi-wiring + // models. Users click to expand individual nodes. } else { From ef0dbb0a8db449468d5d2295199a32e36568bc26 Mon Sep 17 00:00:00 2001 From: heffneil Date: Thu, 18 Jun 2026 14:03:18 -0400 Subject: [PATCH 30/31] Vendor catalog: flat de-duplicated model list per vendor (#6256) Replace the multi-level category tree with a two-level tree: Vendor -> Model. Each vendor node now holds a flat list of its models built by iterating the vendor's owning _models list, so a model listed under several categories appears exactly once (de-duplicated by identity) instead of repeating per category. Filtering stays hierarchy-aware: a model matches if the vendor name or any of its category paths (resolved via Category::GetPath) plus the model name satisfies every filter token, so typing a vendor or category name still surfaces that node's models even though categories are no longer shown as rows. Removed AddHierachy / AddModels / DeleteEmptyCategories (category-node machinery no longer needed); PruneEmptyBranches still drops vendors with no matching models. Selection, detail pane and download are unchanged - model and wiring leaves keep their existing item data. --- src-ui-wx/import_export/VendorModelDialog.cpp | 163 ++++++++---------- src-ui-wx/import_export/VendorModelDialog.h | 11 +- 2 files changed, 80 insertions(+), 94 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index 19da41f217..d56543a5af 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -16,6 +16,8 @@ //*) #include +#include +#include #include #include #include @@ -519,7 +521,7 @@ void VendorModelDialog::RebuildTreeUI() } if (!IsVendorSuppressed(it->_name)) { - AddHierachy(v, it, it->_categories, it->_name); + AddVendorModelList(v, it); } spdlog::info("VMD::RTUI vendor[{}] '{}' end", vIdx, it->_name); vIdx++; @@ -535,31 +537,12 @@ void VendorModelDialog::RebuildTreeUI() _initialBuild = false; } - // 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. - spdlog::info("VMD::RTUI step 6: DeleteEmptyCategories pass start"); - wxTreeItemIdValue cookie; - int dec = 0; - for (auto l1 = TreeCtrl_Navigator->GetFirstChild(root, cookie); l1.IsOk(); l1 = TreeCtrl_Navigator->GetNextChild(root, cookie)) - { - spdlog::info("VMD::RTUI DEC vendor {} begin", dec); - UNUSED(DeleteEmptyCategories(l1)); - spdlog::info("VMD::RTUI DEC vendor {} end", dec); - dec++; - } - spdlog::info("VMD::RTUI step 7: DeleteEmptyCategories pass done"); - spdlog::info("VMD::RTUI step 8: PruneEmptyBranches start"); + // The tree is now two levels (Vendor -> Model), so there are no + // category nodes to prune; PruneEmptyBranches just drops Vendor nodes + // that ended up with no matching models under the active filter. + spdlog::info("VMD::RTUI step 6: PruneEmptyBranches start"); PruneEmptyBranches(root); - spdlog::info("VMD::RTUI step 9: PruneEmptyBranches done"); + spdlog::info("VMD::RTUI step 7: PruneEmptyBranches done"); // Per-vendor auto-expand removed: even with collapsed categories // it hung on Windows for some vendors (EFL Designs). Tree shows @@ -614,7 +597,7 @@ bool VendorModelDialog::PruneEmptyBranches(wxTreeItemId parent) TreeCtrl_Navigator->GetChildrenCount(parent) == 0) { // Suppressed vendors intentionally have no children (we skipped - // AddHierachy for them). When no filter is active, leave them + // AddVendorModelList 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 @@ -717,87 +700,85 @@ void VendorModelDialog::OnCatalogFilterDebounce(wxTimerEvent& /*event*/) } } -bool VendorModelDialog::DeleteEmptyCategories(wxTreeItemId& parent) +// Does a model satisfy the active filter? A model can sit in several +// categories, so it matches if ANY of its category paths (or the vendor +// name alone, for category-less models) passes every filter token. The +// path keeps the hierarchy-aware behaviour: typing a vendor or category +// name still surfaces that node's models even though categories are no +// longer shown as their own rows. +bool VendorModelDialog::ModelMatchesFilter(MVendor* vendor, const MModel* model, + const std::unordered_map& catById) const { - VendorBaseTreeItemData* tid = (VendorBaseTreeItemData*)TreeCtrl_Navigator->GetItemData(parent); - if (tid->GetType() == "Category" && TreeCtrl_Navigator->GetChildrenCount(parent) == 0) - { - TreeCtrl_Navigator->Delete(parent); + if (_filterTokens.empty()) { return true; } - else if (tid->GetType() == "Category" || tid->GetType() == "Vendor") - { - wxTreeItemIdValue cookie; - for (auto l1 = TreeCtrl_Navigator->GetFirstChild(parent, cookie); - l1.IsOk(); - ) - { - auto next = TreeCtrl_Navigator->GetNextChild(parent, cookie); - UNUSED(DeleteEmptyCategories(l1)); - l1 = next; + if (CatalogFilterMatchesPath(vendor->_name, model->_name)) { + return true; + } + for (const auto& cid : model->_categoryIds) { + auto it = catById.find(cid); + if (it == catById.end() || it->second == nullptr) { + continue; + } + const std::string path = vendor->_name + " / " + it->second->GetPath(); + if (CatalogFilterMatchesPath(path, model->_name)) { + return true; } } return false; } -void VendorModelDialog::AddHierachy(wxTreeItemId id, MVendor* vendor, std::list categories, const std::string& pathSoFar) +// Append one model as a leaf under its vendor node. Mirrors the leaf +// shapes the old per-category code produced: a multi-wiring model is a +// model row with one child per wiring; a single-wiring model is the +// wiring leaf itself; a model with no wiring is a plain model leaf. +void VendorModelDialog::AppendModelLeaf(wxTreeItemId vendorNode, MModel* model) { - for (const auto& it : categories) + if (model->_wiring.size() > 1) { - wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(id, it->_name, -1, -1, new MCategoryTreeItemData(it)); - std::string nextPath = pathSoFar.empty() - ? it->_name - : pathSoFar + " / " + it->_name; - AddHierachy(tid, vendor, it->_categories, nextPath); - AddModels(tid, vendor, it->_id, nextPath); - // Under an active filter, leave categories collapsed so the - // vendor Expand below doesn't trigger a Win32 TreeView hang - // from the resulting visible-item explosion. - if (_filterTokens.empty()) { - TreeCtrl_Navigator->Expand(tid); + wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(vendorNode, model->_name, -1, -1, new MModelTreeItemData(model)); + for (const auto& w : model->_wiring) + { + wxTreeItemId id = TreeCtrl_Navigator->AppendItem(tid, w->_name, -1, -1, new MWiringTreeItemData(w)); + TreeCtrl_Navigator->SetItemTextColour(id, TreeItemColourForModel(model)); } } + else if (model->_wiring.size() == 0) + { + wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(vendorNode, model->_name, -1, -1, new MModelTreeItemData(model)); + TreeCtrl_Navigator->SetItemTextColour(tid, TreeItemColourForModel(model)); + } + else + { + wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(vendorNode, model->_name, -1, -1, new MWiringTreeItemData(model->_wiring.front())); + TreeCtrl_Navigator->SetItemTextColour(tid, TreeItemColourForModel(model)); + } } -void VendorModelDialog::AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId, const std::string& pathSoFar) +// Populate a vendor node with a flat, de-duplicated list of its models. +// Iterating the vendor's owning _models list visits each model exactly +// once, so a model listed under multiple categories no longer repeats. +// Categories are not shown as rows - the result is a two-level tree: +// Vendor -> Model. +void VendorModelDialog::AddVendorModelList(wxTreeItemId vendorNode, MVendor* vendor) { - 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. - if (!CatalogFilterMatchesPath(pathSoFar, it->_name)) { - continue; - } - if (it->_wiring.size() > 1) - { - wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(v, it->_name, -1, -1, new MModelTreeItemData(it)); - for (const auto& it2 : it->_wiring) - { - wxTreeItemId id = TreeCtrl_Navigator->AppendItem(tid, it2->_name, -1, -1, new MWiringTreeItemData(it2)); - TreeCtrl_Navigator->SetItemTextColour(id, TreeItemColourForModel(it)); + // Index this vendor's categories by id so a model's _categoryIds can + // be resolved to a path for filter matching. + std::unordered_map catById; + std::function&)> indexCats = + [&](const std::list& cats) { + for (auto* c : cats) { + if (c == nullptr) continue; + catById[c->_id] = c; + indexCats(c->_categories); } - // Multi-wiring auto-expand under filter removed: cumulative - // Expand calls during AddModels could chain into a Windows - // hang when a vendor like EFL Designs has many multi-wiring - // models. Users click to expand individual nodes. - } - else - { - if (it->_wiring.size() == 0) - { - wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(v, it->_name, -1, -1, new MModelTreeItemData(it)); - TreeCtrl_Navigator->SetItemTextColour(tid, TreeItemColourForModel(it)); - } - else - { - wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(v, it->_name, -1, -1, new MWiringTreeItemData(it->_wiring.front())); - TreeCtrl_Navigator->SetItemTextColour(tid, TreeItemColourForModel(it)); - } - } + }; + indexCats(vendor->_categories); + + for (auto* model : vendor->_models) { + if (model == nullptr) continue; + if (!ModelMatchesFilter(vendor, model, catById)) continue; + AppendModelLeaf(vendorNode, model); } } diff --git a/src-ui-wx/import_export/VendorModelDialog.h b/src-ui-wx/import_export/VendorModelDialog.h index c334f07aac..d65771197c 100644 --- a/src-ui-wx/import_export/VendorModelDialog.h +++ b/src-ui-wx/import_export/VendorModelDialog.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include "CachedFileDownloader.h" @@ -72,15 +73,19 @@ 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 categories, const std::string& pathSoFar = ""); - void AddModels(wxTreeItemId v, MVendor* vendor, std::string categoryId, const std::string& pathSoFar = ""); + // Two-level tree population: each vendor node gets a flat, de-duplicated + // list of its models (no category rows). See the .cpp for the dedup and + // filter-matching details. + void AddVendorModelList(wxTreeItemId vendorNode, MVendor* vendor); + void AppendModelLeaf(wxTreeItemId vendorNode, MModel* model); + [[nodiscard]] bool ModelMatchesFilter(MVendor* vendor, const MModel* model, + const std::unordered_map& catById) const; void ValidateWindow(); void PopulateVendorPanel(MVendor* vendor); void PopulateModelPanel(MModel* vendor); void PopulateModelPanel(MModelWiring* vendor); void LoadModelImage(const std::list& imageFiles, int image); void LoadImage(wxStaticBitmap* sb, wxImage* img) const; - [[nodiscard]] bool DeleteEmptyCategories(wxTreeItemId& parent); [[nodiscard]] bool IsVendorSuppressed(const std::string& vendor); void SuppressVendor(const std::string& vendor, bool suppress); [[nodiscard]] bool DownloadModel(MModelWiring* wiring); From 53dcf41a91a87ecff16d6b8b0fc6148c3e98d736 Mon Sep 17 00:00:00 2001 From: heffneil Date: Thu, 18 Jun 2026 15:21:25 -0400 Subject: [PATCH 31/31] Vendor catalog: strictly flat results + auto-expand under filter - Multi-wiring models no longer create a nested wiring sub-level. Each wiring becomes its own flat 'Model - Wiring' leaf under the vendor, so every result row is one level deep and directly downloadable (the download path needs a wiring node; a bare multi-wiring model node resolves to no wiring). - Auto-expand surviving vendor nodes when a filter is active so matches show without a click. Safe now that the tree is two levels (Vendor -> Model) - no deep category subtree to explode the visible-item count. Vendors stay collapsed with no filter. --- src-ui-wx/import_export/VendorModelDialog.cpp | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src-ui-wx/import_export/VendorModelDialog.cpp b/src-ui-wx/import_export/VendorModelDialog.cpp index d56543a5af..b7d23ebc27 100644 --- a/src-ui-wx/import_export/VendorModelDialog.cpp +++ b/src-ui-wx/import_export/VendorModelDialog.cpp @@ -544,10 +544,20 @@ void VendorModelDialog::RebuildTreeUI() PruneEmptyBranches(root); spdlog::info("VMD::RTUI step 7: PruneEmptyBranches done"); - // Per-vendor auto-expand removed: even with collapsed categories - // it hung on Windows for some vendors (EFL Designs). Tree shows - // the matched vendors, user clicks to drill in — same UX as the - // unfiltered tree. + // Under an active filter, auto-expand each surviving vendor so the + // matching models are visible without a click. This is safe now that + // the tree is only two levels (Vendor -> Model): we expand one level, + // revealing a bounded flat model list - unlike the old deep category + // hierarchy whose expansion exploded the visible-item count and hung + // Win32. With no filter we leave vendors collapsed, since a single + // vendor can carry hundreds of models. + if (!_filterTokens.empty()) { + wxTreeItemIdValue cookie; + for (auto v = TreeCtrl_Navigator->GetFirstChild(root, cookie); v.IsOk(); + v = TreeCtrl_Navigator->GetNextChild(root, cookie)) { + TreeCtrl_Navigator->Expand(v); + } + } int rootKids = TreeCtrl_Navigator->GetChildrenCount(root, false); spdlog::info("VMD::RebuildTreeUI EXIT root_children={} tokens={}", @@ -728,30 +738,33 @@ bool VendorModelDialog::ModelMatchesFilter(MVendor* vendor, const MModel* model, return false; } -// Append one model as a leaf under its vendor node. Mirrors the leaf -// shapes the old per-category code produced: a multi-wiring model is a -// model row with one child per wiring; a single-wiring model is the -// wiring leaf itself; a model with no wiring is a plain model leaf. +// Append a model's downloadable end node(s) as flat leaves under its +// vendor node - one level only, no nested wiring rows. A model with one +// (or no) wiring is a single leaf. A multi-wiring model becomes one +// "Model - Wiring" leaf per wiring, since each wiring is a separate +// downloadable .xmodel and the download path needs a wiring node (a +// bare multi-wiring model node resolves to no wiring). void VendorModelDialog::AppendModelLeaf(wxTreeItemId vendorNode, MModel* model) { + const wxColour colour = TreeItemColourForModel(model); if (model->_wiring.size() > 1) { - wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(vendorNode, model->_name, -1, -1, new MModelTreeItemData(model)); for (const auto& w : model->_wiring) { - wxTreeItemId id = TreeCtrl_Navigator->AppendItem(tid, w->_name, -1, -1, new MWiringTreeItemData(w)); - TreeCtrl_Navigator->SetItemTextColour(id, TreeItemColourForModel(model)); + const wxString label = wxString::FromUTF8(model->_name) + " - " + wxString::FromUTF8(w->_name); + wxTreeItemId id = TreeCtrl_Navigator->AppendItem(vendorNode, label, -1, -1, new MWiringTreeItemData(w)); + TreeCtrl_Navigator->SetItemTextColour(id, colour); } } else if (model->_wiring.size() == 0) { - wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(vendorNode, model->_name, -1, -1, new MModelTreeItemData(model)); - TreeCtrl_Navigator->SetItemTextColour(tid, TreeItemColourForModel(model)); + wxTreeItemId id = TreeCtrl_Navigator->AppendItem(vendorNode, wxString::FromUTF8(model->_name), -1, -1, new MModelTreeItemData(model)); + TreeCtrl_Navigator->SetItemTextColour(id, colour); } else { - wxTreeItemId tid = TreeCtrl_Navigator->AppendItem(vendorNode, model->_name, -1, -1, new MWiringTreeItemData(model->_wiring.front())); - TreeCtrl_Navigator->SetItemTextColour(tid, TreeItemColourForModel(model)); + wxTreeItemId id = TreeCtrl_Navigator->AppendItem(vendorNode, wxString::FromUTF8(model->_name), -1, -1, new MWiringTreeItemData(model->_wiring.front())); + TreeCtrl_Navigator->SetItemTextColour(id, colour); } }