Files
OrcaSlicer/src/slic3r/GUI/PluginSort.hpp
Andrew ee7339b64b Refine plugin sorting to use name-first base order
Ensure plugins are sorted alphabetically by name
when no other column is sorted, and resolve ties
by source, status, type, and plugin_key.
2026-07-06 16:25:07 +08:00

211 lines
8.9 KiB
C++

#pragma once
#include "PluginSource.hpp"
#include "PluginStatus.hpp"
#include "libslic3r/Semver.hpp"
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
#include <vector>
namespace Slic3r::GUI
{
enum class PluginSortKey
{
Status,
Name,
Source,
Version,
// why: neutral "no column selected" state - clearing a header sort returns here and the
// list falls to compare_plugin_base_order only. Header UI reaches it via the asc/desc/clear cycle.
None
};
enum class PluginSortOrder
{
Asc,
Desc
};
inline std::string to_string(PluginSortKey sort_key)
{
switch (sort_key)
{
case PluginSortKey::Status: return "status";
case PluginSortKey::Name: return "name";
case PluginSortKey::Source: return "source";
case PluginSortKey::Version: return "version";
case PluginSortKey::None: return "none";
}
return "status";
}
inline std::string to_string(PluginSortOrder sort_order)
{
return sort_order == PluginSortOrder::Desc ? "desc" : "asc";
}
inline PluginSortKey plugin_sort_key_from_string(const std::string& sort_key, PluginSortKey fallback)
{
if (sort_key == "status")
return PluginSortKey::Status;
if (sort_key == "name")
return PluginSortKey::Name;
if (sort_key == "source")
return PluginSortKey::Source;
if (sort_key == "version")
return PluginSortKey::Version;
if (sort_key == "none")
return PluginSortKey::None;
return fallback;
}
inline PluginSortOrder plugin_sort_order_from_string(const std::string& sort_order, PluginSortOrder fallback)
{
if (sort_order == "asc")
return PluginSortOrder::Asc;
if (sort_order == "desc")
return PluginSortOrder::Desc;
return fallback;
}
// Natural, case-insensitive ASCII compare returning -1 / 0 / +1. Digit runs compare by
// numeric value; other chars compare lowercased; on a prefix tie the shorter string is less.
// e.g. "item2" < "item10" (2 < 10, not '2' > '1')
// "Camera" == "camera" (case ignored)
// "app" < "apple" (prefix is shorter)
// "1" < "01" (equal value, fewer leading zeros wins the tie)
// note: ASCII only - no locale/Unicode; accented or non-Latin names fall back to byte order.
inline int compare_ascii_case_insensitive_natural(const std::string& lhs, const std::string& rhs)
{
std::size_t li = 0;
std::size_t ri = 0;
while (li < lhs.size() && ri < rhs.size())
{
const unsigned char lc = static_cast<unsigned char>(lhs[li]);
const unsigned char rc = static_cast<unsigned char>(rhs[ri]);
if (std::isdigit(lc) && std::isdigit(rc))
{
const std::size_t lhs_digit_begin = li;
const std::size_t rhs_digit_begin = ri;
while (li < lhs.size() && std::isdigit(static_cast<unsigned char>(lhs[li])))
++li;
while (ri < rhs.size() && std::isdigit(static_cast<unsigned char>(rhs[ri])))
++ri;
const std::string_view lhs_run(lhs.data() + lhs_digit_begin, li - lhs_digit_begin);
const std::string_view rhs_run(rhs.data() + rhs_digit_begin, ri - rhs_digit_begin);
// why: digit runs compare numerically; leading zeros only break exact ties ("1" < "01").
const std::string_view lhs_num = lhs_run.substr(std::min(lhs_run.find_first_not_of('0'), lhs_run.size()));
const std::string_view rhs_num = rhs_run.substr(std::min(rhs_run.find_first_not_of('0'), rhs_run.size()));
if (lhs_num.size() != rhs_num.size())
return lhs_num.size() < rhs_num.size() ? -1 : 1;
if (const int cmp = lhs_num.compare(rhs_num); cmp != 0)
return cmp;
// note: fewer-leading-zeros-first is our convention, not an industry standard (impls
// diverge here); it only matters as a deterministic total order for unstable std::sort.
if (lhs_run.size() != rhs_run.size())
return lhs_run.size() < rhs_run.size() ? -1 : 1;
continue;
}
const int lower_lhs = std::tolower(lc);
const int lower_rhs = std::tolower(rc);
if (lower_lhs != lower_rhs)
return lower_lhs < lower_rhs ? -1 : 1;
++li;
++ri;
}
if (li == lhs.size() && ri == rhs.size())
return 0;
return li == lhs.size() ? -1 : 1;
}
// Neutral baseline order: the whole order when no column is sorted, and the tie-breaker under
// every primary sort key. Name-first so the default view is intuitively alphabetical:
// name, then source, then status, then type, with plugin_key as the final deterministic tie.
// e.g. with no column sorted the list reads A..Z by name.
template <class PluginItem>
int compare_plugin_base_order(const PluginItem& lhs, const PluginItem& rhs)
{
if (const int cmp = compare_ascii_case_insensitive_natural(lhs.display_name, rhs.display_name); cmp != 0)
return cmp;
if (const int cmp = static_cast<int>(lhs.source) - static_cast<int>(rhs.source); cmp != 0)
return cmp;
if (const int cmp = static_cast<int>(lhs.status) - static_cast<int>(rhs.status); cmp != 0)
return cmp;
if (const int cmp = lhs.type_key.compare(rhs.type_key); cmp != 0)
return cmp;
return lhs.plugin_key.compare(rhs.plugin_key);
}
// Compares two version strings returning -1 / 0 / +1. Uses Slic3r::Semver (the same parser the
// plugin catalog's update-available check uses); on unparseable input falls back to the natural
// compare so the order stays deterministic.
// e.g. "1.2.0" < "1.10.0" (numeric), "1.0.0-rc1" < "1.0.0" (semver prerelease rule).
inline int compare_plugin_version(const std::string& lhs, const std::string& rhs)
{
const auto lhs_semver = Semver::parse(lhs);
const auto rhs_semver = Semver::parse(rhs);
if (lhs_semver && rhs_semver)
{
if (*lhs_semver < *rhs_semver) return -1;
if (*rhs_semver < *lhs_semver) return 1;
return 0;
}
return compare_ascii_case_insensitive_natural(lhs, rhs);
}
// Compares two items by the chosen primary key, returning -1 / 0 / +1. Status and Source
// rank by enum ordinal (the declared dialog priority); Name uses the natural compare above.
// e.g. Status: an enabled item (lower ordinal) sorts before a disabled one.
// Name: "Plugin 2" sorts before "Plugin 10".
template <class PluginItem>
int compare_plugin_sort_key(const PluginItem& lhs, const PluginItem& rhs, PluginSortKey sort_key)
{
switch (sort_key)
{
case PluginSortKey::Status:
// why: PluginStatus/PluginSource declare the dialog sort priority as their ordinal order.
return static_cast<int>(lhs.status) - static_cast<int>(rhs.status);
case PluginSortKey::Name:
return compare_ascii_case_insensitive_natural(lhs.display_name, rhs.display_name);
case PluginSortKey::Source:
return static_cast<int>(lhs.source) - static_cast<int>(rhs.source);
case PluginSortKey::Version:
return compare_plugin_version(lhs.sort_version, rhs.sort_version);
case PluginSortKey::None:
// why: no primary key - every pair ties here so sort_plugin_items_for_dialog falls
// straight to the ascending base order (direction is irrelevant for the baseline).
return 0;
}
return 0;
}
// Sorts the dialog list in place by primary key + direction. Ties always fall back to the
// ascending base order, so the result is deterministic regardless of the primary direction.
// e.g. sort_key=Name, order=Desc -> names Z..A, but equal names keep the stable base order.
template <class PluginItem>
void sort_plugin_items_for_dialog(std::vector<PluginItem>& items, PluginSortKey sort_key,
PluginSortOrder sort_order)
{
std::sort(items.begin(), items.end(),
[sort_key, sort_order](const PluginItem& lhs, const PluginItem& rhs)
{
if (const int cmp = compare_plugin_sort_key(lhs, rhs, sort_key); cmp != 0)
return sort_order == PluginSortOrder::Asc ? cmp < 0 : cmp > 0;
// why: ties fall back to ascending base order regardless of the primary direction.
return compare_plugin_base_order(lhs, rhs) < 0;
});
}
} // namespace Slic3r::GUI