diff --git a/assets/translations/en.json b/assets/translations/en.json index a76340e13..a0e78aebf 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -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" diff --git a/example.toml b/example.toml index b08a801e3..e668442e0 100644 --- a/example.toml +++ b/example.toml @@ -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" diff --git a/src/config/config_overrides.cpp b/src/config/config_overrides.cpp index bee06d482..5669b4d49 100644 --- a/src/config/config_overrides.cpp +++ b/src/config/config_overrides.cpp @@ -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 && diff --git a/src/config/config_service.cpp b/src/config/config_service.cpp index 4ad1e36ce..7f42365e0 100644 --- a/src/config/config_service.cpp +++ b/src/config/config_service.cpp @@ -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()) { + shell.clipboardImageActionCommand = *v; + } } // Parse [theme] diff --git a/src/config/config_types.h b/src/config/config_types.h index 798277518..7b9c45889 100644 --- a/src/config/config_types.h +++ b/src/config/config_types.h @@ -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; diff --git a/src/shell/clipboard/clipboard_panel.cpp b/src/shell/clipboard/clipboard_panel.cpp index 6bc81af2a..3692354a5 100644 --- a/src/shell/clipboard/clipboard_panel.cpp +++ b/src/shell/clipboard/clipboard_panel.cpp @@ -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