mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
1061 lines
36 KiB
C++
1061 lines
36 KiB
C++
#include "shell/clipboard/clipboard_panel.h"
|
|
|
|
#include "config/config_service.h"
|
|
#include "core/ui_phase.h"
|
|
#include "i18n/i18n.h"
|
|
#include "render/core/renderer.h"
|
|
#include "render/scene/input_area.h"
|
|
#include "shell/control_center/tab.h"
|
|
#include "shell/panel/panel_manager.h"
|
|
#include "time/time_format.h"
|
|
#include "ui/controls/box.h"
|
|
#include "ui/controls/button.h"
|
|
#include "ui/controls/flex.h"
|
|
#include "ui/controls/glyph.h"
|
|
#include "ui/controls/image.h"
|
|
#include "ui/controls/input.h"
|
|
#include "ui/controls/label.h"
|
|
#include "ui/controls/scroll_view.h"
|
|
#include "ui/controls/virtual_grid_view.h"
|
|
#include "ui/palette.h"
|
|
#include "ui/style.h"
|
|
#include "wayland/clipboard_service.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <ctime>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <xkbcommon/xkbcommon-keysyms.h>
|
|
|
|
namespace {
|
|
|
|
constexpr float kSidebarWidth = 272.0f;
|
|
constexpr float kRowHeight = 46.0f;
|
|
constexpr float kPreviewImageHeight = 280.0f;
|
|
constexpr float kListGlyphSize = 24.0f;
|
|
constexpr float kListThumbSize = 40.0f;
|
|
constexpr std::size_t kListOverscanRows = 3;
|
|
constexpr auto kPreviewPayloadDebounceInterval = std::chrono::milliseconds(75);
|
|
constexpr auto kFilterDebounceInterval = std::chrono::milliseconds(120);
|
|
|
|
std::string collapseWhitespace(std::string_view text) {
|
|
std::string out;
|
|
out.reserve(text.size());
|
|
|
|
bool lastWasSpace = true;
|
|
for (char ch : text) {
|
|
const bool isWhitespace = (ch == ' ' || ch == '\n' || ch == '\t' || ch == '\r');
|
|
if (isWhitespace) {
|
|
if (!lastWasSpace) {
|
|
out.push_back(' ');
|
|
}
|
|
lastWasSpace = true;
|
|
continue;
|
|
}
|
|
out.push_back(ch);
|
|
lastWasSpace = false;
|
|
}
|
|
|
|
if (!out.empty() && out.back() == ' ') {
|
|
out.pop_back();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string formatBytes(std::size_t bytes) {
|
|
const char* units[] = {"B", "KB", "MB", "GB"};
|
|
double value = static_cast<double>(bytes);
|
|
std::size_t unitIndex = 0;
|
|
while (value >= 1024.0 && unitIndex + 1 < std::size(units)) {
|
|
value /= 1024.0;
|
|
++unitIndex;
|
|
}
|
|
|
|
char buffer[32];
|
|
if (unitIndex == 0) {
|
|
std::snprintf(buffer, sizeof(buffer), "%zu %s", bytes, units[unitIndex]);
|
|
} else {
|
|
std::snprintf(buffer, sizeof(buffer), "%.1f %s", value, units[unitIndex]);
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
std::string entryTitle(const ClipboardEntry& entry) {
|
|
if (!entry.textPreview.empty()) {
|
|
return entry.textPreview;
|
|
}
|
|
if (entry.isImage()) {
|
|
return i18n::tr("clipboard.entry.image");
|
|
}
|
|
return entry.dataMimeType.empty() ? i18n::tr("clipboard.entry.title") : entry.dataMimeType;
|
|
}
|
|
|
|
std::string previewTitle(const ClipboardEntry& entry) {
|
|
if (entry.isImage()) {
|
|
return i18n::tr("clipboard.preview.image-title");
|
|
}
|
|
return i18n::tr("clipboard.preview.text-title");
|
|
}
|
|
|
|
class ClipboardListRow final : public InputArea {
|
|
public:
|
|
ClipboardListRow(float scale, ThumbnailService* thumbnails) : m_scale(scale), m_thumbnails(thumbnails) {
|
|
setVisible(false);
|
|
|
|
auto background = std::make_unique<Box>();
|
|
background->setRadius(Style::radiusMd * scale);
|
|
m_background = static_cast<Box*>(addChild(std::move(background)));
|
|
|
|
auto row = std::make_unique<Flex>();
|
|
row->setDirection(FlexDirection::Horizontal);
|
|
row->setAlign(FlexAlign::Center);
|
|
row->setGap(Style::spaceMd * scale);
|
|
row->setPadding(Style::spaceXs * scale, Style::spaceSm * scale);
|
|
m_row = static_cast<Flex*>(addChild(std::move(row)));
|
|
|
|
auto lead = std::make_unique<Flex>();
|
|
lead->setDirection(FlexDirection::Horizontal);
|
|
lead->setAlign(FlexAlign::Center);
|
|
lead->setJustify(FlexJustify::Center);
|
|
m_lead = static_cast<Flex*>(m_row->addChild(std::move(lead)));
|
|
|
|
auto image = std::make_unique<Image>();
|
|
image->setFit(ImageFit::Cover);
|
|
image->setRadius(Style::radiusSm * scale);
|
|
image->setVisible(false);
|
|
m_image = static_cast<Image*>(m_lead->addChild(std::move(image)));
|
|
|
|
auto glyph = std::make_unique<Glyph>();
|
|
glyph->setGlyphSize(kListGlyphSize * scale);
|
|
m_glyph = static_cast<Glyph*>(m_lead->addChild(std::move(glyph)));
|
|
|
|
auto textColumn = std::make_unique<Flex>();
|
|
textColumn->setDirection(FlexDirection::Vertical);
|
|
textColumn->setAlign(FlexAlign::Start);
|
|
textColumn->setGap(Style::spaceXs * scale);
|
|
textColumn->setFlexGrow(1.0f);
|
|
m_textColumn = static_cast<Flex*>(m_row->addChild(std::move(textColumn)));
|
|
|
|
auto title = std::make_unique<Label>();
|
|
title->setFontSize(Style::fontSizeBody * scale);
|
|
title->setBold(true);
|
|
title->setMaxLines(1);
|
|
title->setHitTestVisible(false);
|
|
m_title = static_cast<Label*>(m_textColumn->addChild(std::move(title)));
|
|
|
|
auto meta = std::make_unique<Label>();
|
|
meta->setCaptionStyle();
|
|
meta->setFontSize(Style::fontSizeCaption * scale);
|
|
meta->setMaxLines(1);
|
|
meta->setHitTestVisible(false);
|
|
m_meta = static_cast<Label*>(m_textColumn->addChild(std::move(meta)));
|
|
}
|
|
|
|
~ClipboardListRow() override { releaseThumbnail(); }
|
|
|
|
void setThumbnailService(ThumbnailService* thumbnails) {
|
|
if (m_thumbnails == thumbnails) {
|
|
return;
|
|
}
|
|
releaseThumbnail();
|
|
m_thumbnails = thumbnails;
|
|
}
|
|
|
|
void bind(Renderer& renderer, const ClipboardEntry& entry, std::size_t historyIndex, float width, bool selected,
|
|
bool hovered) {
|
|
m_historyIndex = historyIndex;
|
|
m_selected = selected;
|
|
m_hovered = hovered;
|
|
m_isImage = entry.isImage();
|
|
setVisible(true);
|
|
setEnabled(true);
|
|
setSize(width, kRowHeight * m_scale);
|
|
|
|
const std::string nextThumbPath = m_isImage ? entry.payloadPath : std::string();
|
|
if (m_thumbnailPath != nextThumbPath) {
|
|
if (m_image != nullptr) {
|
|
m_image->clear(renderer);
|
|
m_image->setVisible(false);
|
|
}
|
|
releaseThumbnail();
|
|
m_thumbnailPath = nextThumbPath;
|
|
if (!m_thumbnailPath.empty() && m_thumbnails != nullptr) {
|
|
(void)m_thumbnails->acquire(m_thumbnailPath);
|
|
}
|
|
}
|
|
|
|
const std::string rawTitle = entryTitle(entry);
|
|
m_title->setText(m_isImage ? rawTitle : collapseWhitespace(rawTitle));
|
|
m_meta->setText(formatTimeAgo(entry.capturedAt) + " • " + formatBytes(entry.byteSize));
|
|
|
|
if (m_glyph != nullptr) {
|
|
m_glyph->setGlyph(m_isImage ? "photo" : "file-text");
|
|
}
|
|
refreshThumbnail(renderer);
|
|
applyVisualState();
|
|
layout(renderer);
|
|
}
|
|
|
|
void refreshThumbnail(Renderer& renderer) {
|
|
if (m_image == nullptr || m_glyph == nullptr) {
|
|
return;
|
|
}
|
|
if (!m_isImage || m_thumbnailPath.empty() || m_thumbnails == nullptr) {
|
|
m_image->clear(renderer);
|
|
m_image->setVisible(false);
|
|
m_glyph->setVisible(true);
|
|
return;
|
|
}
|
|
|
|
const TextureHandle handle = m_thumbnails->peek(m_thumbnailPath);
|
|
if (handle.id == 0) {
|
|
m_image->clear(renderer);
|
|
m_image->setVisible(false);
|
|
m_glyph->setVisible(true);
|
|
return;
|
|
}
|
|
|
|
m_image->setExternalTexture(renderer, handle);
|
|
m_image->setVisible(true);
|
|
m_glyph->setVisible(false);
|
|
}
|
|
|
|
private:
|
|
void doLayout(Renderer& renderer) override {
|
|
const float thumbPx = kListThumbSize * m_scale;
|
|
const float rowW = width();
|
|
const float rowH = height();
|
|
if (m_background != nullptr) {
|
|
m_background->setPosition(0.0f, 0.0f);
|
|
m_background->setSize(rowW, rowH);
|
|
}
|
|
if (m_row != nullptr) {
|
|
m_row->setPosition(0.0f, 0.0f);
|
|
m_row->setSize(rowW, rowH);
|
|
}
|
|
if (m_lead != nullptr) {
|
|
m_lead->setSize(thumbPx, thumbPx);
|
|
m_lead->setMinWidth(thumbPx);
|
|
m_lead->setMinHeight(thumbPx);
|
|
}
|
|
if (m_image != nullptr) {
|
|
m_image->setSize(thumbPx, thumbPx);
|
|
}
|
|
if (m_title != nullptr && m_meta != nullptr) {
|
|
const float textWidth =
|
|
std::max(0.0f, rowW - thumbPx - Style::spaceMd * m_scale - Style::spaceSm * m_scale * 2.0f);
|
|
m_title->setMaxWidth(textWidth);
|
|
m_meta->setMaxWidth(textWidth);
|
|
}
|
|
|
|
InputArea::doLayout(renderer);
|
|
}
|
|
|
|
void releaseThumbnail() {
|
|
if (!m_thumbnailPath.empty() && m_thumbnails != nullptr) {
|
|
m_thumbnails->release(m_thumbnailPath);
|
|
}
|
|
m_thumbnailPath.clear();
|
|
}
|
|
|
|
void applyVisualState() {
|
|
if (m_background == nullptr || m_glyph == nullptr || m_title == nullptr || m_meta == nullptr) {
|
|
return;
|
|
}
|
|
|
|
const Color bg = m_selected ? colorForRole(ColorRole::SurfaceVariant)
|
|
: m_hovered ? colorForRole(ColorRole::SurfaceVariant, 0.45f)
|
|
: clearColor();
|
|
m_background->setFill(bg);
|
|
m_glyph->setColor(colorSpecFromRole(m_isImage ? ColorRole::Secondary : ColorRole::Primary));
|
|
m_title->setColor(colorSpecFromRole(ColorRole::OnSurface));
|
|
m_meta->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
|
}
|
|
|
|
float m_scale = 1.0f;
|
|
ThumbnailService* m_thumbnails = nullptr;
|
|
Box* m_background = nullptr;
|
|
Flex* m_row = nullptr;
|
|
Flex* m_lead = nullptr;
|
|
Image* m_image = nullptr;
|
|
Glyph* m_glyph = nullptr;
|
|
Flex* m_textColumn = nullptr;
|
|
Label* m_title = nullptr;
|
|
Label* m_meta = nullptr;
|
|
std::size_t m_historyIndex = static_cast<std::size_t>(-1);
|
|
bool m_selected = false;
|
|
bool m_hovered = false;
|
|
bool m_isImage = false;
|
|
std::string m_thumbnailPath;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
class ClipboardListAdapter final : public VirtualGridAdapter {
|
|
public:
|
|
ClipboardListAdapter(float scale, ClipboardService* clipboard, ThumbnailService* thumbnails)
|
|
: m_scale(scale), m_clipboard(clipboard), m_thumbnails(thumbnails) {}
|
|
|
|
void setRenderer(Renderer* renderer) { m_renderer = renderer; }
|
|
void setFilteredIndices(const std::vector<std::size_t>* indices) { m_filteredIndices = indices; }
|
|
void setThumbnailService(ThumbnailService* thumbnails) {
|
|
m_thumbnails = thumbnails;
|
|
for (ClipboardListRow* row : m_pool) {
|
|
if (row != nullptr) {
|
|
row->setThumbnailService(thumbnails);
|
|
}
|
|
}
|
|
}
|
|
void setOnActivate(std::function<void(std::size_t)> callback) { m_onActivate = std::move(callback); }
|
|
|
|
void refreshVisibleThumbnails(Renderer& renderer) {
|
|
for (ClipboardListRow* row : m_pool) {
|
|
if (row != nullptr && row->visible()) {
|
|
row->refreshThumbnail(renderer);
|
|
}
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] std::size_t itemCount() const override {
|
|
return m_filteredIndices == nullptr ? 0 : m_filteredIndices->size();
|
|
}
|
|
|
|
[[nodiscard]] std::unique_ptr<Node> createTile() override {
|
|
auto row = std::make_unique<ClipboardListRow>(m_scale, m_thumbnails);
|
|
m_pool.push_back(row.get());
|
|
return row;
|
|
}
|
|
|
|
void bindTile(Node& tile, std::size_t index, bool selected, bool hovered) override {
|
|
if (m_renderer == nullptr || m_clipboard == nullptr || m_filteredIndices == nullptr ||
|
|
index >= m_filteredIndices->size()) {
|
|
return;
|
|
}
|
|
const std::size_t historyIndex = (*m_filteredIndices)[index];
|
|
const auto& history = m_clipboard->history();
|
|
if (historyIndex >= history.size()) {
|
|
return;
|
|
}
|
|
auto* row = static_cast<ClipboardListRow*>(&tile);
|
|
row->bind(*m_renderer, history[historyIndex], historyIndex, row->width(), selected, hovered && !selected);
|
|
}
|
|
|
|
void onActivate(std::size_t index) override {
|
|
if (m_onActivate) {
|
|
m_onActivate(index);
|
|
}
|
|
}
|
|
|
|
private:
|
|
float m_scale = 1.0f;
|
|
ClipboardService* m_clipboard = nullptr;
|
|
ThumbnailService* m_thumbnails = nullptr;
|
|
Renderer* m_renderer = nullptr;
|
|
const std::vector<std::size_t>* m_filteredIndices = nullptr;
|
|
std::vector<ClipboardListRow*> m_pool;
|
|
std::function<void(std::size_t)> m_onActivate;
|
|
};
|
|
|
|
ClipboardPanel::ClipboardPanel(ClipboardService* clipboard, ConfigService* config, ThumbnailService* thumbnails)
|
|
: m_clipboard(clipboard), m_config(config), m_thumbnails(thumbnails) {}
|
|
|
|
ClipboardPanel::~ClipboardPanel() = default;
|
|
|
|
bool ClipboardPanel::prefersAttachedToBar() const noexcept {
|
|
return m_config != nullptr && m_config->config().shell.panel.attachClipboard;
|
|
}
|
|
|
|
void ClipboardPanel::setActivateCallback(std::function<void(const ClipboardEntry&)> callback) {
|
|
m_activateCallback = std::move(callback);
|
|
}
|
|
|
|
void ClipboardPanel::create() {
|
|
const float scale = contentScale();
|
|
auto rootLayout = std::make_unique<Flex>();
|
|
rootLayout->setDirection(FlexDirection::Horizontal);
|
|
rootLayout->setAlign(FlexAlign::Stretch);
|
|
rootLayout->setGap(Style::spaceSm * scale);
|
|
m_rootLayout = rootLayout.get();
|
|
|
|
auto focusArea = std::make_unique<InputArea>();
|
|
focusArea->setFocusable(true);
|
|
focusArea->setVisible(false);
|
|
focusArea->setOnKeyDown([this](const InputArea::KeyData& key) {
|
|
if (key.pressed) {
|
|
handleKeyEvent(key.sym, key.modifiers);
|
|
}
|
|
});
|
|
m_focusArea = static_cast<InputArea*>(rootLayout->addChild(std::move(focusArea)));
|
|
|
|
auto sidebar = std::make_unique<Flex>();
|
|
sidebar->setDirection(FlexDirection::Vertical);
|
|
sidebar->setAlign(FlexAlign::Stretch);
|
|
sidebar->setPadding(Style::spaceSm * scale);
|
|
sidebar->setGap(Style::spaceSm * scale);
|
|
m_sidebar = sidebar.get();
|
|
|
|
auto sidebarHeader = std::make_unique<Flex>();
|
|
sidebarHeader->setDirection(FlexDirection::Horizontal);
|
|
sidebarHeader->setAlign(FlexAlign::Center);
|
|
sidebarHeader->setJustify(FlexJustify::SpaceBetween);
|
|
sidebarHeader->setGap(Style::spaceSm * scale);
|
|
m_sidebarHeaderRow = sidebarHeader.get();
|
|
|
|
auto title = std::make_unique<Label>();
|
|
title->setText(i18n::tr("clipboard.title"));
|
|
title->setFontSize(Style::fontSizeTitle * scale);
|
|
title->setBold(true);
|
|
title->setColor(colorSpecFromRole(ColorRole::Primary));
|
|
m_sidebarTitle = title.get();
|
|
sidebarHeader->addChild(std::move(title));
|
|
|
|
auto clearHistoryButton = std::make_unique<Button>();
|
|
clearHistoryButton->setGlyph("trash");
|
|
clearHistoryButton->setVariant(ButtonVariant::Destructive);
|
|
clearHistoryButton->setGlyphSize(Style::fontSizeBody * scale);
|
|
clearHistoryButton->setMinWidth(Style::controlHeightSm * scale);
|
|
clearHistoryButton->setMinHeight(Style::controlHeightSm * scale);
|
|
clearHistoryButton->setPadding(Style::spaceXs * scale);
|
|
clearHistoryButton->setRadius(Style::radiusMd * scale);
|
|
clearHistoryButton->setOnClick([this]() {
|
|
if (m_clipboard != nullptr) {
|
|
m_clipboard->clearHistory();
|
|
}
|
|
});
|
|
m_clearHistoryButton = clearHistoryButton.get();
|
|
sidebarHeader->addChild(std::move(clearHistoryButton));
|
|
sidebar->addChild(std::move(sidebarHeader));
|
|
|
|
auto filterInput = std::make_unique<Input>();
|
|
filterInput->setPlaceholder(i18n::tr("clipboard.filter-placeholder"));
|
|
filterInput->setFontSize(Style::fontSizeBody * scale);
|
|
filterInput->setControlHeight(Style::controlHeight * scale);
|
|
filterInput->setHorizontalPadding(Style::spaceMd * scale);
|
|
filterInput->setClearButtonEnabled(true);
|
|
filterInput->setOnChange([this](const std::string& text) { onFilterChanged(text); });
|
|
filterInput->setOnSubmit([this](const std::string& /*text*/) { activateSelected(); });
|
|
filterInput->setOnKeyEvent(
|
|
[this](std::uint32_t sym, std::uint32_t modifiers) { return handleKeyEvent(sym, modifiers); });
|
|
m_filterInput = filterInput.get();
|
|
sidebar->addChild(std::move(filterInput));
|
|
|
|
m_listAdapter = std::make_unique<ClipboardListAdapter>(scale, m_clipboard, m_thumbnails);
|
|
m_listAdapter->setFilteredIndices(&m_filteredIndices);
|
|
m_listAdapter->setOnActivate([this](std::size_t index) {
|
|
if (m_selectedIndex == index) {
|
|
activateSelected();
|
|
return;
|
|
}
|
|
selectIndex(index);
|
|
});
|
|
|
|
auto listGrid = std::make_unique<VirtualGridView>();
|
|
listGrid->setColumns(1);
|
|
listGrid->setSquareCells(false);
|
|
listGrid->setCellHeight(kRowHeight * scale);
|
|
listGrid->setRowGap(Style::spaceXs * scale);
|
|
listGrid->setColumnGap(0.0f);
|
|
listGrid->setOverscanRows(kListOverscanRows);
|
|
listGrid->setFlexGrow(1.0f);
|
|
listGrid->scrollView().setScrollbarVisible(true);
|
|
listGrid->setAdapter(m_listAdapter.get());
|
|
m_listGrid = static_cast<VirtualGridView*>(sidebar->addChild(std::move(listGrid)));
|
|
|
|
auto listEmpty = std::make_unique<Label>();
|
|
listEmpty->setCaptionStyle();
|
|
listEmpty->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
|
listEmpty->setVisible(false);
|
|
listEmpty->setParticipatesInLayout(false);
|
|
m_listEmptyLabel = static_cast<Label*>(sidebar->addChild(std::move(listEmpty)));
|
|
|
|
rootLayout->addChild(std::move(sidebar));
|
|
|
|
auto preview = std::make_unique<Flex>();
|
|
preview->setDirection(FlexDirection::Vertical);
|
|
preview->setAlign(FlexAlign::Stretch);
|
|
preview->setGap(Style::spaceSm * scale);
|
|
preview->setPadding(Style::spaceSm * scale);
|
|
preview->setFlexGrow(1.0f);
|
|
m_previewCard = preview.get();
|
|
|
|
auto previewHeader = std::make_unique<Flex>();
|
|
previewHeader->setDirection(FlexDirection::Horizontal);
|
|
previewHeader->setAlign(FlexAlign::Center);
|
|
previewHeader->setJustify(FlexJustify::SpaceBetween);
|
|
previewHeader->setGap(Style::spaceSm * scale);
|
|
m_previewHeaderRow = previewHeader.get();
|
|
|
|
auto previewTitleLabel = std::make_unique<Label>();
|
|
previewTitleLabel->setText(i18n::tr("clipboard.entry.title"));
|
|
previewTitleLabel->setFontSize(Style::fontSizeTitle * scale);
|
|
previewTitleLabel->setBold(true);
|
|
previewTitleLabel->setColor(colorSpecFromRole(ColorRole::Primary));
|
|
m_previewTitle = previewTitleLabel.get();
|
|
previewTitleLabel->setFlexGrow(1.0f);
|
|
previewHeader->addChild(std::move(previewTitleLabel));
|
|
|
|
auto previewActions = std::make_unique<Flex>();
|
|
previewActions->setDirection(FlexDirection::Horizontal);
|
|
previewActions->setAlign(FlexAlign::Center);
|
|
previewActions->setGap(Style::spaceXs * scale);
|
|
|
|
auto copyButton = std::make_unique<Button>();
|
|
copyButton->setGlyph("copy");
|
|
copyButton->setVariant(ButtonVariant::Secondary);
|
|
copyButton->setGlyphSize(Style::fontSizeBody * scale);
|
|
copyButton->setMinWidth(Style::controlHeightSm * scale);
|
|
copyButton->setMinHeight(Style::controlHeightSm * scale);
|
|
copyButton->setPadding(Style::spaceXs * scale);
|
|
copyButton->setRadius(Style::radiusMd * scale);
|
|
copyButton->setOnClick([this]() { activateSelected(); });
|
|
m_copyButton = copyButton.get();
|
|
previewActions->addChild(std::move(copyButton));
|
|
|
|
auto deleteEntryButton = std::make_unique<Button>();
|
|
deleteEntryButton->setGlyph("trash");
|
|
deleteEntryButton->setVariant(ButtonVariant::Destructive);
|
|
deleteEntryButton->setGlyphSize(Style::fontSizeBody * scale);
|
|
deleteEntryButton->setMinWidth(Style::controlHeightSm * scale);
|
|
deleteEntryButton->setMinHeight(Style::controlHeightSm * scale);
|
|
deleteEntryButton->setPadding(Style::spaceXs * scale);
|
|
deleteEntryButton->setRadius(Style::radiusMd * scale);
|
|
deleteEntryButton->setOnClick([this]() { deleteSelectedEntry(); });
|
|
m_deleteEntryButton = deleteEntryButton.get();
|
|
previewActions->addChild(std::move(deleteEntryButton));
|
|
|
|
previewHeader->addChild(std::move(previewActions));
|
|
preview->addChild(std::move(previewHeader));
|
|
|
|
auto previewMetaLabel = std::make_unique<Label>();
|
|
previewMetaLabel->setCaptionStyle();
|
|
previewMetaLabel->setFontSize(Style::fontSizeCaption * scale);
|
|
previewMetaLabel->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
|
m_previewMeta = previewMetaLabel.get();
|
|
preview->addChild(std::move(previewMetaLabel));
|
|
|
|
auto previewScroll = std::make_unique<ScrollView>();
|
|
previewScroll->setScrollbarVisible(true);
|
|
previewScroll->setCardStyle(scale);
|
|
previewScroll->setFlexGrow(1.0f);
|
|
m_previewScrollView = previewScroll.get();
|
|
m_previewContent = previewScroll->content();
|
|
m_previewContent->setDirection(FlexDirection::Vertical);
|
|
m_previewContent->setAlign(FlexAlign::Start);
|
|
m_previewContent->setGap(Style::spaceSm * scale);
|
|
preview->addChild(std::move(previewScroll));
|
|
|
|
rootLayout->addChild(std::move(preview));
|
|
|
|
setRoot(std::move(rootLayout));
|
|
if (m_animations != nullptr) {
|
|
root()->setAnimationManager(m_animations);
|
|
}
|
|
|
|
if (m_thumbnails != nullptr) {
|
|
m_thumbnailPendingSub = m_thumbnails->subscribePendingUpload([this]() {
|
|
m_thumbnailRefreshPending = true;
|
|
PanelManager::instance().requestUpdateOnly();
|
|
});
|
|
}
|
|
|
|
schedulePreviewPayloadRefresh(false);
|
|
}
|
|
|
|
void ClipboardPanel::doLayout(Renderer& renderer, float width, float height) {
|
|
if (m_rootLayout == nullptr || m_sidebar == nullptr || m_previewCard == nullptr || m_listGrid == nullptr ||
|
|
m_previewScrollView == nullptr) {
|
|
return;
|
|
}
|
|
|
|
m_lastWidth = width;
|
|
m_lastHeight = height;
|
|
|
|
const float sidebarWidth = std::min(kSidebarWidth, std::max(220.0f, width * 0.34f));
|
|
m_sidebar->setSize(sidebarWidth, 0.0f);
|
|
|
|
m_focusArea->setPosition(0.0f, 0.0f);
|
|
m_focusArea->setSize(1.0f, 1.0f);
|
|
|
|
if (m_listAdapter != nullptr) {
|
|
m_listAdapter->setRenderer(&renderer);
|
|
}
|
|
|
|
// Flex layout handles all sizing: sidebar title is measured automatically,
|
|
// listGrid fills remaining sidebar height (flexGrow), preview fills
|
|
// remaining root width (flexGrow), previewScroll fills remaining preview
|
|
// height (flexGrow). Stretch alignment propagates cross-axis sizes.
|
|
m_rootLayout->setSize(width, height);
|
|
m_rootLayout->layout(renderer);
|
|
|
|
bool relayoutNeeded = false;
|
|
const float previewScrollH = m_previewScrollView->height();
|
|
if (m_lastPreviewWidth != m_previewScrollView->contentViewportWidth() || m_lastPreviewHeight != previewScrollH) {
|
|
rebuildPreview(renderer, m_previewScrollView->contentViewportWidth(), previewScrollH);
|
|
relayoutNeeded = true;
|
|
}
|
|
|
|
if (relayoutNeeded) {
|
|
m_rootLayout->layout(renderer);
|
|
}
|
|
|
|
if (m_thumbnails != nullptr) {
|
|
const bool changed = m_thumbnails->uploadPending(renderer.textureManager());
|
|
if (changed) {
|
|
m_thumbnailRefreshPending = false;
|
|
if (m_listAdapter != nullptr) {
|
|
m_listAdapter->setRenderer(&renderer);
|
|
m_listAdapter->refreshVisibleThumbnails(renderer);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (m_pendingScrollToSelected) {
|
|
scrollToSelected();
|
|
m_pendingScrollToSelected = false;
|
|
}
|
|
}
|
|
|
|
void ClipboardPanel::doUpdate(Renderer& renderer) {
|
|
if (m_thumbnailRefreshPending && m_thumbnails != nullptr) {
|
|
const bool changed = m_thumbnails->uploadPending(renderer.textureManager());
|
|
m_thumbnailRefreshPending = false;
|
|
if (changed && m_listAdapter != nullptr) {
|
|
m_listAdapter->setRenderer(&renderer);
|
|
m_listAdapter->refreshVisibleThumbnails(renderer);
|
|
}
|
|
}
|
|
|
|
if (m_clipboard == nullptr || m_lastWidth <= 0.0f) {
|
|
return;
|
|
}
|
|
|
|
if (m_lastChangeSerial != m_clipboard->changeSerial()) {
|
|
applyFilter();
|
|
if (m_filteredIndices.empty()) {
|
|
m_selectedIndex = 0;
|
|
} else if (m_selectedIndex >= m_filteredIndices.size()) {
|
|
m_selectedIndex = m_filteredIndices.size() - 1;
|
|
}
|
|
|
|
m_lastChangeSerial = m_clipboard->changeSerial();
|
|
updateListState();
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->notifyDataChanged();
|
|
m_listGrid->setSelectedIndex(m_filteredIndices.empty() ? std::nullopt
|
|
: std::optional<std::size_t>(m_selectedIndex));
|
|
}
|
|
|
|
schedulePreviewPayloadRefresh(false);
|
|
const float previewWidth =
|
|
m_previewScrollView != nullptr ? m_previewScrollView->contentViewportWidth() : m_lastWidth;
|
|
const float previewHeight = m_previewScrollView != nullptr ? m_previewScrollView->height() : m_lastHeight;
|
|
rebuildPreview(renderer, previewWidth, previewHeight);
|
|
}
|
|
}
|
|
|
|
void ClipboardPanel::onOpen(std::string_view /*context*/) {
|
|
m_selectedIndex = 0;
|
|
m_previewPayloadIndex = static_cast<std::size_t>(-1);
|
|
m_pendingPreviewPayloadIndex = static_cast<std::size_t>(-1);
|
|
m_previewPayloadDebounceTimer.stop();
|
|
m_lastPreviewWidth = -1.0f;
|
|
m_lastPreviewHeight = -1.0f;
|
|
m_pendingScrollToSelected = false;
|
|
m_filterQuery.clear();
|
|
m_pendingFilterQuery.clear();
|
|
m_filterDebounceTimer.stop();
|
|
if (m_filterInput != nullptr) {
|
|
m_filterInput->setValue("");
|
|
}
|
|
applyFilter();
|
|
updateListState();
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->notifyDataChanged();
|
|
m_listGrid->setSelectedIndex(m_filteredIndices.empty() ? std::nullopt
|
|
: std::optional<std::size_t>(m_selectedIndex));
|
|
m_listGrid->scrollView().setScrollOffset(0.0f);
|
|
}
|
|
m_lastChangeSerial = m_clipboard != nullptr ? m_clipboard->changeSerial() : 0;
|
|
schedulePreviewPayloadRefresh(false);
|
|
}
|
|
|
|
void ClipboardPanel::onClose() {
|
|
m_thumbnailPendingSub.disconnect();
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->setAdapter(nullptr);
|
|
}
|
|
m_listAdapter.reset();
|
|
m_rootLayout = nullptr;
|
|
m_focusArea = nullptr;
|
|
m_sidebar = nullptr;
|
|
m_sidebarHeaderRow = nullptr;
|
|
m_sidebarTitle = nullptr;
|
|
m_clearHistoryButton = nullptr;
|
|
m_filterInput = nullptr;
|
|
m_listGrid = nullptr;
|
|
m_listEmptyLabel = nullptr;
|
|
m_filteredIndices.clear();
|
|
m_previewCard = nullptr;
|
|
m_previewHeaderRow = nullptr;
|
|
m_previewTitle = nullptr;
|
|
m_previewMeta = nullptr;
|
|
m_copyButton = nullptr;
|
|
m_deleteEntryButton = nullptr;
|
|
m_previewScrollView = nullptr;
|
|
m_previewContent = nullptr;
|
|
m_previewImage = nullptr;
|
|
m_previewPayloadDebounceTimer.stop();
|
|
m_filterDebounceTimer.stop();
|
|
m_pendingFilterQuery.clear();
|
|
m_filterQuery.clear();
|
|
clearReleasedRoot();
|
|
m_lastWidth = 0.0f;
|
|
m_lastHeight = 0.0f;
|
|
m_pendingScrollToSelected = false;
|
|
m_thumbnailRefreshPending = false;
|
|
}
|
|
|
|
InputArea* ClipboardPanel::initialFocusArea() const {
|
|
return m_filterInput != nullptr ? m_filterInput->inputArea() : m_focusArea;
|
|
}
|
|
|
|
void ClipboardPanel::schedulePreviewPayloadRefresh(bool debounced) {
|
|
const std::size_t historyIndex = selectedHistoryIndex();
|
|
if (m_clipboard == nullptr || historyIndex == static_cast<std::size_t>(-1)) {
|
|
m_previewPayloadDebounceTimer.stop();
|
|
m_previewPayloadIndex = static_cast<std::size_t>(-1);
|
|
m_pendingPreviewPayloadIndex = static_cast<std::size_t>(-1);
|
|
m_lastPreviewWidth = -1.0f;
|
|
m_lastPreviewHeight = -1.0f;
|
|
return;
|
|
}
|
|
|
|
if (!debounced || historyIndex == m_previewPayloadIndex) {
|
|
m_previewPayloadDebounceTimer.stop();
|
|
m_previewPayloadIndex = historyIndex;
|
|
m_pendingPreviewPayloadIndex = static_cast<std::size_t>(-1);
|
|
m_lastPreviewWidth = -1.0f;
|
|
m_lastPreviewHeight = -1.0f;
|
|
return;
|
|
}
|
|
|
|
m_pendingPreviewPayloadIndex = historyIndex;
|
|
m_lastPreviewWidth = -1.0f;
|
|
m_lastPreviewHeight = -1.0f;
|
|
m_previewPayloadDebounceTimer.start(kPreviewPayloadDebounceInterval, [this]() {
|
|
if (m_pendingPreviewPayloadIndex == static_cast<std::size_t>(-1)) {
|
|
return;
|
|
}
|
|
m_previewPayloadIndex = m_pendingPreviewPayloadIndex;
|
|
m_pendingPreviewPayloadIndex = static_cast<std::size_t>(-1);
|
|
m_lastPreviewWidth = -1.0f;
|
|
m_lastPreviewHeight = -1.0f;
|
|
PanelManager::instance().refresh();
|
|
});
|
|
}
|
|
|
|
void ClipboardPanel::updateListState() {
|
|
const auto& history = m_clipboard != nullptr ? m_clipboard->history() : std::deque<ClipboardEntry>{};
|
|
const bool empty = history.empty() || m_filteredIndices.empty();
|
|
|
|
if (m_listEmptyLabel != nullptr) {
|
|
m_listEmptyLabel->setText(history.empty() ? i18n::tr("clipboard.empty.history-title")
|
|
: m_filterQuery.empty() ? i18n::tr("clipboard.empty.history-title")
|
|
: i18n::tr("clipboard.empty.no-matches-title"));
|
|
m_listEmptyLabel->setVisible(empty);
|
|
m_listEmptyLabel->setParticipatesInLayout(empty);
|
|
}
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->setVisible(!empty);
|
|
m_listGrid->setParticipatesInLayout(!empty);
|
|
}
|
|
}
|
|
|
|
void ClipboardPanel::rebuildPreview(Renderer& renderer, float width, float height) {
|
|
uiAssertNotRendering("ClipboardPanel::rebuildPreview");
|
|
if (m_previewContent == nullptr || m_previewTitle == nullptr || m_previewMeta == nullptr) {
|
|
return;
|
|
}
|
|
|
|
while (!m_previewContent->children().empty()) {
|
|
m_previewContent->removeChild(m_previewContent->children().front().get());
|
|
}
|
|
m_previewImage = nullptr;
|
|
|
|
const auto& history = m_clipboard != nullptr ? m_clipboard->history() : std::deque<ClipboardEntry>{};
|
|
const std::size_t historyIndex = selectedHistoryIndex();
|
|
if (history.empty() || historyIndex == static_cast<std::size_t>(-1)) {
|
|
m_previewTitle->setText(i18n::tr("clipboard.entry.title"));
|
|
m_previewMeta->setText(history.empty() ? i18n::tr("clipboard.empty.history-message")
|
|
: i18n::tr("clipboard.empty.no-matches-title"));
|
|
|
|
auto empty = std::make_unique<Label>();
|
|
empty->setText(history.empty() ? i18n::tr("clipboard.empty.history-message")
|
|
: i18n::tr("clipboard.empty.no-matches-message"));
|
|
empty->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
|
empty->setMaxWidth(width);
|
|
m_previewContent->addChild(std::move(empty));
|
|
m_lastPreviewWidth = width;
|
|
m_lastPreviewHeight = height;
|
|
return;
|
|
}
|
|
|
|
const auto& entry = history[historyIndex];
|
|
m_previewTitle->setText(previewTitle(entry));
|
|
m_previewTitle->setMaxWidth(width);
|
|
m_previewMeta->setText(formatTimeAgo(entry.capturedAt) + " • " + formatBytes(entry.byteSize));
|
|
m_previewMeta->setMaxWidth(width);
|
|
|
|
if (m_previewPayloadIndex != historyIndex) {
|
|
auto pending = std::make_unique<Label>();
|
|
pending->setText(i18n::tr("clipboard.preview.loading"));
|
|
pending->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
|
pending->setMaxWidth(width);
|
|
m_previewContent->addChild(std::move(pending));
|
|
m_lastPreviewWidth = width;
|
|
m_lastPreviewHeight = height;
|
|
return;
|
|
}
|
|
|
|
if (m_clipboard != nullptr) {
|
|
(void)m_clipboard->ensureEntryLoaded(historyIndex);
|
|
}
|
|
const auto& loadedEntry = m_clipboard != nullptr ? m_clipboard->history()[historyIndex] : entry;
|
|
|
|
if (loadedEntry.isImage()) {
|
|
auto image = std::make_unique<Image>();
|
|
image->setSize(width, std::min(kPreviewImageHeight, std::max(180.0f, height - Style::spaceMd)));
|
|
image->setFit(ImageFit::Contain);
|
|
image->setSourceBytes(renderer, loadedEntry.data.data(), loadedEntry.data.size());
|
|
m_previewImage = image.get();
|
|
m_previewContent->addChild(std::move(image));
|
|
} else {
|
|
constexpr std::size_t kMaxPreviewChars = 8000;
|
|
constexpr int kMaxPreviewLines = 200;
|
|
|
|
std::string text(loadedEntry.data.begin(), loadedEntry.data.end());
|
|
const bool truncated = text.size() > kMaxPreviewChars;
|
|
if (truncated) {
|
|
text.resize(kMaxPreviewChars);
|
|
}
|
|
|
|
// Expand tabs to 4 spaces once up front; Pango's natural wrapping then
|
|
// handles everything else — newlines become paragraph breaks, each
|
|
// paragraph's leading whitespace stays on its first line, continuations
|
|
// have no indent, and the whole layout ellipsizes at kMaxPreviewLines.
|
|
std::string expanded;
|
|
expanded.reserve(text.size());
|
|
for (char ch : text) {
|
|
if (ch == '\t') {
|
|
expanded.append(" ");
|
|
} else {
|
|
expanded.push_back(ch);
|
|
}
|
|
}
|
|
|
|
if (expanded.empty()) {
|
|
auto empty = std::make_unique<Label>();
|
|
empty->setText(i18n::tr("clipboard.preview.empty-text-payload"));
|
|
empty->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
|
m_previewContent->addChild(std::move(empty));
|
|
} else {
|
|
auto label = std::make_unique<Label>();
|
|
label->setText(expanded);
|
|
label->setFontSize(Style::fontSizeBody);
|
|
label->setColor(colorSpecFromRole(ColorRole::OnSurface));
|
|
label->setMaxWidth(width);
|
|
label->setMaxLines(kMaxPreviewLines);
|
|
m_previewContent->addChild(std::move(label));
|
|
if (truncated) {
|
|
auto hint = std::make_unique<Label>();
|
|
hint->setText(i18n::tr("clipboard.preview.truncated"));
|
|
hint->setCaptionStyle();
|
|
hint->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
|
m_previewContent->addChild(std::move(hint));
|
|
}
|
|
}
|
|
}
|
|
|
|
m_previewContent->layout(renderer);
|
|
m_lastPreviewWidth = width;
|
|
m_lastPreviewHeight = height;
|
|
}
|
|
|
|
std::size_t ClipboardPanel::selectedHistoryIndex() const {
|
|
if (m_selectedIndex >= m_filteredIndices.size()) {
|
|
return static_cast<std::size_t>(-1);
|
|
}
|
|
return m_filteredIndices[m_selectedIndex];
|
|
}
|
|
|
|
void ClipboardPanel::applyFilter() {
|
|
m_filteredIndices.clear();
|
|
if (m_clipboard == nullptr) {
|
|
return;
|
|
}
|
|
const auto& history = m_clipboard->history();
|
|
|
|
// Case-insensitive substring match on the entry title.
|
|
std::string needle;
|
|
needle.reserve(m_filterQuery.size());
|
|
for (char ch : m_filterQuery) {
|
|
needle.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(ch))));
|
|
}
|
|
|
|
m_filteredIndices.reserve(history.size());
|
|
for (std::size_t i = 0; i < history.size(); ++i) {
|
|
if (needle.empty()) {
|
|
m_filteredIndices.push_back(i);
|
|
continue;
|
|
}
|
|
std::string haystack = entryTitle(history[i]);
|
|
for (char& ch : haystack) {
|
|
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
|
}
|
|
if (haystack.find(needle) != std::string::npos) {
|
|
m_filteredIndices.push_back(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
void ClipboardPanel::onFilterChanged(const std::string& text) {
|
|
if (text == m_pendingFilterQuery && text == m_filterQuery) {
|
|
return;
|
|
}
|
|
m_pendingFilterQuery = text;
|
|
|
|
auto commit = [this]() {
|
|
if (m_pendingFilterQuery == m_filterQuery) {
|
|
return;
|
|
}
|
|
m_filterQuery = m_pendingFilterQuery;
|
|
applyFilter();
|
|
m_selectedIndex = 0;
|
|
updateListState();
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->notifyDataChanged();
|
|
m_listGrid->setSelectedIndex(m_filteredIndices.empty() ? std::nullopt
|
|
: std::optional<std::size_t>(m_selectedIndex));
|
|
}
|
|
schedulePreviewPayloadRefresh(true);
|
|
m_pendingScrollToSelected = true;
|
|
PanelManager::instance().refresh();
|
|
};
|
|
|
|
m_filterDebounceTimer.start(kFilterDebounceInterval, commit);
|
|
}
|
|
|
|
void ClipboardPanel::selectIndex(std::size_t index) {
|
|
if (m_clipboard == nullptr || index >= m_filteredIndices.size()) {
|
|
return;
|
|
}
|
|
if (m_selectedIndex == index) {
|
|
return;
|
|
}
|
|
m_selectedIndex = index;
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->setSelectedIndex(index);
|
|
}
|
|
schedulePreviewPayloadRefresh(true);
|
|
m_pendingScrollToSelected = true;
|
|
PanelManager::instance().refresh();
|
|
}
|
|
|
|
void ClipboardPanel::deleteSelectedEntry() {
|
|
if (m_clipboard == nullptr) {
|
|
return;
|
|
}
|
|
const std::size_t historyIndex = selectedHistoryIndex();
|
|
if (historyIndex == static_cast<std::size_t>(-1)) {
|
|
return;
|
|
}
|
|
const std::size_t filterPos = m_selectedIndex;
|
|
if (!m_clipboard->removeHistoryEntry(historyIndex)) {
|
|
return;
|
|
}
|
|
applyFilter();
|
|
if (m_filteredIndices.empty()) {
|
|
m_selectedIndex = 0;
|
|
} else {
|
|
m_selectedIndex = std::min(filterPos, m_filteredIndices.size() - 1);
|
|
}
|
|
updateListState();
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->notifyDataChanged();
|
|
m_listGrid->setSelectedIndex(m_filteredIndices.empty() ? std::nullopt
|
|
: std::optional<std::size_t>(m_selectedIndex));
|
|
}
|
|
schedulePreviewPayloadRefresh(false);
|
|
m_pendingScrollToSelected = true;
|
|
PanelManager::instance().refresh();
|
|
}
|
|
|
|
void ClipboardPanel::activateSelected() {
|
|
if (m_clipboard == nullptr) {
|
|
return;
|
|
}
|
|
const std::size_t historyIndex = selectedHistoryIndex();
|
|
if (historyIndex == static_cast<std::size_t>(-1)) {
|
|
return;
|
|
}
|
|
if (!m_clipboard->ensureEntryLoaded(historyIndex)) {
|
|
return;
|
|
}
|
|
const ClipboardEntry entry = m_clipboard->history()[historyIndex];
|
|
const bool promoted = m_clipboard->promoteEntry(historyIndex);
|
|
const bool copied = m_clipboard->copyEntry(entry);
|
|
if (copied || promoted) {
|
|
if (m_activateCallback) {
|
|
m_activateCallback(entry);
|
|
return;
|
|
}
|
|
m_selectedIndex = 0;
|
|
applyFilter();
|
|
updateListState();
|
|
if (m_listGrid != nullptr) {
|
|
m_listGrid->notifyDataChanged();
|
|
m_listGrid->setSelectedIndex(m_filteredIndices.empty() ? std::nullopt
|
|
: std::optional<std::size_t>(m_selectedIndex));
|
|
}
|
|
schedulePreviewPayloadRefresh(false);
|
|
PanelManager::instance().refresh();
|
|
}
|
|
}
|
|
|
|
bool ClipboardPanel::handleKeyEvent(std::uint32_t sym, std::uint32_t modifiers) {
|
|
if (m_clipboard == nullptr || m_filteredIndices.empty()) {
|
|
return false;
|
|
}
|
|
|
|
if (m_config != nullptr && m_config->matchesKeybind(KeybindAction::Up, sym, modifiers)) {
|
|
if (m_selectedIndex > 0) {
|
|
selectIndex(m_selectedIndex - 1);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (m_config != nullptr && m_config->matchesKeybind(KeybindAction::Down, sym, modifiers)) {
|
|
if (m_selectedIndex + 1 < m_filteredIndices.size()) {
|
|
selectIndex(m_selectedIndex + 1);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (m_config != nullptr && m_config->matchesKeybind(KeybindAction::Validate, sym, modifiers)) {
|
|
activateSelected();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void ClipboardPanel::scrollToSelected() {
|
|
if (m_listGrid == nullptr || m_selectedIndex >= m_filteredIndices.size()) {
|
|
return;
|
|
}
|
|
m_listGrid->scrollToIndex(m_selectedIndex);
|
|
}
|