feat: factorized list_editor control and make it slightly tighter

This commit is contained in:
Lemmy
2026-05-06 09:11:21 -04:00
parent 4ac81c754e
commit 20dd6faad5
4 changed files with 316 additions and 183 deletions
+1
View File
@@ -547,6 +547,7 @@ _noctalia_sources = files(
'src/ui/controls/image.cpp',
'src/ui/controls/input.cpp',
'src/ui/controls/label.cpp',
'src/ui/controls/list_editor.cpp',
'src/ui/controls/progress_bar.cpp',
'src/ui/controls/radio_button.cpp',
'src/ui/controls/scroll_view.cpp',
+40 -183
View File
@@ -10,6 +10,7 @@
#include "ui/controls/glyph.h"
#include "ui/controls/input.h"
#include "ui/controls/label.h"
#include "ui/controls/list_editor.h"
#include "ui/controls/search_picker.h"
#include "ui/controls/segmented.h"
#include "ui/controls/select.h"
@@ -24,8 +25,10 @@
#include <algorithm>
#include <charconv>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <format>
#include <functional>
#include <limits>
#include <locale>
#include <memory>
@@ -1035,194 +1038,48 @@ namespace settings {
colorSpecFromRole(ColorRole::OnSurfaceVariant), false));
}
const auto resolveItemLabel = [&list](const std::string& value) -> std::string {
for (const auto& opt : list.suggestedOptions) {
if (opt.value == value) {
return opt.label;
}
}
return value;
};
const float labelCellWidth = 200.0f * scale;
for (std::size_t i = 0; i < list.items.size(); ++i) {
auto itemRow = std::make_unique<Flex>();
itemRow->setDirection(FlexDirection::Horizontal);
itemRow->setAlign(FlexAlign::Center);
itemRow->setGap(Style::spaceXs * scale);
itemRow->setMinHeight(Style::controlHeightSm * scale);
auto labelCell = std::make_unique<Flex>();
labelCell->setDirection(FlexDirection::Horizontal);
labelCell->setAlign(FlexAlign::Center);
labelCell->setMinWidth(labelCellWidth);
labelCell->addChild(makeLabel(resolveItemLabel(list.items[i]), Style::fontSizeCaption * scale,
colorSpecFromRole(ColorRole::OnSurface), false));
itemRow->addChild(std::move(labelCell));
auto removeBtn = std::make_unique<Button>();
removeBtn->setGlyph("close");
removeBtn->setVariant(ButtonVariant::Ghost);
removeBtn->setGlyphSize(Style::fontSizeCaption * scale);
removeBtn->setMinWidth(Style::controlHeightSm * scale);
removeBtn->setMinHeight(Style::controlHeightSm * scale);
removeBtn->setPadding(Style::spaceXs * scale);
removeBtn->setRadius(Style::radiusSm * scale);
{
auto items = list.items;
auto path = entry.path;
const Config& config = cfg;
removeBtn->setOnClick(
[setOverride = ctx.setOverride, setOverrides = ctx.setOverrides, &config, items, path, i]() mutable {
const std::string removedItem = items[i];
items.erase(items.begin() + static_cast<std::ptrdiff_t>(i));
const auto overrides = capsuleGroupRemovalOverrides(config, path, removedItem, items);
if (overrides.size() == 1) {
setOverride(path, items);
return;
}
setOverrides(overrides);
});
}
itemRow->addChild(std::move(removeBtn));
if (i > 0) {
auto upBtn = std::make_unique<Button>();
upBtn->setGlyph("chevron-up");
upBtn->setVariant(ButtonVariant::Ghost);
upBtn->setGlyphSize(Style::fontSizeCaption * scale);
upBtn->setMinWidth(Style::controlHeightSm * scale);
upBtn->setMinHeight(Style::controlHeightSm * scale);
upBtn->setPadding(Style::spaceXs * scale);
upBtn->setRadius(Style::radiusSm * scale);
auto items = list.items;
auto path = entry.path;
upBtn->setOnClick([setOverride = ctx.setOverride, items, path, i]() mutable {
std::swap(items[i], items[i - 1]);
auto listEditor = std::make_unique<ListEditor>();
listEditor->setScale(scale);
listEditor->setAddPlaceholder(i18n::tr("settings.controls.list.add-entry-placeholder"));
std::vector<ListEditorOption> suggestedOptions;
suggestedOptions.reserve(list.suggestedOptions.size());
for (const auto& opt : list.suggestedOptions) {
suggestedOptions.push_back(ListEditorOption{.value = opt.value, .label = opt.label});
}
listEditor->setSuggestedOptions(std::move(suggestedOptions));
listEditor->setItems(list.items);
listEditor->setOnAddRequested(
[setOverride = ctx.setOverride, items = list.items, path = entry.path](std::string value) mutable {
if (value.empty()) {
return;
}
items.push_back(std::move(value));
setOverride(path, items);
});
itemRow->addChild(std::move(upBtn));
}
if (i + 1 < list.items.size()) {
auto downBtn = std::make_unique<Button>();
downBtn->setGlyph("chevron-down");
downBtn->setVariant(ButtonVariant::Ghost);
downBtn->setGlyphSize(Style::fontSizeCaption * scale);
downBtn->setMinWidth(Style::controlHeightSm * scale);
downBtn->setMinHeight(Style::controlHeightSm * scale);
downBtn->setPadding(Style::spaceXs * scale);
downBtn->setRadius(Style::radiusSm * scale);
auto items = list.items;
auto path = entry.path;
downBtn->setOnClick([setOverride = ctx.setOverride, items, path, i]() mutable {
std::swap(items[i], items[i + 1]);
setOverride(path, items);
});
itemRow->addChild(std::move(downBtn));
}
block->addChild(std::move(itemRow));
}
auto addRow = std::make_unique<Flex>();
addRow->setDirection(FlexDirection::Horizontal);
addRow->setAlign(FlexAlign::Center);
addRow->setGap(Style::spaceSm * scale);
const bool useSelectAdder = !list.suggestedOptions.empty();
std::vector<SelectOption> remaining;
if (useSelectAdder) {
remaining.reserve(list.suggestedOptions.size());
for (const auto& opt : list.suggestedOptions) {
if (std::find(list.items.begin(), list.items.end(), opt.value) == list.items.end()) {
remaining.push_back(opt);
}
}
}
if (useSelectAdder) {
if (remaining.empty()) {
// Every suggested value is already in the list — nothing to add.
section.addChild(std::move(block));
listEditor->setOnRemoveRequested([setOverride = ctx.setOverride, setOverrides = ctx.setOverrides,
config = std::cref(cfg), items = list.items,
path = entry.path](std::size_t index) mutable {
if (index >= items.size()) {
return;
}
std::vector<std::string> remainingLabels;
remainingLabels.reserve(remaining.size());
for (const auto& opt : remaining) {
remainingLabels.push_back(opt.label);
}
auto select = std::make_unique<Select>();
select->setOptions(remainingLabels);
select->setPlaceholder(i18n::tr("settings.controls.list.add-entry-placeholder"));
select->setFontSize(Style::fontSizeCaption * scale);
select->setControlHeight(Style::controlHeightSm * scale);
select->setGlyphSize(Style::fontSizeCaption * scale);
select->setSize(labelCellWidth, Style::controlHeightSm * scale);
auto* selectPtr = select.get();
auto addBtn = std::make_unique<Button>();
addBtn->setGlyph("add");
addBtn->setVariant(ButtonVariant::Ghost);
addBtn->setGlyphSize(Style::fontSizeCaption * scale);
addBtn->setMinWidth(Style::controlHeightSm * scale);
addBtn->setMinHeight(Style::controlHeightSm * scale);
addBtn->setPadding(Style::spaceXs * scale);
addBtn->setRadius(Style::radiusSm * scale);
auto items = list.items;
auto path = entry.path;
addBtn->setOnClick([setOverride = ctx.setOverride, selectPtr, remaining, items, path]() mutable {
const std::size_t index = selectPtr->selectedIndex();
if (index >= remaining.size()) {
return;
}
items.push_back(remaining[index].value);
const std::string removedItem = items[index];
items.erase(items.begin() + static_cast<std::ptrdiff_t>(index));
const auto overrides = capsuleGroupRemovalOverrides(config.get(), path, removedItem, items);
if (overrides.size() == 1) {
setOverride(path, items);
});
addRow->addChild(std::move(select));
addRow->addChild(std::move(addBtn));
} else {
auto addInput = std::make_unique<Input>();
addInput->setPlaceholder(i18n::tr("settings.controls.list.add-entry-placeholder"));
addInput->setFontSize(Style::fontSizeBody * scale);
addInput->setControlHeight(Style::controlHeight * scale);
addInput->setHorizontalPadding(Style::spaceSm * scale);
addInput->setSize(190.0f * scale, Style::controlHeight * scale);
auto* addInputPtr = addInput.get();
auto addBtn = std::make_unique<Button>();
addBtn->setGlyph("add");
addBtn->setVariant(ButtonVariant::Ghost);
addBtn->setGlyphSize(Style::fontSizeBody * scale);
addBtn->setMinWidth(Style::controlHeight * scale);
addBtn->setMinHeight(Style::controlHeight * scale);
addBtn->setPadding(Style::spaceSm * scale);
addBtn->setRadius(Style::radiusMd * scale);
auto items = list.items;
auto path = entry.path;
addBtn->setOnClick([setOverride = ctx.setOverride, addInputPtr, items, path]() mutable {
const auto& text = addInputPtr->value();
if (!text.empty()) {
items.push_back(text);
setOverride(path, items);
}
});
addInput->setOnSubmit([setOverride = ctx.setOverride, items, path](const std::string& text) mutable {
if (!text.empty()) {
items.push_back(text);
setOverride(path, items);
}
});
addRow->addChild(std::move(addInput));
addRow->addChild(std::move(addBtn));
}
block->addChild(std::move(addRow));
return;
}
setOverrides(overrides);
});
listEditor->setOnMoveRequested([setOverride = ctx.setOverride, items = list.items,
path = entry.path](std::size_t from, std::size_t to) mutable {
if (from >= items.size() || to >= items.size() || from == to) {
return;
}
std::swap(items[from], items[to]);
setOverride(path, items);
});
block->addChild(std::move(listEditor));
section.addChild(std::move(block));
};
+231
View File
@@ -0,0 +1,231 @@
#include "ui/controls/list_editor.h"
#include "ui/controls/button.h"
#include "ui/controls/input.h"
#include "ui/controls/label.h"
#include "ui/controls/select.h"
#include "ui/palette.h"
#include "ui/style.h"
#include <algorithm>
#include <memory>
#include <utility>
namespace {
constexpr float kLabelCellWidth = 200.0f;
constexpr float kFreeformInputWidth = 190.0f;
constexpr float kItemRowHeight = 26.0f;
constexpr float kSuggestedAddHeight = 30.0f;
constexpr float kVerticalGap = 2.0f;
std::unique_ptr<Label> makeListLabel(std::string_view text, float scale) {
auto label = std::make_unique<Label>();
label->setText(text);
label->setFontSize(Style::fontSizeCaption * scale);
label->setColor(colorSpecFromRole(ColorRole::OnSurface));
return label;
}
} // namespace
ListEditor::ListEditor() {
setDirection(FlexDirection::Vertical);
setAlign(FlexAlign::Stretch);
setGap(kVerticalGap);
}
void ListEditor::setItems(std::vector<std::string> items) {
m_items = std::move(items);
rebuildRows();
}
void ListEditor::setSuggestedOptions(std::vector<ListEditorOption> options) {
m_suggestedOptions = std::move(options);
rebuildRows();
}
void ListEditor::setAddPlaceholder(std::string_view placeholder) {
m_addPlaceholder = std::string(placeholder);
rebuildRows();
}
void ListEditor::setScale(float scale) {
m_scale = std::max(0.1f, scale);
setGap(kVerticalGap * m_scale);
rebuildRows();
}
void ListEditor::setOnAddRequested(std::function<void(std::string)> callback) {
m_onAddRequested = std::move(callback);
}
void ListEditor::setOnRemoveRequested(std::function<void(std::size_t)> callback) {
m_onRemoveRequested = std::move(callback);
}
void ListEditor::setOnMoveRequested(std::function<void(std::size_t, std::size_t)> callback) {
m_onMoveRequested = std::move(callback);
}
std::string ListEditor::labelForValue(std::string_view value) const {
for (const auto& opt : m_suggestedOptions) {
if (opt.value == value) {
return opt.label;
}
}
return std::string(value);
}
std::vector<ListEditorOption> ListEditor::remainingOptions() const {
std::vector<ListEditorOption> remaining;
remaining.reserve(m_suggestedOptions.size());
for (const auto& opt : m_suggestedOptions) {
if (std::find(m_items.begin(), m_items.end(), opt.value) == m_items.end()) {
remaining.push_back(opt);
}
}
return remaining;
}
void ListEditor::rebuildRows() {
while (!children().empty()) {
removeChild(children().back().get());
}
const float labelCellWidth = kLabelCellWidth * m_scale;
const float itemRowHeight = kItemRowHeight * m_scale;
const float suggestedAddHeight = kSuggestedAddHeight * m_scale;
for (std::size_t i = 0; i < m_items.size(); ++i) {
auto itemRow = std::make_unique<Flex>();
itemRow->setDirection(FlexDirection::Horizontal);
itemRow->setAlign(FlexAlign::Center);
itemRow->setGap(Style::spaceXs * m_scale);
itemRow->setMinHeight(itemRowHeight);
auto labelCell = std::make_unique<Flex>();
labelCell->setDirection(FlexDirection::Horizontal);
labelCell->setAlign(FlexAlign::Center);
labelCell->setMinWidth(labelCellWidth);
labelCell->addChild(makeListLabel(labelForValue(m_items[i]), m_scale));
itemRow->addChild(std::move(labelCell));
addGhostIconButton(*itemRow, "close", Style::fontSizeCaption * m_scale, [this, i] {
if (m_onRemoveRequested) {
m_onRemoveRequested(i);
}
});
if (i > 0) {
addGhostIconButton(*itemRow, "chevron-up", Style::fontSizeCaption * m_scale, [this, i] {
if (m_onMoveRequested) {
m_onMoveRequested(i, i - 1);
}
});
}
if (i + 1 < m_items.size()) {
addGhostIconButton(*itemRow, "chevron-down", Style::fontSizeCaption * m_scale, [this, i] {
if (m_onMoveRequested) {
m_onMoveRequested(i, i + 1);
}
});
}
addChild(std::move(itemRow));
}
auto addRow = std::make_unique<Flex>();
addRow->setDirection(FlexDirection::Horizontal);
addRow->setAlign(FlexAlign::Center);
addRow->setGap(Style::spaceSm * m_scale);
if (!m_suggestedOptions.empty()) {
const auto remaining = remainingOptions();
if (remaining.empty()) {
markLayoutDirty();
return;
}
std::vector<std::string> remainingLabels;
remainingLabels.reserve(remaining.size());
for (const auto& opt : remaining) {
remainingLabels.push_back(opt.label);
}
auto select = std::make_unique<Select>();
select->setOptions(std::move(remainingLabels));
select->setPlaceholder(m_addPlaceholder);
select->setFontSize(Style::fontSizeCaption * m_scale);
select->setControlHeight(suggestedAddHeight);
select->setGlyphSize(Style::fontSizeCaption * m_scale);
select->setSize(labelCellWidth, suggestedAddHeight);
auto* selectPtr = select.get();
auto addBtn = std::make_unique<Button>();
addBtn->setGlyph("add");
addBtn->setVariant(ButtonVariant::Ghost);
addBtn->setGlyphSize(Style::fontSizeCaption * m_scale);
addBtn->setMinWidth(suggestedAddHeight);
addBtn->setMinHeight(suggestedAddHeight);
addBtn->setPadding(Style::spaceXs * m_scale);
addBtn->setRadius(Style::radiusSm * m_scale);
addBtn->setOnClick([this, selectPtr, remaining] {
const std::size_t index = selectPtr->selectedIndex();
if (index < remaining.size() && m_onAddRequested) {
m_onAddRequested(remaining[index].value);
}
});
addRow->addChild(std::move(select));
addRow->addChild(std::move(addBtn));
} else {
auto addInput = std::make_unique<Input>();
addInput->setPlaceholder(m_addPlaceholder);
addInput->setFontSize(Style::fontSizeBody * m_scale);
addInput->setControlHeight(Style::controlHeight * m_scale);
addInput->setHorizontalPadding(Style::spaceSm * m_scale);
addInput->setSize(kFreeformInputWidth * m_scale, Style::controlHeight * m_scale);
auto* addInputPtr = addInput.get();
auto addBtn = std::make_unique<Button>();
addBtn->setGlyph("add");
addBtn->setVariant(ButtonVariant::Ghost);
addBtn->setGlyphSize(Style::fontSizeBody * m_scale);
addBtn->setMinWidth(Style::controlHeight * m_scale);
addBtn->setMinHeight(Style::controlHeight * m_scale);
addBtn->setPadding(Style::spaceSm * m_scale);
addBtn->setRadius(Style::radiusMd * m_scale);
addBtn->setOnClick([this, addInputPtr] {
const auto& text = addInputPtr->value();
if (!text.empty() && m_onAddRequested) {
m_onAddRequested(text);
}
});
addInput->setOnSubmit([this](const std::string& text) {
if (!text.empty() && m_onAddRequested) {
m_onAddRequested(text);
}
});
addRow->addChild(std::move(addInput));
addRow->addChild(std::move(addBtn));
}
addChild(std::move(addRow));
markLayoutDirty();
}
void ListEditor::addGhostIconButton(Flex& row, std::string_view glyph, float size, std::function<void()> callback) {
auto button = std::make_unique<Button>();
button->setGlyph(glyph);
button->setVariant(ButtonVariant::Ghost);
button->setGlyphSize(size);
button->setMinWidth(kItemRowHeight * m_scale);
button->setMinHeight(kItemRowHeight * m_scale);
button->setPadding(Style::spaceXs * m_scale);
button->setRadius(Style::radiusSm * m_scale);
button->setOnClick(std::move(callback));
row.addChild(std::move(button));
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "ui/controls/flex.h"
#include <cstddef>
#include <functional>
#include <string>
#include <string_view>
#include <vector>
struct ListEditorOption {
std::string value;
std::string label;
};
class ListEditor : public Flex {
public:
ListEditor();
void setItems(std::vector<std::string> items);
void setSuggestedOptions(std::vector<ListEditorOption> options);
void setAddPlaceholder(std::string_view placeholder);
void setScale(float scale);
void setOnAddRequested(std::function<void(std::string)> callback);
void setOnRemoveRequested(std::function<void(std::size_t)> callback);
void setOnMoveRequested(std::function<void(std::size_t, std::size_t)> callback);
[[nodiscard]] const std::vector<std::string>& items() const noexcept { return m_items; }
[[nodiscard]] const std::vector<ListEditorOption>& suggestedOptions() const noexcept { return m_suggestedOptions; }
private:
[[nodiscard]] std::string labelForValue(std::string_view value) const;
[[nodiscard]] std::vector<ListEditorOption> remainingOptions() const;
void rebuildRows();
void addGhostIconButton(Flex& row, std::string_view glyph, float size, std::function<void()> callback);
std::vector<std::string> m_items;
std::vector<ListEditorOption> m_suggestedOptions;
std::string m_addPlaceholder;
std::function<void(std::string)> m_onAddRequested;
std::function<void(std::size_t)> m_onRemoveRequested;
std::function<void(std::size_t, std::size_t)> m_onMoveRequested;
float m_scale = 1.0f;
};