424 lines
13 KiB
Diff
424 lines
13 KiB
Diff
From b1580fc69a8417b45367ed3a2dbd946f270ac683 Mon Sep 17 00:00:00 2001
|
|
From: =?UTF-8?q?Florian=20M=C3=BCllner?= <fmuellner@gnome.org>
|
|
Date: Fri, 18 Jul 2025 21:34:36 +0200
|
|
Subject: [PATCH] Add screenshot CLI tool
|
|
|
|
The tool provides the same API as gnome-screenshot, but leverages
|
|
GNOME's built-in screenshot UI instead of implementing its own
|
|
interactive dialog.
|
|
---
|
|
man/gnome-screenshot-tool.rst | 49 +++++++
|
|
man/meson.build | 23 ++-
|
|
meson.build | 2 +
|
|
src/gnome-screenshot-tool | 266 ++++++++++++++++++++++++++++++++++
|
|
src/meson.build | 26 ++++
|
|
5 files changed, 358 insertions(+), 8 deletions(-)
|
|
create mode 100644 man/gnome-screenshot-tool.rst
|
|
create mode 100755 src/gnome-screenshot-tool
|
|
|
|
diff --git a/man/gnome-screenshot-tool.rst b/man/gnome-screenshot-tool.rst
|
|
new file mode 100644
|
|
index 0000000000..a471738394
|
|
--- /dev/null
|
|
+++ b/man/gnome-screenshot-tool.rst
|
|
@@ -0,0 +1,49 @@
|
|
+=====================
|
|
+gnome-screenshot-tool
|
|
+=====================
|
|
+
|
|
+-----------------------------------------
|
|
+Screenshot CLI tool for the GNOME desktop
|
|
+-----------------------------------------
|
|
+
|
|
+:Manual section: 1
|
|
+:Manual group: User Commands
|
|
+
|
|
+SYNOPSIS
|
|
+--------
|
|
+**gnome-screenshot-tool** [*OPTION*...]
|
|
+
|
|
+DESCRIPTION
|
|
+-----------
|
|
+**gnome-screenshot-tool** is a GNOME utility for taking screenshots of
|
|
+the entire screen, a window or a user-defined area of the screen.
|
|
+
|
|
+OPTIONS
|
|
+-------
|
|
+``-w``, ``--window``
|
|
+
|
|
+ Grab the currently active window instead of the entire screen
|
|
+
|
|
+``-a``, ``--area``
|
|
+
|
|
+ Grab an area of the screen instead of the entire screen
|
|
+
|
|
+``-i``, ``--interactive``
|
|
+
|
|
+ Bring up GNOME's native screenshot dialog
|
|
+
|
|
+``-p``, ``--include-pointer``
|
|
+
|
|
+ Include the pointer with the screenshot
|
|
+
|
|
+``-d``, ``--delay``\ =\ *SECONDS*
|
|
+
|
|
+ Take the screenshot after the specified delay [in seconds]
|
|
+
|
|
+``-f``, ``--file``\ =\ *FILENAME*
|
|
+
|
|
+ Save the screenshot directly to this file
|
|
+
|
|
+``-h``, ``--help``
|
|
+
|
|
+ Show a summary of the available options
|
|
diff --git a/man/meson.build b/man/meson.build
|
|
index c61bf51513..cfb9c8e07f 100644
|
|
--- a/man/meson.build
|
|
+++ b/man/meson.build
|
|
@@ -1,8 +1,15 @@
|
|
-custom_target('man page',
|
|
- input: 'gnome-shell.rst',
|
|
- output: 'gnome-shell.1',
|
|
- command: [rst2man, '--syntax-highlight=none', '@INPUT@'],
|
|
- capture: true,
|
|
- install_dir: mandir + '/man1',
|
|
- install: true
|
|
-)
|
|
+man_pages = [
|
|
+ 'gnome-shell.rst',
|
|
+ 'gnome-screenshot-tool.rst',
|
|
+]
|
|
+
|
|
+foreach page : man_pages
|
|
+ custom_target(f'@page@ man page',
|
|
+ input: page,
|
|
+ output: '@BASENAME@.1',
|
|
+ command: [rst2man, '--syntax-highlight=none', '@INPUT@'],
|
|
+ capture: true,
|
|
+ install_dir: mandir + '/man1',
|
|
+ install: true
|
|
+ )
|
|
+endforeach
|
|
diff --git a/meson.build b/meson.build
|
|
index 561ab30832..aed3f89611 100644
|
|
--- a/meson.build
|
|
+++ b/meson.build
|
|
@@ -134,6 +134,8 @@ if get_option('man')
|
|
subdir('man')
|
|
endif
|
|
|
|
+bash_completion = dependency('bash-completion', required: false)
|
|
+
|
|
mutter_typelibdir = mutter_dep.get_variable('typelibdir')
|
|
python = find_program('python3')
|
|
gjs = find_program('gjs')
|
|
diff --git a/src/gnome-screenshot-tool b/src/gnome-screenshot-tool
|
|
new file mode 100755
|
|
index 0000000000..e29650abfe
|
|
--- /dev/null
|
|
+++ b/src/gnome-screenshot-tool
|
|
@@ -0,0 +1,266 @@
|
|
+#!/usr/bin/env python3
|
|
+
|
|
+import argparse
|
|
+import os
|
|
+import gi
|
|
+
|
|
+gi.require_version('Gdk', '4.0')
|
|
+gi.require_version('Gtk', '4.0')
|
|
+gi.require_version('GdkPixbuf', '2.0')
|
|
+from gi.repository import GLib, Gio, GdkPixbuf, Gdk, Gtk # type: ignore
|
|
+
|
|
+try:
|
|
+ import argcomplete
|
|
+except ModuleNotFoundError:
|
|
+ pass
|
|
+
|
|
+NAME = "org.gnome.Shell.Screenshot"
|
|
+OBJECT_PATH = "/org/gnome/Shell/Screenshot"
|
|
+INTERFACE = "org.gnome.Shell.Screenshot"
|
|
+
|
|
+class Screenshot:
|
|
+ def __init__(self):
|
|
+ self._proxy = Gio.DBusProxy.new_for_bus_sync(
|
|
+ bus_type = Gio.BusType.SESSION,
|
|
+ flags = Gio.DBusProxyFlags.NONE,
|
|
+ info = None,
|
|
+ name = NAME,
|
|
+ object_path = OBJECT_PATH,
|
|
+ interface_name = INTERFACE,
|
|
+ cancellable = None,
|
|
+ )
|
|
+
|
|
+ def screenshot(self, include_cursor) -> GdkPixbuf.Pixbuf:
|
|
+ variant = self._proxy.call_sync(
|
|
+ method_name = "Screenshot",
|
|
+ parameters = GLib.Variant(
|
|
+ "(bbs)",
|
|
+ (
|
|
+ include_cursor,
|
|
+ True,
|
|
+ self._get_tmp_filename(),
|
|
+ ),
|
|
+ ),
|
|
+ flags = Gio.DBusCallFlags.NO_AUTO_START,
|
|
+ timeout_msec = -1,
|
|
+ cancellable = None,
|
|
+ )
|
|
+ return self._get_screenshot_from_result(variant)
|
|
+
|
|
+ def screenshot_window(self, include_cursor) -> GdkPixbuf.Pixbuf:
|
|
+ variant = self._proxy.call_sync(
|
|
+ method_name = "ScreenshotWindow",
|
|
+ parameters = GLib.Variant(
|
|
+ "(bbbs)",
|
|
+ (
|
|
+ True,
|
|
+ include_cursor,
|
|
+ True,
|
|
+ self._get_tmp_filename(),
|
|
+ ),
|
|
+ ),
|
|
+ flags = Gio.DBusCallFlags.NO_AUTO_START,
|
|
+ timeout_msec = -1,
|
|
+ cancellable = None,
|
|
+ )
|
|
+ return self._get_screenshot_from_result(variant)
|
|
+
|
|
+ def screenshot_area(self, x, y, width, height) -> GdkPixbuf.Pixbuf:
|
|
+ variant = self._proxy.call_sync(
|
|
+ method_name = "ScreenshotArea",
|
|
+ parameters = GLib.Variant(
|
|
+ "(iiiibs)",
|
|
+ (
|
|
+ x,
|
|
+ y,
|
|
+ width,
|
|
+ height,
|
|
+ True,
|
|
+ self._get_tmp_filename(),
|
|
+ ),
|
|
+ ),
|
|
+ flags = Gio.DBusCallFlags.NO_AUTO_START,
|
|
+ timeout_msec = -1,
|
|
+ cancellable = None,
|
|
+ )
|
|
+ return self._get_screenshot_from_result(variant)
|
|
+
|
|
+ def interactive_screenshot(self) -> GdkPixbuf.Pixbuf:
|
|
+ variant = self._proxy.call_sync(
|
|
+ method_name = "InteractiveScreenshot",
|
|
+ parameters = None,
|
|
+ flags = Gio.DBusCallFlags.NO_AUTO_START,
|
|
+ timeout_msec = GLib.MAXINT,
|
|
+ cancellable = None,
|
|
+ )
|
|
+ [success, uri] = variant
|
|
+ file = Gio.File.new_for_uri(uri)
|
|
+ return self._get_screenshot_from_result((success, file.get_path()))
|
|
+
|
|
+ def select_area(self) -> tuple[int, int, int, int]:
|
|
+ variant = self._proxy.call_sync(
|
|
+ method_name = "SelectArea",
|
|
+ parameters = None,
|
|
+ flags = Gio.DBusCallFlags.NO_AUTO_START,
|
|
+ timeout_msec = -1,
|
|
+ cancellable = None,
|
|
+ )
|
|
+ [x, y, width, height] = variant
|
|
+ return (x, y, width, height)
|
|
+
|
|
+ def _get_screenshot_from_result(self, variant):
|
|
+ [success, filename] = variant
|
|
+ assert success
|
|
+ screenshot = GdkPixbuf.Pixbuf.new_from_file(filename)
|
|
+ GLib.unlink(filename)
|
|
+ return screenshot
|
|
+
|
|
+ def _get_tmp_filename(self):
|
|
+ path = GLib.build_filenamev([
|
|
+ GLib.get_user_cache_dir(),
|
|
+ "gnome-screenshot-tool",
|
|
+ ])
|
|
+ GLib.mkdir_with_parents(path, 0o700)
|
|
+ return GLib.build_filenamev([path, f'scr-{GLib.random_int()}'])
|
|
+
|
|
+class ScreenshotApp(Gtk.Application):
|
|
+ def __init__(self, config):
|
|
+ super().__init__(application_id="org.gnome.Screenshot")
|
|
+
|
|
+ self._config = config
|
|
+
|
|
+ def do_startup(self):
|
|
+ Gtk.Application.do_startup(self)
|
|
+ self._proxy = Screenshot()
|
|
+
|
|
+ def do_activate(self):
|
|
+ self.hold()
|
|
+ if self._config.area:
|
|
+ self._selected_area = self._proxy.select_area()
|
|
+ self._start_screenshot_timeout()
|
|
+
|
|
+ def _start_screenshot_timeout(self):
|
|
+ GLib.timeout_add_seconds(self._config.delay, self._take_screenshot)
|
|
+
|
|
+ def _take_screenshot(self):
|
|
+ include_pointer = self._config.include_pointer
|
|
+
|
|
+ try:
|
|
+ if self._config.interactive:
|
|
+ screenshot = self._proxy.interactive_screenshot()
|
|
+ elif self._config.area:
|
|
+ [x, y, width, height] = self._selected_area
|
|
+ screenshot = self._proxy.screenshot_area(x, y, width, height)
|
|
+ elif self._config.window:
|
|
+ screenshot = self._proxy.screenshot_window(include_pointer)
|
|
+ else:
|
|
+ screenshot = self._proxy.screenshot(include_pointer)
|
|
+
|
|
+ # if self._config.clipboard:
|
|
+ # self._save_screenshot_to_clipboard(screenshot)
|
|
+
|
|
+ if self._config.file:
|
|
+ target_file = Gio.File.new_for_commandline_arg(self._config.file)
|
|
+ override = True
|
|
+ else:
|
|
+ target_file = self._get_default_target_file()
|
|
+ override = False
|
|
+
|
|
+ self._save_screenshot_to_file(screenshot, target_file, override)
|
|
+ except Exception as e:
|
|
+ print(f'Failed to take screenshot: {e}')
|
|
+ finally:
|
|
+ self.release()
|
|
+
|
|
+ return GLib.SOURCE_REMOVE
|
|
+
|
|
+ def _save_screenshot_to_clipboard(self, screenshot):
|
|
+ dpy = Gdk.Display.get_default()
|
|
+ clipboard = dpy.get_clipboard()
|
|
+ clipboard.set(screenshot)
|
|
+
|
|
+ def _save_screenshot_to_file(self, screenshot, file, override):
|
|
+ if override:
|
|
+ ostream = file.replace(None, False, Gio.FileCreateFlags.NONE, None)
|
|
+ else:
|
|
+ ostream = file.create(Gio.FileCreateFlags.NONE, None)
|
|
+
|
|
+ [_, ext] = os.path.splitext(file.get_basename())
|
|
+ if not ext:
|
|
+ ext = 'png'
|
|
+ else:
|
|
+ ext = ext[1:]
|
|
+
|
|
+ def find_writable_format(formats, ext):
|
|
+ for f in formats:
|
|
+ for e in f.get_extensions():
|
|
+ if e == ext and f.is_writable():
|
|
+ return f.get_name()
|
|
+ return None
|
|
+
|
|
+ formats = GdkPixbuf.Pixbuf.get_formats()
|
|
+ format = find_writable_format(formats, ext)
|
|
+
|
|
+ if format == 'png':
|
|
+ keys = ["tEXt::Software"]
|
|
+ values = ["gnome-screenshot-tool"]
|
|
+ else:
|
|
+ keys = None
|
|
+ values = None
|
|
+
|
|
+ screenshot.save_to_streamv(ostream, format, keys, values, None)
|
|
+
|
|
+ def _get_default_target_file(self):
|
|
+ path = GLib.build_filenamev([
|
|
+ GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES) or GLib.get_home_dir(),
|
|
+ "Screenshots",
|
|
+ ])
|
|
+ dir = Gio.File.new_for_path(path)
|
|
+ try:
|
|
+ dir.make_directory_with_parents(None)
|
|
+ except GLib.Error as e:
|
|
+ if not e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.EXISTS):
|
|
+ raise e
|
|
+
|
|
+ timestamp = GLib.DateTime.new_now_local().format('%Y-%m-%d %H-%M-%S')
|
|
+ name = "Screenshot From %s" % (timestamp)
|
|
+
|
|
+ def suffixes() -> str:
|
|
+ yield ''
|
|
+
|
|
+ i = 1
|
|
+ while True:
|
|
+ yield f'-{i}'
|
|
+ i = i + 1
|
|
+
|
|
+ for suffix in suffixes():
|
|
+ file = dir.get_child(f'{name}{suffix}.png')
|
|
+ if not file.query_exists(None):
|
|
+ return file
|
|
+
|
|
+def main():
|
|
+ parser = argparse.ArgumentParser()
|
|
+
|
|
+ group = parser.add_mutually_exclusive_group()
|
|
+ group.add_argument("-w", "--window", action="store_true", help="Grab a window instead of the entire screen")
|
|
+ group.add_argument("-a", "--area", action="store_true", help="Grab an area of the screen instead of the entire screen")
|
|
+ group.add_argument("-i", "--interactive", action="store_true", help="Interactively set options")
|
|
+
|
|
+ # parser.add_argument("-c", "--clipboard", action="store_true", help="Send the grab directly to the clipboard")
|
|
+ parser.add_argument("-p", "--include-pointer", action="store_true", help="Include the pointer with the screenshot")
|
|
+ parser.add_argument("-d", "--delay", type=int, default=0, help="Take a screenshot after the specified delay (in seconds)")
|
|
+ parser.add_argument("-f", "--file", type=str, help="Save screenshot directly to this file")
|
|
+
|
|
+ if argcomplete:
|
|
+ argcomplete.autocomplete(parser)
|
|
+ args = parser.parse_args()
|
|
+
|
|
+ if args.interactive:
|
|
+ if args.include_pointer:
|
|
+ print("Option --include-pointer is ignored in interactive mode.")
|
|
+
|
|
+ app = ScreenshotApp(args)
|
|
+ app.run(None)
|
|
+
|
|
+if __name__ == "__main__":
|
|
+ main()
|
|
diff --git a/src/meson.build b/src/meson.build
|
|
index 23d23b4f38..4ccdd3b055 100644
|
|
--- a/src/meson.build
|
|
+++ b/src/meson.build
|
|
@@ -310,3 +310,29 @@ run_test = executable('run-js-test', 'run-js-test.c',
|
|
include_directories: [conf_inc],
|
|
build_rpath: mutter_typelibdir,
|
|
)
|
|
+
|
|
+install_data(
|
|
+ 'gnome-screenshot-tool',
|
|
+ install_dir: bindir
|
|
+)
|
|
+
|
|
+if bash_completion.found()
|
|
+ bash_completion_dir = bash_completion.get_variable(
|
|
+ pkgconfig: 'completionsdir',
|
|
+ pkgconfig_define: ['datadir', datadir],
|
|
+ )
|
|
+
|
|
+ register_python_argcomplete = find_program('register-python-argcomplete')
|
|
+
|
|
+ custom_target(
|
|
+ 'screenshot-tool-bash-completion',
|
|
+ output: 'gnome-screenshot-tool',
|
|
+ command: [
|
|
+ register_python_argcomplete,
|
|
+ 'gnome-screenshot-tool',
|
|
+ ],
|
|
+ capture: true,
|
|
+ install_dir: bash_completion_dir,
|
|
+ install: true,
|
|
+ )
|
|
+endif
|
|
--
|
|
2.50.1
|
|
|