mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
feat(clipboard): added button and settings to call an external tool on image entries
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user