From 12a480d37bb96643ae39848d47db57f329006454 Mon Sep 17 00:00:00 2001 From: "Simanga H. Khoza" Date: Sat, 11 Jul 2026 09:50:02 +0200 Subject: [PATCH] feat: add image paste support and clipboard documentation --- README.md | 6 +++-- doc/CLIPBOARD.md | 16 ++++++++--- lib/ncutil/src/clipboard.cpp | 52 +++++++++++++++++++++++++++++++++--- src/main.cpp | 2 +- src/nchat.1 | 2 +- src/uimodel.cpp | 44 +++++++++++++++++++++++++----- src/uimodel.h | 2 ++ 7 files changed, 106 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a16110ad4..bdc6bf274 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Interactive Commands for Text Input: Alt-Delete delete next word Alt-Tab insert tab (four spaces) Alt-c copy input buffer to clipboard (if no message selected) - Alt-v paste into input buffer from clipboard + Alt-v paste text or attach image from clipboard Alt-x cut input buffer to clipboard @@ -583,7 +583,9 @@ or a chat. ### confirm_send_pasted_image Specifies whether to prompt the user for confirmation when pasting an image -to a chat. +to a chat. Pasted images are attached to the current chat and sent when the +message is sent; existing input text is used as caption when transfer captions +are enabled. ### desktop_notify_active_current diff --git a/doc/CLIPBOARD.md b/doc/CLIPBOARD.md index 4f0284dcb..fb93d8643 100644 --- a/doc/CLIPBOARD.md +++ b/doc/CLIPBOARD.md @@ -1,8 +1,16 @@ Clipboard ========= On supported platforms (macOS, X11, Wayland) nchat uses the system -clipboard. For other platforms, or custom behavior, one may configure below -parameters in `app.conf`. +clipboard. Text paste inserts clipboard text into the input buffer. Image +paste attaches the clipboard image to the current chat after optional +confirmation. The attached image is sent when the message is sent. + +For other platforms, or custom behavior, one may configure below parameters in +`app.conf`. + +Image paste requires nchat to be built with `libpng` support. Wayland image +paste uses `wl-clipboard` by default. X11 and macOS use the bundled clipboard +backend when no custom image commands are configured. **Note:** The examples provided below are mainly for reference, not useful as actual configuration values (except the `File-based` examples), as the @@ -44,9 +52,11 @@ Examples: ### clipboard_paste_image_command Specifies a custom image paste command to be used instead of system clipboard. +The command shall write PNG image bytes to stdout. nchat redirects stdout to a +temporary `.png` file and verifies that the result is non-empty PNG data before +sending it. Examples: | Platform | Command | |-------------|-----------------------------------------------------------| | Wayland | `wl-paste --type image/png` | - diff --git a/lib/ncutil/src/clipboard.cpp b/lib/ncutil/src/clipboard.cpp index c8e8835d1..d436daf2d 100644 --- a/lib/ncutil/src/clipboard.cpp +++ b/lib/ncutil/src/clipboard.cpp @@ -14,6 +14,8 @@ #include #include +#include + #ifdef HAVE_PNG_H #include #endif @@ -63,6 +65,31 @@ bool IsDisplayServer(DisplayServer p_DisplayServer) return (p_DisplayServer == displayServer); } +static std::string ShellQuote(const std::string& p_Str) +{ + std::string quoted = "'"; + for (char c : p_Str) + { + if (c == '\'') + { + quoted += "'\\''"; + } + else + { + quoted += c; + } + } + + quoted += "'"; + return quoted; +} + +static bool IsNonEmptyFile(const std::string& p_Path) +{ + struct stat sb; + return (stat(p_Path.c_str(), &sb) == 0) && S_ISREG(sb.st_mode) && (sb.st_size > 0); +} + #ifdef HAVE_PNG_H static bool WriteRgbaPng(const std::string& p_Path, unsigned p_W, unsigned p_H, const uint8_t* p_Rgba, size_t p_Stride) { @@ -222,7 +249,7 @@ void Clipboard::SetText(const std::string& p_Text) { const std::string tempPath = FileUtil::GetTempDir() + "/clipboard.txt"; FileUtil::WriteFile(tempPath, p_Text); - const std::string cmd = "cat " + tempPath + " | " + clipboardCopyCommand; + const std::string cmd = "cat " + ShellQuote(tempPath) + " | " + clipboardCopyCommand; SysUtil::RunCommand(cmd); FileUtil::RmFile(tempPath); } @@ -300,9 +327,26 @@ bool Clipboard::GetImage(const std::string& p_Path) if (!clipboardPasteImageCommand.empty()) { - std::string command = clipboardPasteImageCommand + " | tee " + p_Path; - bool rv = SysUtil::RunCommand(command); - return rv; + const std::string command = clipboardPasteImageCommand + " > " + ShellQuote(p_Path); + if (!SysUtil::RunCommand(command)) + { + return false; + } + + if (!IsNonEmptyFile(p_Path)) + { + LOG_WARNING("clipboard image output is empty"); + return false; + } + + std::string mimeType = FileUtil::GetMimeType(p_Path); + if (!mimeType.empty() && (mimeType != "image/png")) + { + LOG_WARNING("clipboard image output is not png: %s", mimeType.c_str()); + return false; + } + + return true; } else { diff --git a/src/main.cpp b/src/main.cpp index c12cfec53..cef2b8b64 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -700,7 +700,7 @@ void ShowHelp() " Alt-Backsp delete previous word\n" " Alt-Delete delete next word\n" " Alt-c copy input buffer to clipboard (if no message selected)\n" - " Alt-v paste into input buffer from clipboard\n" + " Alt-v paste text or attach image from clipboard\n" " Alt-x cut input buffer to clipboard\n" "\n" "Report bugs at https://github.com/d99kris/nchat\n" diff --git a/src/nchat.1 b/src/nchat.1 index 6aa034ed7..0d449ba37 100644 --- a/src/nchat.1 +++ b/src/nchat.1 @@ -190,7 +190,7 @@ Alt\-c copy input buffer to clipboard (if no message selected) .TP Alt\-v -paste into input buffer from clipboard +paste text or attach image from clipboard .TP Alt\-x cut input buffer to clipboard diff --git a/src/uimodel.cpp b/src/uimodel.cpp index fd7dc4098..d1a530bc1 100644 --- a/src/uimodel.cpp +++ b/src/uimodel.cpp @@ -183,6 +183,16 @@ void UiModel::Impl::SendMessage() std::string chatId = m_CurrentChat.second; std::wstring& entryStr = m_EntryStr[profileId][chatId]; int& entryPos = m_EntryPos[profileId][chatId]; + std::vector& pendingFilePaths = GetPendingFilePaths(profileId, chatId); + + if (!pendingFilePaths.empty()) + { + std::vector filePaths = pendingFilePaths; + pendingFilePaths.clear(); + TransferFile(filePaths); + FileUtil::RmFilesByAge(FileUtil::GetTempDir(), "clipboard-*.png", 3600); + return; + } if (entryStr.empty()) return; @@ -1390,6 +1400,12 @@ void UiModel::Impl::TransferFile(const std::vector& p_FilePaths) SetHistoryInteraction(true); } +std::vector& UiModel::Impl::GetPendingFilePaths(const std::string& p_ProfileId, + const std::string& p_ChatId) +{ + return m_PendingFilePaths[p_ProfileId][p_ChatId]; +} + void UiModel::Impl::InsertEmoji(const std::wstring& p_Emoji) { std::string profileId = m_CurrentChat.first; @@ -5627,6 +5643,12 @@ void UiModel::OnKeyPaste() { if (Clipboard::HasImage()) { + { + std::unique_lock lock(m_ModelMutex); + if (GetImpl().GetEditMessageActive()) return; + GetImpl().AnyUserKeyInput(); + } + // Open modal dialog without model mutex held static const bool confirmSendPastedImage = UiConfig::GetBool("confirm_send_pasted_image"); if (confirmSendPastedImage) @@ -5638,23 +5660,31 @@ void UiModel::OnKeyPaste() } static int index = 0; - ++index; - const std::string tempPath = FileUtil::GetTempDir() + "/clipboard-" + std::to_string(index) + ".png"; + const std::string tempDir = FileUtil::GetTempDir(); + std::string tempPath; + do + { + tempPath = tempDir + "/clipboard-" + std::to_string(getpid()) + "-" + + std::to_string(TimeUtil::GetCurrentTimeMSec()) + "-" + std::to_string(++index) + ".png"; + } while (FileUtil::Exists(tempPath)); + if (!Clipboard::GetImage(tempPath)) { MessageDialog("Warning", "Failed getting clipboard image.", 0.7, 5); + FileUtil::RmFile(tempPath); return; } - const std::vector tempPaths = { tempPath }; - { std::unique_lock lock(m_ModelMutex); - GetImpl().TransferFile(tempPaths); + std::pair& currentChat = GetImpl().GetCurrentChat(); + GetImpl().GetPendingFilePaths(currentChat.first, currentChat.second).push_back(tempPath); } - // Delete temp clipboard images after some time, allowing for it to be sent async - FileUtil::RmFilesByAge(FileUtil::GetTempDir(), "clipboard-*.png", 60); + MessageDialog("Info", "Image attached. Press send to send it.", 0.6, 5); + + // Delete stale temp clipboard images, keeping recent staged/sending files available. + FileUtil::RmFilesByAge(tempDir, "clipboard-*.png", 3600); } else { diff --git a/src/uimodel.h b/src/uimodel.h index d245d4aa5..0e0d53dc6 100644 --- a/src/uimodel.h +++ b/src/uimodel.h @@ -74,6 +74,7 @@ class UiModel void OnKeyOpenLink(); std::string OnKeySaveAttachment(std::string p_FilePath = std::string()); void TransferFile(const std::vector& p_FilePaths); + std::vector& GetPendingFilePaths(const std::string& p_ProfileId, const std::string& p_ChatId); void InsertEmoji(const std::wstring& p_Emoji); void InsertText(const std::wstring& p_Text); void RequestGroupMembers(const std::string& p_ProfileId, const std::string& p_ChatId); @@ -271,6 +272,7 @@ class UiModel std::unordered_map> m_EntryStr; std::unordered_map> m_EntryPos; + std::unordered_map>> m_PendingFilePaths; std::unordered_map> m_EntryStrCleared; std::unordered_map> m_EntryPosCleared;