Skip to content

feat(search): port CSearchListCtrl to wxDataViewCtrl - #796

Merged
got3nks merged 10 commits into
amule-org:masterfrom
LSalami:search-dataview-phase2-port
Aug 5, 2026
Merged

feat(search): port CSearchListCtrl to wxDataViewCtrl#796
got3nks merged 10 commits into
amule-org:masterfrom
LSalami:search-dataview-phase2-port

Conversation

@LSalami

@LSalami LSalami commented Aug 4, 2026

Copy link
Copy Markdown

Summary

Ports CSearchListCtrl (the search-results list) from wxListCtrl (via CMuleListCtrl) to wxDataViewCtrl, for screen-reader accessibility (#180 phase 2, part of #675). This is PR 2 of the two-PR plan agreed in #180 — PR 1 (#787, merged) extracted column persistence into CListColumnStore/IColumnWidthProvider so this port could reuse it unchanged.

CMuleListCtrl wraps MuleExtern::wxGenericListCtrl, a vendored copy of wx's generic (owner-drawn) list control with no native platform widget behind it — VoiceOver/Orca have nothing to attach to. wxDataViewCtrl is natively backed on GTK and macOS (wxHAS_NATIVE_DATAVIEWCTRL).

What changed

  • New src/SearchListModel.{h,cpp}: a wxDataViewModel presenting the existing CSearchFile parent/children forest as a native tree. CSearchFile::GetParent()/GetChildren() drive it directly; expand/collapse state is now owned by the control itself (IsExpanded()), replacing the hand-drawn tree in the old OnDrawItem (manual DrawLine/DrawCircle connectors) and the CSearchFile::ShowChildren()/SetShowChildren() bookkeeping that faked expand state via row insertion/removal.
  • CSearchListCtrl now inherits wxDataViewCtrl directly rather than CMuleListCtrl, per the Accessibility bug report: Search results list is invisible with VoiceOver on macOS #180 discussion (generalizing CMuleListCtrl itself was explicitly rejected, to avoid pulling Downloads/Servers/Shared Files into scope). Column persistence is reused unchanged via CListColumnStore/IColumnWidthProvider through a small ColumnWidthAdapter object (not multiple inheritance like CMuleListCtrl, since wxDataViewCtrl::GetColumnCount() is itself virtual with an incompatible signature).
  • Rating column (smiley icon + text) uses the built-in wxDataViewIconTextRenderer via AppendIconTextColumn(), already precedented in-tree (PrefsUnifiedDlg's sidebar), instead of a custom renderer.
  • FindItem()-based O(n) pointer-to-row lookups are gone: a wxDataViewItem carries the CSearchFile* directly as its ID.

Behaviour notes (flagged for review)

  • Regex/known-file filtering excludes rows at the model level (IsContainer()/GetChildren()) rather than removing/reinserting wxListCtrl rows. A parent that fails its own filter but has a filter-passing child is now always shown as a container regardless of current expand state, whereas the old code only kept it visible while its children were already expanded — a deliberate simplification now that expand/collapse is a pure display concern owned by the control.
  • Alt-sort (the Sources column's total/complete-count tie-break swap) is preserved by driving sorting entirely through CSearchListCtrl's own chain (m_sort_orders) rather than wxDataViewCtrl's native single-criterion sort state; header clicks are intercepted and replicate the exact ascending/descending/alt cycle CMuleListCtrl::OnColumnLClick used.
  • Cross-tab column-width sync (previously EVT_LIST_COL_END_DRAG) has no portable wxDataViewCtrl equivalent event, so it's detected via idle-time width polling instead (CSearchListCtrl::OnIdle, gated on wxWS_EX_PROCESS_IDLE).

Test plan

  • Full amule target builds clean (macOS)
  • clang-format v18 (pinned) applied; clang-tidy Tier-1 (whole-tree) + Tier-2 (changed-lines) both clean
  • Interactive testing (automated via cliclick + macOS Accessibility API, against an isolated test instance with a live ed2k connection, so real search results with real grouped variants were available): tree expand/collapse on grouped results, regex filtering, the sort cycle (direction alternating correctly on repeated clicks), and the rating column's icon+text render all confirmed working against real data.
  • Two real bugs surfaced by that testing, both fixed: (1) the live idle-driven cross-tab width sync never fired for lack of wxWS_EX_PROCESS_IDLE; (2) closing tabs in a different order than a resize happened in could drop the resize -- fixed by syncing outward from a tab's destructor before it's removed from the tab list. Confirmed the resize survives a full clean app exit.
  • Row coloring by download status -- not independently confirmed; no rated/downloading results turned up in the ad-hoc test searches used (the code is a direct, line-for-line port of the old UpdateItemColor palette logic).
  • Linux and Windows: I can't test either from here. If anyone can check this branch on Linux and/or Windows -- especially the tree rendering (native expander glyph vs. the old hand-drawn connectors) and general visual sanity -- that'd be very helpful before merge.
  • Before/after screenshots on all 3 platforms, per the merge gate discussed in Accessibility bug report: Search results list is invisible with VoiceOver on macOS #180: I don't currently have a way to attach screenshots to this PR (no image-upload path available from this environment). I did capture a macOS "after" screenshot locally (real search results, tree expander + rating icon/text visible) confirming the port renders correctly, but can't post it here. If a maintainer can pull this branch and eyeball it directly (or let me know a way to get the image attached), that would close this out -- happy to describe specific renders in more detail via comments in the meantime.

LSalami added 2 commits August 4, 2026 17:26
Replaces the wxListCtrl-based (CMuleListCtrl-derived) search-results
control with a wxDataViewCtrl-backed one, for screen-reader
accessibility (amule-org#180 phase 2, part of amule-org#675). CMuleListCtrl wraps
MuleExtern::wxGenericListCtrl, a vendored copy of wx's *generic*
(owner-drawn) list control with no native platform widget behind it,
so VoiceOver/Orca have nothing to attach to; wxDataViewCtrl is
natively backed on GTK and macOS (wxHAS_NATIVE_DATAVIEWCTRL).

New src/SearchListModel.{h,cpp}: a wxDataViewModel presenting the
existing CSearchFile parent/children forest as a native tree.
CSearchFile::GetParent()/GetChildren() drive it directly; expand/
collapse state is now owned by the control itself (IsExpanded()),
replacing the old hand-drawn tree in
CSearchListCtrl::OnDrawItem (manual DrawLine/DrawCircle connectors)
and the CSearchFile::ShowChildren()/SetShowChildren() bookkeeping
that faked expand state via row insertion/removal.

CSearchListCtrl (src/SearchListCtrl.{h,cpp}) is rewritten to inherit
wxDataViewCtrl directly rather than CMuleListCtrl, per the amule-org#180
discussion: generalizing CMuleListCtrl itself was explicitly rejected
to avoid pulling Downloads/Servers/Shared Files into scope. Column
persistence is reused unchanged via CListColumnStore/
IColumnWidthProvider (src/ListColumnStore.h, from the prior
extraction PR) through a small ColumnWidthAdapter, since
wxDataViewCtrl::GetColumnCount() is itself virtual with an
incompatible signature -- unlike CMuleListCtrl's forwarding-override
trick, that requires a separate adapter object rather than multiple
inheritance. The rating column (smiley icon + text) uses the
built-in wxDataViewIconTextRenderer via AppendIconTextColumn(),
already precedented in-tree (PrefsUnifiedDlg's sidebar), instead of a
custom renderer. FindItem()-based O(n) pointer-to-row lookups are
gone: a wxDataViewItem carries the CSearchFile* directly as its ID.

Behaviour notes (flagged for review):
 - Regex/known-file filtering excludes rows at the model level
   (IsContainer()/GetChildren()) rather than removing/reinserting
   wxListCtrl rows. A parent that fails its own filter but has a
   filter-passing child is now always shown as a container regardless
   of current expand state, whereas the old code only kept it visible
   while its children were already expanded -- a deliberate
   simplification now that expand/collapse is a pure display concern
   owned by the control rather than something filtering needs to
   consult.
 - Alt-sort (the Sources column's total/complete-count tie-break
   swap) is preserved by driving sorting entirely through
   CSearchListCtrl's own chain (m_sort_orders) rather than
   wxDataViewCtrl's native single-criterion sort state; header clicks
   are intercepted (EVT_DATAVIEW_COLUMN_HEADER_CLICK) and replicate
   the exact ascending/descending/alt cycle CMuleListCtrl::
   OnColumnLClick used.
 - Cross-tab column-width sync (previously EVT_LIST_COL_END_DRAG) has
   no portable wxDataViewCtrl equivalent event, so it's detected via
   idle-time width polling instead (CSearchListCtrl::OnIdle).

Verified so far: full `amule` target builds clean (macOS); clang-format
v18 (pinned) applied. Ran the local clang-tidy CI replica (Tier-1
whole-tree + Tier-2 changed-lines); Tier-1 reported no hits in the
new/changed files, Tier-2 flagged only modernize-use-emplace /
modernize-loop-convert suggestions on the new code, all fixed and
re-verified clean. Launched the built app against an isolated test
config (own ports, not the real app) and confirmed it starts without
crashing. Interactive verification of the tree/filter/sort/rating
behaviour and the got3nks-required before/after screenshots on
macOS/Linux/Windows are still outstanding before this is ready to
open as a PR.

Interactive verification (automated via cliclick + macOS Accessibility
API against the isolated test instance, which has a live ed2k
connection, so real search results with real grouped variants were
available): tree expand/collapse on grouped results, regex filtering
(93 -> 29/164 results for a substring match), and the sort cycle
(4 clicks on the Sources column, direction visibly alternating each
time, never getting stuck) all work correctly against real data.

That testing surfaced two real bugs in the cross-tab column-width
sync, both fixed here:
 - The live idle-driven resize sync (CSearchListCtrl::OnIdle) never
   fired: by default a wxWindow doesn't receive idle events unless it
   opts in, and this control didn't. Fixed with
   SetExtraStyle(GetExtraStyle() | wxWS_EX_PROCESS_IDLE) in the
   constructor -- confirmed a resize in one tab now immediately
   mirrors to a second, already-open tab.
 - Resizing a column in one tab and then closing tabs in a different
   order than the resize happened in dropped the resize entirely: the
   destructor only saved *its own* current width, and relied on the
   idle sync having already propagated it there, which is not
   guaranteed by the time tabs are closed. Fixed by calling
   SyncOtherLists(this) unconditionally at the top of the destructor,
   before removing this list from s_lists -- whichever tab happens to
   be closed last now always reflects the most recently touched
   state regardless of which tab the user actually resized. Confirmed
   the resized width survives a full clean app exit (persistence
   requires a clean shutdown for wxConfig to flush to disk -- pre-
   existing behaviour, not introduced by this port).

clang-format v18 and the clang-tidy CI replica (Tier-1 + Tier-2) are
clean after these fixes.

Not yet verified: row coloring and the rating column's icon+text
render (no rated results turned up in the ad-hoc searches used for
testing). Cross-platform (Linux/Windows) behaviour and the
got3nks-required before/after screenshots are still outstanding.
… entry

The wxDataViewCtrl port's rewritten CSearchListCtrl::OnRightClick
dropped the #if 0-guarded "Mark as known file" menu entry that was
never actually compiled/shown (OnMarkAsKnown itself is untouched,
just permanently unreachable, exactly as before). xgettext scans
_("...") calls textually regardless of #if 0, so removing that dead
line dropped the string from the source scan and put the checked-in
catalogs out of sync with scripts/update-po.sh's output -- caught by
CI's "App catalogs in sync with source" gate on PR amule-org#796.

Verified: only change across every po/*.po and po/amule.pot is the
removal of the single "Mark as known file" msgid (and its already-
untranslated-or-obsoleted msgstr entries) -- symmetric, matches the
one line removed from source, no POT-Creation-Date-only churn.
@got3nks

got3nks commented Aug 4, 2026

Copy link
Copy Markdown

Reviewed and built this on macOS (arm64, wx 3.3.3) and Ubuntu 26.04 arm64 (wxGTK 3.2.6); Windows ARM64 is building. Row colouring is confirmed working on macOS, so that box can be ticked.

1. Grouped rows show only the filename until expanded. wxDataViewModel::HasValue() is col == 0 || !IsContainer(item) || HasContainerColumns(item), and HasContainerColumns() defaults to false — so a container draws column 0 and nothing else. GetValue() already computes every column for parents, so the data is there; the control just never asks. wxListCtrl had no notion of container rows, which is why this only appears after the port.

Returning true is right for this model specifically: a grouped parent is a real result, and its children are alternative sources of the same file — not a section header over unrelated rows.

Patch
--- a/src/SearchListModel.h
+++ b/src/SearchListModel.h
 	bool IsContainer(const wxDataViewItem &item) const wxOVERRIDE;
+	//! A grouped result is a row in its own right, not a section header: the
+	//! parent carries the same name/size/sources/rating as any other result
+	//! and its children are alternative sources for the same file. Without
+	//! this, wxDataViewModel::HasValue() draws only column 0 for a container
+	//! -- the group shows its filename and nothing else until expanded.
+	bool HasContainerColumns(const wxDataViewItem &item) const wxOVERRIDE;

--- a/src/SearchListModel.cpp
+++ b/src/SearchListModel.cpp
+bool CSearchListModel::HasContainerColumns(const wxDataViewItem &WXUNUSED(item)) const
+{
+	return true;
+}

2. A new child of an existing group is never announced to the control. When a duplicate result arrives, CSearchList::AddToList does item->AddChild(toadd); Notify_Search_Update_Sources(item); — which reaches ItemChanged(parent). That refreshes the parent's values but doesn't tell the control the container gained a child. The model handles children correctly already (NotifyFileAdded sends ItemAdded(parent, child)); nothing calls it on this path, because Notify_Search_Add_Result fires only for new unique results.

Two consequences: a variant arriving for an already-expanded group won't appear until something forces a rebuild, and the leaf→container transition (first child of what was a plain row) relies on the port re-querying IsContainer() off an ItemChanged, which isn't guaranteed — wxGTK generally wants the item re-added to become a container.
Fix: send ItemAdded(parent, child) from the duplicate path, either via a NotifyChildAdded on the model or by routing children through Notify_Search_Add_Result.

3. NotifyFileRemoved doesn't apply the filter NotifyFileAdded does. Add is gated on ShouldShow(file); remove calls ItemDeleted unconditionally, so a result that was filtered out and never added gets deleted anyway — a no-op on some ports, an assertion on others.
Fix: gate both identically, or drop the gate on add and let GetChildren() do the filtering it already does.

4. A comment now names members this PR deletes. SearchList.cpp:307-312 justifies notifying the GUI before freeing results by citing SetItemPtrData and m_filteredOut, both gone with the port. The ordering requirement is still real — the control holds the same pointers as wxDataViewItem IDs — so the comment should say that instead.

@got3nks

got3nks commented Aug 4, 2026

Copy link
Copy Markdown

Built this on Ubuntu 26.04 arm64 (wxGTK 3.2.6) and Windows 11 arm64 from f26334647 and gave both a visual pass. Grouped results have no expander on either platform, so child rows can't be reached at all. macOS is fine — this is the point 2 from my earlier comment, now observed rather than reasoned, and it's more serious than I put it there.

IsContainer() is ToFile(item)->HasChildren(), and a result is handed to the control the moment it arrives — before it has any children, so it registers as a leaf. The child turns up later, in CSearchList::AddToList:

item->AddChild(toadd);
Notify_Search_Update_Sources(item);   // -> ItemChanged(parent)

ItemChanged is a value notification. Leaf→container is a structural change, and the notifier API signals those with ItemAdded/ItemDeleted. macOS survives the omission because its backend is an NSOutlineView (wxDataViewCtrl → wxCocoaDataViewControl ↔ wxCocoaOutlineView) which re-asks isItemExpandable: on every draw, so IsContainer() is consulted continuously and the triangle appears as soon as HasChildren() flips. wxGTK drives a GtkTreeView through its own GtkTreeModel, and MSW uses the generic implementation; both maintain their own node tree and update container-ness only from the structural notifications — which this path never sends.

Confirmed on Linux: toggling the filter checkbox makes expanders appear on the groups that already exist. That routes through EnableFiltering()NotifyFilterChanged()Cleared(), which re-queries the model with the children present, so the control does render them as containers once it re-derives the tree — it simply never learns about the transition otherwise. (Switching tabs doesn't do this; each tab is its own CSearchListCtrl and OnSearchPageChanged never touches the model.)

Fix: send ItemAdded(ToItem(parent), ToItem(child)) from the duplicate-result path. CSearchListModel::NotifyFileAdded already handles a child correctly — file->GetParent() is non-null there — it's just never called for one. Keep the ItemChanged(parent) afterwards so the parent's aggregate source counts still refresh.

…und in review

Addresses four issues got3nks found reviewing PR amule-org#796 (the
CSearchListCtrl wxDataViewCtrl port):

1. Grouped results only showed the filename column until expanded.
   wxDataViewModel::HasValue() defaults to drawing just column 0 for
   a container (HasContainerColumns() defaults to false) -- a
   wxListCtrl had no notion of container rows, so this only surfaced
   with the port. A grouped parent is a real result carrying the
   same size/sources/rating as any other row, not a section header,
   so CSearchListModel::HasContainerColumns() now returns true.

2. Grouped results never got an expander triangle on GTK or MSW
   (confirmed by got3nks building this branch on Ubuntu 26.04 arm64
   and Windows 11 arm64 -- child rows were completely unreachable on
   both). CSearchList::AddToList's duplicate-result path calls
   item->AddChild(toadd) then only Notify_Search_Update_Sources(item),
   which reaches the model as ItemChanged(parent) -- a *value*
   notification. Leaf-to-container is a *structural* change, which
   the wxDataViewModel notifier API signals via ItemAdded/ItemDeleted
   instead. macOS masked this: its NSOutlineView backend re-queries
   IsContainer() on every draw, so the triangle appeared as soon as
   HasChildren() flipped regardless of notification. GTK's
   GtkTreeView and the MSW generic implementation both maintain their
   own node tree and only learn container-ness from structural
   notifications, which this path never sent. Fixed by also calling
   Notify_Search_Add_Result(toadd) -- routes through the existing
   CSearchDlg::AddResult -> CSearchListCtrl::AddResult ->
   CSearchListModel::NotifyFileAdded path, which already handles a
   child correctly (ItemAdded(parent, child)) once actually called.

3. NotifyFileRemoved() didn't gate on the same ShouldShow() check
   NotifyFileAdded() does, so a result that was filtered out (and
   therefore never added to the control) could still get an
   ItemDeleted call for a row the control never knew about. Now
   symmetric.

4. A comment in CSearchList::RemoveResults still named SetItemPtrData
   and m_filteredOut, both gone with the port; updated to describe
   the current mechanism (wxDataViewItem IDs are the raw CSearchFile
   pointers).

Verified: full `amule` target builds clean (macOS); clang-format v18
(pinned) applied; clang-tidy CI replica (Tier-1 whole-tree + Tier-2
changed-lines) both clean.
@got3nks

got3nks commented Aug 5, 2026

Copy link
Copy Markdown

Reviewed 30b83ac7f. Point 1 is right as committed. Two problems in the other two, same root cause, plus a perf note.

1. Gating ItemDeleted on ShouldShow() can skip a delete for a row that exists — the use-after-free RemoveResults guards against. ShouldShow() reduces to IsFiltered(), which isn't stable for a given file:

if (result && m_filterKnown) {
    result = file->GetDownloadStatus() == CSearchFile::NEW;
}

With "filter known files" on, a result shown while NEW stops passing the moment the user starts downloading it — no Cleared() in between, so the row stays on screen. When the search is later removed, NotifyFileRemoved returns early, no ItemDeleted is sent, and RemoveResults frees the CSearchFile under a row the control still holds by pointer.

2. Notify_Search_Add_Result(child) can add a child under a parent the control was never told about. A parent that fails the filter and has no children yet isn't announced — ShouldShow() is just IsFiltered() at that point. When a filter-passing variant arrives later, ItemAdded(ToItem(parent), ToItem(child)) runs with an unknown parent, which is invalid precisely on the backends this fix is for.

Both are the same mistake: deciding what to notify by re-evaluating a predicate, when the question is what the control was actually told. Those diverge as soon as the predicate depends on mutable state (download status) or on data that arrives later (children).

Fix: track announcement rather than recomputing it — a std::set<const CSearchFile*> in the model, or a flag on CSearchFile. NotifyFileAdded inserts on success, announcing the parent first if it isn't in the set; NotifyFileRemoved sends ItemDeleted if and only if the item is in it; Cleared() empties it.

Both need an active filter, so they're edge cases — but the first is a crash, and "filter known files" plus starting a download is an ordinary sequence.

3. The child path now recomputes the tab label on every duplicate. Notify_Search_Add_Result reaches CSearchDlg::AddResultUpdateHitCount, which walks the search's entire result list twice (GetItemCount() and GetHiddenItemCount(), with ShouldShow() looping each file's children inside). That already ran per unique result; it now runs per duplicate as well, so a heavily grouped search pays it far more often. It isn't wasted — a new child can change the hidden count — but it's worth coalescing (dirty flag, recompute on idle or a timer) rather than synchronously per result.

@got3nks

got3nks commented Aug 5, 2026

Copy link
Copy Markdown

Built this on Ubuntu 26.04 arm64 (wxGTK 3.2.9) and worked through the failures with a debug build. Five distinct problems, all reproduced and all fixed in the diff below — the first is a crash, and two of them aren't GTK-specific.

1. Use-after-free on every grouped search (crash). CSearchFile::AddChild() merges a duplicate filename into an existing result and deletes the object it was handed, on two of its four paths. Notify_Search_Add_Result(toadd) afterwards passes that freed pointer onward — CSearchDlg::AddResult() dereferences it to pick the tab, then the model hands it to ItemAdded() as an item ID. Duplicate filenames are the common case in search results, so this faults within seconds of a search on GTK. Guarded by checking whether the child count actually grew.

2. Groups never gain an expander until something forces a rebuild. A result is announced when it arrives, before it has children, so the control records it as a leaf. Nothing afterwards makes wxGTK re-derive container-ness: ItemChanged is a value notification, and deleting and re-adding the row doesn't do it either — I tried both. Only Cleared() works, which is why toggling the filter fixed the display. But a reset can't be interleaved with incremental Item* calls in the same burst without leaving the tree inconsistent, so the model now marks itself dirty on every arrival and OnIdle flushes one rebuild, with selection and expansion captured and restored around it.

3. Dead expanders. IsContainer() asked HasChildren() while GetChildren() yields only children passing the filter, so a group whose variants were all filtered out drew an expander that opened onto nothing. Now both ask the same question.

4. Sort order is never restored (not GTK-specific). CListColumnStore::LoadSettings() returns the orders primary-last — its doc comment says so, and CMuleListCtrl consumed them by calling SetSorting() on each in turn, each pushing to the front. LoadColumnSettings() instead takes m_sort_orders.front() as the primary, which is the least significant entry. With TableOrderingSearch=Y:0:0,R:1:0,N:0:0,u:1:1 saved, sorting by Sources came back as a sort by Type. Fixed by replaying the decoded list through ApplySorting() in order.

5. No sort indicator in the header (not GTK-specific). The columns are appended wxDATAVIEW_COL_RESIZABLE only; wx won't render a sort caret on a column that isn't wxDATAVIEW_COL_SORTABLE. Adding the flag brings the caret back, and the custom click cycle — ascending, descending, alt-sort — still behaves. If it was omitted deliberately to keep wx's native sorting out of the way, that's worth a comment, since it currently reads as an oversight.

Patch (applies to 30b83ac)
diff --git a/src/SearchList.cpp b/src/SearchList.cpp
index 1ed9f9205..d4177bffa 100644
--- a/src/SearchList.cpp
+++ b/src/SearchList.cpp
@@ -816,7 +816,12 @@ bool CSearchList::AddToList(CSearchFile *toadd, bool clientResponse)
 				CFormat("Received duplicate results for '%s' : %s") % item->GetFileName() %
 					item->GetFileHash().Encode());
 			// Add the child, possibly updating the parents filename.
+			const size_t childrenBefore = item->GetChildren().size();
 			item->AddChild(toadd);
+			// AddChild MERGES a duplicate filename into an existing result
+			// and `delete`s the file it was handed -- notifying with `toadd`
+			// after that reads freed memory.
+			const bool survived = item->GetChildren().size() > childrenBefore;
 			// Structural change (leaf-or-nothing -> container, or a new row
 			// under an existing container) needs its own notification --
 			// Search_Update_Sources only signals that the parent's values
@@ -826,7 +831,9 @@ bool CSearchList::AddToList(CSearchFile *toadd, bool clientResponse)
 			// becomes reachable there. Native NSOutlineView survives the
 			// omission by re-querying IsContainer() on every draw, which
 			// masked this on macOS.
-			Notify_Search_Add_Result(toadd);
+			if (survived) {
+				Notify_Search_Add_Result(toadd);
+			}
 			Notify_Search_Update_Sources(item);
 			return true;
 		}
diff --git a/src/SearchListCtrl.cpp b/src/SearchListCtrl.cpp
index f27d017ee..12348d839 100644
--- a/src/SearchListCtrl.cpp
+++ b/src/SearchListCtrl.cpp
@@ -105,44 +105,44 @@ CSearchListCtrl::CSearchListCtrl(
 		wxDATAVIEW_CELL_INERT,
 		500,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	AppendTextColumn(_("Size"),
 		CSearchListModel::COL_SIZE,
 		wxDATAVIEW_CELL_INERT,
 		100,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	AppendTextColumn(_("Sources"),
 		CSearchListModel::COL_SOURCES,
 		wxDATAVIEW_CELL_INERT,
 		50,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	AppendTextColumn(_("Type"),
 		CSearchListModel::COL_TYPE,
 		wxDATAVIEW_CELL_INERT,
 		65,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	// Rating: smiley icon + text label in one cell.
 	AppendIconTextColumn(_("Rating"),
 		CSearchListModel::COL_RATING,
 		wxDATAVIEW_CELL_INERT,
 		120,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	AppendTextColumn(_("FileID"),
 		CSearchListModel::COL_FILEID,
 		wxDATAVIEW_CELL_INERT,
 		280,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	AppendTextColumn(_("Status"),
 		CSearchListModel::COL_STATUS,
 		wxDATAVIEW_CELL_INERT,
 		100,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	// Media tag columns: ed2k/Kad publishers (eMule, eMule AI, aMule) can
 	// advertise per-file media metadata in FT_MEDIA_LENGTH / _BITRATE /
 	// _CODEC. Cells stay empty for non-media results.
@@ -151,19 +151,19 @@ CSearchListCtrl::CSearchListCtrl(
 		wxDATAVIEW_CELL_INERT,
 		80,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	AppendTextColumn(_("Bitrate"),
 		CSearchListModel::COL_BITRATE,
 		wxDATAVIEW_CELL_INERT,
 		80,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	AppendTextColumn(_("Codec"),
 		CSearchListModel::COL_CODEC,
 		wxDATAVIEW_CELL_INERT,
 		80,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 	// Directories is almost always empty (only populated when the result
 	// came from a "view shared files" request, rare in practice), so put
 	// it at the end with the other usually-empty columns.
@@ -173,7 +173,7 @@ CSearchListCtrl::CSearchListCtrl(
 		wxDATAVIEW_CELL_INERT,
 		280,
 		wxALIGN_LEFT,
-		wxDATAVIEW_COL_RESIZABLE);
+		wxDATAVIEW_COL_RESIZABLE | wxDATAVIEW_COL_SORTABLE);
 
 	m_columnStore.RegisterColumn(CSearchListModel::COL_NAME, 500, "N");
 	m_columnStore.RegisterColumn(CSearchListModel::COL_SIZE, 100, "Z");
@@ -237,17 +237,22 @@ void CSearchListCtrl::LoadColumnSettings()
 	CListColumnStore::CSortingList decoded;
 	m_columnStore.LoadSettings(m_widthAdapter, "N,Z,u,Y,I,S", decoded);
 
+	// LoadSettings() returns the orders primary-LAST: CMuleListCtrl applied
+	// them by calling SetSorting() on each in turn, and each call pushes to
+	// the front, so the last one processed ends up primary. ApplySorting()
+	// has the same push-to-front semantics, so replaying them in order
+	// reproduces that -- taking front() as the primary instead picks the
+	// least significant entry.
 	m_sort_orders.clear();
 	for (const CListColumnStore::CColPair &pair : decoded) {
-		m_sort_orders.emplace_back(pair.first, pair.second);
+		ApplySorting(pair.first, pair.second);
 	}
 	if (m_sort_orders.empty()) {
-		m_sort_orders.emplace_back(CSearchListModel::COL_NAME, 0);
+		ApplySorting(CSearchListModel::COL_NAME, 0);
 	}
+	return;
+
 
-	const CColPair &primary = m_sort_orders.front();
-	GetColumn(primary.first)->SetSortOrder(!(primary.second & SORT_DES));
-	GetModel()->Resort();
 }
 
 void CSearchListCtrl::SaveColumnSettings()
@@ -626,6 +631,46 @@ void CSearchListCtrl::OnIdle(wxIdleEvent &event)
 {
 	event.Skip();
 
+	// One coalesced rebuild per idle for everything that arrived since the
+	// last one. Cleared() throws away the control's own view state,
+	// so selection and expansion are captured and re-applied around it --
+	// otherwise a result landing mid-search deselects whatever the user had
+	// picked. Items are CSearchFile*, still valid across the rebuild; the ones
+	// that went away are dropped by re-checking membership after it.
+	if (m_model->HasPending()) {
+		wxDataViewItemArray selected;
+		GetSelections(selected);
+		wxDataViewItemArray expanded;
+		{
+			wxDataViewItemArray roots;
+			m_model->GetChildren(wxDataViewItem(), roots);
+			for (size_t i = 0; i < roots.GetCount(); ++i) {
+				if (IsExpanded(roots[i])) {
+					expanded.Add(roots[i]);
+				}
+			}
+		}
+
+		m_model->FlushPending();
+
+		wxDataViewItemArray live;
+		m_model->GetChildren(wxDataViewItem(), live);
+		for (size_t i = 0; i < expanded.GetCount(); ++i) {
+			if (live.Index(expanded[i]) != wxNOT_FOUND) {
+				Expand(expanded[i]);
+			}
+		}
+		wxDataViewItemArray restore;
+		for (size_t i = 0; i < selected.GetCount(); ++i) {
+			if (live.Index(selected[i]) != wxNOT_FOUND) {
+				restore.Add(selected[i]);
+			}
+		}
+		if (!restore.IsEmpty()) {
+			SetSelections(restore);
+		}
+	}
+
 	// No portable wxDataViewCtrl "column resized" event exists to hook
 	// directly (unlike wxListCtrl's EVT_LIST_COL_END_DRAG), so a drag-resize
 	// is detected here by simply comparing against the last-seen widths.
diff --git a/src/SearchListModel.cpp b/src/SearchListModel.cpp
index 615968696..75e796eee 100644
--- a/src/SearchListModel.cpp
+++ b/src/SearchListModel.cpp
@@ -43,42 +43,36 @@ CSearchListModel::CSearchListModel(CSearchListCtrl *owner)
 {
 }
 
-void CSearchListModel::NotifyFileAdded(CSearchFile *file)
+void CSearchListModel::NotifyFileAdded(CSearchFile *)
 {
-	if (!m_owner->ShouldShow(file)) {
-		return;
-	}
-	CSearchFile *parent = file->GetParent();
-	ItemAdded(parent ? ToItem(parent) : wxDataViewItem(), ToItem(file));
+	MarkDirty();
 }
 
-void CSearchListModel::NotifyFileRemoved(CSearchFile *file)
+void CSearchListModel::NotifyFileRemoved(CSearchFile *)
 {
-	// Mirror NotifyFileAdded's gate: a file the control was never told about
-	// (filtered out at add time) must not be deleted from it either -- that
-	// gate is exactly ShouldShow(), the same test GetChildren() itself uses
-	// to decide what's visible (root items via ShouldShow(); children via
-	// PassesFilter(), which ShouldShow() reduces to for a childless file).
-	if (!m_owner->ShouldShow(file)) {
-		return;
-	}
-	CSearchFile *parent = file->GetParent();
-	ItemDeleted(parent ? ToItem(parent) : wxDataViewItem(), ToItem(file));
+	MarkDirty();
 }
 
-void CSearchListModel::NotifyFileUpdated(CSearchFile *file)
+void CSearchListModel::NotifyFileUpdated(CSearchFile *)
 {
-	ItemChanged(ToItem(file));
+	MarkDirty();
 }
 
 void CSearchListModel::NotifyFilterChanged()
 {
-	// The set of visible root items (and which parents count as containers)
-	// may have changed arbitrarily; a full reset is the only thing that's
-	// guaranteed correct here, at the cost of resetting expand state -- the
-	// same cost EnableFiltering()/SetFilter() already paid via
-	// DeleteAllItems()-equivalent behaviour before this port.
+	// User action: reset now rather than waiting for idle.
+	m_pendingReset = false;
+	Cleared();
+}
+
+bool CSearchListModel::FlushPending()
+{
+	if (!m_pendingReset) {
+		return false;
+	}
+	m_pendingReset = false;
 	Cleared();
+	return true;
 }
 
 unsigned int CSearchListModel::GetColumnCount() const
@@ -233,7 +227,17 @@ bool CSearchListModel::IsContainer(const wxDataViewItem &item) const
 	if (!item.IsOk()) {
 		return true; // invisible root
 	}
-	return ToFile(item)->HasChildren();
+	// Must agree with GetChildren(), which only yields children that pass the
+	// filter: answering "has children" here for a group whose variants are all
+	// filtered out draws an expander that opens onto nothing.
+	const CSearchFile *file = ToFile(item);
+	const CSearchResultList &kids = file->GetChildren();
+	for (const CSearchFile *kid : kids) {
+		if (m_owner->PassesFilter(kid)) {
+			return true;
+		}
+	}
+	return false;
 }
 
 bool CSearchListModel::HasContainerColumns(const wxDataViewItem &WXUNUSED(item)) const
diff --git a/src/SearchListModel.h b/src/SearchListModel.h
index 21a0bd8e4..478758b72 100644
--- a/src/SearchListModel.h
+++ b/src/SearchListModel.h
@@ -70,6 +70,14 @@ public:
 	//! tree should be re-evaluated (some rows may appear/disappear).
 	void NotifyFilterChanged();
 
+	//! Results arrive in bursts, and mixing incremental Item*
+	//! notifications with the full Cleared() that a group formation requires
+	//! leaves the control's tree inconsistent. Every arrival now just marks
+	//! the model dirty; the control flushes one reset per idle.
+	void MarkDirty() { m_pendingReset = true; }
+	bool FlushPending();
+	bool HasPending() const { return m_pendingReset; }
+
 	static CSearchFile *ToFile(const wxDataViewItem &item)
 	{
 		return static_cast<CSearchFile *>(item.GetID());
@@ -120,6 +128,7 @@ public:
 
 private:
 	CSearchListCtrl *m_owner;
+	bool m_pendingReset = false;
 };
 
 #endif // SEARCHLISTMODEL_H

Testing. All five reproduced and verified fixed on Linux/GTK against live searches. Windows was built with the first three but not visually checked; macOS never exhibited 1–3, because NSOutlineView re-derives the tree every draw — but 4 and 5 are platform-independent and should reproduce there too.

Two caveats on the patch. The use-after-free guard infers deletion from a child-count delta; cleaner would be for AddChild() to report what it did rather than callers deducing it. And the coalesced rebuild is a design change rather than a fix — it's the shape that worked after several narrower attempts failed, but it's your call whether to take it, throttle it, or find a lighter way to force container re-derivation.

@got3nks

got3nks commented Aug 5, 2026

Copy link
Copy Markdown

Two of my comments above review the same commit (30b83ac7f) with different prescriptions. The later one supersedes the earlier — implementing both would be wrong.

Withdrawing point 1 of the earlier comment (the ItemDeleted use-after-free). It isn't reachable. CSearchListModel::NotifyFileRemoved has no callers: its only route in was CSearchListCtrl::RemoveResult, whose sole pre-port caller was ShowChildren() — the flat-list expand/collapse emulation this port correctly retires. And CSearchList::RemoveResults doesn't take that path at all, it fires Notify_Search_Removed and closes the tab before freeing anything. The ShouldShow() gate there is latent-wrong, not a crash, and I overstated it.

Concrete follow-up instead: CSearchListCtrl::RemoveResult and CSearchListModel::NotifyFileRemoved are dead code after this port and can be deleted.

Point 2 of that comment (child announced under a parent the control never heard about) was real against 30b83ac7f, but the later patch removes the mechanism rather than repairing it — NotifyFileAdded/Removed/Updated no longer gate or emit Item* at all; they mark the model dirty and one Cleared() per idle re-derives the tree from GetChildren(). So the std::set announcement-tracking suggestion is moot — it solves a problem the later patch deletes.

Still open, and not addressed by that patch: point 3, UpdateHitCount recomputing per duplicate. Notify_Search_Add_Result still fires on the surviving-child path, so a heavily grouped search keeps paying the double list walk per duplicate.

Testing correction. The later comment said Windows was built with the first three fixes but not visually checked. Windows ARM64 has since been rebuilt with all five: points 4 and 5 (sort restore and the header caret) are confirmed fixed there. 1–3 are in that build, but the visual pass concentrated on sorting.

Unrelated to this PR: fast touchpad scrolling bounces the view back toward the top on Windows. It reproduces on the Shared Files list, which this PR doesn't touch — both it and wxDataViewCtrl use wx's generic implementation on MSW, where accumulated wheel rotation overflows a 16-bit field. Pre-existing, and not something to chase here.

LSalami and others added 2 commits August 5, 2026 12:33
Addresses five issues got3nks found reviewing 30b83ac on real Ubuntu
26.04 arm64 (wxGTK 3.2.9) and Windows 11 arm64 builds, worked through
with a debug build -- the first is a crash.

1. Use-after-free on every grouped search. CSearchFile::AddChild()
   merges a duplicate filename into an existing result and deletes the
   object it was handed, on two of its four paths.
   Notify_Search_Add_Result(toadd) afterwards passed that freed
   pointer onward -- CSearchDlg::AddResult() dereferences it to pick
   the tab, then the model hands it to ItemAdded() as an item ID.
   Duplicate filenames are the common case in search results, so this
   faulted within seconds of a search on GTK. Fixed by checking
   whether the child count actually grew before notifying (AddChild()
   itself doesn't report what it did).

2. Groups never gained an expander until something forced a rebuild.
   A result is announced when it arrives, before it has children, so
   the control records it as a leaf. Nothing afterwards made wxGTK
   re-derive container-ness: ItemChanged() is a value notification,
   and deleting and re-adding the row doesn't do it either -- both
   were tried. Only Cleared() works, which is why toggling the filter
   used to "fix" the display. A full reset can't be interleaved with
   incremental Item* calls in the same burst without leaving the tree
   inconsistent, so every notification now just marks the model dirty
   (CSearchListModel::MarkDirty()) and CSearchListCtrl::OnIdle()
   flushes one coalesced Cleared() rebuild, with selection and
   expansion captured and restored around it so a result landing
   mid-search doesn't reset what the user had picked.

3. Dead expanders: IsContainer() asked HasChildren() while
   GetChildren() yields only children passing the filter, so a group
   whose variants were all filtered out drew an expander that opened
   onto nothing. Both now ask the same question.

4. Sort order was never restored correctly. CListColumnStore::
   LoadSettings() returns the decoded orders primary-LAST (its own
   doc comment says so); CMuleListCtrl consumed them by calling
   SetSorting() on each in turn, each push-to-front, so the last one
   processed ends up primary. CSearchListCtrl::LoadColumnSettings()
   instead built the chain directly and took front() as primary --
   the *least* significant entry. Fixed by replaying the decoded list
   through ApplySorting() in order, matching the original semantics.

5. No sort indicator in the column headers: columns were appended
   with wxDATAVIEW_COL_RESIZABLE only: wx won't render a sort caret on
   a column that isn't also wxDATAVIEW_COL_SORTABLE. Added the flag to
   all 11 columns; the custom click cycle (ascending/descending/alt)
   still drives the actual sort, this only restores the visual cue.

Also fixes an unrelated narrowing-conversion warning clang-tidy
flagged nearby (OnRightClick's per-category menu item IDs).

Verified: full `amule` target builds clean (macOS); clang-format v18
(pinned) applied; clang-tidy CI replica (Tier-1 whole-tree + Tier-2
changed-lines) both clean. Interactive testing (cliclick + macOS
Accessibility API against the isolated test instance's live ed2k
connection): no crash across repeated live searches producing
duplicate/grouped results (the exact use-after-free trigger); a group
forming live during a search shows its expander immediately, with no
need to toggle the filter; sorting by a non-default column (Sources)
persists and is correctly restored as primary after a full app
restart (previously silently reverted to Name).
Two follow-ups from the PR amule-org#796 review that the previous commit didn't
cover.

CSearchDlg::UpdateHitCount() walks the search's whole result list twice
-- GetItemCount() and GetHiddenItemCount(), each looping every result's
children through ShouldShow()/IsFiltered() -- and it ran once per
arriving result, so a search was quadratic in its own result count. The
child path made it worse: since the notification rework, a duplicate
result also reaches CSearchDlg::AddResult, so a heavily grouped search
paid the double walk per duplicate as well.

Results arrive in bursts and only a burst's final label is ever seen, so
AddResult/UpdateResult now just mark the tab pending and one recompute
per idle flushes them -- the same coalescing CSearchListCtrl::OnIdle
already does for the tree rebuild. The flush iterates the notebook's
live pages and updates those present in the pending set, so a tab closed
between mark and flush is never matched and its pointer never followed.
The other UpdateHitCount() call sites are user actions (tab change,
filter toggle, browse status) and stay immediate.

CSearchDlg needs wxWS_EX_PROCESS_IDLE for any of that to run: amuleDlg
sets wxIdleEvent::SetMode(wxIDLE_PROCESS_SPECIFIED), so idle events only
reach windows that opt in (CSearchListCtrl already does for its own
OnIdle). Without the style the flush never fires and the tab label stops
updating entirely -- it builds and lints clean either way.

CSearchListCtrl::RemoveResult() and CSearchListModel::NotifyFileRemoved()
are dead after this port: their only caller was ShowChildren(), the flat
-list expand/collapse emulation wxDataViewCtrl makes unnecessary, and
CSearchList::RemoveResults() never took that path -- it fires
Notify_Search_Removed and closes the tab before freeing anything.
Removed both. (This also retires the ShouldShow() gate an earlier review
comment of mine flagged as a use-after-free; the gate was latent-wrong
rather than reachable, since nothing called into it.)
@got3nks

got3nks commented Aug 5, 2026

Copy link
Copy Markdown

Pushed 25d103aae onto this branch with the two follow-ups from my last comment.

Dead code removed. CSearchListCtrl::RemoveResult() and CSearchListModel::NotifyFileRemoved() have no callers after the port: the only route in was ShowChildren(), the flat-list expand/collapse emulation wxDataViewCtrl makes unnecessary. Swept the whole tree including the #ifdef-gated remote-GUI paths — CSearchListRem::RemoveResults() is a different method.

Hit-count recompute coalesced. UpdateHitCount() walks the result list twice, each pass looping every result's children, and it ran once per arriving result — quadratic over a search, and since the notification rework it ran per duplicate as well. AddResult/UpdateResult now mark the tab pending and one recompute per idle flushes it, mirroring the coalescing CSearchListCtrl::OnIdle already does for the tree rebuild. The flush iterates the notebook's live pages and updates those in the pending set, so a tab closed between mark and flush is never matched and its pointer never followed. The other five call sites are user actions (tab change, filter toggle, browse status) and stay immediate.

One trap worth knowing if you touch idle handling here: amuleDlg sets wxIdleEvent::SetMode(wxIDLE_PROCESS_SPECIFIED), so idle events only reach windows carrying wxWS_EX_PROCESS_IDLE. CSearchListCtrl opts in for its own OnIdle; CSearchDlg didn't, so the flush silently never ran — it builds and lints clean either way, and the only symptom is the tab counter freezing mid-search. The commit adds the style.

Verified on macOS and Ubuntu 26.04 arm64 (wxGTK 3.2.9); Windows ARM64 is still building. clang-format and both clang-tidy tiers clean on the diff.

got3nks added 4 commits August 5, 2026 14:01
Catalog conflict only: master picked up a Weblate translation round
while this branch had regenerated the catalogs after dropping the dead
"Mark as known file" menu entry, so all 41 po/ files collided. No source
conflicts.

Resolved by taking master's catalogs -- keeping the new Weblate
translations -- and re-running scripts/update-po.sh against the merged
source, which re-applies this branch's string removal. "Mark as known
file" moves back to an obsolete (#~) entry with its translation
retained, and the only other change is POT-Creation-Date.
wxDataViewCtrl defaults to single selection; the wxListCtrl it replaces
was multi-select unless given wxLC_SINGLE_SEL, and the pre-port
construction site passed wxLC_REPORT | wxNO_BORDER, so it was on.

Without wxDV_MULTIPLE none of the list's multi-selection behaviour
works: downloading, downloading into a category, copying eD2k links,
and the related-search/comments entries all iterate GetSelections(),
and OnRightClick still enables the single-selection-only items via
GetSelectedItemCount() == 1 -- a test that can never be false.
The menu entry that reached OnMarkAsKnown was removed with the port --
it had been sitting behind #if 0 since long before, with a comment
warning it might break known.met -- but the handler, its event-table
binding and the MP_MARK_AS_KNOWN id survived, so nothing can invoke it.
Removed all three, plus the KnownFileList.h include that was only there
for it. MP_ASSIGNCAT is anchored at MP_LISTCOL_15 + 1, so dropping an
earlier enumerator doesn't shift the category id range.

Verified both variants build: the removed body was inside
#ifndef CLIENT_GUI, so amule and amulegui were checked separately.
CMuleListCtrl::OnChar() gave every list type-to-select: typing jumps the
selection to the first row whose name starts with what was typed,
accumulating keystrokes until a 1.5s pause resets them. The port lost it
along with the rest of that handler, and the wxDataViewCtrl backends
disagree about what to do instead -- GTK pops up its own interactive
search box, macOS and MSW do nothing at all -- so the three ports no
longer behaved alike.

Reimplemented on CSearchListCtrl so all three match, and so GTK's search
popup no longer surfaces (the handler consumes the keystroke rather than
skipping it).

Matching walks the top-level rows through this list's own comparator,
the one CSearchListModel::Compare() already uses, so the jump order
follows what is actually on screen under the current sort.
GetItemByRow()/GetRowByItem() would be the direct route but exist only
in wx's generic implementation, not on GTK or macOS.

Navigation and shortcut keys are skipped to the backend: page up/down
keeps its per-platform behaviour (macOS scrolls without moving the
selection, by convention), and select-all stays native.
@LSalami

LSalami commented Aug 5, 2026

Copy link
Copy Markdown
Author

Pulled 25d103aae/db7a34156 and the master merge, rebuilt clean locally (macOS). Agreed on both: RemoveResult()/NotifyFileRemoved() were genuinely dead after the port, and the hit-count coalescing mirrors the tree-rebuild pattern well. Good catch on wxDV_MULTIPLE -- missed that the pre-port style flags carried multi-select implicitly.

Nothing further from me right now; CI looks green apart from mingw-w64 and clang-tidy still running.

Only wxGTK's backend implements select-all for wxDataViewCtrl; macOS and
MSW leave the shortcut unhandled, so the result list lost it on two of
three ports. CMuleListCtrl::OnChar() implemented it explicitly for the
same reason, so do it here too rather than relying on the backend.

A control-modified 'a' arrives as SOH on most ports -- the test the old
handler used -- but the letter is accepted as well so the shortcut can't
go missing on a port that reports it differently. Every other modified
key still goes to the backend, which keeps shift+page-up/down extending
the selection where the platform supports it.
@got3nks

got3nks commented Aug 5, 2026

Copy link
Copy Markdown

Pushed four more commits after testing the port on all three platforms. Three of them are regressions the port introduced by inheriting wxDataViewCtrl's defaults where the old wxListCtrl behaved differently — worth calling out as a class, since they were found one at a time by chance rather than by looking.

db7a34156 — multi-selection. wxDataViewCtrl defaults to single selection; wxListCtrl was multi-select unless given wxLC_SINGLE_SEL, and the pre-port construction site passed wxLC_REPORT | wxNO_BORDER. Without wxDV_MULTIPLE every multi-selection action silently reduced to one item — download, download-into-category, copy eD2k links, related search — and OnRightClick's GetSelectedItemCount() == 1 guard could never be false.

b3866cf75 — type-to-select. CMuleListCtrl::OnChar() gave every list type-ahead: typing jumps the selection to the first row whose name starts with what was typed, resetting after a 1.5 s pause. The backends disagree about what to do instead — GTK pops up its own interactive-search box, macOS and MSW do nothing — so it's reimplemented on CSearchListCtrl and the three ports match again. Matching walks the top-level rows through the list's own CompareFiles(), so the jump follows the displayed order under the current sort; GetItemByRow()/GetRowByItem() would be the direct route but exist only in wx's generic implementation, not on GTK or macOS.

609ede048 — Cmd/Ctrl+A select-all. Only wxGTK implements it for wxDataViewCtrl, so it was gone on macOS and Windows. CMuleListCtrl::OnChar() implemented it explicitly for the same reason.

1f635882a — dead Mark as known file handler. The menu entry went with the port (it had been behind #if 0 for years, with a comment warning it might corrupt known.met), but the handler, its event-table binding and the MP_MARK_AS_KNOWN id survived with nothing able to reach them. Removed, along with the KnownFileList.h include that existed only for it. MP_ASSIGNCAT is anchored at MP_LISTCOL_15 + 1, so dropping an earlier enumerator doesn't shift the category id range.

Page Up/Down is deliberately left alone: it moves the selection on GTK and MSW but scrolls without moving it on macOS, which is the platform convention (Finder, Mail). Shift+PgUp/Dn extends the selection on Linux and Windows. Navigation and other shortcut keys are skipped to the backend so this stays true.

Also merged master in (ea979a44f) — the conflict was catalogs only, resolved by taking master's Weblate round and re-running scripts/update-po.sh against the merged source.

Verified interactively on macOS, Ubuntu 26.04 arm64 (wxGTK 3.2.9) and Windows 11 arm64: multi-select, type-to-select, select-all, sorting and grouping all behave the same on the three ports. clang-format and both clang-tidy tiers clean throughout; amule and amulegui built separately where #ifndef CLIENT_GUI was involved.

@got3nks
got3nks merged commit deae03b into amule-org:master Aug 5, 2026
14 checks passed
@LSalami
LSalami deleted the search-dataview-phase2-port branch August 5, 2026 14:05
got3nks added a commit that referenced this pull request Aug 5, 2026
#796 ported the search list to wxDataViewCtrl and #805 fixed what that
port left behind. #801 asked whether the remaining lists should each be
ported independently or share a base, and deferred the answer until the
shape was clear from more than one list. It now is.

CMuleDataViewCtrl is the wxDataViewCtrl counterpart of CMuleListCtrl:
column widths and their persistence, the header show/hide menu, hidden
state, the multi-column sort chain, type-to-select, Cmd/Ctrl+A, the
macOS shifted page/home/end keys, the trailing-column spacer, and
drag-resize detection. It owns no data; a list supplies its rows through
GetDisplayOrder(), their label through GetRowLabel(), and how two of
them compare through CompareByColumn().

CMuleVirtualDataViewCtrl is the counterpart of CMuleVirtualListCtrl,
and virtual in the same sense: rows are addressed by index through a
wxDataViewIndexListModel and nothing is materialised per row. It carries
the item-identity bookkeeping an identity-addressed port avoids -- a
wxDataViewItem from a row-addressed model encodes the row number, so a
deletion silently retargets any item held across it, which is why
everything here speaks in wxUIntPtr and re-resolves selection after each
mutation. Also the legacy filter API, live re-sort coalesced through one
CallAfter and deferred while the user is interacting, bulk append and
batch removal, and icon columns.

CSearchListCtrl moves onto the plain base, losing 499 lines.
got3nks pushed a commit that referenced this pull request Aug 7, 2026
…) (#830)

* feat(gui): port CFriendListCtrl to CMuleVirtualDataViewCtrl (#180, #801)

Continues the wxListCtrl -> wxDataViewCtrl migration (#180/#801) onto
the shared base PR #811 extracted after the Servers port (#807, now
absorbed into #811). CFriendListCtrl was picked next after reading all
the remaining candidates directly rather than going by line count
alone: it has no CBarShader rendering (unlike Downloads/SharedFiles/
Sources-Peers, which additionally need a still-nonexistent
wxDataViewCustomRenderer), it's already pointer-identity addressed
(UpdateFriend(CFriend*)/RemoveFriend(CFriend*) map directly onto
AddItemData/RefreshItemData/RemoveItemData), and its blast radius is
tiny (ChatWnd.cpp, muuli_wdr.cpp construction only).

CFileDetailListCtrl looked smaller by line count but its caller
(FileDetailDialog.cpp) drives it with raw position-indexed CRUD
(FindItem by name, SetItem by column index, DeleteItem) plus a
pre-existing duplicate-type quirk (two unrelated SourcenameItem
structs relying on compatible layout) -- porting it cleanly means
refactoring the caller too, which is a separate, more invasive piece
of work than this one.

CServerListCtrl (current, post-#811) is the template mirrored here:
same AppendTextColumn/AppendSpacerColumn/AssociateVirtualModel/
LoadColumnSettings/InitColumnState ctor sequence, same GetItemColumnText/
GetItemAttr/CompareItemData/OnListKey hook shape. Public API
(UpdateFriend, RemoveFriend) and the constructor signature are
unchanged, so ChatWnd.cpp and muuli_wdr.cpp needed no edits.

Notable deltas from the pre-port behaviour: added a real CompareItemData
so header-click sort now works (the old list never called SetSortFunc
at all); GetItemAttr replaces the old SetItemTextColour call with the
same visible result (blue for linked friends, default text colour
otherwise).

Verified: builds clean; clang-format v18 (pinned Docker image) applied;
clang-tidy Tier-1 (whole-tree) and Tier-2 (changed-lines,
.clang-tidy-new-code) both clean via the ~/aMuleTest/ci-local replica
-- Tier-2 caught one real modernize-use-nullptr hit, fixed. Grepped
ChatWnd.cpp/.h and muuli_wdr.cpp to confirm no other call sites exist.
Interactive verification (sort, right-click menu states, Delete-key
removal, chat-session activation, VoiceOver) left for manual testing
per project convention, same as PR #796/#807.

* fix(gui): resolve activated friend through selection, not row-as-pointer

got3nks's review on #830 found a real crash: OnItemActivated() cast
event.GetItem()'s ID directly to CFriend* on the assumption it was the
item's data pointer, but CMuleVirtualDataViewCtrl's row-addressed
model returns the row index (+1) as that ID -- the "item identity is
not row identity" case MuleVirtualDataViewCtrl.h itself documents.
Every double-click/Enter on a friend dereferenced a bogus pointer.
Fixed by selecting the activated row and resolving it through
GetSelectedItemData(), matching CServerListCtrl::OnItemActivated.

Also from the same review: IsLiveSortColumn() now returns true, since
the name (the only sortable column) can change after a friend is
already listed and the UpdateFriend() comment claimed a re-sort that
the base's default-false hook never actually triggered; and dropped a
no-op static_cast<int>() around a call that already returns int.

Verified: builds clean, clang-format v18 applied, clang-tidy Tier-2
(changed lines, .clang-tidy-new-code) clean via the local CI replica.

* chore: retrigger CI

The previous run hit a GitHub Actions infrastructure outage (job not
acquired by any runner, "Failed to resolve action download info" /
"Service Unavailable" on clang-format, Translation checks, mingw-w64
Debug and clang-tidy Tier-1) unrelated to this branch's code -- every
job that did run passed. No admin rights to rerun the failed jobs
directly, so retriggering with an empty commit instead.
got3nks added a commit that referenced this pull request Aug 7, 2026
…#801) (#839)

* feat(gui): port CFileDetailListCtrl to CMuleVirtualDataViewCtrl (#180, #801)

Continues the wxListCtrl -> wxDataViewCtrl migration (#180/#801) onto
the shared base from #811, after Search (#796), Servers (#807/#811)
and Friends (#830). CFileDetailListCtrl was next because it has no
CBarShader rendering (unlike Downloads/SharedFiles/Sources-Peers,
which additionally need a still-nonexistent wxDataViewCustomRenderer).

Its caller, CFileDetailDialog::FillSourcenameList(), drove the old
list with raw position-indexed CRUD (FindItem by name, SetItemPtrData,
SetItem by column index, DeleteItem) rather than the pointer-identity
style CServerListCtrl/CFriendListCtrl already use, so porting the
control cleanly meant refactoring the caller too:

- CFileDetailDialog gained a std::map<wxString, SourcenameItem *>
  m_sourcenames member, replacing "search the list widget by name"
  with a map lookup. FillSourcenameList()'s reset/update/prune shape
  is otherwise unchanged, just re-keyed to the map and driven through
  the control's new AddSource/RefreshSource/RemoveSource API.
- OnBnClickedTakeOver()/OnListClickedTakeOver() resolve the selected
  row through GetSelectedItemData() (inherited from
  CMuleVirtualDataViewCtrl) instead of GetNextItem+GetItemText.
- The dialog's EVT_LIST_ITEM_ACTIVATED binding on IDC_LISTCTRLFILENAMES
  becomes EVT_DATAVIEW_ITEM_ACTIVATED; wxDataViewEvent is a
  wxNotifyEvent/wxCommandEvent descendant so it still propagates from
  the child control to the dialog-level handler the same way.
- Fixed a latent leak: nothing in ~CFileDetailDialog() freed the
  SourcenameItem objects still referenced by open rows -- only the
  "not a partfile" and "pruned to zero" paths in FillSourcenameList()
  ever deleted them. Now freed in the destructor via m_sourcenames.

CMuleDataViewCtrl always ORs in wxDV_MULTIPLE (no single-selection
mode exists on the shared base), but this list's "take over filename"
actions assume exactly one selection, same as the old list did (it
never actually requested wxLC_SINGLE_SEL either -- checked
muuli_wdr.cpp -- it enforced single-selection itself via OnSelect()
deselecting every other row). CFileDetailListCtrl::OnSelectionChanged()
does the equivalent: collapses to the just-clicked row whenever more
than one ends up selected.

Also deleted a vestigial nested SourcenameItem struct in
FileDetailListCtrl.h that duplicated (by accident of matching layout)
the real one in PartFile.h, and dropped a per-row background-colour
set that reproduced the default and carried its own "do we still need
this?" comment.

Verified: builds clean; clang-format v18 (pinned Docker image) applied;
clang-tidy Tier-1 (whole-tree) and Tier-2 (changed-lines,
.clang-tidy-new-code) both clean via the ~/aMuleTest/ci-local replica.
Grepped for other CFileDetailListCtrl/IDC_LISTCTRLFILENAMES references
to confirm muuli_wdr.cpp's construction call needed no changes.
Interactive verification (source list populate/re-tally, take-over via
button and double-click, single-selection enforcement, sort, VoiceOver)
left for manual testing per project convention.

* fix(gui): keep the source-name list sorted, and drop rows before freeing them

Review follow-up to the port.

FillSourcenameList() zeroes every count and then rewrites them in place, so
while it runs the list is not ordered by the column it is sorted on -- and
AddSource() places a new row with a binary search, which needs that ordering
to hold. An insertion therefore leaves rows in arbitrary positions, and the
repair has to happen here, exactly as the pre-port code did.

Only on insertion, though. A count that merely changed is what the live-sort
preference governs: RefreshSource() re-sorts when it is on, and when it is
off the row is meant to stay put rather than move under the user. Sorting
unconditionally would quietly override that setting for this list.

The destructor and the not-a-partfile path also freed the SourcenameItem
objects while the list control still held pointers to them. Nothing can
paint or sort in either window today -- both dialog call sites are stack
temporaries, so destruction is synchronous -- but that rests on wx's teardown
order rather than on anything guaranteed, and the base states the rule
plainly: it has to be told before the caller frees the item data. Both now
clear the rows first, as the prune loop already did.

* fix(gui): decide the source list's live re-sort per column

IsLiveSortColumn() answered true for every column, so a refresh tick
scheduled a re-sort even when the list was sorted by File Name -- a value
that never changes, since the name is the key each row was created under.

Answer per column instead, the shape CServerListCtrl already uses, so only a
sources-sorted list re-sorts on its own. The header comment already
described it this way; only the implementation did not.

* chore: drop a duplicated paragraph from the file header

FileDetailDialog.cpp carried the "Any parts of this program derived from
the xMule, lMule or eMule project" paragraph twice. It is the only file in
src/ that does, and the file is already being touched here.

---------

Co-authored-by: got3nks <[email protected]>
got3nks added a commit that referenced this pull request Aug 8, 2026
Three ways the ported lists threw away the row the user was reading.

SortList() ended in wxDataViewIndexListModel::Reset(), which says the
model was replaced; every backend rebuilds and lands at row 0, both
axes. A sort reorders rows without replacing them and the count is the
same on both sides of the std::sort, so a repaint is all it needs. The
bulk path is the exception: AppendItemData() adds rows without telling
the control -- that is what makes a bulk load cheap -- so Reset() was
doubling as the only notification that the rows existed, and repainting
there drew a list the control still believed was empty. The sort now
splits into SortItems(), which is silent, SortList(), which repaints,
and FinishBulkLoad(), which keeps its Reset(). Affects servers, shared
files, clients, downloads, file details and friends.

The unshifted page and home/end keys on macOS were left to
NSOutlineView, which scrolls without moving the cursor, so the next
arrow key snapped the view straight back and the page key looked inert.
They now run through the handler the shifted keys already used:
unshifted replaces the selection with the row landed on, shifted keeps
extending, and both move the cursor. GTK and MSW get this from their
backends.

The search list answered every arriving result with Cleared(), which
during a search is continuous. Arrivals now batch per idle into
ItemsAdded()/ItemsChanged() on macOS. GTK cannot take them at all --
feeding this model's arrivals in one notification at a time aborts
inside GtkTreeView's red-black tree, first from ItemAdded() under a
newly formed group and again from the batch of top-level additions
after that case was routed away. PR #796 had already found that neither
ItemChanged() nor a delete-and-re-add made GTK or MSW re-derive
container-ness; two aborts from two different notifications say the
constraint is broader than that, so those platforms keep one Cleared()
per idle and put the view back afterwards instead. The idle branch
already restored the selection and the expanded rows, so the top row
joins them -- which works here and not in the virtual lists because a
CSearchFile pointer still names the same result after a rebuild, where
a row-addressed item names whatever has since moved into it.

Measured against wx 3.3.3 rather than argued: Refresh() and
SetSelections() move no viewport on either backend; SetCurrentItem()
moves it only when the target is off screen, and then onto the cursor;
EnsureVisible() after Cleared() restores exactly on GTK and lands at
the bottom on macOS. So the cursor is restored only while its row is on
screen -- with no row ever clicked GTK's cursor sits at row 0, and
restoring it unconditionally clamped the view to the top. GetScrollPos()
is unusable on this control: wx asserts that the window is not
scrollable and answers 0.

Four things review turned up on the way. The synthetic first child of a
new group is now announced -- AddChild() creates a copy of the parent as
the first child and notified nobody, so a freshly formed group reported
one child where the model had two. Both batches de-duplicate, since
SetDownloadStatus() notifies every child of the parent it updated and a
single arrival into a 50-variant group queued 51 entries. Pending
pointers are dropped through MuleNotify::SearchFileBeingDestroyed, which
CCommentDialogLst already consumes for this purpose, replacing a guard
that checked membership in a list nothing ever unlinks from. And
SortList() restores the cursor by identity alongside the selection,
which Reset() used to invalidate for it.

Builds clean on macOS ARM64, Ubuntu ARM64 and Windows ARM64, with
clang-format and both clang-tidy tiers clean over the diff. Confirmed
interactively on amulegui on all three: sorting holds position in shared
files and clients, the search list holds position while results stream
in, group expansion is intact, and the macOS page keys move the cursor
with the view.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants