Screenshot: add option to save screenshot to clipboard

This commit is contained in:
dec05eba
2025-08-25 22:26:54 +02:00
parent 9bbec944de
commit dacf6126bf
12 changed files with 396 additions and 6 deletions

2
TODO
View File

@@ -171,8 +171,6 @@ Add a bug report page that automatically includes system info (make this clear t
Make it possible to change controller hotkeys. Also read from /dev/input/eventN instead of /dev/input/jsN. This is readable for controllers.
Add option to copy screenshot to clipboard. Does it work properly on Wayland compositors? Maybe need to wait until the application becomes Wayland native instead of XWayland.
Show message that replay/streaming has to be restarted if recording settings are changed while replay/streaming is ongoing.
Support vector graphics. Maybe support svg, rendering it to a texture for better performance.

60
include/ClipboardFile.hpp Normal file
View File

@@ -0,0 +1,60 @@
#pragma once
#include <string>
#include <thread>
#include <mutex>
#include <vector>
#include <X11/Xlib.h>
namespace gsr {
struct ClipboardCopy {
Window requestor = None;
uint64_t file_offset = 0;
Atom property = None;
Atom requestor_target = None;
};
class ClipboardFile {
public:
enum class FileType {
JPG,
PNG
};
ClipboardFile();
~ClipboardFile();
ClipboardFile(const ClipboardFile&) = delete;
ClipboardFile& operator=(const ClipboardFile&) = delete;
// Set this to an empty string to unset clipboard
void set_current_file(const std::string &filepath, FileType file_type);
private:
bool file_type_matches_request_atom(FileType file_type, Atom request_atom);
const char* file_type_clipboard_get_name(Atom request_atom);
const char* file_type_get_name(FileType file_type);
void send_clipboard_start(XSelectionRequestEvent *xselectionrequest);
void transfer_clipboard_data(XSelectionRequestEvent *xselectionrequest, ClipboardCopy *clipboard_copy);
ClipboardCopy* get_clipboard_copy_by_requestor(Window requestor);
void remove_clipboard_copy(Window requestor);
private:
Display *dpy = nullptr;
Window clipboard_window = None;
int file_fd = -1;
uint64_t file_size = 0;
FileType file_type = FileType::JPG;
Atom incr_atom = None;
Atom targets_atom = None;
Atom clipboard_atom = None;
Atom image_jpg_atom = None;
Atom image_jpeg_atom = None;
Atom image_png_atom = None;
std::thread event_thread;
std::mutex mutex;
bool running = true;
std::vector<ClipboardCopy> clipboard_copies;
bool should_clear_selection = false;
};
}

View File

@@ -143,6 +143,7 @@ namespace gsr {
bool restore_portal_session = true;
bool save_screenshot_in_game_folder = false;
bool save_screenshot_to_clipboard = false;
bool show_screenshot_saved_notifications = true;
std::string save_directory;
ConfigHotkey take_screenshot_hotkey;

View File

@@ -10,6 +10,7 @@
#include "AudioPlayer.hpp"
#include "RegionSelector.hpp"
#include "WindowSelector.hpp"
#include "ClipboardFile.hpp"
#include "CursorTracker/CursorTracker.hpp"
#include <mglpp/window/Window.hpp>
@@ -253,5 +254,6 @@ namespace gsr {
bool hide_ui = false;
double notification_duration_multiplier = 1.0;
ClipboardFile clipboard_file;
};
}

View File

@@ -42,6 +42,7 @@ namespace gsr {
std::unique_ptr<List> create_image_format_section();
std::unique_ptr<Widget> create_file_info_section();
std::unique_ptr<CheckBox> create_save_screenshot_in_game_folder();
std::unique_ptr<CheckBox> create_save_screenshot_to_clipboard();
std::unique_ptr<Widget> create_general_section();
std::unique_ptr<Widget> create_notifications_section();
std::unique_ptr<Widget> create_settings();
@@ -69,6 +70,7 @@ namespace gsr {
ComboBox *image_format_box_ptr = nullptr;
Button *save_directory_button_ptr = nullptr;
CheckBox *save_screenshot_in_game_folder_checkbox_ptr = nullptr;
CheckBox *save_screenshot_to_clipboard_checkbox_ptr = nullptr;
CheckBox *show_screenshot_saved_notification_checkbox_ptr = nullptr;
PageStack *page_stack = nullptr;

View File

@@ -1,4 +1,6 @@
project('gsr-ui', ['c', 'cpp'], version : '1.7.3', default_options : ['warning_level=2', 'cpp_std=c++17'], subproject_dir : 'depends')
project('gsr-ui', ['c', 'cpp'], version : '1.7.4', default_options : ['warning_level=2', 'cpp_std=c++17'], subproject_dir : 'depends')
add_project_arguments('-D_FILE_OFFSET_BITS=64', language : ['c', 'cpp'])
if get_option('buildtype') == 'debug'
add_project_arguments('-g3', language : ['c', 'cpp'])
@@ -47,6 +49,7 @@ src = [
'src/Overlay.cpp',
'src/AudioPlayer.cpp',
'src/Hotplug.cpp',
'src/ClipboardFile.cpp',
'src/Rpc.cpp',
'src/main.cpp',
]

View File

@@ -1,7 +1,7 @@
[package]
name = "gsr-ui"
type = "executable"
version = "1.7.3"
version = "1.7.4"
platforms = ["posix"]
[lang.cpp]
@@ -10,6 +10,9 @@ version = "c++17"
[config]
ignore_dirs = ["build", "tools"]
[define]
_FILE_OFFSET_BITS = "64"
[dependencies]
xcomposite = ">=0"
xfixes = ">=0"

294
src/ClipboardFile.cpp Normal file
View File

@@ -0,0 +1,294 @@
#include "../include/ClipboardFile.hpp"
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <inttypes.h>
#include <poll.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <X11/Xatom.h>
#define FORMAT_I64 "%" PRIi64
#define FORMAT_U64 "%" PRIu64
namespace gsr {
ClipboardFile::ClipboardFile() {
dpy = XOpenDisplay(nullptr);
if(!dpy) {
fprintf(stderr, "gsr ui: error: ClipboardFile: failed to connect to the X11 server\n");
return;
}
clipboard_window = XCreateSimpleWindow(dpy, DefaultRootWindow(dpy), 0, 0, 8, 8, 0, 0, 0);
if(!clipboard_window) {
fprintf(stderr, "gsr ui: error: ClipboardFile: failed to create clipboard window\n");
XCloseDisplay(dpy);
dpy = nullptr;
return;
}
incr_atom = XInternAtom(dpy, "INCR", False);
targets_atom = XInternAtom(dpy, "TARGETS", False);
clipboard_atom = XInternAtom(dpy, "CLIPBOARD", False);
image_jpg_atom = XInternAtom(dpy, "image/jpg", False);
image_jpeg_atom = XInternAtom(dpy, "image/jpeg", False);
image_png_atom = XInternAtom(dpy, "image/png", False);
event_thread = std::thread([&]() {
pollfd poll_fds[1];
poll_fds[0].fd = ConnectionNumber(dpy);
poll_fds[0].events = POLLIN;
poll_fds[0].revents = 0;
XEvent xev;
while(running) {
poll(poll_fds, 1, 100);
while(XPending(dpy)) {
XNextEvent(dpy, &xev);
switch(xev.type) {
case SelectionClear: {
should_clear_selection = true;
if(clipboard_copies.empty()) {
should_clear_selection = false;
set_current_file("", file_type);
}
break;
}
case SelectionRequest:
send_clipboard_start(&xev.xselectionrequest);
break;
case PropertyNotify: {
if(xev.xproperty.state == PropertyDelete) {
std::lock_guard<std::mutex> lock(mutex);
ClipboardCopy *clipboard_copy = get_clipboard_copy_by_requestor(xev.xproperty.window);
if(!clipboard_copy || xev.xproperty.atom != clipboard_copy->property)
return;
XSelectionRequestEvent xselectionrequest;
xselectionrequest.display = xev.xproperty.display;;
xselectionrequest.requestor = xev.xproperty.window;
xselectionrequest.selection = clipboard_atom;
xselectionrequest.target = clipboard_copy->requestor_target;
xselectionrequest.property = clipboard_copy->property;
xselectionrequest.time = xev.xproperty.time;
transfer_clipboard_data(&xselectionrequest, clipboard_copy);
}
break;
}
}
}
}
});
}
ClipboardFile::~ClipboardFile() {
running = false;
if(event_thread.joinable())
event_thread.join();
if(file_fd > 0)
close(file_fd);
if(dpy) {
XDestroyWindow(dpy, clipboard_window);
XCloseDisplay(dpy);
}
}
bool ClipboardFile::file_type_matches_request_atom(FileType file_type, Atom request_atom) {
switch(file_type) {
case FileType::JPG:
return request_atom == image_jpg_atom || request_atom == image_jpeg_atom;
case FileType::PNG:
return request_atom == image_png_atom;
}
return false;
}
const char* ClipboardFile::file_type_clipboard_get_name(Atom request_atom) {
if(request_atom == image_jpg_atom)
return "image/jpg";
else if(request_atom == image_jpeg_atom)
return "image/jpeg";
else if(request_atom == image_png_atom)
return "image/png";
return "Unknown";
}
const char* ClipboardFile::file_type_get_name(FileType file_type) {
switch(file_type) {
case FileType::JPG:
return "image/jpeg";
case FileType::PNG:
return "image/png";
}
return "Unknown";
}
void ClipboardFile::send_clipboard_start(XSelectionRequestEvent *xselectionrequest) {
std::lock_guard<std::mutex> lock(mutex);
if(file_fd <= 0) {
fprintf(stderr, "gsr ui: warning: ClipboardFile::send_clipboard: requestor window " FORMAT_I64 " tried to get clipboard from us but we don't have any clipboard file open\n", (int64_t)xselectionrequest->requestor);
return;
}
if(xselectionrequest->selection != clipboard_atom) {
fprintf(stderr, "gsr ui: warning: ClipboardFile::send_clipboard: requestor window " FORMAT_I64 " tried to non-clipboard selection from us\n", (int64_t)xselectionrequest->requestor);
return;
}
XSelectionEvent selection_event;
selection_event.type = SelectionNotify;
selection_event.display = xselectionrequest->display;
selection_event.requestor = xselectionrequest->requestor;
selection_event.selection = xselectionrequest->selection;
selection_event.property = xselectionrequest->property;
selection_event.time = xselectionrequest->time;
selection_event.target = xselectionrequest->target;
if(xselectionrequest->target == targets_atom) {
int num_targets = 1;
Atom targets[3];
targets[0] = targets_atom;
switch(file_type) {
case FileType::JPG:
num_targets = 3;
targets[1] = image_jpg_atom;
targets[2] = image_jpeg_atom;
break;
case FileType::PNG:
num_targets = 2;
targets[1] = image_png_atom;
break;
}
XChangeProperty(dpy, selection_event.requestor, selection_event.property, XA_ATOM, 32, PropModeReplace, (unsigned char*)targets, num_targets);
} else if(xselectionrequest->target == image_jpg_atom || xselectionrequest->target == image_jpeg_atom || xselectionrequest->target == image_png_atom) {
if(!file_type_matches_request_atom(file_type, xselectionrequest->target)) {
fprintf(stderr, "gsr ui: warning: ClipboardFile::send_clipboard: requestor window " FORMAT_I64 " tried to request clipboard of type %s, but %s was expected\n", (int64_t)xselectionrequest->requestor, file_type_clipboard_get_name(xselectionrequest->target), file_type_get_name(file_type));
return;
}
ClipboardCopy *clipboard_copy = get_clipboard_copy_by_requestor(xselectionrequest->requestor);
if(!clipboard_copy) {
clipboard_copies.push_back({ xselectionrequest->requestor, 0 });
clipboard_copy = &clipboard_copies.back();
}
*clipboard_copy = { xselectionrequest->requestor, 0 };
clipboard_copy->property = selection_event.property;
clipboard_copy->requestor_target = selection_event.target;
XSelectInput(dpy, selection_event.requestor, PropertyChangeMask);
const long lower_bound = std::min((uint64_t)1<<16, file_size);
XChangeProperty(dpy, selection_event.requestor, selection_event.property, incr_atom, 32, PropModeReplace, (const unsigned char*)&lower_bound, 1);
} else {
char *target_clipboard_name = XGetAtomName(dpy, xselectionrequest->target);
fprintf(stderr, "gsr ui: warning: ClipboardFile::send_clipboard: requestor window " FORMAT_I64 " tried to request clipboard of type %s, expected TARGETS, image/jpg, image/jpeg or image/png\n", (int64_t)xselectionrequest->requestor, target_clipboard_name ? target_clipboard_name : "Unknown");
if(target_clipboard_name)
XFree(target_clipboard_name);
selection_event.property = None;
}
XSendEvent(dpy, selection_event.requestor, False, NoEventMask, (XEvent*)&selection_event);
XFlush(dpy);
}
void ClipboardFile::transfer_clipboard_data(XSelectionRequestEvent *xselectionrequest, ClipboardCopy *clipboard_copy) {
uint8_t file_buffer[1<<16];
ssize_t file_bytes_read = 0;
if(lseek(file_fd, clipboard_copy->file_offset, SEEK_SET) == -1) {
fprintf(stderr, "gsr ui: error: ClipboardFile::send_clipboard: failed to seek in clipboard file to offset " FORMAT_U64 " for requestor window " FORMAT_I64 ", error: %s\n", (uint64_t)clipboard_copy->file_offset, (int64_t)xselectionrequest->requestor, strerror(errno));
clipboard_copy->file_offset = 0;
// TODO: Cancel transfer
return;
}
file_bytes_read = read(file_fd, file_buffer, sizeof(file_buffer));
if(file_bytes_read < 0) {
fprintf(stderr, "gsr ui: error: ClipbaordFile::send_clipboard: failed to read data from offset " FORMAT_U64 " for requestor window " FORMAT_I64 ", error: %s\n", (uint64_t)clipboard_copy->file_offset, (int64_t)xselectionrequest->requestor, strerror(errno));
clipboard_copy->file_offset = 0;
// TODO: Cancel transfer
return;
}
XChangeProperty(dpy, xselectionrequest->requestor, xselectionrequest->property, xselectionrequest->target, 8, PropModeReplace, (const unsigned char*)file_buffer, file_bytes_read);
XSendEvent(dpy, xselectionrequest->requestor, False, NoEventMask, (XEvent*)xselectionrequest);
XFlush(dpy);
clipboard_copy->file_offset += file_bytes_read;
if(file_bytes_read == 0)
remove_clipboard_copy(clipboard_copy->requestor);
}
ClipboardCopy* ClipboardFile::get_clipboard_copy_by_requestor(Window requestor) {
for(ClipboardCopy &clipboard_copy : clipboard_copies) {
if(clipboard_copy.requestor == requestor)
return &clipboard_copy;
}
return nullptr;
}
void ClipboardFile::remove_clipboard_copy(Window requestor) {
for(auto it = clipboard_copies.begin(), end = clipboard_copies.end(); it != end; ++it) {
if(it->requestor == requestor) {
clipboard_copies.erase(it);
XSelectInput(dpy, requestor, 0);
if(clipboard_copies.empty() && should_clear_selection) {
should_clear_selection = false;
set_current_file("", file_type);
}
return;
}
}
}
void ClipboardFile::set_current_file(const std::string &filepath, FileType file_type) {
if(!dpy)
return;
std::lock_guard<std::mutex> lock(mutex);
for(ClipboardCopy &clipboard_copy : clipboard_copies) {
XSelectInput(dpy, clipboard_copy.requestor, 0);
}
clipboard_copies.clear();
if(filepath.empty()) {
if(file_fd > 0) {
close(file_fd);
file_fd = -1;
}
file_size = 0;
return;
}
if(file_fd > 0) {
close(file_fd);
file_fd = -1;
file_size = 0;
}
file_fd = open(filepath.c_str(), O_RDONLY);
if(file_fd <= 0) {
fprintf(stderr, "gsr ui: error: ClipboardFile::set_current_file: failed to open file %s, error: %s\n", filepath.c_str(), strerror(errno));
return;
}
struct stat64 stat;
if(fstat64(file_fd, &stat) == -1) {
fprintf(stderr, "gsr ui: error: ClipboardFile::set_current_file: failed to get file size for file %s, error: %s\n", filepath.c_str(), strerror(errno));
close(file_fd);
file_fd = -1;
return;
}
file_size = stat.st_size;
this->file_type = file_type;
XSetSelectionOwner(dpy, clipboard_atom, clipboard_window, CurrentTime);
XFlush(dpy);
}
}

View File

@@ -284,6 +284,7 @@ namespace gsr {
{"screenshot.record_cursor", &config.screenshot_config.record_cursor},
{"screenshot.restore_portal_session", &config.screenshot_config.restore_portal_session},
{"screenshot.save_screenshot_in_game_folder", &config.screenshot_config.save_screenshot_in_game_folder},
{"screenshot.save_screenshot_to_clipboard", &config.screenshot_config.save_screenshot_to_clipboard},
{"screenshot.show_screenshot_saved_notifications", &config.screenshot_config.show_screenshot_saved_notifications},
{"screenshot.save_directory", &config.screenshot_config.save_directory},
{"screenshot.take_screenshot_hotkey", &config.screenshot_config.take_screenshot_hotkey},

View File

@@ -1842,6 +1842,15 @@ namespace gsr {
return replay_duration_sec;
}
static ClipboardFile::FileType filename_to_clipboard_file_type(const std::string &filename) {
if(ends_with(filename, ".jpg") || ends_with(filename, ".jpeg"))
return ClipboardFile::FileType::JPG;
else if(ends_with(filename, ".png"))
return ClipboardFile::FileType::PNG;
assert(false);
return ClipboardFile::FileType::PNG;
}
void Overlay::save_video_in_current_game_directory(const char *video_filepath, NotificationType notification_type) {
mgl_context *context = mgl_get_context();
Display *display = (Display*)context->connection;
@@ -1896,6 +1905,9 @@ namespace gsr {
snprintf(msg, sizeof(msg), "Saved a screenshot of %s\nto \"%s\"",
capture_target_get_notification_name(screenshot_capture_target.c_str(), true).c_str(), focused_window_name.c_str());
capture_target = screenshot_capture_target.c_str();
if(config.screenshot_config.save_screenshot_to_clipboard)
clipboard_file.set_current_file(new_video_filepath, filename_to_clipboard_file_type(new_video_filepath));
break;
}
case NotificationType::NONE:
@@ -2086,6 +2098,9 @@ namespace gsr {
snprintf(msg, sizeof(msg), "Saved a screenshot of %s",
capture_target_get_notification_name(screenshot_capture_target.c_str(), true).c_str());
show_notification(msg, notification_timeout_seconds, mgl::Color(255, 255, 255), get_color_theme().tint_color, NotificationType::SCREENSHOT, screenshot_capture_target.c_str());
if(config.screenshot_config.save_screenshot_to_clipboard)
clipboard_file.set_current_file(screenshot_filepath, filename_to_clipboard_file_type(screenshot_filepath));
}
} else {
fprintf(stderr, "Warning: gpu-screen-recorder (%d) exited with exit status %d\n", (int)gpu_screen_recorder_screenshot_process, exit_code);

View File

@@ -215,8 +215,17 @@ namespace gsr {
return checkbox;
}
std::unique_ptr<CheckBox> ScreenshotSettingsPage::create_save_screenshot_to_clipboard() {
auto checkbox = std::make_unique<CheckBox>(&get_theme().body_font, "Save screenshot to clipboard");
save_screenshot_to_clipboard_checkbox_ptr = checkbox.get();
return checkbox;
}
std::unique_ptr<Widget> ScreenshotSettingsPage::create_general_section() {
return std::make_unique<Subsection>("General", create_save_screenshot_in_game_folder(), mgl::vec2f(settings_scrollable_page_ptr->get_inner_size().x, 0.0f));
auto list = std::make_unique<List>(List::Orientation::VERTICAL);
list->add_widget(create_save_screenshot_in_game_folder());
list->add_widget(create_save_screenshot_to_clipboard());
return std::make_unique<Subsection>("General", std::move(list), mgl::vec2f(settings_scrollable_page_ptr->get_inner_size().x, 0.0f));
}
std::unique_ptr<Widget> ScreenshotSettingsPage::create_notifications_section() {
@@ -281,6 +290,7 @@ namespace gsr {
restore_portal_session_checkbox_ptr->set_checked(config.screenshot_config.restore_portal_session);
save_directory_button_ptr->set_text(config.screenshot_config.save_directory);
save_screenshot_in_game_folder_checkbox_ptr->set_checked(config.screenshot_config.save_screenshot_in_game_folder);
save_screenshot_to_clipboard_checkbox_ptr->set_checked(config.screenshot_config.save_screenshot_to_clipboard);
show_screenshot_saved_notification_checkbox_ptr->set_checked(config.screenshot_config.show_screenshot_saved_notifications);
if(config.screenshot_config.image_width == 0)
@@ -309,6 +319,7 @@ namespace gsr {
config.screenshot_config.restore_portal_session = restore_portal_session_checkbox_ptr->is_checked();
config.screenshot_config.save_directory = save_directory_button_ptr->get_text();
config.screenshot_config.save_screenshot_in_game_folder = save_screenshot_in_game_folder_checkbox_ptr->is_checked();
config.screenshot_config.save_screenshot_to_clipboard = save_screenshot_to_clipboard_checkbox_ptr->is_checked();
config.screenshot_config.show_screenshot_saved_notifications = show_screenshot_saved_notification_checkbox_ptr->is_checked();
if(config.screenshot_config.image_width == 0)