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 1/4] 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 2/4] 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 3/4] 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 4/4] 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 {