feat(clipboard): added button and settings to call an external tool on image entries

This commit is contained in:
Lemmy
2026-05-09 23:20:32 -04:00
parent 94ce4d7c31
commit db71ad851f
10 changed files with 238 additions and 2 deletions
+7 -1
View File
@@ -21,7 +21,8 @@
"text-title": "Text Clipboard Entry",
"loading": "Loading preview...",
"empty-text-payload": "(empty text payload)",
"truncated": "… truncated"
"truncated": "… truncated",
"image-action": "Open image action"
},
"empty": {
"history-title": "Clipboard history is empty",
@@ -1278,6 +1279,11 @@
"label": "Clipboard Auto-Paste",
"description": "Automatically paste clipboard selections"
},
"clipboard-image-action": {
"label": "Image Action Command",
"description": "Command to run from image clipboard entries. Use {path} for an exported image file; use {stdin} or omit {path} to pipe the image to stdin.",
"placeholder": "gimp {path} or satty -f -"
},
"osd-position": {
"label": "OSD Position",
"description": "Screen position for OSD popups"
+1
View File
@@ -17,6 +17,7 @@ settings_show_advanced = false # show advanced settings by default in Sett
middle_click_opens_widget_settings = true # middle-click bar widgets to open their Settings entry
show_location = true # hide weather location text in shell UI when false
clipboard_auto_paste = "auto" # off | auto | ctrl_v | ctrl_shift_v | shift_insert
clipboard_image_action_command = "" # image preview action: gimp {path}, or satty -f - via stdin
# avatar_path = "~/Pictures/avatar.png"
# lang = "en"
+2 -1
View File
@@ -291,7 +291,8 @@ namespace {
nearlyEqual(a.animation.speed, b.animation.speed) && a.avatarPath == b.avatarPath &&
a.settingsShowAdvanced == b.settingsShowAdvanced &&
a.middleClickOpensWidgetSettings == b.middleClickOpensWidgetSettings && a.showLocation == b.showLocation &&
a.clipboardAutoPaste == b.clipboardAutoPaste && a.shadow.blur == b.shadow.blur &&
a.clipboardAutoPaste == b.clipboardAutoPaste &&
a.clipboardImageActionCommand == b.clipboardImageActionCommand && a.shadow.blur == b.shadow.blur &&
a.shadow.offsetX == b.shadow.offsetX && a.shadow.offsetY == b.shadow.offsetY &&
nearlyEqual(a.shadow.alpha, b.shadow.alpha) && a.panel.backgroundBlur == b.panel.backgroundBlur &&
a.panel.attachLauncher == b.panel.attachLauncher && a.panel.attachClipboard == b.panel.attachClipboard &&
+3
View File
@@ -1270,6 +1270,9 @@ void ConfigService::parseTableInto(const toml::table& tbl, Config& config, bool
shell.clipboardAutoPaste = *parsed;
}
}
if (auto v = (*shellTbl)["clipboard_image_action_command"].value<std::string>()) {
shell.clipboardImageActionCommand = *v;
}
}
// Parse [theme]
+1
View File
@@ -382,6 +382,7 @@ struct ShellConfig {
bool middleClickOpensWidgetSettings = true;
bool showLocation = true;
ClipboardAutoPasteMode clipboardAutoPaste = ClipboardAutoPasteMode::Auto;
std::string clipboardImageActionCommand;
ShadowConfig shadow;
PanelConfig panel;
ScreenCornersConfig screenCorners;
+127
View File
@@ -2,6 +2,8 @@
#include "config/config_service.h"
#include "core/deferred_call.h"
#include "core/log.h"
#include "core/process.h"
#include "core/ui_phase.h"
#include "i18n/i18n.h"
#include "render/core/async_texture_cache.h"
@@ -43,6 +45,61 @@ namespace {
constexpr std::size_t kListOverscanRows = 3;
constexpr auto kPreviewPayloadDebounceInterval = std::chrono::milliseconds(75);
constexpr auto kFilterDebounceInterval = std::chrono::milliseconds(120);
constexpr Logger kLog("clipboard");
std::string trim(std::string_view text) {
const auto first = text.find_first_not_of(" \t\r\n");
if (first == std::string_view::npos) {
return {};
}
const auto last = text.find_last_not_of(" \t\r\n");
return std::string(text.substr(first, last - first + 1));
}
std::string shellQuote(std::string_view text) {
std::string quoted;
quoted.reserve(text.size() + 2);
quoted.push_back('\'');
for (char ch : text) {
if (ch == '\'') {
quoted += "'\\''";
} else {
quoted.push_back(ch);
}
}
quoted.push_back('\'');
return quoted;
}
void replaceAll(std::string& text, std::string_view needle, std::string_view replacement) {
if (needle.empty()) {
return;
}
std::size_t pos = 0;
while ((pos = text.find(needle, pos)) != std::string::npos) {
text.replace(pos, needle.size(), replacement);
pos += replacement.size();
}
}
std::string buildImageActionCommand(std::string command, std::string_view imagePath) {
const bool hasPathPlaceholder = command.find("{path}") != std::string::npos;
const bool hasStdinPlaceholder = command.find("{stdin}") != std::string::npos;
const std::string quotedPath = shellQuote(imagePath);
if (hasPathPlaceholder) {
replaceAll(command, "{path}", quotedPath);
}
if (hasStdinPlaceholder) {
replaceAll(command, "{stdin}", "-");
}
if (!hasPathPlaceholder || hasStdinPlaceholder) {
return "cat -- " + quotedPath + " | " + command;
}
return command;
}
std::string collapseWhitespace(std::string_view text) {
std::string out;
@@ -506,6 +563,20 @@ void ClipboardPanel::create() {
previewActions->setAlign(FlexAlign::Center);
previewActions->setGap(Style::spaceXs * scale);
auto imageActionButton = std::make_unique<Button>();
imageActionButton->setGlyph("photo-edit");
imageActionButton->setVariant(ButtonVariant::Secondary);
imageActionButton->setGlyphSize(Style::fontSizeBody * scale);
imageActionButton->setMinWidth(Style::controlHeightSm * scale);
imageActionButton->setMinHeight(Style::controlHeightSm * scale);
imageActionButton->setPadding(Style::spaceXs * scale);
imageActionButton->setRadius(Style::radiusMd * scale);
imageActionButton->setVisible(false);
imageActionButton->setParticipatesInLayout(false);
imageActionButton->setOnClick([this]() { runImageAction(); });
m_imageActionButton = imageActionButton.get();
previewActions->addChild(std::move(imageActionButton));
auto copyButton = std::make_unique<Button>();
copyButton->setGlyph("copy");
copyButton->setVariant(ButtonVariant::Secondary);
@@ -632,6 +703,8 @@ void ClipboardPanel::doUpdate(Renderer& renderer) {
}
}
updatePreviewActions();
if (m_clipboard == nullptr || m_lastWidth <= 0.0f) {
return;
}
@@ -706,6 +779,7 @@ void ClipboardPanel::onClose() {
m_previewHeaderRow = nullptr;
m_previewTitle = nullptr;
m_previewMeta = nullptr;
m_imageActionButton = nullptr;
m_copyButton = nullptr;
m_deleteEntryButton = nullptr;
m_previewScrollView = nullptr;
@@ -785,12 +859,33 @@ void ClipboardPanel::updateListState() {
}
}
void ClipboardPanel::updatePreviewActions() {
if (m_imageActionButton == nullptr) {
return;
}
bool showImageAction = false;
if (m_clipboard != nullptr && m_config != nullptr &&
!trim(m_config->config().shell.clipboardImageActionCommand).empty()) {
const std::size_t historyIndex = selectedHistoryIndex();
const auto& history = m_clipboard->history();
showImageAction = historyIndex != static_cast<std::size_t>(-1) && historyIndex < history.size() &&
history[historyIndex].isImage();
}
m_imageActionButton->setVisible(showImageAction);
m_imageActionButton->setParticipatesInLayout(showImageAction);
m_imageActionButton->setEnabled(showImageAction);
}
void ClipboardPanel::rebuildPreview(Renderer& renderer, float width, float height) {
uiAssertNotRendering("ClipboardPanel::rebuildPreview");
if (m_previewContent == nullptr || m_previewTitle == nullptr || m_previewMeta == nullptr) {
return;
}
updatePreviewActions();
while (!m_previewContent->children().empty()) {
m_previewContent->removeChild(m_previewContent->children().front().get());
}
@@ -1013,6 +1108,38 @@ void ClipboardPanel::deleteSelectedEntry() {
PanelManager::instance().refresh();
}
void ClipboardPanel::runImageAction() {
if (m_clipboard == nullptr || m_config == nullptr) {
return;
}
const std::string configuredCommand = trim(m_config->config().shell.clipboardImageActionCommand);
if (configuredCommand.empty()) {
return;
}
const std::size_t historyIndex = selectedHistoryIndex();
if (historyIndex == static_cast<std::size_t>(-1)) {
return;
}
const auto& history = m_clipboard->history();
if (historyIndex >= history.size() || !history[historyIndex].isImage()) {
return;
}
const std::optional<std::string> exportedPath = m_clipboard->exportEntryForExternalTool(historyIndex);
if (!exportedPath.has_value()) {
kLog.warn("clipboard image action failed: selected image could not be exported");
return;
}
const std::string command = buildImageActionCommand(configuredCommand, *exportedPath);
if (!process::runAsync(command)) {
kLog.warn("clipboard image action failed to launch: {}", configuredCommand);
}
}
void ClipboardPanel::activateSelected() {
if (m_clipboard == nullptr) {
return;
+3
View File
@@ -52,9 +52,11 @@ private:
void doUpdate(Renderer& renderer) override;
void schedulePreviewPayloadRefresh(bool debounced);
void updateListState();
void updatePreviewActions();
void rebuildPreview(Renderer& renderer, float width, float height);
void selectIndex(std::size_t index);
void activateSelected();
void runImageAction();
bool handleKeyEvent(std::uint32_t sym, std::uint32_t modifiers);
void scrollToSelected();
void deleteSelectedEntry();
@@ -85,6 +87,7 @@ private:
Flex* m_previewHeaderRow = nullptr;
Label* m_previewTitle = nullptr;
Label* m_previewMeta = nullptr;
Button* m_imageActionButton = nullptr;
Button* m_copyButton = nullptr;
Button* m_deleteEntryButton = nullptr;
ScrollView* m_previewScrollView = nullptr;
+6
View File
@@ -597,6 +597,12 @@ namespace settings {
tr("settings.schema.shell.clipboard-auto-paste.description"),
{"shell", "clipboard_auto_paste"},
enumSelect(kClipboardAutoPasteModes, cfg.shell.clipboardAutoPaste), "clipboard paste"));
entries.push_back(makeEntry("shell", "clipboard", tr("settings.schema.shell.clipboard-image-action.label"),
tr("settings.schema.shell.clipboard-image-action.description"),
{"shell", "clipboard_image_action_command"},
TextSetting{cfg.shell.clipboardImageActionCommand,
tr("settings.schema.shell.clipboard-image-action.placeholder"), 320.0f},
"clipboard image action annotation editor external gimp satty gradia"));
entries.push_back(makeEntry("shell", "osd", tr("settings.schema.shell.osd-position.label"),
tr("settings.schema.shell.osd-position.description"), {"osd", "position"},
plainSelect({{"top_right", "settings.options.screen-position.top-right"},
+86
View File
@@ -46,6 +46,43 @@ namespace {
constexpr Logger kLog("clipboard");
std::uint64_t gStorageCounter = 0;
std::string extensionForImageMimeType(std::string_view mimeType) {
if (mimeType == "image/png")
return ".png";
if (mimeType == "image/jpeg" || mimeType == "image/jpg")
return ".jpg";
if (mimeType == "image/webp")
return ".webp";
if (mimeType == "image/gif")
return ".gif";
if (mimeType == "image/bmp")
return ".bmp";
if (mimeType == "image/tiff")
return ".tiff";
if (mimeType == "image/svg+xml")
return ".svg";
if (mimeType == "image/avif")
return ".avif";
if (mimeType == "image/heic")
return ".heic";
return ".img";
}
std::string exportExtensionForEntry(const ClipboardEntry& entry) {
std::string extension = extensionForImageMimeType(entry.dataMimeType);
if (extension != ".img") {
return extension;
}
for (const auto& mimeType : entry.mimeTypes) {
extension = extensionForImageMimeType(mimeType);
if (extension != ".img") {
return extension;
}
}
return extension;
}
void closeFd(int& fd) {
if (fd >= 0) {
close(fd);
@@ -406,6 +443,55 @@ bool ClipboardService::ensureEntryLoaded(std::size_t index) {
return loadEntryPayload(m_history[index]);
}
std::optional<std::string> ClipboardService::exportEntryForExternalTool(std::size_t index) {
namespace fs = std::filesystem;
if (index >= m_history.size()) {
return std::nullopt;
}
ClipboardEntry& entry = m_history[index];
if (!entry.isImage()) {
return std::nullopt;
}
const bool wasLoaded = entry.payloadLoaded;
if (!loadEntryPayload(entry)) {
return std::nullopt;
}
try {
if (entry.storageId.empty()) {
entry.storageId = generateStorageId();
}
const fs::path exportDir = fs::path(stateDirectory()) / "exports";
fs::create_directories(exportDir);
const fs::path exportPath = exportDir / (entry.storageId + exportExtensionForEntry(entry));
std::ofstream out(exportPath, std::ios::binary | std::ios::trunc);
if (!out.is_open()) {
throw std::runtime_error("failed to open exported clipboard image for writing");
}
out.write(reinterpret_cast<const char*>(entry.data.data()), static_cast<std::streamsize>(entry.data.size()));
out.flush();
if (!out.good()) {
throw std::runtime_error("failed to write exported clipboard image");
}
if (!wasLoaded) {
evictPayloadData(entry);
}
return exportPath.string();
} catch (const std::exception& e) {
if (!wasLoaded) {
evictPayloadData(entry);
}
kLog.warn("failed to export clipboard image: {}", e.what());
return std::nullopt;
}
}
void ClipboardService::evictEntryPayload(std::size_t index) {
if (index >= m_history.size()) {
return;
+2
View File
@@ -6,6 +6,7 @@
#include <deque>
#include <functional>
#include <memory>
#include <optional>
#include <poll.h>
#include <string>
#include <string_view>
@@ -73,6 +74,7 @@ public:
[[nodiscard]] std::size_t addPollFds(std::vector<pollfd>& fds) const;
bool ensureEntryLoaded(std::size_t index);
[[nodiscard]] std::optional<std::string> exportEntryForExternalTool(std::size_t index);
void evictEntryPayload(std::size_t index);
void evictAllPayloads();
bool copyText(std::string text);