From b724cb6631aa9c9cfc89b70be96379a54977cac6 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:29:54 +0800 Subject: [PATCH 1/8] Implement plugin sorting functionality Add support for sorting plugins by status, name, or source in both ascending and descending order. --- resources/web/dialog/PluginsDialog/index.html | 41 ++++ resources/web/dialog/PluginsDialog/index.js | 8 + .../web/dialog/PluginsDialog/plugin-sort.css | 89 +++++++++ .../web/dialog/PluginsDialog/plugin-sort.js | 90 +++++++++ src/slic3r/CMakeLists.txt | 3 + src/slic3r/GUI/PluginSort.hpp | 175 ++++++++++++++++++ src/slic3r/GUI/PluginSource.hpp | 29 +++ src/slic3r/GUI/PluginStatus.hpp | 31 ++++ src/slic3r/GUI/PluginsDialog.cpp | 169 ++++++++--------- src/slic3r/GUI/PluginsDialog.hpp | 21 +-- tests/slic3rutils/CMakeLists.txt | 1 + tests/slic3rutils/test_plugin_sort.cpp | 146 +++++++++++++++ 12 files changed, 696 insertions(+), 107 deletions(-) create mode 100644 resources/web/dialog/PluginsDialog/plugin-sort.css create mode 100644 resources/web/dialog/PluginsDialog/plugin-sort.js create mode 100644 src/slic3r/GUI/PluginSort.hpp create mode 100644 src/slic3r/GUI/PluginSource.hpp create mode 100644 src/slic3r/GUI/PluginStatus.hpp create mode 100644 tests/slic3rutils/test_plugin_sort.cpp diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index 5b647f3d3b..a63f880044 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -5,6 +5,7 @@ Plugins + @@ -14,6 +15,7 @@ +
@@ -21,6 +23,45 @@ +
+ + +
diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index 7310201a92..1a82c9774c 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -118,6 +118,10 @@ function ShowExploreMenu() { if (!exploreMenu || !exploreMenuButton) return; + // why: our stopPropagation blocks the outside-click handler, so close the sibling sort menu ourselves (mirror of ToggleSortMenu). + if (typeof HideSortMenu === "function") + HideSortMenu(); + exploreMenu.hidden = false; exploreMenuButton.setAttribute("aria-expanded", "true"); } @@ -214,6 +218,10 @@ function HandleStudio(value) { if (payload.command === "list_plugins") { SetSelectedInstallAction(payload.install_action, false); + if (typeof NormalizePluginSort === "function") { + pluginSort = NormalizePluginSort(payload.sort_key, payload.sort_order); + RenderSortMenuState(); + } ApplyPlugins(payload.data || []); } else if (payload.command === "status_message") { ShowStatusMessage(String(payload.message || ""), String(payload.level || "info")); diff --git a/resources/web/dialog/PluginsDialog/plugin-sort.css b/resources/web/dialog/PluginsDialog/plugin-sort.css new file mode 100644 index 0000000000..2dc94502ba --- /dev/null +++ b/resources/web/dialog/PluginsDialog/plugin-sort.css @@ -0,0 +1,89 @@ +/* why: isolate dropdown styling from the existing plugin dialog styles. */ + +.sort-dropdown { + position: relative; + display: inline-flex; + margin-right: auto; +} + +.sort-menu-btn { + height: 32px !important; + display: inline-flex; + align-items: center; + gap: 6px; + /* why: global .ButtonTypeChoice adds margin-left:15px (dialog button rows); as the leftmost + toolbar item that stacks on .app's 8px padding - kill it like .explore-dropdown does. */ + margin-left: 0 !important; +} + +/* note: asc is bars growing downward + up arrow; a vertical flip yields the desc icon. */ +.sort-order-icon { + display: block; + flex: none; +} + +.sort-menu-btn.order-desc .sort-order-icon, +.sort-order-icon.desc { + transform: scaleY(-1); +} + +.sort-caret { + width: 7px; + height: 7px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: translateY(-2px) rotate(45deg); +} + +.sort-menu { + position: absolute; + left: 0; + top: calc(100% + 2px); + z-index: 20; + min-width: 200px; + padding: 4px 0; + background: var(--main-color); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18); +} + +.sort-menu[hidden] { + display: none !important; +} + +.sort-group-label { + padding: 8px 10px 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(255, 255, 255, 0.55); +} + +.sort-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px 8px 28px; /* why: reserve space for the active-row checkmark. */ + border: 0; + background: transparent; + color: #ffffff; + box-sizing: border-box; + text-align: left; + font: inherit; + cursor: pointer; + position: relative; +} + +/* note: same tint as .explore-menu-item hover/.selected, so the two menus match */ +.sort-menu-item:hover, +.sort-menu-item:focus-visible, +.sort-menu-item[aria-checked="true"] { + outline: none; + background: rgba(255, 255, 255, 0.14); +} + +.sort-menu-item[aria-checked="true"]::before { + content: "\2713"; /* note: checkmark on the active field/order. */ + position: absolute; + left: 10px; +} diff --git a/resources/web/dialog/PluginsDialog/plugin-sort.js b/resources/web/dialog/PluginsDialog/plugin-sort.js new file mode 100644 index 0000000000..f83ba2f767 --- /dev/null +++ b/resources/web/dialog/PluginsDialog/plugin-sort.js @@ -0,0 +1,90 @@ +// why: C++ owns ordering; this file only sends and reflects sort state. + +const DEFAULT_PLUGIN_SORT = { key: "status", order: "asc" }; +const SORT_FIELDS = new Set(["status", "name", "source"]); +let pluginSort = { ...DEFAULT_PLUGIN_SORT }; + +// why: C++ returns canonical sort state; guard stale or malformed values before reflecting them. +function NormalizePluginSort(sortKey, sortOrder) { + const key = String(sortKey || ""); + return { + key: SORT_FIELDS.has(key) ? key : DEFAULT_PLUGIN_SORT.key, + order: sortOrder === "desc" ? "desc" : DEFAULT_PLUGIN_SORT.order, + }; +} + +function RequestPluginSort(sortKey, sortOrder) { + pluginSort = NormalizePluginSort(sortKey, sortOrder); + RenderSortMenuState(); + + if (typeof SendMessage === "function") + SendMessage("set_plugin_sort", { + sort_key: pluginSort.key, + sort_order: pluginSort.order, + }); +} + +let sortMenuEl = null; +let sortMenuButton = null; +let sortCurrentLabel = null; + +function InitSortDropdown() { + sortMenuEl = document.getElementById("sortMenu"); + sortMenuButton = document.getElementById("sort_menu_btn"); + sortCurrentLabel = document.getElementById("sort_current_label"); + if (!sortMenuButton || !sortMenuEl) + return; + sortMenuButton.addEventListener("click", ToggleSortMenu); + sortMenuEl.addEventListener("click", OnSortMenuClick); + RenderSortMenuState(); +} + +function ToggleSortMenu(event) { + event.preventDefault(); + event.stopPropagation(); + if (!sortMenuEl) + return; + const willShow = sortMenuEl.hidden; + // why: our stopPropagation blocks index.js's outside-click handler, so close the sibling explore menu ourselves. + if (willShow && typeof HideExploreMenu === "function") + HideExploreMenu(); + sortMenuEl.hidden = !willShow; + sortMenuButton.setAttribute("aria-expanded", willShow ? "true" : "false"); +} + +function HideSortMenu() { + if (!sortMenuEl || sortMenuEl.hidden) + return; + sortMenuEl.hidden = true; + sortMenuButton.setAttribute("aria-expanded", "false"); +} + +function OnSortMenuClick(event) { + const item = event.target.closest("[data-sort-field],[data-sort-order]"); + if (!item) + return; + event.preventDefault(); + const sortKey = item.dataset.sortField || pluginSort.key; + const sortOrder = item.dataset.sortOrder || pluginSort.order; + // why: leave the menu open so the user can set field then order in one visit; outside-click/Escape close it. + RequestPluginSort(sortKey, sortOrder); +} + +function RenderSortMenuState() { + const field = sortMenuEl?.querySelector(`[data-sort-field="${pluginSort.key}"]`); + if (sortCurrentLabel) + sortCurrentLabel.textContent = field?.textContent.trim() || "Status"; + sortMenuButton?.classList.toggle("order-desc", pluginSort.order === "desc"); + sortMenuEl?.querySelectorAll("[data-sort-field]").forEach((el) => + el.setAttribute("aria-checked", el.dataset.sortField === pluginSort.key ? "true" : "false")); + sortMenuEl?.querySelectorAll("[data-sort-order]").forEach((el) => + el.setAttribute("aria-checked", el.dataset.sortOrder === pluginSort.order ? "true" : "false")); +} + +// why: guarded so the module can be loaded in headless syntax checks. +// note: owns its own lifecycle + outside-click/Escape, so index.js's OnInit needs no edit. +if (typeof document !== "undefined") { + document.addEventListener("DOMContentLoaded", InitSortDropdown); + document.addEventListener("click", (event) => { if (!event.target.closest(".sort-dropdown")) HideSortMenu(); }); + document.addEventListener("keydown", (event) => { if (event.key === "Escape") HideSortMenu(); }); +} diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 16636517d7..647af33174 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -109,6 +109,9 @@ set(SLIC3R_GUI_SOURCES GUI/Downloader.hpp GUI/DownloadProgressDialog.cpp GUI/DownloadProgressDialog.hpp + GUI/PluginSource.hpp + GUI/PluginSort.hpp + GUI/PluginStatus.hpp GUI/PluginPickerDialog.cpp GUI/PluginPickerDialog.hpp GUI/PluginsDialog.cpp diff --git a/src/slic3r/GUI/PluginSort.hpp b/src/slic3r/GUI/PluginSort.hpp new file mode 100644 index 0000000000..5808f693b4 --- /dev/null +++ b/src/slic3r/GUI/PluginSort.hpp @@ -0,0 +1,175 @@ +#pragma once + +#include "PluginSource.hpp" +#include "PluginStatus.hpp" + +#include +#include +#include +#include +#include + +namespace Slic3r::GUI +{ + enum class PluginSortKey + { + Status, + Name, + Source + }; + + 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"; + } + + 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; + 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(lhs[li]); + const unsigned char rc = static_cast(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(lhs[li]))) + ++li; + while (ri < rhs.size() && std::isdigit(static_cast(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 used as the tie-breaker under every primary sort key. Always + // ascending: source priority, then type_key, then display_name, then plugin_key. + // e.g. two items with equal Status sort by source priority (Mine, then Subscribed, + // then Local), then by name. + template + int compare_plugin_base_order(const PluginItem& lhs, const PluginItem& rhs) + { + // why: source ties use the declared PluginSource ordinal priority - the same order the + // Source sort key uses - so the neutral baseline never contradicts it. + if (const int cmp = static_cast(lhs.source) - static_cast(rhs.source); cmp != 0) + return cmp; + if (const int cmp = lhs.type_key.compare(rhs.type_key); cmp != 0) + return cmp; + if (const int cmp = lhs.display_name.compare(rhs.display_name); cmp != 0) + return cmp; + return lhs.plugin_key.compare(rhs.plugin_key); + } + + // 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 + 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(lhs.status) - static_cast(rhs.status); + case PluginSortKey::Name: + return compare_ascii_case_insensitive_natural(lhs.display_name, rhs.display_name); + case PluginSortKey::Source: + return static_cast(lhs.source) - static_cast(rhs.source); + } + + 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 + void sort_plugin_items_for_dialog(std::vector& 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 diff --git a/src/slic3r/GUI/PluginSource.hpp b/src/slic3r/GUI/PluginSource.hpp new file mode 100644 index 0000000000..ee1464c8df --- /dev/null +++ b/src/slic3r/GUI/PluginSource.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace Slic3r +{ + namespace GUI + { + enum class PluginSource + { + // IMPORTANT: ordinal order is the Plugins dialog Source sort priority. + Mine, + Subscribed, + Local + }; + + inline std::string to_string(PluginSource source) + { + switch (source) + { + case PluginSource::Mine: return "mine"; + case PluginSource::Subscribed: return "subscribed"; + case PluginSource::Local: return "local"; + } + + return "local"; + } + } +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PluginStatus.hpp b/src/slic3r/GUI/PluginStatus.hpp new file mode 100644 index 0000000000..477e9d99fe --- /dev/null +++ b/src/slic3r/GUI/PluginStatus.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace Slic3r +{ + namespace GUI + { + enum class PluginStatus + { + // IMPORTANT: ordinal order is the Plugins dialog Status sort priority. + Activated, + Error, + Inactive, + Loading + }; + + inline std::string to_string(PluginStatus status) + { + switch (status) + { + case PluginStatus::Activated: return "Activated"; + case PluginStatus::Error: return "Error"; + case PluginStatus::Inactive: return "Inactive"; + case PluginStatus::Loading: return "Loading"; + } + + return "Inactive"; + } + } +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 9b0b253698..bed37f2ebe 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -25,7 +25,6 @@ #include #include -#include #include #include #include @@ -103,7 +102,7 @@ struct PluginDialogItem PluginUpdateStatus update_status = PluginUpdateStatus::Normal; std::string error_text; bool has_error = false; - bool loaded = false; + bool is_loaded = false; bool loading = false; // Installation and capability flags @@ -187,27 +186,62 @@ void refresh_plugin_catalog_blocking(bool fetch_cloud) } } -std::string to_string(PluginSource source) +std::string to_string(PluginUpdateStatus status); +nlohmann::json build_context_actions_payload(const PluginAvailableActions& available_actions); + +nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item) { - switch (source) { - case PluginSource::Local: return "local"; - case PluginSource::Mine: return "mine"; - case PluginSource::Subscribed: return "subscribed"; + nlohmann::json payload_item; + payload_item["plugin_key"] = dialog_item.plugin_key; + payload_item["plugin_id"] = dialog_item.plugin_id; + payload_item["name"] = dialog_item.display_name; + payload_item["description"] = dialog_item.description; + payload_item["author"] = dialog_item.author; + payload_item["version"] = dialog_item.version; + payload_item["type"] = dialog_item.type_label; + payload_item["type_key"] = dialog_item.type_key; + payload_item["types"] = dialog_item.type_labels; + + nlohmann::json caps = nlohmann::json::array(); + for (const PluginCapabilityView& capability : dialog_item.capabilities) { + nlohmann::json c; + c["name"] = capability.name; + c["type"] = capability.type_label; + c["type_key"] = capability.type_key; + c["enabled"] = capability.enabled; + c["can_toggle"] = capability.can_toggle; + c["can_run"] = capability.can_run; + caps.push_back(std::move(c)); } + payload_item["capabilities"] = std::move(caps); - return "local"; -} - -std::string to_string(PluginStatus status) -{ - switch (status) { - case PluginStatus::Inactive: return "Inactive"; - case PluginStatus::Error: return "Error"; - case PluginStatus::Loading: return "Loading"; - case PluginStatus::Activated: return "Activated"; + nlohmann::json changelog = nlohmann::json::array(); + for (const PluginChangelogView& entry : dialog_item.changelog) { + nlohmann::json c; + c["version"] = entry.version; + c["changelog"] = entry.changelog; + c["created_time"] = entry.created_time; + changelog.push_back(std::move(c)); } + payload_item["changelog"] = std::move(changelog); - return "Inactive"; + payload_item["label"] = dialog_item.display_name; + payload_item["source"] = to_string(dialog_item.source); + payload_item["status"] = to_string(dialog_item.status); + payload_item["error"] = dialog_item.error_text; + payload_item["update_status"] = to_string(dialog_item.update_status); + payload_item["unauthorized"] = dialog_item.unauthorized; + payload_item["context_actions"] = build_context_actions_payload(dialog_item.available_actions); + payload_item["update_available"] = dialog_item.update_status == PluginUpdateStatus::UpdateAvailable; + payload_item["can_toggle"] = dialog_item.available_actions.can_toggle; + payload_item["has_script_capability"] = dialog_item.has_script_capability; + payload_item["can_run_script"] = dialog_item.can_run_script; + payload_item["sharing_token"] = dialog_item.sharing_token; + payload_item["thumbnail_url"] = dialog_item.thumbnail_url; + payload_item["installed"] = dialog_item.has_local_package; + payload_item["installed_version"] = dialog_item.installed_version; + payload_item["latest_version"] = dialog_item.latest_version; + return payload_item; } std::string to_string(PluginUpdateStatus status) @@ -311,9 +345,9 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) item.has_local_package = descriptor.has_local_package(); item.unauthorized = descriptor.is_unauthorized(); item.has_script_capability = descriptor.has_capability_type(Slic3r::PluginCapabilityType::Script); - item.loaded = loader.is_plugin_loaded(descriptor.plugin_key); + item.is_loaded = loader.is_plugin_loaded(descriptor.plugin_key); item.loading = loader.is_plugin_load_in_progress(descriptor.plugin_key); - if (item.loaded) { + if (item.is_loaded) { for (const auto& cap : loader.get_loaded_plugin_capabilities(descriptor.plugin_key)) { if (cap) { item.capabilities.push_back({cap->name, plugin_capability_type_display_name(cap->type), @@ -342,7 +376,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) item.status = PluginStatus::Loading; else if (item.has_error) item.status = PluginStatus::Error; - else if (item.loaded) + else if (item.is_loaded) item.status = PluginStatus::Activated; else item.status = PluginStatus::Inactive; @@ -352,7 +386,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) [](const PluginCapabilityView& capability) { return capability.type_key == "script" && capability.enabled; }); - item.can_run_script = descriptor.is_metadata_valid() && !descriptor.has_error() && item.has_script_capability && item.loaded && + item.can_run_script = descriptor.is_metadata_valid() && !descriptor.has_error() && item.has_script_capability && item.is_loaded && !item.loading && has_enabled_script; for (PluginCapabilityView& capability : item.capabilities) { capability.can_run = item.can_run_script && capability.type_key == "script" && capability.enabled; @@ -478,6 +512,8 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) open_plugin_on_cloud(payload.value("sharing_token", "")); } else if (command == "open_plugin_hub") { open_plugin_hub(); + } else if (command == "set_plugin_sort") { + set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", "")); } else if (command == "set_plugin_install_action") { const std::string action = payload.value("action", ""); if (action == "explore" || action == "install-local") @@ -487,92 +523,41 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) void PluginsDialog::send_plugins() { call_web_handler(build_plugins_payload()); } +void PluginsDialog::set_plugin_sort(const std::string& sort_key, const std::string& sort_order) +{ + m_plugin_sort_key = plugin_sort_key_from_string(sort_key, m_plugin_sort_key); + m_plugin_sort_order = plugin_sort_order_from_string(sort_order, m_plugin_sort_order); + send_plugins(); +} + nlohmann::json PluginsDialog::build_plugins_payload() const { nlohmann::json response; response["command"] = "list_plugins"; response["install_action"] = s_selected_plugin_install_action; + response["sort_key"] = to_string(m_plugin_sort_key); + response["sort_order"] = to_string(m_plugin_sort_order); response["data"] = nlohmann::json::array(); - auto append_plugin = [&response](const PluginDescriptor& row) { - const PluginDialogItem dialog_item = build_plugin_dialog_item(row); - - nlohmann::json payload_item; - payload_item["plugin_key"] = dialog_item.plugin_key; - payload_item["plugin_id"] = dialog_item.plugin_id; - payload_item["name"] = row.name; - payload_item["description"] = dialog_item.description; - payload_item["author"] = dialog_item.author; - payload_item["version"] = dialog_item.version; - payload_item["installed_version"] = dialog_item.installed_version; - payload_item["latest_version"] = dialog_item.latest_version; - payload_item["installed"] = dialog_item.has_local_package; - payload_item["type"] = dialog_item.type_label; - payload_item["type_key"] = dialog_item.type_key; - payload_item["types"] = dialog_item.type_labels; - nlohmann::json caps = nlohmann::json::array(); - for (const PluginCapabilityView& capability : dialog_item.capabilities) { - nlohmann::json c; - c["name"] = capability.name; - c["type"] = capability.type_label; - c["type_key"] = capability.type_key; - c["enabled"] = capability.enabled; - c["can_toggle"] = capability.can_toggle; - c["can_run"] = capability.can_run; - caps.push_back(std::move(c)); - } - payload_item["capabilities"] = std::move(caps); - nlohmann::json changelog = nlohmann::json::array(); - for (const PluginChangelogView& entry : dialog_item.changelog) { - nlohmann::json c; - c["version"] = entry.version; - c["changelog"] = entry.changelog; - c["created_time"] = entry.created_time; - changelog.push_back(std::move(c)); - } - payload_item["changelog"] = std::move(changelog); - payload_item["label"] = dialog_item.display_name; - payload_item["source"] = to_string(dialog_item.source); - payload_item["status"] = to_string(dialog_item.status); - payload_item["error"] = dialog_item.error_text; - payload_item["update_status"] = to_string(dialog_item.update_status); - payload_item["unauthorized"] = dialog_item.unauthorized; - payload_item["context_actions"] = build_context_actions_payload(dialog_item.available_actions); - payload_item["update_available"] = dialog_item.update_status == PluginUpdateStatus::UpdateAvailable; - payload_item["can_toggle"] = dialog_item.available_actions.can_toggle; - payload_item["has_script_capability"] = dialog_item.has_script_capability; - payload_item["can_run_script"] = dialog_item.can_run_script; - payload_item["sharing_token"] = dialog_item.sharing_token; - payload_item["thumbnail_url"] = dialog_item.thumbnail_url; - response["data"].push_back(std::move(payload_item)); - }; - const auto& catalog = PluginManager::instance().get_catalog(); const auto valid = catalog.get_all_plugin_descriptors(); const auto invalid = catalog.get_invalid_plugins(); BOOST_LOG_TRIVIAL(info) << "Prepared " << valid.size() + invalid.size() << " plugin rows for Plugins dialog"; + std::vector items; + items.reserve(valid.size() + invalid.size()); + for (const PluginDescriptor& row : valid) - append_plugin(row); + items.push_back(build_plugin_dialog_item(row)); for (const PluginDescriptor& row : invalid) - append_plugin(row); + items.push_back(build_plugin_dialog_item(row)); - auto sort_value = [](const nlohmann::json& payload_item, const char* key) { return payload_item.value(key, std::string{}); }; + // In-place sort + sort_plugin_items_for_dialog(items, m_plugin_sort_key, m_plugin_sort_order); - std::sort(response["data"].begin(), response["data"].end(), [&sort_value](const nlohmann::json& lhs, const nlohmann::json& rhs) { - const std::string lhs_source = sort_value(lhs, "source"); - const std::string rhs_source = sort_value(rhs, "source"); - if (lhs_source != rhs_source) - return lhs_source < rhs_source; - - const std::string lhs_type = sort_value(lhs, "type_key"); - const std::string rhs_type = sort_value(rhs, "type_key"); - if (lhs_type != rhs_type) - return lhs_type < rhs_type; - - return sort_value(lhs, "name") < sort_value(rhs, "name"); - }); + for (const PluginDialogItem& item : items) + response["data"].push_back(build_plugin_payload_item(item)); return response; } @@ -1274,7 +1259,7 @@ void PluginsDialog::delete_mine_local_and_cloud_plugin(const std::string& plugin // delete_mine_local_and_cloud_plugin already updated the in-memory catalog // (finalize_cloud_plugin_removal removes the row and, when a local package existed, - // re-syncs the cloud list itself), so a UI refresh is sufficient here — an extra + // re-syncs the cloud list itself), so a UI refresh is sufficient here - an extra // clearing rescan + cloud fetch would be redundant. send_plugins(); show_status(wxString::Format(_L("Deleted \"%s\"."), plugin_name), "success"); diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index f7e657ecdf..4115776e71 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -2,6 +2,9 @@ #define slic3r_PluginsDialog_hpp_ #include "Widgets/WebViewHostDialog.hpp" +#include "PluginSource.hpp" +#include "PluginStatus.hpp" +#include "PluginSort.hpp" #include "slic3r/plugin/PluginDescriptor.hpp" #include @@ -27,21 +30,6 @@ enum class PluginCapabilityType; namespace GUI { -enum class PluginSource -{ - Local, - Mine, - Subscribed -}; - -enum class PluginStatus -{ - Inactive, - Error, - Loading, - Activated -}; - class PluginsDialog : public Slic3r::GUI::WebViewHostDialog { public: @@ -63,6 +51,7 @@ private: void on_script_message(const nlohmann::json& payload) override; void send_plugins(); + void set_plugin_sort(const std::string& sort_key, const std::string& sort_order); nlohmann::json build_plugins_payload() const; bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const; @@ -221,6 +210,8 @@ private: } std::function m_open_terminal_dlg_fn; + PluginSortKey m_plugin_sort_key = PluginSortKey::Status; + PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc; // Serializes run_script_plugin. With main-thread execution a plugin's orca.host.ui modal // (message/show_dialog) or the result message box pumps a nested event loop, which could diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 626ae128e1..1e62c1a6ba 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -4,6 +4,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_host_api.cpp test_plugin_capability_identifier.cpp test_plugin_install.cpp + test_plugin_sort.cpp ) if (MSVC) diff --git a/tests/slic3rutils/test_plugin_sort.cpp b/tests/slic3rutils/test_plugin_sort.cpp new file mode 100644 index 0000000000..83889bf4f8 --- /dev/null +++ b/tests/slic3rutils/test_plugin_sort.cpp @@ -0,0 +1,146 @@ +#include + +#include + +#include +#include + +using Slic3r::GUI::compare_ascii_case_insensitive_natural; +using Slic3r::GUI::PluginSortKey; +using Slic3r::GUI::PluginSortOrder; +using Slic3r::GUI::PluginSource; +using Slic3r::GUI::PluginStatus; +using Slic3r::GUI::plugin_sort_key_from_string; +using Slic3r::GUI::plugin_sort_order_from_string; +using Slic3r::GUI::sort_plugin_items_for_dialog; + +namespace { + +struct SortFixtureItem +{ + std::string plugin_key; + PluginSource source; + PluginStatus status; + std::string type_key; + std::string display_name; +}; + +std::vector keys(const std::vector& items) +{ + std::vector result; + result.reserve(items.size()); + for (const SortFixtureItem& item : items) + result.push_back(item.plugin_key); + return result; +} + +} // namespace + +TEST_CASE("plugin dialog status sort uses requested priority and base-order ties", "[plugin][sort]") +{ + std::vector items = { + {"local_inactive", PluginSource::Local, PluginStatus::Inactive, "script", "Local Inactive"}, + {"mine_error", PluginSource::Mine, PluginStatus::Error, "script", "Mine Error"}, + {"mine_activated", PluginSource::Mine, PluginStatus::Activated, "script", "Mine Activated"}, + {"local_activated", PluginSource::Local, PluginStatus::Activated, "script", "Local Activated"}, + {"subscribed_loading", PluginSource::Subscribed, PluginStatus::Loading, "script", "Subscribed Loading"}, + }; + + sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Asc); + + // why: local_activated and mine_activated tie on Status, so base order breaks the tie by + // declared source priority - Mine (ordinal 0) before Local (ordinal 2). + const std::vector expected = { + "mine_activated", + "local_activated", + "mine_error", + "local_inactive", + "subscribed_loading", + }; + CHECK(keys(items) == expected); + + sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Desc); + + // why: Desc reverses the status ordinal, but the Activated tie still resolves by ascending + // base order (Mine before Local) - the direction only flips the primary key. + const std::vector desc_expected = { + "subscribed_loading", + "local_inactive", + "mine_error", + "mine_activated", + "local_activated", + }; + CHECK(keys(items) == desc_expected); +} + +TEST_CASE("plugin dialog source sort uses enum priority", "[plugin][sort]") +{ + std::vector items = { + {"local", PluginSource::Local, PluginStatus::Activated, "script", "Local"}, + {"mine", PluginSource::Mine, PluginStatus::Activated, "script", "Mine"}, + {"subscribed", PluginSource::Subscribed, PluginStatus::Activated, "script", "Subscribed"}, + }; + + sort_plugin_items_for_dialog(items, PluginSortKey::Source, PluginSortOrder::Asc); + const std::vector asc_expected = {"mine", "subscribed", "local"}; + CHECK(keys(items) == asc_expected); + + sort_plugin_items_for_dialog(items, PluginSortKey::Source, PluginSortOrder::Desc); + const std::vector desc_expected = {"local", "subscribed", "mine"}; + CHECK(keys(items) == desc_expected); +} + +TEST_CASE("plugin dialog name sort is case-insensitive and numeric-aware", "[plugin][sort]") +{ + std::vector items = { + {"rig10", PluginSource::Local, PluginStatus::Activated, "script", "Rig 10"}, + {"ada_lower", PluginSource::Local, PluginStatus::Activated, "script", "ada"}, + {"rig2", PluginSource::Local, PluginStatus::Activated, "script", "Rig 2"}, + {"ada_upper", PluginSource::Local, PluginStatus::Activated, "script", "Ada"}, + }; + + sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Asc); + + const std::vector expected = {"ada_upper", "ada_lower", "rig2", "rig10"}; + CHECK(keys(items) == expected); + + sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Desc); + + // why: names reverse ("Rig 10" before "Rig 2"), but "Ada"/"ada" tie on the case-insensitive + // key and keep ascending base order (case-sensitive "Ada" < "ada"). + const std::vector desc_expected = {"rig10", "rig2", "ada_upper", "ada_lower"}; + CHECK(keys(items) == desc_expected); +} + +TEST_CASE("natural compare handles digits, case, prefixes and leading zeros", "[plugin][sort]") +{ + // numeric runs compare by value, not lexically + CHECK(compare_ascii_case_insensitive_natural("item2", "item10") < 0); + CHECK(compare_ascii_case_insensitive_natural("item10", "item2") > 0); + CHECK(compare_ascii_case_insensitive_natural("2", "10") < 0); + + // case is ignored on the primary comparison + CHECK(compare_ascii_case_insensitive_natural("Camera", "camera") == 0); + + // a prefix is less than the longer string it prefixes + CHECK(compare_ascii_case_insensitive_natural("app", "apple") < 0); + CHECK(compare_ascii_case_insensitive_natural("apple", "app") > 0); + + // equal numeric value: fewer leading zeros wins the tie + CHECK(compare_ascii_case_insensitive_natural("1", "01") < 0); + CHECK(compare_ascii_case_insensitive_natural("01", "1") > 0); + + // reflexivity and empty-string boundaries + CHECK(compare_ascii_case_insensitive_natural("plugin", "plugin") == 0); + CHECK(compare_ascii_case_insensitive_natural("", "") == 0); + CHECK(compare_ascii_case_insensitive_natural("", "a") < 0); +} + +TEST_CASE("plugin dialog sort request parsing keeps previous state on invalid values", "[plugin][sort]") +{ + CHECK(plugin_sort_key_from_string("source", PluginSortKey::Status) == PluginSortKey::Source); + CHECK(plugin_sort_key_from_string("missing", PluginSortKey::Name) == PluginSortKey::Name); + + CHECK(plugin_sort_order_from_string("desc", PluginSortOrder::Asc) == PluginSortOrder::Desc); + CHECK(plugin_sort_order_from_string("down", PluginSortOrder::Asc) == PluginSortOrder::Asc); +} From 714fe54f77140ae03294a94e0a92329785ea372e Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:46:31 +0800 Subject: [PATCH 2/8] Move plugin sort from dropdown to clickable column headers Replace the toolbar sort dropdown with sortable Name/Source/Status column headers that cycle ascending, descending, then clear. Add a Source column and a PluginSortKey::None baseline for the cleared state. The whole header cell is the click target and the sort triangle snaps in without a fade. --- resources/web/dialog/PluginsDialog/index.html | 47 +------- resources/web/dialog/PluginsDialog/index.js | 27 ++++- .../web/dialog/PluginsDialog/plugin-sort.css | 113 +++++------------- .../web/dialog/PluginsDialog/plugin-sort.js | 95 ++++++--------- resources/web/dialog/PluginsDialog/styles.css | 27 ++++- src/slic3r/GUI/PluginSort.hpp | 12 +- src/slic3r/GUI/PluginsDialog.hpp | 2 +- tests/slic3rutils/test_plugin_sort.cpp | 21 ++++ 8 files changed, 150 insertions(+), 194 deletions(-) diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index a63f880044..438f6eb4e9 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -23,45 +23,6 @@ -
- - -
@@ -84,9 +45,13 @@
Activate - Name + Name Plugin Version - Status + Source + Status
diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index 1a82c9774c..b2e7b235ea 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -118,10 +118,6 @@ function ShowExploreMenu() { if (!exploreMenu || !exploreMenuButton) return; - // why: our stopPropagation blocks the outside-click handler, so close the sibling sort menu ourselves (mirror of ToggleSortMenu). - if (typeof HideSortMenu === "function") - HideSortMenu(); - exploreMenu.hidden = false; exploreMenuButton.setAttribute("aria-expanded", "true"); } @@ -220,7 +216,7 @@ function HandleStudio(value) { SetSelectedInstallAction(payload.install_action, false); if (typeof NormalizePluginSort === "function") { pluginSort = NormalizePluginSort(payload.sort_key, payload.sort_order); - RenderSortMenuState(); + RenderSortHeaders(); } ApplyPlugins(payload.data || []); } else if (payload.command === "status_message") { @@ -337,6 +333,7 @@ function RenderPlugins() { row.appendChild(CheckCell(row, plugin)); row.appendChild(LabelCell(plugin, isExpanded, capabilities.length)); row.appendChild(VersionCell(plugin)); + row.appendChild(SourceCell(plugin)); row.appendChild(StatusCell(plugin)); block.appendChild(row); @@ -531,11 +528,24 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0) { nameWrap.appendChild(countBadge); } labelCell.appendChild(nameWrap); - labelCell.appendChild(SourceBadge(plugin.source)); return labelCell; } +function SourceCell(plugin) { + const cell = document.createElement("span"); + const normalized = String(plugin.source || "").toLowerCase(); + const variant = (normalized === "mine" || normalized === "subscribed") ? normalized : "local"; + cell.className = `source-cell source-${variant}`; + + const sourceLabel = document.createElement("span"); + sourceLabel.className = "source-label"; + sourceLabel.textContent = SourceLabel(plugin.source); + cell.appendChild(sourceLabel); + + return cell; +} + function RenderCapabilityTree(plugin, capabilities) { const tree = document.createElement("div"); tree.className = "capabilities-tree"; @@ -574,6 +584,11 @@ function RenderCapabilityRow(plugin, capability, isLast) { typeCell.textContent = String(capability?.type || "-"); row.appendChild(typeCell); + // why: empty placeholder for the new Source column so the run-action cell stays under Status. + const sourceSpacer = document.createElement("span"); + sourceSpacer.className = "capability-source-cell"; + row.appendChild(sourceSpacer); + const actionsCell = document.createElement("span"); actionsCell.className = "capability-actions-cell"; const capabilityName = String(capability?.name || ""); diff --git a/resources/web/dialog/PluginsDialog/plugin-sort.css b/resources/web/dialog/PluginsDialog/plugin-sort.css index 2dc94502ba..31c5ec627c 100644 --- a/resources/web/dialog/PluginsDialog/plugin-sort.css +++ b/resources/web/dialog/PluginsDialog/plugin-sort.css @@ -1,89 +1,42 @@ -/* why: isolate dropdown styling from the existing plugin dialog styles. */ +/* why: sort affordance lives on the list column headers, not a toolbar dropdown. */ -.sort-dropdown { - position: relative; - display: inline-flex; - margin-right: auto; -} - -.sort-menu-btn { - height: 32px !important; - display: inline-flex; - align-items: center; - gap: 6px; - /* why: global .ButtonTypeChoice adds margin-left:15px (dialog button rows); as the leftmost - toolbar item that stacks on .app's 8px padding - kill it like .explore-dropdown does. */ - margin-left: 0 !important; -} - -/* note: asc is bars growing downward + up arrow; a vertical flip yields the desc icon. */ -.sort-order-icon { - display: block; - flex: none; -} - -.sort-menu-btn.order-desc .sort-order-icon, -.sort-order-icon.desc { - transform: scaleY(-1); -} - -.sort-caret { - width: 7px; - height: 7px; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: translateY(-2px) rotate(45deg); -} - -.sort-menu { - position: absolute; - left: 0; - top: calc(100% + 2px); - z-index: 20; - min-width: 200px; - padding: 4px 0; - background: var(--main-color); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18); -} - -.sort-menu[hidden] { - display: none !important; -} - -.sort-group-label { - padding: 8px 10px 4px; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: rgba(255, 255, 255, 0.55); -} - -.sort-menu-item { +.hdr .sort-th { display: flex; align-items: center; - gap: 8px; - width: 100%; - padding: 8px 10px 8px 28px; /* why: reserve space for the active-row checkmark. */ - border: 0; - background: transparent; - color: #ffffff; - box-sizing: border-box; - text-align: left; - font: inherit; + gap: 6px; cursor: pointer; - position: relative; + user-select: none; } -/* note: same tint as .explore-menu-item hover/.selected, so the two menus match */ -.sort-menu-item:hover, -.sort-menu-item:focus-visible, -.sort-menu-item[aria-checked="true"] { - outline: none; - background: rgba(255, 255, 255, 0.14); +.hdr .sort-th .sort-tri { + width: 0; + height: 0; + flex: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + display: none; } -.sort-menu-item[aria-checked="true"]::before { - content: "\2713"; /* note: checkmark on the active field/order. */ - position: absolute; - left: 10px; +/* faint up-triangle hint on hover, only while the column is not the active sort */ +.hdr .sort-th:hover .sort-tri { + display: block; + border-bottom: 5px solid var(--muted); +} + +/* active column wins over the hover hint (same specificity, declared later) */ +.hdr .sort-th[data-sort="asc"] .sort-tri { + display: block; + border-bottom: 5px solid var(--text); + border-top: 0; +} + +.hdr .sort-th[data-sort="desc"] .sort-tri { + display: block; + border-top: 5px solid var(--text); + border-bottom: 0; +} + +.hdr .sort-th[data-sort="asc"], +.hdr .sort-th[data-sort="desc"] { + color: var(--text); } diff --git a/resources/web/dialog/PluginsDialog/plugin-sort.js b/resources/web/dialog/PluginsDialog/plugin-sort.js index f83ba2f767..f35968d1ec 100644 --- a/resources/web/dialog/PluginsDialog/plugin-sort.js +++ b/resources/web/dialog/PluginsDialog/plugin-sort.js @@ -1,6 +1,8 @@ // why: C++ owns ordering; this file only sends and reflects sort state. -const DEFAULT_PLUGIN_SORT = { key: "status", order: "asc" }; +const DEFAULT_PLUGIN_SORT = { key: "none", order: "asc" }; +// note: SORT_FIELDS are the clickable columns. "none" is the baseline/cleared state, not a field - +// it is special-cased in NormalizePluginSort and produced by CyclePluginSort's third click. const SORT_FIELDS = new Set(["status", "name", "source"]); let pluginSort = { ...DEFAULT_PLUGIN_SORT }; @@ -8,14 +10,14 @@ let pluginSort = { ...DEFAULT_PLUGIN_SORT }; function NormalizePluginSort(sortKey, sortOrder) { const key = String(sortKey || ""); return { - key: SORT_FIELDS.has(key) ? key : DEFAULT_PLUGIN_SORT.key, + key: key === "none" ? "none" : (SORT_FIELDS.has(key) ? key : DEFAULT_PLUGIN_SORT.key), order: sortOrder === "desc" ? "desc" : DEFAULT_PLUGIN_SORT.order, }; } function RequestPluginSort(sortKey, sortOrder) { pluginSort = NormalizePluginSort(sortKey, sortOrder); - RenderSortMenuState(); + RenderSortHeaders(); if (typeof SendMessage === "function") SendMessage("set_plugin_sort", { @@ -24,67 +26,42 @@ function RequestPluginSort(sortKey, sortOrder) { }); } -let sortMenuEl = null; -let sortMenuButton = null; -let sortCurrentLabel = null; - -function InitSortDropdown() { - sortMenuEl = document.getElementById("sortMenu"); - sortMenuButton = document.getElementById("sort_menu_btn"); - sortCurrentLabel = document.getElementById("sort_current_label"); - if (!sortMenuButton || !sortMenuEl) +// why: one click per column cycles asc -> desc -> clear; setting any column clears the previous +// one for free because C++ (and pluginSort) only ever hold a single key. +function CyclePluginSort(field) { + if (!SORT_FIELDS.has(field)) return; - sortMenuButton.addEventListener("click", ToggleSortMenu); - sortMenuEl.addEventListener("click", OnSortMenuClick); - RenderSortMenuState(); + if (pluginSort.key !== field) + RequestPluginSort(field, "asc"); + else if (pluginSort.order === "asc") + RequestPluginSort(field, "desc"); + else + RequestPluginSort("none", "asc"); // third click: back to baseline } -function ToggleSortMenu(event) { - event.preventDefault(); - event.stopPropagation(); - if (!sortMenuEl) - return; - const willShow = sortMenuEl.hidden; - // why: our stopPropagation blocks index.js's outside-click handler, so close the sibling explore menu ourselves. - if (willShow && typeof HideExploreMenu === "function") - HideExploreMenu(); - sortMenuEl.hidden = !willShow; - sortMenuButton.setAttribute("aria-expanded", willShow ? "true" : "false"); +// why: paints the sort indicator for headers +// e.g., when user clicks triangle to change sort order, or change to sort by a new different field +function RenderSortHeaders() { + document.querySelectorAll(".hdr .sort-th").forEach((th) => { + // "" | "asc" | "desc" - renders the triangle via plugin-sort.css [data-sort=...]. + th.dataset.sort = th.dataset.sortField === pluginSort.key ? pluginSort.order : ""; + }); } -function HideSortMenu() { - if (!sortMenuEl || sortMenuEl.hidden) - return; - sortMenuEl.hidden = true; - sortMenuButton.setAttribute("aria-expanded", "false"); -} - -function OnSortMenuClick(event) { - const item = event.target.closest("[data-sort-field],[data-sort-order]"); - if (!item) - return; - event.preventDefault(); - const sortKey = item.dataset.sortField || pluginSort.key; - const sortOrder = item.dataset.sortOrder || pluginSort.order; - // why: leave the menu open so the user can set field then order in one visit; outside-click/Escape close it. - RequestPluginSort(sortKey, sortOrder); -} - -function RenderSortMenuState() { - const field = sortMenuEl?.querySelector(`[data-sort-field="${pluginSort.key}"]`); - if (sortCurrentLabel) - sortCurrentLabel.textContent = field?.textContent.trim() || "Status"; - sortMenuButton?.classList.toggle("order-desc", pluginSort.order === "desc"); - sortMenuEl?.querySelectorAll("[data-sort-field]").forEach((el) => - el.setAttribute("aria-checked", el.dataset.sortField === pluginSort.key ? "true" : "false")); - sortMenuEl?.querySelectorAll("[data-sort-order]").forEach((el) => - el.setAttribute("aria-checked", el.dataset.sortOrder === pluginSort.order ? "true" : "false")); +function InitSortHeaders() { + document.querySelectorAll(".hdr .sort-th").forEach((th) => { + th.addEventListener("click", () => CyclePluginSort(th.dataset.sortField)); + // note: role="button" cells need Enter/Space to match the old dropdown's keyboard access. + th.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + CyclePluginSort(th.dataset.sortField); + } + }); + }); + RenderSortHeaders(); // paint the initial state (baseline = no triangle) } // why: guarded so the module can be loaded in headless syntax checks. -// note: owns its own lifecycle + outside-click/Escape, so index.js's OnInit needs no edit. -if (typeof document !== "undefined") { - document.addEventListener("DOMContentLoaded", InitSortDropdown); - document.addEventListener("click", (event) => { if (!event.target.closest(".sort-dropdown")) HideSortMenu(); }); - document.addEventListener("keydown", (event) => { if (event.key === "Escape") HideSortMenu(); }); -} +if (typeof document !== "undefined") + document.addEventListener("DOMContentLoaded", InitSortHeaders); diff --git a/resources/web/dialog/PluginsDialog/styles.css b/resources/web/dialog/PluginsDialog/styles.css index ce9d4f3a3d..ef1c3b4d92 100644 --- a/resources/web/dialog/PluginsDialog/styles.css +++ b/resources/web/dialog/PluginsDialog/styles.css @@ -187,7 +187,27 @@ body { } .plugin-cols { - grid-template-columns: 70px minmax(0, 2.8fr) minmax(120px, 0.9fr) minmax(140px, 1fr); + grid-template-columns: 70px minmax(0, 2.4fr) minmax(110px, 0.85fr) minmax(96px, 0.7fr) minmax(130px, 0.95fr); +} + +/* Source is its own (sortable) column, shown as colored text (mirrors .status-cell), not a chip. */ +.source-cell { + display: flex; + align-items: center; +} + +.source-cell.source-mine { + color: var(--plugin-source-mine-text); + font-weight: 600; +} + +.source-cell.source-subscribed { + color: var(--plugin-source-subscribed-text); + font-weight: 600; +} + +.source-cell.source-local { + color: var(--plugin-source-neutral-text); } /* Center the "Activate" header over the centered checkbox in each row. */ @@ -835,11 +855,6 @@ body { white-space: nowrap; } -/* In a list row, sit at the right edge of the Name column (the name fills the rest). */ -.label-cell .plugin-source-badge { - margin-right: 4px; -} - .plugin-source-badge.source-local { background: var(--plugin-source-neutral-bg); color: var(--plugin-source-neutral-text); diff --git a/src/slic3r/GUI/PluginSort.hpp b/src/slic3r/GUI/PluginSort.hpp index 5808f693b4..608c2888ef 100644 --- a/src/slic3r/GUI/PluginSort.hpp +++ b/src/slic3r/GUI/PluginSort.hpp @@ -15,7 +15,10 @@ namespace Slic3r::GUI { Status, Name, - Source + Source, + // 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 @@ -31,6 +34,7 @@ namespace Slic3r::GUI case PluginSortKey::Status: return "status"; case PluginSortKey::Name: return "name"; case PluginSortKey::Source: return "source"; + case PluginSortKey::None: return "none"; } return "status"; @@ -49,6 +53,8 @@ namespace Slic3r::GUI return PluginSortKey::Name; if (sort_key == "source") return PluginSortKey::Source; + if (sort_key == "none") + return PluginSortKey::None; return fallback; } @@ -151,6 +157,10 @@ namespace Slic3r::GUI return compare_ascii_case_insensitive_natural(lhs.display_name, rhs.display_name); case PluginSortKey::Source: return static_cast(lhs.source) - static_cast(rhs.source); + 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; diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 4115776e71..55885262a5 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -210,7 +210,7 @@ private: } std::function m_open_terminal_dlg_fn; - PluginSortKey m_plugin_sort_key = PluginSortKey::Status; + PluginSortKey m_plugin_sort_key = PluginSortKey::None; PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc; // Serializes run_script_plugin. With main-thread execution a plugin's orca.host.ui modal diff --git a/tests/slic3rutils/test_plugin_sort.cpp b/tests/slic3rutils/test_plugin_sort.cpp index 83889bf4f8..98ac71c656 100644 --- a/tests/slic3rutils/test_plugin_sort.cpp +++ b/tests/slic3rutils/test_plugin_sort.cpp @@ -136,9 +136,30 @@ TEST_CASE("natural compare handles digits, case, prefixes and leading zeros", "[ CHECK(compare_ascii_case_insensitive_natural("", "a") < 0); } +TEST_CASE("plugin dialog None sort key falls to ascending base order in both directions", "[plugin][sort]") +{ + std::vector items = { + {"local_z", PluginSource::Local, PluginStatus::Activated, "script", "Zeta"}, + {"mine_a", PluginSource::Mine, PluginStatus::Inactive, "script", "Alpha"}, + {"sub_m", PluginSource::Subscribed, PluginStatus::Error, "script", "Mu"}, + }; + + // why: base order is source priority (Mine, Subscribed, Local) then name - and it ignores the + // requested status/order entirely, so the neutral baseline is deterministic. + const std::vector base_expected = {"mine_a", "sub_m", "local_z"}; + + sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Asc); + CHECK(keys(items) == base_expected); + + // why: None has no direction - Desc must not reverse the baseline. + sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Desc); + CHECK(keys(items) == base_expected); +} + TEST_CASE("plugin dialog sort request parsing keeps previous state on invalid values", "[plugin][sort]") { CHECK(plugin_sort_key_from_string("source", PluginSortKey::Status) == PluginSortKey::Source); + CHECK(plugin_sort_key_from_string("none", PluginSortKey::Status) == PluginSortKey::None); CHECK(plugin_sort_key_from_string("missing", PluginSortKey::Name) == PluginSortKey::Name); CHECK(plugin_sort_order_from_string("desc", PluginSortOrder::Asc) == PluginSortOrder::Desc); From a11e442f1d73ff6f08ff4862c27d707fbb614d39 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:01:21 +0800 Subject: [PATCH 3/8] Add version sorting for plugins Enhance plugin dialog with semver-aware sorting by version, enabling users to sort plugins based on version comparisons. --- resources/web/dialog/PluginsDialog/index.html | 3 ++- .../web/dialog/PluginsDialog/plugin-sort.js | 2 +- src/slic3r/GUI/PluginSort.hpp | 25 +++++++++++++++++++ src/slic3r/GUI/PluginsDialog.cpp | 4 +++ tests/slic3rutils/test_plugin_sort.cpp | 20 +++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index 438f6eb4e9..853d1a8464 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -47,7 +47,8 @@ Activate Name - Plugin Version + Plugin Version Source #include #include @@ -16,6 +18,7 @@ namespace Slic3r::GUI 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 @@ -34,6 +37,7 @@ namespace Slic3r::GUI 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"; } @@ -53,6 +57,8 @@ namespace Slic3r::GUI 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; @@ -141,6 +147,23 @@ namespace Slic3r::GUI 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. @@ -157,6 +180,8 @@ namespace Slic3r::GUI return compare_ascii_case_insensitive_natural(lhs.display_name, rhs.display_name); case PluginSortKey::Source: return static_cast(lhs.source) - static_cast(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). diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index bed37f2ebe..070bc36a14 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -89,6 +89,7 @@ struct PluginDialogItem std::string version; std::string installed_version; std::string latest_version; + std::string sort_version; // Version shown in the row (installed if installed, else latest); used by the Version sort. std::string type_label; std::string type_key; std::string sharing_token; @@ -319,6 +320,9 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) (descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version) : std::string{}; item.latest_version = descriptor.latest_available_version(); + // why: sort by the same version the row displays (GetDisplayVersion in index.js) - installed when + // installed, otherwise latest - so the Version sort matches what the user sees. + item.sort_version = item.installed_version.empty() ? item.latest_version : item.installed_version; item.type_label = descriptor.type_label(); item.type_key = plugin_capability_type_to_string(descriptor.primary_capability_type()); // "types" is the display-only compatibility list. Cloud plugins show the raw labels the diff --git a/tests/slic3rutils/test_plugin_sort.cpp b/tests/slic3rutils/test_plugin_sort.cpp index 98ac71c656..2c1b3b7a2c 100644 --- a/tests/slic3rutils/test_plugin_sort.cpp +++ b/tests/slic3rutils/test_plugin_sort.cpp @@ -23,6 +23,7 @@ struct SortFixtureItem PluginStatus status; std::string type_key; std::string display_name; + std::string sort_version; }; std::vector keys(const std::vector& items) @@ -90,6 +91,25 @@ TEST_CASE("plugin dialog source sort uses enum priority", "[plugin][sort]") CHECK(keys(items) == desc_expected); } +TEST_CASE("plugin dialog version sort is semver-aware with base-order ties", "[plugin][sort]") +{ + std::vector items = { + {"v_1_2_0", PluginSource::Local, PluginStatus::Activated, "script", "B", "1.2.0"}, + {"v_1_10_0", PluginSource::Local, PluginStatus::Activated, "script", "A", "1.10.0"}, + {"v_0_9_3", PluginSource::Local, PluginStatus::Activated, "script", "C", "0.9.3"}, + }; + + sort_plugin_items_for_dialog(items, PluginSortKey::Version, PluginSortOrder::Asc); + // why: semver numeric compare - 1.10.0 > 1.2.0 (not lexical "1.10" < "1.2"), so ascending is + // 0.9.3 < 1.2.0 < 1.10.0. + const std::vector asc_expected = {"v_0_9_3", "v_1_2_0", "v_1_10_0"}; + CHECK(keys(items) == asc_expected); + + sort_plugin_items_for_dialog(items, PluginSortKey::Version, PluginSortOrder::Desc); + const std::vector desc_expected = {"v_1_10_0", "v_1_2_0", "v_0_9_3"}; + CHECK(keys(items) == desc_expected); +} + TEST_CASE("plugin dialog name sort is case-insensitive and numeric-aware", "[plugin][sort]") { std::vector items = { From ee7339b64bec9ced661cce63106665657090d0a1 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:24:44 +0800 Subject: [PATCH 4/8] 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. --- src/slic3r/GUI/PluginSort.hpp | 16 +++++++------- tests/slic3rutils/test_plugin_sort.cpp | 30 ++++++++++++++------------ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/slic3r/GUI/PluginSort.hpp b/src/slic3r/GUI/PluginSort.hpp index c75d0fdf53..d0a45cbd05 100644 --- a/src/slic3r/GUI/PluginSort.hpp +++ b/src/slic3r/GUI/PluginSort.hpp @@ -129,20 +129,20 @@ namespace Slic3r::GUI return li == lhs.size() ? -1 : 1; } - // Neutral baseline order used as the tie-breaker under every primary sort key. Always - // ascending: source priority, then type_key, then display_name, then plugin_key. - // e.g. two items with equal Status sort by source priority (Mine, then Subscribed, - // then Local), then by name. + // 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 int compare_plugin_base_order(const PluginItem& lhs, const PluginItem& rhs) { - // why: source ties use the declared PluginSource ordinal priority - the same order the - // Source sort key uses - so the neutral baseline never contradicts it. + 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(lhs.source) - static_cast(rhs.source); cmp != 0) return cmp; - if (const int cmp = lhs.type_key.compare(rhs.type_key); cmp != 0) + if (const int cmp = static_cast(lhs.status) - static_cast(rhs.status); cmp != 0) return cmp; - if (const int cmp = lhs.display_name.compare(rhs.display_name); cmp != 0) + if (const int cmp = lhs.type_key.compare(rhs.type_key); cmp != 0) return cmp; return lhs.plugin_key.compare(rhs.plugin_key); } diff --git a/tests/slic3rutils/test_plugin_sort.cpp b/tests/slic3rutils/test_plugin_sort.cpp index 2c1b3b7a2c..58db799dd8 100644 --- a/tests/slic3rutils/test_plugin_sort.cpp +++ b/tests/slic3rutils/test_plugin_sort.cpp @@ -49,11 +49,11 @@ TEST_CASE("plugin dialog status sort uses requested priority and base-order ties sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Asc); - // why: local_activated and mine_activated tie on Status, so base order breaks the tie by - // declared source priority - Mine (ordinal 0) before Local (ordinal 2). + // why: local_activated and mine_activated tie on Status, so base order breaks the tie by name + // (case-insensitive) - "Local Activated" before "Mine Activated". const std::vector expected = { - "mine_activated", "local_activated", + "mine_activated", "mine_error", "local_inactive", "subscribed_loading", @@ -63,13 +63,13 @@ TEST_CASE("plugin dialog status sort uses requested priority and base-order ties sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Desc); // why: Desc reverses the status ordinal, but the Activated tie still resolves by ascending - // base order (Mine before Local) - the direction only flips the primary key. + // base order (name: "Local Activated" before "Mine Activated") - direction only flips the key. const std::vector desc_expected = { "subscribed_loading", "local_inactive", "mine_error", - "mine_activated", "local_activated", + "mine_activated", }; CHECK(keys(items) == desc_expected); } @@ -121,14 +121,16 @@ TEST_CASE("plugin dialog name sort is case-insensitive and numeric-aware", "[plu sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Asc); - const std::vector expected = {"ada_upper", "ada_lower", "rig2", "rig10"}; + // why: "Ada"/"ada" tie on the case-insensitive name (primary AND base name level), so the tie + // falls through source/status/type to plugin_key: "ada_lower" before "ada_upper". + const std::vector expected = {"ada_lower", "ada_upper", "rig2", "rig10"}; CHECK(keys(items) == expected); sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Desc); // why: names reverse ("Rig 10" before "Rig 2"), but "Ada"/"ada" tie on the case-insensitive - // key and keep ascending base order (case-sensitive "Ada" < "ada"). - const std::vector desc_expected = {"rig10", "rig2", "ada_upper", "ada_lower"}; + // key and keep ascending base order, which resolves by plugin_key ("ada_lower" < "ada_upper"). + const std::vector desc_expected = {"rig10", "rig2", "ada_lower", "ada_upper"}; CHECK(keys(items) == desc_expected); } @@ -159,14 +161,14 @@ TEST_CASE("natural compare handles digits, case, prefixes and leading zeros", "[ TEST_CASE("plugin dialog None sort key falls to ascending base order in both directions", "[plugin][sort]") { std::vector items = { - {"local_z", PluginSource::Local, PluginStatus::Activated, "script", "Zeta"}, - {"mine_a", PluginSource::Mine, PluginStatus::Inactive, "script", "Alpha"}, - {"sub_m", PluginSource::Subscribed, PluginStatus::Error, "script", "Mu"}, + {"z_mine", PluginSource::Mine, PluginStatus::Activated, "script", "Zebra"}, + {"a_local", PluginSource::Local, PluginStatus::Activated, "script", "Apple"}, + {"m_sub", PluginSource::Subscribed, PluginStatus::Error, "script", "Mango"}, }; - // why: base order is source priority (Mine, Subscribed, Local) then name - and it ignores the - // requested status/order entirely, so the neutral baseline is deterministic. - const std::vector base_expected = {"mine_a", "sub_m", "local_z"}; + // why: no primary key -> pure name-first base order (Apple < Mango < Zebra). A source-first + // baseline would instead give {z_mine, m_sub, a_local}, so this pins the name-first order. + const std::vector base_expected = {"a_local", "m_sub", "z_mine"}; sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Asc); CHECK(keys(items) == base_expected); From bc215511853054a2e93eab9abd6655c04158d8f4 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:01:21 +0800 Subject: [PATCH 5/8] Implement plugin search functionality --- resources/web/dialog/PluginsDialog/index.html | 23 +++ resources/web/dialog/PluginsDialog/index.js | 83 ++++++++-- .../dialog/PluginsDialog/plugin-search.css | 116 +++++++++++++ .../web/dialog/PluginsDialog/plugin-search.js | 156 ++++++++++++++++++ 4 files changed, 368 insertions(+), 10 deletions(-) create mode 100644 resources/web/dialog/PluginsDialog/plugin-search.css create mode 100644 resources/web/dialog/PluginsDialog/plugin-search.js diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index 853d1a8464..fa15b792e0 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -6,6 +6,7 @@ Plugins + @@ -16,10 +17,32 @@ +
+ diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index b2e7b235ea..7b7821e6b5 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -11,6 +11,12 @@ const pluginInstallActions = { }; let expandedPluginIds = new Set(); + +// why: transient per-search override on top of expandedPluginIds. A search +// auto-expands rows whose capabilities match, display-only. This lets a +// triangle click during search collapse/reopen such a row without touching +// the base (id -> bool). +let searchExpandOverride = new Map(); let selectedPluginId = ""; let contextPluginId = ""; let activeDetailTab = "plugin-info"; @@ -299,6 +305,27 @@ function SyncPluginListHeaderGutter() { listPane.style.setProperty("--plugin-list-scrollbar-width", `${scrollbarWidth}px`); } +// why: paint matched-character ranges as without an innerHTML build +// note: if no ranges -> return the plain text node +function ApplyHighlight(container, text, ranges) { + if (!ranges || !ranges.length) { + container.appendChild(document.createTextNode(text)); + return; + } + let pos = 0; + for (const [start, end] of ranges) { + if (start > pos) + container.appendChild(document.createTextNode(text.slice(pos, start))); + const mark = document.createElement("mark"); + mark.className = "plugin-search-hit"; + mark.textContent = text.slice(start, end); + container.appendChild(mark); + pos = end; + } + if (pos < text.length) + container.appendChild(document.createTextNode(text.slice(pos))); +} + function RenderPlugins() { if (!pluginList) return; @@ -313,10 +340,28 @@ function RenderPlugins() { return; } + // why: stable filter over the existing C++ sort order - no scoring, no reorder. The empty query + // short-circuits (searching=false), leaving every existing render path untouched. + const searching = typeof PluginSearchActive === "function" && PluginSearchActive(); + let shown = 0; + for (const plugin of pluginsById.values()) { const pluginKey = String(plugin.plugin_key || ""); const capabilities = GetCapabilities(plugin); - const isExpanded = expandedPluginIds.has(pluginKey) && capabilities.length > 0; + const match = searching ? ComputePluginMatch(plugin) : null; + if (searching && !match.matched) + continue; + shown++; + + // why: transient override wins. Otherwise while searching start collapsed and auto-expand only + // capability matches (the persistent expand state is ignored so unrelated caps don't clutter + // results); when not searching use the persistent state. The base is never written while + // searching, so clearing the search restores exactly what the user had. + const open = searchExpandOverride.has(pluginKey) + ? searchExpandOverride.get(pluginKey) + : (searching ? match.hasCapMatch : expandedPluginIds.has(pluginKey)); + const isExpanded = open && capabilities.length > 0; + const block = document.createElement("div"); block.className = "plugin-block"; block.dataset.pluginKey = pluginKey; @@ -331,16 +376,28 @@ function RenderPlugins() { row.classList.add("selected"); row.appendChild(CheckCell(row, plugin)); - row.appendChild(LabelCell(plugin, isExpanded, capabilities.length)); + row.appendChild(LabelCell(plugin, isExpanded, capabilities.length, match?.nameRanges)); row.appendChild(VersionCell(plugin)); row.appendChild(SourceCell(plugin)); row.appendChild(StatusCell(plugin)); block.appendChild(row); if (isExpanded) - block.appendChild(RenderCapabilityTree(plugin, capabilities)); + block.appendChild(RenderCapabilityTree(plugin, capabilities, match?.capRanges)); pluginList.appendChild(block); } + + // why: distinct from the size===0 "no plugins" state - here plugins exist but none match the query. + if (searching && shown === 0) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.appendChild(document.createTextNode('No plugins match "')); + const term = document.createElement("b"); + term.textContent = pluginSearch.query; + empty.appendChild(term); + empty.appendChild(document.createTextNode('"')); + pluginList.appendChild(empty); + } } function GetErrorText(plugin) { @@ -479,7 +536,7 @@ function CheckCell(row, plugin) { return checkCell; } -function LabelCell(plugin, isExpanded = false, capabilityCount = 0) { +function LabelCell(plugin, isExpanded = false, capabilityCount = 0, nameRanges = null) { const labelCell = document.createElement("span"); labelCell.className = "label-cell"; @@ -510,7 +567,7 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0) { nameWrap.className = "plugin-name-wrap"; const labelElement = document.createElement(hasCloudLink ? "a" : "span"); - labelElement.textContent = pluginLabelText; + ApplyHighlight(labelElement, pluginLabelText, nameRanges); labelElement.className = "plugin-name-text"; if (hasCloudLink) { @@ -546,20 +603,20 @@ function SourceCell(plugin) { return cell; } -function RenderCapabilityTree(plugin, capabilities) { +function RenderCapabilityTree(plugin, capabilities, capRanges = null) { const tree = document.createElement("div"); tree.className = "capabilities-tree"; tree.setAttribute("role", "group"); tree.setAttribute("aria-label", "Capabilities"); capabilities.forEach((capability, index) => { - tree.appendChild(RenderCapabilityRow(plugin, capability, index === capabilities.length - 1)); + tree.appendChild(RenderCapabilityRow(plugin, capability, index === capabilities.length - 1, capRanges)); }); return tree; } -function RenderCapabilityRow(plugin, capability, isLast) { +function RenderCapabilityRow(plugin, capability, isLast, capRanges = null) { const row = document.createElement("div"); row.className = "capability-row plugin-cols"; row.classList.toggle("is-last", isLast); @@ -574,7 +631,8 @@ function RenderCapabilityRow(plugin, capability, isLast) { branch.setAttribute("aria-hidden", "true"); const name = document.createElement("span"); name.className = "capability-name"; - name.textContent = String(capability?.name || "") || "-"; + const capabilityLabel = String(capability?.name || ""); + ApplyHighlight(name, capabilityLabel || "-", capRanges?.get(capabilityLabel)); nameCell.appendChild(branch); nameCell.appendChild(name); row.appendChild(nameCell); @@ -921,7 +979,12 @@ function OnPluginListClick(event) { const pluginKey = String(block.dataset.pluginKey || ""); selectedPluginId = pluginKey; - if (expandedPluginIds.has(pluginKey)) + // why: during a search the triangle writes to the transient override (read from the on-screen open + // state), so an auto-expanded row collapses without touching the saved layout. With no search + // active, toggle the persistent base exactly as before. + if (typeof PluginSearchActive === "function" && PluginSearchActive()) + searchExpandOverride.set(pluginKey, !block.classList.contains("expanded")); + else if (expandedPluginIds.has(pluginKey)) expandedPluginIds.delete(pluginKey); else expandedPluginIds.add(pluginKey); diff --git a/resources/web/dialog/PluginsDialog/plugin-search.css b/resources/web/dialog/PluginsDialog/plugin-search.css new file mode 100644 index 0000000000..99d9128aa2 --- /dev/null +++ b/resources/web/dialog/PluginsDialog/plugin-search.css @@ -0,0 +1,116 @@ +.plugin-search { + --search-width: 300px; + flex: 0 0 auto; + width: var(--search-width); + /* why: the search bar takes the auto margin (pinned far left); sort + Refresh + Install cluster right. */ + margin-right: auto; + display: flex; + align-items: center; + gap: 2px; + /* why: match the compact toolbar buttons (.toolbar .ButtonTypeChoice is 26px) so the row aligns. */ + height: 26px; + padding: 0 6px; + box-sizing: border-box; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; +} + +.plugin-search:focus-within { + border-color: var(--main-color); + box-shadow: 0 0 0 2px rgba(0, 150, 136, 0.25); +} + +.plugin-search-icon { + display: inline-flex; + color: var(--muted); +} + +.plugin-search-input { + flex: 1; + min-width: 0; + background: transparent; + border: 0; + outline: 0; + color: var(--text); + font: inherit; +} + +.plugin-search-input::placeholder { + color: var(--muted); +} + +/* why: compact squarish toggles - min-width keeps single-char W from collapsing while two-char Cc + grows just enough to fit; tight horizontal padding keeps them from reading as wide pills. */ +.plugin-search-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 1px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; + box-sizing: border-box; +} + +/* why: set the clear x apart from the Cc/W pair while keeping the pair itself tight - margins on the + toggles only, so the icon-to-text gap is left as-is. clear+toggle = Cc, toggle+toggle = W. */ +.plugin-search-clear + .plugin-search-toggle { + margin-left: 4px; +} +.plugin-search-toggle + .plugin-search-toggle { + margin-left: -1px; +} + +/* why: subtle round clear affordance - a small muted disc, not a square button. The x is an inline SVG + (not a text glyph) so it centers pixel-perfectly regardless of the platform font. JS flips its + visibility (not display) so its reserved slot never reflows Cc/W. */ +.plugin-search-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 10px; + height: 10px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--muted); + cursor: pointer; + visibility: hidden; + background: rgba(127, 127, 127, 0.20); +} + +.plugin-search-clear svg { + display: block; +} + +.plugin-search-clear:hover { + color: var(--text); + background: rgba(127, 127, 127, 0.45); +} + +.plugin-search-toggle:hover { + color: var(--text); + background: var(--row-hover); +} + +.plugin-search-toggle.on { + color: var(--button-fg-light, #fff); + background: var(--main-color); + border-color: var(--main-color-hover); +} + +/* why: reuse the themed warn tokens so matched-char marks track light and dark automatically. + note: no padding/margin/border - a highlight must not change text width, else rows reflow. */ +mark.plugin-search-hit { + border-radius: 2px; + background: var(--plugin-status-warn-bg); + color: var(--plugin-status-warn); +} diff --git a/resources/web/dialog/PluginsDialog/plugin-search.js b/resources/web/dialog/PluginsDialog/plugin-search.js new file mode 100644 index 0000000000..2df851507b --- /dev/null +++ b/resources/web/dialog/PluginsDialog/plugin-search.js @@ -0,0 +1,156 @@ +const pluginSearch = { query: "", caseSensitive: false, wholeWord: false }; + +function PluginSearchActive() { + return pluginSearch.query.length > 0; +} + +// --- matcher: fold per-character on the fly so matched offsets stay in ORIGINAL coordinates --- +// why: highlighting marks slices of the original string; a separate folded string would desync offsets. +function FoldChar(ch) { + return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded (both Cc states) +} +function Norm(ch, caseSensitive) { + const folded = FoldChar(ch); + return caseSensitive ? folded : folded.toLowerCase(); // Cc controls case only +} +function EscapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function MatchText(text, query) { + if (!query) + return []; + return pluginSearch.wholeWord ? WholeWordRanges(text, query) : FuzzyRanges(text, query); +} + +// Fuzzy: ordered subsequence. Builds ranges in original coordinates, merging adjacent runs on the fly. +function FuzzyRanges(text, query) { + const caseSensitive = pluginSearch.caseSensitive; + const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join(""); + const ranges = []; + let qi = 0; + for (let i = 0; i < text.length && qi < needle.length; i++) { + if (Norm(text[i], caseSensitive) === needle[qi]) { + const last = ranges[ranges.length - 1]; + if (last && last[1] === i) + last[1] = i + 1; + else + ranges.push([i, i + 1]); + qi++; + } + } + return qi === needle.length ? ranges : null; +} + +// Whole word: literal \b-bounded match that bypasses fuzzy; Cc still applies. The per-char fold keeps the +// haystack length-aligned to the original text, so regex indices map straight back to original offsets. +// note: one-to-many folds (ligatures, eszett) shift offsets by a char; rare in plugin names, cosmetic only. +function WholeWordRanges(text, query) { + const caseSensitive = pluginSearch.caseSensitive; + const haystack = Array.from(text).map((ch) => Norm(ch, caseSensitive)).join(""); + const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join(""); + if (!needle) + return null; + const re = new RegExp(`\\b${EscapeRegExp(needle)}\\b`, "g"); + const ranges = []; + let match; + // why: needle is non-empty, so \b-bounded matches are never zero-length - no empty-match guard needed. + while ((match = re.exec(haystack)) !== null) + ranges.push([match.index, match.index + match[0].length]); + return ranges.length > 0 ? ranges : null; +} + +// Per-plugin evaluator consumed by RenderPlugins. The name text mirrors LabelCell's pluginLabelText so +// highlight offsets line up with what is rendered. Capability names exist for loaded plugins only. +function ComputePluginMatch(plugin) { + const name = plugin.label || plugin.name || plugin.plugin_id || ""; + const nameRanges = MatchText(name, pluginSearch.query); + const capabilities = Array.isArray(plugin?.capabilities) ? plugin.capabilities : []; + const capRanges = new Map(); + for (const capability of capabilities) { + const key = String(capability?.name || ""); + const ranges = MatchText(key, pluginSearch.query); + if (ranges) + capRanges.set(key, ranges); + } + return { + matched: !!nameRanges || capRanges.size > 0, + nameRanges, + capRanges, + hasCapMatch: capRanges.size > 0, + }; +} + +// --- widget wiring --- +let pluginSearchInput = null; +let pluginSearchClear = null; +let pluginSearchCc = null; +let pluginSearchW = null; + +function InitPluginSearch() { + pluginSearchInput = document.getElementById("plugin_search_input"); + pluginSearchClear = document.getElementById("plugin_search_clear"); + pluginSearchCc = document.getElementById("plugin_search_cc"); + pluginSearchW = document.getElementById("plugin_search_w"); + if (!pluginSearchInput) + return; + + // why: common.js installs a document-level onkeydown that cancels the default action of every key + // (returnValue=false) to block webview shortcuts; on the way up it also swallows typing. Stop the + // field's keydowns from bubbling to it so the input stays editable, leaving the global guard intact. + pluginSearchInput.addEventListener("keydown", (event) => event.stopPropagation()); + + pluginSearchInput.addEventListener("input", OnPluginSearchInput); + pluginSearchClear?.addEventListener("click", ClearPluginSearch); + pluginSearchCc?.addEventListener("click", () => TogglePluginSearchFlag(pluginSearchCc, "caseSensitive")); + pluginSearchW?.addEventListener("click", () => TogglePluginSearchFlag(pluginSearchW, "wholeWord")); + SyncPluginSearchClear(); +} + +function OnPluginSearchInput() { + pluginSearch.query = pluginSearchInput.value; + // why: emptying the box by editing (not just the x) also ends the search - drop the transient vetoes. + if (!pluginSearch.query) + ClearSearchExpandOverride(); + SyncPluginSearchClear(); + RenderPluginsIfReady(); +} + +function ClearPluginSearch() { + pluginSearch.query = ""; + if (pluginSearchInput) + pluginSearchInput.value = ""; + ClearSearchExpandOverride(); + SyncPluginSearchClear(); + RenderPluginsIfReady(); + pluginSearchInput?.focus(); +} + +function TogglePluginSearchFlag(button, key) { + pluginSearch[key] = !pluginSearch[key]; + button.classList.toggle("on", pluginSearch[key]); + button.setAttribute("aria-pressed", String(pluginSearch[key])); + RenderPluginsIfReady(); +} + +// why: toggle visibility (not display / the hidden attribute) so the x keeps its reserved slot and +// showing or hiding it never reflows the Cc / W buttons. +function SyncPluginSearchClear() { + if (pluginSearchClear) + pluginSearchClear.style.visibility = pluginSearch.query.length ? "visible" : "hidden"; +} + +// why: searchExpandOverride lives in index.js; guard so this module stays loadable on its own. +function ClearSearchExpandOverride() { + if (typeof searchExpandOverride !== "undefined") + searchExpandOverride.clear(); +} + +function RenderPluginsIfReady() { + if (typeof RenderPlugins === "function") + RenderPlugins(); +} + +// why: guarded so the module can be loaded in headless syntax checks; mirrors plugin-sort.js. +if (typeof document !== "undefined") + document.addEventListener("DOMContentLoaded", InitPluginSearch); From 8ddb4db0de45c984686ba06d47a1baf0345ccbaa Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:54:37 +0800 Subject: [PATCH 6/8] Recompute scrollbar gutter on every render Address issues with search and sort re-renders by ensuring the scrollbar gutter is consistently updated with each rendering cycle. --- resources/web/dialog/PluginsDialog/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index 7b7821e6b5..238de8ec9a 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -271,7 +271,6 @@ function ApplyPlugins(plugins) { expandedPluginIds = new Set(Array.from(expandedPluginIds).filter((pluginKey) => pluginsById.has(pluginKey))); RenderPlugins(); - SyncPluginListHeaderGutter(); RenderDetails(); } @@ -337,6 +336,7 @@ function RenderPlugins() { empty.className = "empty-state"; empty.textContent = "No plugins found"; pluginList.appendChild(empty); + SyncPluginListHeaderGutter(); return; } @@ -398,6 +398,9 @@ function RenderPlugins() { empty.appendChild(document.createTextNode('"')); pluginList.appendChild(empty); } + + // why: recompute the scrollbar gutter on every render - search and sort re-render via RenderPlugins + SyncPluginListHeaderGutter(); } function GetErrorText(plugin) { From cfe3673b229f9f955f4654a1b531344eb0833257 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:24:44 +0800 Subject: [PATCH 7/8] 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. --- src/slic3r/GUI/PluginSort.hpp | 16 +++++++------- tests/slic3rutils/test_plugin_sort.cpp | 30 ++++++++++++++------------ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/slic3r/GUI/PluginSort.hpp b/src/slic3r/GUI/PluginSort.hpp index c75d0fdf53..d0a45cbd05 100644 --- a/src/slic3r/GUI/PluginSort.hpp +++ b/src/slic3r/GUI/PluginSort.hpp @@ -129,20 +129,20 @@ namespace Slic3r::GUI return li == lhs.size() ? -1 : 1; } - // Neutral baseline order used as the tie-breaker under every primary sort key. Always - // ascending: source priority, then type_key, then display_name, then plugin_key. - // e.g. two items with equal Status sort by source priority (Mine, then Subscribed, - // then Local), then by name. + // 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 int compare_plugin_base_order(const PluginItem& lhs, const PluginItem& rhs) { - // why: source ties use the declared PluginSource ordinal priority - the same order the - // Source sort key uses - so the neutral baseline never contradicts it. + 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(lhs.source) - static_cast(rhs.source); cmp != 0) return cmp; - if (const int cmp = lhs.type_key.compare(rhs.type_key); cmp != 0) + if (const int cmp = static_cast(lhs.status) - static_cast(rhs.status); cmp != 0) return cmp; - if (const int cmp = lhs.display_name.compare(rhs.display_name); cmp != 0) + if (const int cmp = lhs.type_key.compare(rhs.type_key); cmp != 0) return cmp; return lhs.plugin_key.compare(rhs.plugin_key); } diff --git a/tests/slic3rutils/test_plugin_sort.cpp b/tests/slic3rutils/test_plugin_sort.cpp index 2c1b3b7a2c..58db799dd8 100644 --- a/tests/slic3rutils/test_plugin_sort.cpp +++ b/tests/slic3rutils/test_plugin_sort.cpp @@ -49,11 +49,11 @@ TEST_CASE("plugin dialog status sort uses requested priority and base-order ties sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Asc); - // why: local_activated and mine_activated tie on Status, so base order breaks the tie by - // declared source priority - Mine (ordinal 0) before Local (ordinal 2). + // why: local_activated and mine_activated tie on Status, so base order breaks the tie by name + // (case-insensitive) - "Local Activated" before "Mine Activated". const std::vector expected = { - "mine_activated", "local_activated", + "mine_activated", "mine_error", "local_inactive", "subscribed_loading", @@ -63,13 +63,13 @@ TEST_CASE("plugin dialog status sort uses requested priority and base-order ties sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Desc); // why: Desc reverses the status ordinal, but the Activated tie still resolves by ascending - // base order (Mine before Local) - the direction only flips the primary key. + // base order (name: "Local Activated" before "Mine Activated") - direction only flips the key. const std::vector desc_expected = { "subscribed_loading", "local_inactive", "mine_error", - "mine_activated", "local_activated", + "mine_activated", }; CHECK(keys(items) == desc_expected); } @@ -121,14 +121,16 @@ TEST_CASE("plugin dialog name sort is case-insensitive and numeric-aware", "[plu sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Asc); - const std::vector expected = {"ada_upper", "ada_lower", "rig2", "rig10"}; + // why: "Ada"/"ada" tie on the case-insensitive name (primary AND base name level), so the tie + // falls through source/status/type to plugin_key: "ada_lower" before "ada_upper". + const std::vector expected = {"ada_lower", "ada_upper", "rig2", "rig10"}; CHECK(keys(items) == expected); sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Desc); // why: names reverse ("Rig 10" before "Rig 2"), but "Ada"/"ada" tie on the case-insensitive - // key and keep ascending base order (case-sensitive "Ada" < "ada"). - const std::vector desc_expected = {"rig10", "rig2", "ada_upper", "ada_lower"}; + // key and keep ascending base order, which resolves by plugin_key ("ada_lower" < "ada_upper"). + const std::vector desc_expected = {"rig10", "rig2", "ada_lower", "ada_upper"}; CHECK(keys(items) == desc_expected); } @@ -159,14 +161,14 @@ TEST_CASE("natural compare handles digits, case, prefixes and leading zeros", "[ TEST_CASE("plugin dialog None sort key falls to ascending base order in both directions", "[plugin][sort]") { std::vector items = { - {"local_z", PluginSource::Local, PluginStatus::Activated, "script", "Zeta"}, - {"mine_a", PluginSource::Mine, PluginStatus::Inactive, "script", "Alpha"}, - {"sub_m", PluginSource::Subscribed, PluginStatus::Error, "script", "Mu"}, + {"z_mine", PluginSource::Mine, PluginStatus::Activated, "script", "Zebra"}, + {"a_local", PluginSource::Local, PluginStatus::Activated, "script", "Apple"}, + {"m_sub", PluginSource::Subscribed, PluginStatus::Error, "script", "Mango"}, }; - // why: base order is source priority (Mine, Subscribed, Local) then name - and it ignores the - // requested status/order entirely, so the neutral baseline is deterministic. - const std::vector base_expected = {"mine_a", "sub_m", "local_z"}; + // why: no primary key -> pure name-first base order (Apple < Mango < Zebra). A source-first + // baseline would instead give {z_mine, m_sub, a_local}, so this pins the name-first order. + const std::vector base_expected = {"a_local", "m_sub", "z_mine"}; sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Asc); CHECK(keys(items) == base_expected); From fb2c0ef23724fff55c7e19d4e95833344ec61c53 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:46:33 +0800 Subject: [PATCH 8/8] Update search button icons and styles Revise button labels and styling for search options to improve UI consistency. --- resources/web/dialog/PluginsDialog/index.html | 4 ++-- .../dialog/PluginsDialog/plugin-search.css | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index fa15b792e0..bca59a9a29 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -39,9 +39,9 @@ + aria-pressed="false" title="Match case">Aa + aria-pressed="false" title="Match whole word">ab
diff --git a/resources/web/dialog/PluginsDialog/plugin-search.css b/resources/web/dialog/PluginsDialog/plugin-search.css index 99d9128aa2..985d37528c 100644 --- a/resources/web/dialog/PluginsDialog/plugin-search.css +++ b/resources/web/dialog/PluginsDialog/plugin-search.css @@ -107,6 +107,28 @@ border-color: var(--main-color-hover); } +/* "ab" over a bracket drawn by ::after box draws it */ +.plugin-search-underline { + position: relative; + display: inline-block; + padding-bottom: 2px; +} +.plugin-search-underline::after { + content: ""; + position: absolute; + /* why: ticks stick out past the outer edges of "a"/"b" on each side. */ + left: -1px; + right: -1px; + bottom: 0; + /* why: short ticks + bottom rule, tucked near the baseline. */ + height: 2px; + border: 1px solid currentColor; + border-top: 0; + /* why: soften the two joints where the ticks meet the bottom rule. */ + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; +} + /* why: reuse the themed warn tokens so matched-char marks track light and dark automatically. note: no padding/margin/border - a highlight must not change text width, else rows reflow. */ mark.plugin-search-hit {