ui(controls): search picker now use proper fuzzy matching and can be reused in other places, ex: community palette picker

This commit is contained in:
Lemmy
2026-05-03 12:29:51 -04:00
parent f52c93325c
commit b9e424cccf
11 changed files with 301 additions and 32 deletions
+2 -1
View File
@@ -1091,7 +1091,8 @@
},
"community-palette": {
"label": "Community Palette",
"description": "Choose a palette from the community catalog"
"description": "Choose a palette from the community catalog",
"search-placeholder": "Search palettes..."
},
"ui-scale": {
"label": "UI Scale",
+4
View File
@@ -1309,7 +1309,11 @@ namespace settings {
pendingDeleteWidgetSettingPath.clear();
requestRebuild();
});
auto* pickerPtr = picker.get();
inspector->addChild(std::move(picker));
if (ctx.focusArea) {
ctx.focusArea(pickerPtr->filterInputArea());
}
}
}
+2
View File
@@ -10,6 +10,7 @@
class Button;
class Flex;
class InputArea;
class Node;
namespace settings {
@@ -29,6 +30,7 @@ namespace settings {
std::function<void()> requestRebuild;
std::function<void()> resetContentScroll;
std::function<void(InputArea*)> focusArea;
std::function<void(std::vector<std::string>, ConfigOverrideValue)> setOverride;
std::function<void(std::vector<std::pair<std::vector<std::string>, ConfigOverrideValue>>)> setOverrides;
std::function<void(std::vector<std::string>)> clearOverride;
+186
View File
@@ -10,6 +10,7 @@
#include "ui/controls/glyph.h"
#include "ui/controls/input.h"
#include "ui/controls/label.h"
#include "ui/controls/search_picker.h"
#include "ui/controls/segmented.h"
#include "ui/controls/select.h"
#include "ui/controls/separator.h"
@@ -58,6 +59,26 @@ namespace settings {
return std::nullopt;
}
std::string pathKey(const std::vector<std::string>& path) {
std::string out;
for (const auto& part : path) {
if (!out.empty()) {
out.push_back('.');
}
out += part;
}
return out;
}
std::string optionLabel(const std::vector<SelectOption>& options, std::string_view value) {
for (const auto& opt : options) {
if (opt.value == value) {
return opt.label;
}
}
return std::string(value);
}
std::vector<std::string> optionLabels(const std::vector<SelectOption>& options) {
std::vector<std::string> labels;
labels.reserve(options.size());
@@ -67,6 +88,19 @@ namespace settings {
return labels;
}
std::vector<SearchPickerOption> searchPickerOptions(const std::vector<SelectOption>& options) {
std::vector<SearchPickerOption> out;
out.reserve(options.size());
for (const auto& opt : options) {
out.push_back(SearchPickerOption{.value = opt.value,
.label = opt.label,
.description = opt.description,
.category = opt.category,
.enabled = true});
}
return out;
}
bool isBlankInput(std::string_view text) { return StringUtils::trim(text).empty(); }
const std::string& localeDecimalSeparator() {
@@ -634,6 +668,149 @@ namespace settings {
return select;
};
const auto makeSearchPickerButton = [&](const SettingEntry& entry,
const SearchPickerSetting& setting) -> std::unique_ptr<Node> {
auto button = std::make_unique<Button>();
button->setVariant(ButtonVariant::Outline);
button->setGlyph("search");
button->setText(optionLabel(setting.options, setting.selectedValue));
button->setContentAlign(ButtonContentAlign::Start);
button->setFontSize(Style::fontSizeBody * scale);
button->setGlyphSize(Style::fontSizeBody * scale);
button->setMinWidth(190.0f * scale);
button->setMinHeight(Style::controlHeight * scale);
button->setPadding(Style::spaceSm * scale, Style::spaceMd * scale);
button->setRadius(Style::radiusMd * scale);
button->setOnClick([&openPath = ctx.openSearchPickerPath, requestContentRebuild = ctx.requestContentRebuild,
path = entry.path]() {
openPath = pathKey(path);
requestContentRebuild();
});
return button;
};
const auto makeSearchPickerBlock = [&](Flex& section, const SettingEntry& entry,
const SearchPickerSetting& setting) {
const bool overridden = (ctx.configService != nullptr && ctx.configService->hasOverride(entry.path));
const std::string pickerPath = pathKey(entry.path);
auto block = std::make_unique<Flex>();
block->setDirection(FlexDirection::Vertical);
block->setAlign(FlexAlign::Stretch);
block->setGap(Style::spaceXs * scale);
const auto makeBadge = [&](std::string_view label, const ColorSpec& fill, const ColorSpec& color) {
auto badge = std::make_unique<Flex>();
badge->setAlign(FlexAlign::Center);
badge->setPadding(1.0f * scale, Style::spaceXs * scale);
badge->setRadius(Style::radiusSm * scale);
badge->setFill(fill);
badge->addChild(makeLabel(label, Style::fontSizeCaption * scale, color, true));
return badge;
};
auto headerRow = std::make_unique<Flex>();
headerRow->setDirection(FlexDirection::Horizontal);
headerRow->setAlign(FlexAlign::Center);
headerRow->setJustify(FlexJustify::SpaceBetween);
headerRow->setGap(Style::spaceXs * scale);
headerRow->setPadding(2.0f * scale, 0.0f);
headerRow->setMinHeight(Style::controlHeight * scale);
auto copy = std::make_unique<Flex>();
copy->setDirection(FlexDirection::Vertical);
copy->setAlign(FlexAlign::Start);
copy->setGap(Style::spaceXs * scale);
copy->setFlexGrow(1.0f);
auto titleRow = std::make_unique<Flex>();
titleRow->setDirection(FlexDirection::Horizontal);
titleRow->setAlign(FlexAlign::Center);
titleRow->setGap(Style::spaceSm * scale);
titleRow->addChild(
makeLabel(entry.title, Style::fontSizeBody * scale, colorSpecFromRole(ColorRole::OnSurface), true));
if (overridden) {
titleRow->addChild(makeBadge(i18n::tr("settings.badges.override"), colorSpecFromRole(ColorRole::Primary, 0.15f),
colorSpecFromRole(ColorRole::Primary)));
}
if (entry.advanced) {
titleRow->addChild(makeBadge(i18n::tr("settings.badges.advanced"),
colorSpecFromRole(ColorRole::OnSurfaceVariant, 0.12f),
colorSpecFromRole(ColorRole::OnSurfaceVariant)));
}
copy->addChild(std::move(titleRow));
if (!entry.subtitle.empty()) {
auto detail = makeLabel(entry.subtitle, Style::fontSizeCaption * scale,
colorSpecFromRole(ColorRole::OnSurfaceVariant), false);
detail->setMaxWidth(360.0f * scale);
copy->addChild(std::move(detail));
}
headerRow->addChild(std::move(copy));
auto actions = std::make_unique<Flex>();
actions->setDirection(FlexDirection::Horizontal);
actions->setAlign(FlexAlign::Center);
actions->setGap(Style::spaceSm * scale);
if (overridden) {
actions->addChild(makeResetButton(entry.path));
}
auto closeBtn = std::make_unique<Button>();
closeBtn->setVariant(ButtonVariant::Ghost);
closeBtn->setGlyph("close");
closeBtn->setGlyphSize(Style::fontSizeCaption * scale);
closeBtn->setMinWidth(Style::controlHeightSm * scale);
closeBtn->setMinHeight(Style::controlHeightSm * scale);
closeBtn->setPadding(Style::spaceXs * scale);
closeBtn->setRadius(Style::radiusSm * scale);
closeBtn->setOnClick([&openPath = ctx.openSearchPickerPath, requestContentRebuild = ctx.requestContentRebuild]() {
openPath.clear();
requestContentRebuild();
});
actions->addChild(std::move(closeBtn));
headerRow->addChild(std::move(actions));
block->addChild(std::move(headerRow));
auto picker = std::make_unique<SearchPicker>();
if (!setting.placeholder.empty()) {
picker->setPlaceholder(setting.placeholder);
}
if (!setting.emptyText.empty()) {
picker->setEmptyText(setting.emptyText);
}
picker->setOptions(searchPickerOptions(setting.options));
picker->setSelectedValue(setting.selectedValue);
picker->setSize(0.0f, setting.preferredHeight * scale);
picker->setFillWidth(true);
auto* pickerPtr = picker.get();
picker->setOnActivated([&openPath = ctx.openSearchPickerPath, requestContentRebuild = ctx.requestContentRebuild,
setOverride = ctx.setOverride, path = entry.path,
selectedValue = setting.selectedValue](const SearchPickerOption& option) {
if (option.value.empty()) {
return;
}
openPath.clear();
if (option.value == selectedValue) {
requestContentRebuild();
return;
}
setOverride(path, option.value);
});
picker->setOnCancel([&openPath = ctx.openSearchPickerPath, requestContentRebuild = ctx.requestContentRebuild]() {
openPath.clear();
requestContentRebuild();
});
block->addChild(std::move(picker));
section.addChild(std::move(block));
if (ctx.openSearchPickerPath == pickerPath && ctx.focusArea) {
ctx.focusArea(pickerPtr->filterInputArea());
}
};
const auto makeMultiSelectBlock = [&](Flex& section, const SettingEntry& entry, const MultiSelectSetting& setting) {
const bool overridden = (ctx.configService != nullptr && ctx.configService->hasOverride(entry.path));
@@ -962,6 +1139,8 @@ namespace settings {
return makeOptionalNumber(control, entry.path);
} else if constexpr (std::is_same_v<T, ColorSetting>) {
return makeColor(control, entry.path);
} else if constexpr (std::is_same_v<T, SearchPickerSetting>) {
return nullptr;
} else if constexpr (std::is_same_v<T, MultiSelectSetting>) {
return nullptr;
} else if constexpr (std::is_same_v<T, ListSetting>) {
@@ -1003,6 +1182,7 @@ namespace settings {
.creatingWidgetType = ctx.creatingWidgetType,
.requestRebuild = ctx.requestRebuild,
.resetContentScroll = ctx.resetContentScroll,
.focusArea = ctx.focusArea,
.setOverride = ctx.setOverride,
.setOverrides = ctx.setOverrides,
.clearOverride = ctx.clearOverride,
@@ -1067,6 +1247,12 @@ namespace settings {
} else if (!isBarWidgetListPath(entry.path)) {
makeListBlock(*activeSection, entry, *list);
}
} else if (const auto* picker = std::get_if<SearchPickerSetting>(&entry.control)) {
if (ctx.openSearchPickerPath == pathKey(entry.path)) {
makeSearchPickerBlock(*activeSection, entry, *picker);
} else {
makeRow(*activeSection, entry, makeSearchPickerButton(entry, *picker));
}
} else if (const auto* multi = std::get_if<MultiSelectSetting>(&entry.control)) {
makeMultiSelectBlock(*activeSection, entry, *multi);
} else {
+4
View File
@@ -11,6 +11,7 @@
#include <vector>
class Flex;
class InputArea;
namespace settings {
@@ -26,6 +27,7 @@ namespace settings {
bool showOverriddenOnly = false;
std::string& openWidgetPickerPath;
std::string& openSearchPickerPath;
std::string& editingWidgetName;
std::string& pendingDeleteWidgetName;
std::string& pendingDeleteWidgetSettingPath;
@@ -33,7 +35,9 @@ namespace settings {
std::string& creatingWidgetType;
std::function<void()> requestRebuild;
std::function<void()> requestContentRebuild;
std::function<void()> resetContentScroll;
std::function<void(InputArea*)> focusArea;
std::function<void(std::vector<std::string>, ConfigOverrideValue)> setOverride;
std::function<void(std::vector<std::pair<std::vector<std::string>, ConfigOverrideValue>>)> setOverrides;
std::function<void(std::vector<std::string>)> clearOverride;
+7 -1
View File
@@ -274,7 +274,13 @@ namespace settings {
} else if (cfg.theme.source == ThemeSource::Community) {
SettingControl communityPaletteControl = TextSetting{cfg.theme.communityPalette, "Oxocarbon"};
if (!env.communityPalettes.empty()) {
communityPaletteControl = SelectSetting{env.communityPalettes, cfg.theme.communityPalette};
communityPaletteControl = SearchPickerSetting{
.options = env.communityPalettes,
.selectedValue = cfg.theme.communityPalette,
.placeholder = tr("settings.schema.appearance.community-palette.search-placeholder"),
.emptyText = tr("ui.controls.search-picker.empty"),
.preferredHeight = 240.0f,
};
}
entries.push_back(makeEntry("appearance", "theme", tr("settings.schema.appearance.community-palette.label"),
tr("settings.schema.appearance.community-palette.description"),
+11 -1
View File
@@ -19,6 +19,8 @@ namespace settings {
struct SelectOption {
std::string value;
std::string label;
std::string description = {};
std::string category = {};
};
struct SelectSetting {
@@ -28,6 +30,14 @@ namespace settings {
bool segmented = false; // render as Segmented pill group instead of dropdown Select
};
struct SearchPickerSetting {
std::vector<SelectOption> options;
std::string selectedValue;
std::string placeholder;
std::string emptyText;
float preferredHeight = 240.0f;
};
struct SliderSetting {
float value = 0.0f;
float minValue = 0.0f;
@@ -80,7 +90,7 @@ namespace settings {
using SettingControl =
std::variant<ToggleSetting, SelectSetting, SliderSetting, TextSetting, OptionalNumberSetting, ListSetting,
ColorSetting, MultiSelectSetting, ButtonSetting, ColorRolePickerSetting>;
ColorSetting, MultiSelectSetting, ButtonSetting, ColorRolePickerSetting, SearchPickerSetting>;
struct SettingEntry {
std::string section;
+6
View File
@@ -311,6 +311,7 @@ void SettingsWindow::clearStatusMessage() {
void SettingsWindow::clearTransientSettingsState() {
m_openWidgetPickerPath.clear();
m_openSearchPickerPath.clear();
m_editingWidgetName.clear();
m_renamingWidgetName.clear();
m_pendingDeleteWidgetName.clear();
@@ -835,6 +836,7 @@ void SettingsWindow::rebuildSettingsContent() {
}
const auto requestRebuild = [this]() { requestSceneRebuild(); };
const auto requestContent = [this]() { requestContentRebuild(); };
const auto setOverride = [this](std::vector<std::string> path, ConfigOverrideValue value) {
setSettingOverride(std::move(path), std::move(value));
};
@@ -895,13 +897,16 @@ void SettingsWindow::rebuildSettingsContent() {
.showAdvanced = m_showAdvanced,
.showOverriddenOnly = m_showOverriddenOnly,
.openWidgetPickerPath = m_openWidgetPickerPath,
.openSearchPickerPath = m_openSearchPickerPath,
.editingWidgetName = m_editingWidgetName,
.pendingDeleteWidgetName = m_pendingDeleteWidgetName,
.pendingDeleteWidgetSettingPath = m_pendingDeleteWidgetSettingPath,
.renamingWidgetName = m_renamingWidgetName,
.creatingWidgetType = m_creatingWidgetType,
.requestRebuild = requestRebuild,
.requestContentRebuild = requestContent,
.resetContentScroll = [this]() { m_contentScrollState.offset = 0.0f; },
.focusArea = [this](InputArea* area) { m_inputDispatcher.setFocus(area); },
.setOverride = setOverride,
.setOverrides = setOverrides,
.clearOverride = clearOverride,
@@ -1110,6 +1115,7 @@ void SettingsWindow::buildScene(std::uint32_t width, std::uint32_t height) {
const bool searchActiveChanged = wasSearchActive != !m_searchQuery.empty();
const bool hadPendingReset = !m_pendingResetPageScope.empty();
m_pendingResetPageScope.clear();
m_openSearchPickerPath.clear();
if (hadPendingReset || searchActiveChanged) {
m_focusSearchOnRebuild = true;
requestSceneRebuild();
+1
View File
@@ -99,6 +99,7 @@ private:
bool m_focusSearchOnRebuild = false;
std::string m_searchQuery;
std::string m_openWidgetPickerPath;
std::string m_openSearchPickerPath;
std::string m_editingWidgetName;
std::string m_pendingDeleteWidgetName;
std::string m_pendingDeleteWidgetSettingPath;
+75 -28
View File
@@ -8,9 +8,10 @@
#include "ui/controls/scroll_view.h"
#include "ui/palette.h"
#include "ui/style.h"
#include "util/fuzzy_match.h"
#include "util/string_utils.h"
#include <algorithm>
#include <cctype>
#include <linux/input-event-codes.h>
#include <memory>
#include <string>
@@ -21,13 +22,6 @@ namespace {
constexpr float kDefaultWidth = 320.0f;
constexpr float kDefaultHeight = 360.0f;
std::string lower(std::string_view value) {
std::string out(value);
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return out;
}
std::string detailText(const SearchPickerOption& option) {
if (!option.description.empty() && !option.category.empty()) {
return option.category + " - " + option.description;
@@ -116,6 +110,10 @@ void SearchPicker::setOnActivated(std::function<void(const SearchPickerOption&)>
void SearchPicker::setOnCancel(std::function<void()> callback) { m_onCancel = std::move(callback); }
InputArea* SearchPicker::filterInputArea() const noexcept {
return m_input != nullptr ? m_input->inputArea() : nullptr;
}
void SearchPicker::doLayout(Renderer& renderer) {
Flex::doLayout(renderer);
for (const auto& row : m_rows) {
@@ -134,13 +132,40 @@ LayoutSize SearchPicker::doMeasure(Renderer& renderer, const LayoutConstraints&
void SearchPicker::doArrange(Renderer& renderer, const LayoutRect& rect) { arrangeByLayout(renderer, rect); }
void SearchPicker::applyFilter() {
struct ScoredOption {
std::size_t index = 0;
double score = 0.0;
};
m_visible.clear();
std::vector<ScoredOption> scored;
const std::string query = StringUtils::trim(m_filter);
for (std::size_t i = 0; i < m_options.size(); ++i) {
if (matchesFilter(m_options[i])) {
if (query.empty()) {
m_visible.push_back(i);
continue;
}
const double score = matchScore(m_options[i], query);
if (FuzzyMatch::isMatch(score)) {
scored.push_back(ScoredOption{.index = i, .score = score});
}
}
if (!query.empty()) {
std::stable_sort(scored.begin(), scored.end(),
[](const ScoredOption& lhs, const ScoredOption& rhs) { return lhs.score > rhs.score; });
m_visible.reserve(scored.size());
for (const auto& item : scored) {
m_visible.push_back(item.index);
}
}
m_highlightedVisibleIndex = 0;
if (m_scroll != nullptr) {
m_scroll->setScrollOffset(0.0f);
}
rebuildRows();
}
@@ -177,10 +202,11 @@ void SearchPicker::rebuildRows() {
auto row = std::make_unique<Flex>();
row->setDirection(FlexDirection::Vertical);
row->setAlign(FlexAlign::Stretch);
row->setGap(1.0f);
const std::string detail = detailText(option);
row->setGap(detail.empty() ? 0.0f : 1.0f);
row->setPadding(Style::spaceXs, Style::spaceSm);
row->setRadius(Style::radiusSm);
row->setMinHeight(Style::controlHeightLg);
row->setMinHeight(detail.empty() ? Style::controlHeight : Style::controlHeightLg);
row->setFillWidth(true);
auto title = std::make_unique<Label>();
@@ -189,11 +215,14 @@ void SearchPicker::rebuildRows() {
title->setStableBaseline(true);
auto* titlePtr = static_cast<Label*>(row->addChild(std::move(title)));
auto detail = std::make_unique<Label>();
detail->setText(detailText(option));
detail->setFontSize(Style::fontSizeCaption);
detail->setStableBaseline(true);
auto* detailPtr = static_cast<Label*>(row->addChild(std::move(detail)));
Label* detailPtr = nullptr;
if (!detail.empty()) {
auto detailLabel = std::make_unique<Label>();
detailLabel->setText(detail);
detailLabel->setFontSize(Style::fontSizeCaption);
detailLabel->setStableBaseline(true);
detailPtr = static_cast<Label*>(row->addChild(std::move(detailLabel)));
}
auto area = std::make_unique<InputArea>();
area->setCursorShape(WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_POINTER);
@@ -226,6 +255,7 @@ void SearchPicker::setHighlightedVisibleIndex(std::size_t index) {
}
m_highlightedVisibleIndex = index;
applyRowStates();
ensureHighlightedVisible();
}
void SearchPicker::moveHighlight(int delta) {
@@ -247,10 +277,31 @@ void SearchPicker::activateHighlighted() {
}
}
void SearchPicker::ensureHighlightedVisible() {
if (m_scroll == nullptr || m_highlightedVisibleIndex >= m_rows.size()) {
return;
}
const auto& row = m_rows[m_highlightedVisibleIndex];
if (row.row == nullptr || row.row->height() <= 0.0f || m_scroll->height() <= 0.0f) {
return;
}
const float top = row.row->y();
const float bottom = top + row.row->height();
const float offset = m_scroll->scrollOffset();
const float viewportHeight = m_scroll->height();
if (top < offset) {
m_scroll->setScrollOffset(top);
} else if (bottom > offset + viewportHeight) {
m_scroll->setScrollOffset(bottom - viewportHeight);
}
}
void SearchPicker::applyRowStates() {
for (std::size_t i = 0; i < m_rows.size(); ++i) {
auto& row = m_rows[i];
if (row.row == nullptr || row.title == nullptr || row.detail == nullptr || i >= m_visible.size()) {
if (row.row == nullptr || row.title == nullptr || i >= m_visible.size()) {
continue;
}
@@ -264,8 +315,10 @@ void SearchPicker::applyRowStates() {
row.title->setColor(highlighted ? colorSpecFromRole(ColorRole::OnPrimary)
: (enabled ? colorSpecFromRole(ColorRole::OnSurface)
: colorSpecFromRole(ColorRole::OnSurface, 0.55f)));
row.detail->setColor(highlighted ? colorSpecFromRole(ColorRole::OnPrimary, 0.78f)
: colorSpecFromRole(ColorRole::OnSurfaceVariant, enabled ? 1.0f : 0.55f));
if (row.detail != nullptr) {
row.detail->setColor(highlighted ? colorSpecFromRole(ColorRole::OnPrimary, 0.78f)
: colorSpecFromRole(ColorRole::OnSurfaceVariant, enabled ? 1.0f : 0.55f));
}
if (row.area != nullptr) {
row.area->setEnabled(enabled);
}
@@ -273,13 +326,7 @@ void SearchPicker::applyRowStates() {
markPaintDirty();
}
bool SearchPicker::matchesFilter(const SearchPickerOption& option) const {
const std::string query = lower(m_filter);
if (query.empty()) {
return true;
}
const std::string haystack =
lower(option.label + " " + option.value + " " + option.description + " " + option.category);
return haystack.find(query) != std::string::npos;
double SearchPicker::matchScore(const SearchPickerOption& option, std::string_view query) const {
const std::string haystack = option.label + " " + option.value + " " + option.description + " " + option.category;
return FuzzyMatch::score(query, haystack);
}
+3 -1
View File
@@ -34,6 +34,7 @@ public:
void setOnCancel(std::function<void()> callback);
[[nodiscard]] const std::string& filter() const noexcept { return m_filter; }
[[nodiscard]] InputArea* filterInputArea() const noexcept;
private:
struct RowView {
@@ -51,8 +52,9 @@ private:
void setHighlightedVisibleIndex(std::size_t index);
void moveHighlight(int delta);
void activateHighlighted();
void ensureHighlightedVisible();
void applyRowStates();
[[nodiscard]] bool matchesFilter(const SearchPickerOption& option) const;
[[nodiscard]] double matchScore(const SearchPickerOption& option, std::string_view query) const;
Input* m_input = nullptr;
ScrollView* m_scroll = nullptr;