diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7087d5e..2178d8d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,7 +28,7 @@ jobs: # uses: cclauss/GitHub-Action-for-pylint@8ef4d22e119fb1cdc0f58f2e95cb1f8d8b0d55e6 uses: cclauss/GitHub-Action-for-pylint@0.7.0 with: - args: '"pylint src/ --disable=import-error --disable=no-self-use --disable=no-else-return --disable=too-many-public-methods --disable=too-many-instance-attributes --disable=duplicate-code --disable=useless-object-inheritance --disable=too-few-public-methods"' + args: '"pylint src/ --disable=import-error --disable=no-self-use --disable=no-else-return --disable=too-many-public-methods --disable=too-many-instance-attributes --disable=duplicate-code --disable=useless-object-inheritance --disable=too-few-public-methods" --disable=missing-module-docstring' pytest: diff --git a/src/gscreenshot/__init__.py b/src/gscreenshot/__init__.py index efded0c..eedae97 100755 --- a/src/gscreenshot/__init__.py +++ b/src/gscreenshot/__init__.py @@ -25,8 +25,7 @@ from PIL import Image from gscreenshot.compat import get_resource_file, get_resource_string, get_version from gscreenshot.screenshot import ScreenshotCollection -from gscreenshot.screenshooter import Screenshooter -from gscreenshot.screenshooter.factory import ScreenshooterFactory +from gscreenshot.screenshooter import Screenshooter, ScreenshooterFactory from gscreenshot.util import session_is_wayland _ = gettext.gettext diff --git a/src/gscreenshot/frontend/gtk/__init__.py b/src/gscreenshot/frontend/gtk/__init__.py index 1256dfe..2ea9352 100644 --- a/src/gscreenshot/frontend/gtk/__init__.py +++ b/src/gscreenshot/frontend/gtk/__init__.py @@ -19,7 +19,7 @@ from gscreenshot.frontend.gtk.dialogs import FileSaveDialog, FileOpenDialog from gscreenshot.frontend.gtk.view import View from gscreenshot.screenshooter.exceptions import NoSupportedScreenshooterError -from gscreenshot.screenshot.effects.crop import CropEffect +from gscreenshot.screenshot.effects import CropEffect pygtkcompat.enable() pygtkcompat.enable_gtk(version='3.0') diff --git a/src/gscreenshot/frontend/gtk/dialogs.py b/src/gscreenshot/frontend/gtk/dialogs.py deleted file mode 100644 index 0179ae0..0000000 --- a/src/gscreenshot/frontend/gtk/dialogs.py +++ /dev/null @@ -1,157 +0,0 @@ -#pylint: disable=wrong-import-order -#pylint: disable=wrong-import-position -#pylint: disable=ungrouped-imports -''' -Dialog boxes for the GTK frontend to gscreenshot -''' -import gettext -import pygtkcompat - -pygtkcompat.enable() -pygtkcompat.enable_gtk(version='3.0') -from gi.repository import Gtk - -i18n = gettext.gettext - - -class OpenWithDialog(Gtk.AppChooserDialog): - '''The "Open With" dialog''' - - def __init__(self, parent=None): - - Gtk.AppChooserDialog.__init__(self, content_type="image/png", parent=parent) - self.set_title(i18n("Choose an Application")) - self.connect("response", self._on_response) - self.appinfo = None - - def _on_response(self, _, response): - if response == Gtk.ResponseType.OK: - self.appinfo = self.get_app_info() - else: - self.appinfo = None - - -class FileOpenDialog(object): - '''The 'open a file' dialog''' - #pylint: disable=too-many-arguments - def __init__(self, default_filename=None, default_folder=None, - parent=None, choose_directory=False, file_filter=None, - ): - self.default_filename = default_filename - self.default_folder = default_folder - self.parent = parent - self._choose_directory = choose_directory - self._filter = file_filter - - def run(self): - ''' Run the dialog''' - filename = self.request_file() - - return filename - - def request_file(self): - '''Run the file selection dialog''' - action = Gtk.FILE_CHOOSER_ACTION_OPEN - - chooser = Gtk.FileChooserNative( - transient_for=self.parent, - title=None, - action=action, - filter=self._filter, - ) - - if self.default_filename is not None: - chooser.set_current_name(self.default_filename) - - if self.default_folder is not None: - chooser.set_current_folder(self.default_folder) - - chooser.set_do_overwrite_confirmation(True) - - response = chooser.run() - - if response in [Gtk.RESPONSE_OK, Gtk.ResponseType.ACCEPT]: - return_value = chooser.get_filename() - else: - return_value = None - - chooser.destroy() - return return_value - - -class FileSaveDialog(object): - '''The 'save as' dialog''' - def __init__(self, default_filename=None, default_folder=None, - parent=None, choose_directory=False - ): - self.default_filename = default_filename - self.default_folder = default_folder - self.parent = parent - self._choose_directory = choose_directory - - def run(self): - ''' Run the dialog''' - filename = self.request_file() - - return filename - - def request_file(self): - '''Run the file selection dialog''' - action = Gtk.FILE_CHOOSER_ACTION_SAVE - if self._choose_directory: - action = Gtk.FILE_CHOOSER_ACTION_CREATE_FOLDER - - chooser = Gtk.FileChooserDialog( - transient_for=self.parent, - title=None, - action=action, - buttons=( - Gtk.STOCK_CANCEL, - Gtk.RESPONSE_CANCEL, - Gtk.STOCK_SAVE, - Gtk.RESPONSE_OK - ) - ) - - if self.default_filename is not None: - chooser.set_current_name(self.default_filename) - - if self.default_folder is not None: - chooser.set_current_folder(self.default_folder) - - chooser.set_do_overwrite_confirmation(True) - - response = chooser.run() - - if response == Gtk.RESPONSE_OK: - return_value = chooser.get_filename() - else: - return_value = None - - chooser.destroy() - return return_value - - -class WarningDialog(): - '''A warning dialog''' - - def __init__(self, message, parent=None): - self.parent = parent - self.message_dialog = Gtk.MessageDialog( - parent, - None, - Gtk.MESSAGE_WARNING, - Gtk.BUTTONS_OK, - message - ) - - def run(self): - '''Run the warning dialog''' - if self.parent is not None: - self.parent.set_sensitive(False) - - self.message_dialog.run() - self.message_dialog.destroy() - - if self.parent is not None: - self.parent.set_sensitive(True) diff --git a/src/gscreenshot/frontend/gtk/dialogs/__init__.py b/src/gscreenshot/frontend/gtk/dialogs/__init__.py new file mode 100644 index 0000000..0c49825 --- /dev/null +++ b/src/gscreenshot/frontend/gtk/dialogs/__init__.py @@ -0,0 +1,12 @@ +from .file_open_dialog import FileOpenDialog +from .file_save_dialog import FileSaveDialog +from .open_with_dialog import OpenWithDialog +from .warning_dialog import WarningDialog + + +__all__ = [ + "FileOpenDialog", + "FileSaveDialog", + "OpenWithDialog", + "WarningDialog", +] diff --git a/src/gscreenshot/frontend/gtk/dialogs/file_open_dialog.py b/src/gscreenshot/frontend/gtk/dialogs/file_open_dialog.py new file mode 100644 index 0000000..63850d1 --- /dev/null +++ b/src/gscreenshot/frontend/gtk/dialogs/file_open_dialog.py @@ -0,0 +1,62 @@ +#pylint: disable=wrong-import-order +#pylint: disable=wrong-import-position +#pylint: disable=ungrouped-imports +''' +Dialog boxes for the GTK frontend to gscreenshot +''' +import gettext +import pygtkcompat + +pygtkcompat.enable() +pygtkcompat.enable_gtk(version='3.0') +from gi.repository import Gtk + +i18n = gettext.gettext + + +class FileOpenDialog(object): + '''The 'open a file' dialog''' + #pylint: disable=too-many-arguments + def __init__(self, default_filename=None, default_folder=None, + parent=None, choose_directory=False, file_filter=None, + ): + self.default_filename = default_filename + self.default_folder = default_folder + self.parent = parent + self._choose_directory = choose_directory + self._filter = file_filter + + def run(self): + ''' Run the dialog''' + filename = self.request_file() + + return filename + + def request_file(self): + '''Run the file selection dialog''' + action = Gtk.FILE_CHOOSER_ACTION_OPEN + + chooser = Gtk.FileChooserNative( + transient_for=self.parent, + title=None, + action=action, + filter=self._filter, + ) + + if self.default_filename is not None: + chooser.set_current_name(self.default_filename) + + if self.default_folder is not None: + chooser.set_current_folder(self.default_folder) + + chooser.set_do_overwrite_confirmation(True) + + response = chooser.run() + + if response in [Gtk.RESPONSE_OK, Gtk.ResponseType.ACCEPT]: + return_value = chooser.get_filename() + else: + return_value = None + + chooser.destroy() + return return_value diff --git a/src/gscreenshot/frontend/gtk/dialogs/file_save_dialog.py b/src/gscreenshot/frontend/gtk/dialogs/file_save_dialog.py new file mode 100644 index 0000000..83e3aab --- /dev/null +++ b/src/gscreenshot/frontend/gtk/dialogs/file_save_dialog.py @@ -0,0 +1,67 @@ +#pylint: disable=wrong-import-order +#pylint: disable=wrong-import-position +#pylint: disable=ungrouped-imports +''' +Dialog boxes for the GTK frontend to gscreenshot +''' +import gettext +import pygtkcompat + +pygtkcompat.enable() +pygtkcompat.enable_gtk(version='3.0') +from gi.repository import Gtk + +i18n = gettext.gettext + + +class FileSaveDialog(object): + '''The 'save as' dialog''' + def __init__(self, default_filename=None, default_folder=None, + parent=None, choose_directory=False + ): + self.default_filename = default_filename + self.default_folder = default_folder + self.parent = parent + self._choose_directory = choose_directory + + def run(self): + ''' Run the dialog''' + filename = self.request_file() + + return filename + + def request_file(self): + '''Run the file selection dialog''' + action = Gtk.FILE_CHOOSER_ACTION_SAVE + if self._choose_directory: + action = Gtk.FILE_CHOOSER_ACTION_CREATE_FOLDER + + chooser = Gtk.FileChooserDialog( + transient_for=self.parent, + title=None, + action=action, + buttons=( + Gtk.STOCK_CANCEL, + Gtk.RESPONSE_CANCEL, + Gtk.STOCK_SAVE, + Gtk.RESPONSE_OK + ) + ) + + if self.default_filename is not None: + chooser.set_current_name(self.default_filename) + + if self.default_folder is not None: + chooser.set_current_folder(self.default_folder) + + chooser.set_do_overwrite_confirmation(True) + + response = chooser.run() + + if response == Gtk.RESPONSE_OK: + return_value = chooser.get_filename() + else: + return_value = None + + chooser.destroy() + return return_value diff --git a/src/gscreenshot/frontend/gtk/dialogs/open_with_dialog.py b/src/gscreenshot/frontend/gtk/dialogs/open_with_dialog.py new file mode 100644 index 0000000..d1efa4a --- /dev/null +++ b/src/gscreenshot/frontend/gtk/dialogs/open_with_dialog.py @@ -0,0 +1,31 @@ +#pylint: disable=wrong-import-order +#pylint: disable=wrong-import-position +#pylint: disable=ungrouped-imports +''' +Dialog boxes for the GTK frontend to gscreenshot +''' +import gettext +import pygtkcompat + +pygtkcompat.enable() +pygtkcompat.enable_gtk(version='3.0') +from gi.repository import Gtk + +i18n = gettext.gettext + + +class OpenWithDialog(Gtk.AppChooserDialog): + '''The "Open With" dialog''' + + def __init__(self, parent=None): + + Gtk.AppChooserDialog.__init__(self, content_type="image/png", parent=parent) + self.set_title(i18n("Choose an Application")) + self.connect("response", self._on_response) + self.appinfo = None + + def _on_response(self, _, response): + if response == Gtk.ResponseType.OK: + self.appinfo = self.get_app_info() + else: + self.appinfo = None diff --git a/src/gscreenshot/frontend/gtk/dialogs/warning_dialog.py b/src/gscreenshot/frontend/gtk/dialogs/warning_dialog.py new file mode 100644 index 0000000..311497a --- /dev/null +++ b/src/gscreenshot/frontend/gtk/dialogs/warning_dialog.py @@ -0,0 +1,39 @@ +#pylint: disable=wrong-import-order +#pylint: disable=wrong-import-position +#pylint: disable=ungrouped-imports +''' +Dialog boxes for the GTK frontend to gscreenshot +''' +import gettext +import pygtkcompat + +pygtkcompat.enable() +pygtkcompat.enable_gtk(version='3.0') +from gi.repository import Gtk + +i18n = gettext.gettext + + +class WarningDialog(): + '''A warning dialog''' + + def __init__(self, message, parent=None): + self.parent = parent + self.message_dialog = Gtk.MessageDialog( + parent, + None, + Gtk.MESSAGE_WARNING, + Gtk.BUTTONS_OK, + message + ) + + def run(self): + '''Run the warning dialog''' + if self.parent is not None: + self.parent.set_sensitive(False) + + self.message_dialog.run() + self.message_dialog.destroy() + + if self.parent is not None: + self.parent.set_sensitive(True) diff --git a/src/gscreenshot/screenshooter/__init__.py b/src/gscreenshot/screenshooter/__init__.py index e526d4b..8016f2e 100644 --- a/src/gscreenshot/screenshooter/__init__.py +++ b/src/gscreenshot/screenshooter/__init__.py @@ -1,258 +1,8 @@ -''' -Interface class for integrating a screenshot utility -''' -import os -import subprocess -import tempfile -import typing -import PIL.Image -from gscreenshot.cursor_locator.factory import CursorLocatorFactory +from .factory import ScreenshooterFactory +from .screenshooter import Screenshooter -from gscreenshot.screenshot import Screenshot -from gscreenshot.screenshot.effects.crop import CropEffect -from gscreenshot.screenshot.effects.stamp import StampEffect -from gscreenshot.selector import RegionSelector -from gscreenshot.selector import SelectionExecError, SelectionParseError -from gscreenshot.selector import SelectionCancelled, NoSupportedSelectorError -from gscreenshot.selector.factory import SelectorFactory -from gscreenshot.util import session_is_wayland, GSCapabilities - -class Screenshooter(object): - """ - Python interface for a screenshooter - """ - - __slots__ = ('_tempfile', '_selector', '_screenshot') - __utilityname__: str = "default" - - _screenshot: typing.Optional[Screenshot] - _tempfile: str - _selector: typing.Optional[RegionSelector] - - def __init__(self, selector: typing.Optional[RegionSelector]=None): - """ - constructor - """ - if selector is None: - try: - self._selector = SelectorFactory().create() - except NoSupportedSelectorError: - self._selector = None - else: - self._selector = selector - - self._screenshot = None - self._tempfile = os.path.join( - tempfile.gettempdir(), - str(os.getpid()) + ".png" - ) - - @property - def image(self) -> typing.Optional[PIL.Image.Image]: - """ - Returns the last screenshot taken - PIL Image - - Deprecated. Use Screenshooter.screenshot.get_image(). - - Returns: - PIL.Image or None - """ - if self._screenshot is not None: - return self._screenshot.get_image() - - return None - - @property - def screenshot(self) -> typing.Optional[Screenshot]: - """ - Returns the last screenshot taken - Screenshot object - """ - return self._screenshot - - def get_capabilities(self) -> typing.Dict[str, str]: - """ - Get supported features. Note that under-the-hood the capabilities - of the selector (if applicable) will be added to this. - - Returns: - [GSCapabilities] - """ - return {} - - def get_capabilities_(self) -> typing.Dict[str, str]: - """ - Get supported features. This should not be overridden by extending - classes. Implement get_capabilities instead. - """ - capabilities = self.get_capabilities() - # If we're running, this is the bare minimum - capabilities[GSCapabilities.CAPTURE_FULLSCREEN] = self.__utilityname__ - - try: - cursor_locator = CursorLocatorFactory().create() - # pylint: disable=bare-except - except: - cursor_locator = None - - if cursor_locator is not None: - capabilities[GSCapabilities.ALTERNATE_CURSOR] = cursor_locator.__utilityname__ - capabilities[GSCapabilities.CURSOR_CAPTURE] = cursor_locator.__utilityname__ - - if self._selector is not None: - capabilities.update(self._selector.get_capabilities()) - - return capabilities - - def grab_fullscreen_(self, delay: int=0, capture_cursor: bool=False, - use_cursor: typing.Optional[PIL.Image.Image]=None): - ''' - Internal API method for grabbing the full screen. This should not - be overridden by extending classes. Implement grab_fullscreen instead. - ''' - if use_cursor is None and GSCapabilities.CURSOR_CAPTURE in self.get_capabilities(): - self.grab_fullscreen(delay, capture_cursor) - else: - self.grab_fullscreen(delay, capture_cursor=False) - if capture_cursor and use_cursor and self._screenshot: - cursor_position = self.get_cursor_position() - if cursor_position is not None: - stamp = StampEffect(use_cursor, cursor_position) - stamp.set_alias("cursor") - self._screenshot.add_effect(stamp) - - def grab_fullscreen(self, delay: int=0, capture_cursor: bool=False): - """ - Takes a screenshot of the full screen with a given delay - - Parameters: - int delay, in seconds - """ - raise NotImplementedError( - "Not implemented. Fullscreen grab called with delay " + str(delay) - ) - - def grab_selection_(self, delay: int=0, capture_cursor: bool=False, - use_cursor: typing.Optional[PIL.Image.Image]=None, - region: typing.Optional[typing.Tuple[int, int, int, int]]=None): - """ - Internal API method for grabbing a selection. This should not - be overridden by extending classes. Implement grab_selection instead. - - Takes an interactive screenshot of a selected area with a - given delay. This has some safety around the interactive selection: - if it fails to run, it will call a fallback method (which defaults to - taking a full screen screenshot). if it gives unexpected output it will - fall back to a full screen screenshot. - - Parameters: - int delay: seconds - """ - if region is not None: - self.grab_fullscreen_(delay, capture_cursor, use_cursor) - if self._screenshot is not None: - crop = CropEffect(region) - crop.set_alias("region") - self._screenshot.add_effect(crop) - return - - if self._selector is None: - self._grab_selection_fallback(delay, capture_cursor) - return - - try: - crop_box = self._selector.region_select() - except SelectionCancelled: - print("Selection was cancelled") - self.grab_fullscreen_(delay, capture_cursor, use_cursor) - return - except (OSError, SelectionExecError): - print("Failed to call region selector -- Using fallback region selection") - self._grab_selection_fallback(delay, capture_cursor) - return - except SelectionParseError: - print("Invalid selection data -- falling back to full screen") - self.grab_fullscreen_(delay, capture_cursor, use_cursor) - return - - self.grab_fullscreen_(delay, capture_cursor, use_cursor) - - if self._screenshot is not None: - crop = CropEffect(crop_box) - crop.set_alias("region") - self._screenshot.add_effect(crop) - - def grab_window_(self, delay: int=0, capture_cursor: bool=False, - use_cursor: typing.Optional[PIL.Image.Image]=None): - ''' - Internal API method for grabbing a window. This should not - be overridden by extending classes. Implement grab_window instead. - - ''' - self.grab_selection_(delay, capture_cursor, use_cursor) - - def grab_window(self, delay: int=0, capture_cursor: bool=False): - """ - Takes an interactive screenshot of a selected window with a - given delay. This has a full implementation and may not need - to be overridden in a child class. By default it will just - use the selection method, as most region selection and screenshot - tools don't differentiate. - - Parameters: - int delay: seconds - """ - self.grab_selection_(delay, capture_cursor) - - @staticmethod - def can_run() -> bool: - """ - Whether this utility can run - """ - return False - - def get_cursor_position(self) -> typing.Optional[typing.Tuple[int, int]]: - """ - Gets the current position of the mouse cursor, if able. - Returns (x, y) or None. - """ - - try: - cursor_locator = CursorLocatorFactory().create() - return cursor_locator.get_cursor_position() - # pylint: disable=bare-except - except: - # We don't really care about the specific error here. If we can't - # get the pointer, then just move on. - return None - - def _grab_selection_fallback(self, delay: int=0, capture_cursor: bool=False): - """ - Fallback for grabbing the selection, in case the selection tool fails to - run entirely. Defaults to giving up and just taking a full screen shot. - - Parameters: - int delay: seconds - """ - self.grab_fullscreen(delay, capture_cursor) - - def _call_screenshooter(self, screenshooter: str, - params: typing.Optional[typing.List[str]]= None) -> bool: - - # This is safer than defaulting to [] - if params is None: - params = [] - - params = [screenshooter] + params - try: - subprocess.check_output(params) - self._screenshot = Screenshot(PIL.Image.open(self._tempfile)) - os.unlink(self._tempfile) - except (subprocess.CalledProcessError, IOError, OSError): - self._screenshot = None - return False - - return True - - def __repr__(self) -> str: - return f'{self.__class__.__name__}(selector={self._selector})' +__all__ = [ + "Screenshooter", + "ScreenshooterFactory", +] diff --git a/src/gscreenshot/screenshooter/factory.py b/src/gscreenshot/screenshooter/factory.py index ffa11b9..85ebfe7 100644 --- a/src/gscreenshot/screenshooter/factory.py +++ b/src/gscreenshot/screenshooter/factory.py @@ -3,15 +3,16 @@ ''' import typing -from gscreenshot.screenshooter import Screenshooter -from gscreenshot.screenshooter.grim import Grim -from gscreenshot.screenshooter.imagemagick import ImageMagick -from gscreenshot.screenshooter.imlib_2 import Imlib2 -from gscreenshot.screenshooter.pil import PILWrapper -from gscreenshot.screenshooter.scrot import Scrot -from gscreenshot.screenshooter.xdg_desktop_portal import XdgDesktopPortal -from gscreenshot.screenshooter.exceptions import NoSupportedScreenshooterError from gscreenshot.util import session_is_wayland +from .grim import Grim +from .imagemagick import ImageMagick +from .imlib_2 import Imlib2 +from .pil import PILWrapper +from .scrot import Scrot +from .xdg_desktop_portal import XdgDesktopPortal +from .exceptions import NoSupportedScreenshooterError +from .screenshooter import Screenshooter + class ScreenshooterFactory(object): '''Selects and instantiates a usable screenshot class''' diff --git a/src/gscreenshot/screenshooter/grim.py b/src/gscreenshot/screenshooter/grim.py index 9c6a19c..6efa657 100644 --- a/src/gscreenshot/screenshooter/grim.py +++ b/src/gscreenshot/screenshooter/grim.py @@ -6,7 +6,7 @@ import typing from gscreenshot.util import find_executable, GSCapabilities -from gscreenshot.screenshooter import Screenshooter +from .screenshooter import Screenshooter class Grim(Screenshooter): diff --git a/src/gscreenshot/screenshooter/imagemagick.py b/src/gscreenshot/screenshooter/imagemagick.py index 7690d61..6bd4b25 100644 --- a/src/gscreenshot/screenshooter/imagemagick.py +++ b/src/gscreenshot/screenshooter/imagemagick.py @@ -4,9 +4,9 @@ from time import sleep import typing -from gscreenshot.screenshooter import Screenshooter from gscreenshot.util import find_executable from gscreenshot.util import GSCapabilities +from .screenshooter import Screenshooter class ImageMagick(Screenshooter): diff --git a/src/gscreenshot/screenshooter/imlib_2.py b/src/gscreenshot/screenshooter/imlib_2.py index 6fd5233..16de775 100644 --- a/src/gscreenshot/screenshooter/imlib_2.py +++ b/src/gscreenshot/screenshooter/imlib_2.py @@ -3,8 +3,8 @@ ''' from time import sleep -from gscreenshot.screenshooter import Screenshooter from gscreenshot.util import find_executable +from .screenshooter import Screenshooter class Imlib2(Screenshooter): diff --git a/src/gscreenshot/screenshooter/pil.py b/src/gscreenshot/screenshooter/pil.py index 8a44a98..e2765f6 100644 --- a/src/gscreenshot/screenshooter/pil.py +++ b/src/gscreenshot/screenshooter/pil.py @@ -2,8 +2,9 @@ Integration for the PIL screenshot functionality ''' from time import sleep -from gscreenshot.screenshooter import Screenshooter from gscreenshot.screenshot import Screenshot +from .screenshooter import Screenshooter + SUPPORTED_PLATFORM = False diff --git a/src/gscreenshot/screenshooter/screenshooter.py b/src/gscreenshot/screenshooter/screenshooter.py new file mode 100644 index 0000000..b01284e --- /dev/null +++ b/src/gscreenshot/screenshooter/screenshooter.py @@ -0,0 +1,257 @@ +''' +Interface class for integrating a screenshot utility +''' +import os +import subprocess +import tempfile +import typing +import PIL.Image +from gscreenshot.cursor_locator.factory import CursorLocatorFactory + +from gscreenshot.screenshot import Screenshot +from gscreenshot.screenshot.effects import CropEffect +from gscreenshot.screenshot.effects import StampEffect +from gscreenshot.selector import RegionSelector, SelectorFactory +from gscreenshot.selector.exceptions import SelectionExecError, SelectionParseError +from gscreenshot.selector.exceptions import SelectionCancelled, NoSupportedSelectorError +from gscreenshot.util import GSCapabilities + + +class Screenshooter(object): + """ + Python interface for a screenshooter + """ + + __slots__ = ('_tempfile', '_selector', '_screenshot') + __utilityname__: str = "default" + + _screenshot: typing.Optional[Screenshot] + _tempfile: str + _selector: typing.Optional[RegionSelector] + + def __init__(self, selector: typing.Optional[RegionSelector]=None): + """ + constructor + """ + if selector is None: + try: + self._selector = SelectorFactory().create() + except NoSupportedSelectorError: + self._selector = None + else: + self._selector = selector + + self._screenshot = None + self._tempfile = os.path.join( + tempfile.gettempdir(), + str(os.getpid()) + ".png" + ) + + @property + def image(self) -> typing.Optional[PIL.Image.Image]: + """ + Returns the last screenshot taken - PIL Image + + Deprecated. Use Screenshooter.screenshot.get_image(). + + Returns: + PIL.Image or None + """ + if self._screenshot is not None: + return self._screenshot.get_image() + + return None + + @property + def screenshot(self) -> typing.Optional[Screenshot]: + """ + Returns the last screenshot taken - Screenshot object + """ + return self._screenshot + + def get_capabilities(self) -> typing.Dict[str, str]: + """ + Get supported features. Note that under-the-hood the capabilities + of the selector (if applicable) will be added to this. + + Returns: + [GSCapabilities] + """ + return {} + + def get_capabilities_(self) -> typing.Dict[str, str]: + """ + Get supported features. This should not be overridden by extending + classes. Implement get_capabilities instead. + """ + capabilities = self.get_capabilities() + # If we're running, this is the bare minimum + capabilities[GSCapabilities.CAPTURE_FULLSCREEN] = self.__utilityname__ + + try: + cursor_locator = CursorLocatorFactory().create() + # pylint: disable=bare-except + except: + cursor_locator = None + + if cursor_locator is not None: + capabilities[GSCapabilities.ALTERNATE_CURSOR] = cursor_locator.__utilityname__ + capabilities[GSCapabilities.CURSOR_CAPTURE] = cursor_locator.__utilityname__ + + if self._selector is not None: + capabilities.update(self._selector.get_capabilities()) + + return capabilities + + def grab_fullscreen_(self, delay: int=0, capture_cursor: bool=False, + use_cursor: typing.Optional[PIL.Image.Image]=None): + ''' + Internal API method for grabbing the full screen. This should not + be overridden by extending classes. Implement grab_fullscreen instead. + ''' + if use_cursor is None and GSCapabilities.CURSOR_CAPTURE in self.get_capabilities(): + self.grab_fullscreen(delay, capture_cursor) + else: + self.grab_fullscreen(delay, capture_cursor=False) + if capture_cursor and use_cursor and self._screenshot: + cursor_position = self.get_cursor_position() + if cursor_position is not None: + stamp = StampEffect(use_cursor, cursor_position) + stamp.set_alias("cursor") + self._screenshot.add_effect(stamp) + + def grab_fullscreen(self, delay: int=0, capture_cursor: bool=False): + """ + Takes a screenshot of the full screen with a given delay + + Parameters: + int delay, in seconds + """ + raise NotImplementedError( + "Not implemented. Fullscreen grab called with delay " + str(delay) + ) + + def grab_selection_(self, delay: int=0, capture_cursor: bool=False, + use_cursor: typing.Optional[PIL.Image.Image]=None, + region: typing.Optional[typing.Tuple[int, int, int, int]]=None): + """ + Internal API method for grabbing a selection. This should not + be overridden by extending classes. Implement grab_selection instead. + + Takes an interactive screenshot of a selected area with a + given delay. This has some safety around the interactive selection: + if it fails to run, it will call a fallback method (which defaults to + taking a full screen screenshot). if it gives unexpected output it will + fall back to a full screen screenshot. + + Parameters: + int delay: seconds + """ + if region is not None: + self.grab_fullscreen_(delay, capture_cursor, use_cursor) + if self._screenshot is not None: + crop = CropEffect(region) + crop.set_alias("region") + self._screenshot.add_effect(crop) + return + + if self._selector is None: + self._grab_selection_fallback(delay, capture_cursor) + return + + try: + crop_box = self._selector.region_select() + except SelectionCancelled: + print("Selection was cancelled") + self.grab_fullscreen_(delay, capture_cursor, use_cursor) + return + except (OSError, SelectionExecError): + print("Failed to call region selector -- Using fallback region selection") + self._grab_selection_fallback(delay, capture_cursor) + return + except SelectionParseError: + print("Invalid selection data -- falling back to full screen") + self.grab_fullscreen_(delay, capture_cursor, use_cursor) + return + + self.grab_fullscreen_(delay, capture_cursor, use_cursor) + + if self._screenshot is not None: + crop = CropEffect(crop_box) + crop.set_alias("region") + self._screenshot.add_effect(crop) + + def grab_window_(self, delay: int=0, capture_cursor: bool=False, + use_cursor: typing.Optional[PIL.Image.Image]=None): + ''' + Internal API method for grabbing a window. This should not + be overridden by extending classes. Implement grab_window instead. + + ''' + self.grab_selection_(delay, capture_cursor, use_cursor) + + def grab_window(self, delay: int=0, capture_cursor: bool=False): + """ + Takes an interactive screenshot of a selected window with a + given delay. This has a full implementation and may not need + to be overridden in a child class. By default it will just + use the selection method, as most region selection and screenshot + tools don't differentiate. + + Parameters: + int delay: seconds + """ + self.grab_selection_(delay, capture_cursor) + + @staticmethod + def can_run() -> bool: + """ + Whether this utility can run + """ + return False + + def get_cursor_position(self) -> typing.Optional[typing.Tuple[int, int]]: + """ + Gets the current position of the mouse cursor, if able. + Returns (x, y) or None. + """ + + try: + cursor_locator = CursorLocatorFactory().create() + return cursor_locator.get_cursor_position() + # pylint: disable=bare-except + except: + # We don't really care about the specific error here. If we can't + # get the pointer, then just move on. + return None + + def _grab_selection_fallback(self, delay: int=0, capture_cursor: bool=False): + """ + Fallback for grabbing the selection, in case the selection tool fails to + run entirely. Defaults to giving up and just taking a full screen shot. + + Parameters: + int delay: seconds + """ + self.grab_fullscreen(delay, capture_cursor) + + def _call_screenshooter(self, screenshooter: str, + params: typing.Optional[typing.List[str]]= None) -> bool: + + # This is safer than defaulting to [] + if params is None: + params = [] + + params = [screenshooter] + params + try: + subprocess.check_output(params) + self._screenshot = Screenshot(PIL.Image.open(self._tempfile)) + os.unlink(self._tempfile) + except (subprocess.CalledProcessError, IOError, OSError): + self._screenshot = None + return False + + return True + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(selector={self._selector})' diff --git a/src/gscreenshot/screenshooter/scrot.py b/src/gscreenshot/screenshooter/scrot.py index c9ae500..4cc7474 100644 --- a/src/gscreenshot/screenshooter/scrot.py +++ b/src/gscreenshot/screenshooter/scrot.py @@ -4,8 +4,8 @@ import subprocess import typing -from gscreenshot.screenshooter import Screenshooter from gscreenshot.util import GSCapabilities +from .screenshooter import Screenshooter class Scrot(Screenshooter): diff --git a/src/gscreenshot/screenshooter/xdg_desktop_portal.py b/src/gscreenshot/screenshooter/xdg_desktop_portal.py index fc2b805..1dd3fe8 100644 --- a/src/gscreenshot/screenshooter/xdg_desktop_portal.py +++ b/src/gscreenshot/screenshooter/xdg_desktop_portal.py @@ -31,7 +31,9 @@ except ImportError: DBusGMainLoop = None -from gscreenshot.screenshooter import Screenshooter +# This MUST be an absolute import path or we either get a circular import +# or the script doesn't work +from gscreenshot.screenshooter.screenshooter import Screenshooter from gscreenshot.screenshooter.exceptions import NoSupportedScreenshooterError diff --git a/src/gscreenshot/screenshot/__init__.py b/src/gscreenshot/screenshot/__init__.py index 79c39fa..297b595 100644 --- a/src/gscreenshot/screenshot/__init__.py +++ b/src/gscreenshot/screenshot/__init__.py @@ -1,234 +1,8 @@ -''' -Screenshot container classes for gscreenshot -''' -import os -import typing -from PIL import Image, ImageFilter -from gscreenshot.screenshot.effects import ScreenshotEffect +from .screenshot import Screenshot +from .screenshot_collection import ScreenshotCollection -class Screenshot(object): - ''' - Represents a screenshot taken via Gscreenshot. - - This stores various runtime metadata about the - individual screenshot whose PIL.Image.Image it contains - ''' - - _image: Image.Image - _saved_to: typing.Optional[str] - _effects: typing.List[ScreenshotEffect] - - def __init__(self, image: Image.Image): - '''Constructor''' - self._image = image - self._saved_to = None - self._effects = [] - - def add_effect(self, effect: ScreenshotEffect): - ''' - Add another overlay effect to this screenshot - ''' - self._effects.append(effect) - - def remove_effect(self, effect: ScreenshotEffect): - ''' - Remove an overlay effect from this screenshot. - Note that effects can also be disabled without - being removed. - ''' - self._effects.remove(effect) - - def get_effects(self) -> typing.List[ScreenshotEffect]: - ''' - Provides the list of effects - ''' - return self._effects - - def get_image(self) -> Image.Image: - '''Gets the underlying PIL.Image.Image''' - image = self._image.copy() - - for effect in self._effects: - if effect.enabled: - image = effect.apply_to(image) - - return image.convert("RGB") - - def get_preview(self, width: int, height: int, with_border=False) -> Image.Image: - ''' - Gets a preview of the image. - - Params: - width: int - height: int - with_border: bool, whether to add a drop shadow for visibility - Returns: - Image - ''' - thumbnail = self.get_image().copy() - - antialias_algo = None - try: - antialias_algo = Image.Resampling.LANCZOS - except AttributeError: # PIL < 9.0 - antialias_algo = Image.ANTIALIAS - - thumbnail.thumbnail((width, height), antialias_algo) - - if with_border: - shadow = Image.new( - 'RGBA', - (int(thumbnail.size[0]+4), int(thumbnail.size[1]+4)), - (70, 70, 70, 50) - ) - shadow.paste(thumbnail, (1, 1)) - return shadow - - return thumbnail - - def set_saved_path(self, path: typing.Optional[str]): - '''Set the path this screenshot image was saved to''' - self._saved_to = path - - def get_saved_path(self) -> typing.Optional[str]: - '''Get the path this screenshot image was saved to''' - return self._saved_to - - def saved(self) -> bool: - '''Whether this screenshot image was saved''' - saved_path = self.get_saved_path() - - if saved_path is None: - return False - - return os.path.exists(saved_path) - - def __repr__(self) -> str: - return f'''{self.__class__.__name__}(image={self._image}) - ''' - - -class ScreenshotCollection(object): - ''' - The collection of screenshots taken by gscreenshot - during the active session - ''' - - _screenshots: typing.List[Screenshot] - _cursor: int - - def __init__(self): - '''constructor''' - self._screenshots = [] - self._cursor = 0 - - def __len__(self) -> int: - '''length''' - return len(self._screenshots) - - def __getitem__(self, idx) -> Screenshot: - '''get the screenshot with the given index''' - return self._screenshots[idx] - - def __iter__(self): - yield from self._screenshots - - def cursor(self) -> int: - '''get the current cursor index''' - return self._cursor - - def append(self, item: Screenshot): - '''adds a screenshot to the end of the collection''' - self._screenshots.append(item) - - def remove(self, item: Screenshot): - '''removes a screenshot''' - self._screenshots.remove(item) - if not self.has_next(): - self.cursor_to_end() - elif not self.has_previous(): - self.cursor_to_start() - - def replace(self, replacement: Screenshot, idx: int = -2): - '''replaces a screenshot at the cursor or provided index''' - if idx == -2: - idx = self._cursor - - if len(self._screenshots) == 0: - self.append(replacement) - return - - try: - self._screenshots[idx] = replacement - except IndexError: - self._screenshots[self._cursor] = replacement - - def insert(self, screenshot: Screenshot): - ''' - Inserts a screenshot at the cursor - ''' - if len(self._screenshots) < 1: - self.append(screenshot) - return - - try: - self._screenshots = self._screenshots[:self._cursor + 1] + \ - [screenshot] + self._screenshots[self._cursor + 1:] - - self._cursor = self._cursor + 1 - - except IndexError: - self.append(screenshot) - self.cursor_to_end() - - def has_next(self) -> bool: - ''' - whether the collection has another screenshot - at the index+1 of the current cursor position - ''' - return (self._cursor + 1) < len(self._screenshots) - - def has_previous(self) -> bool: - ''' - whether the collection has another screenshot at - the index-1 of the current cursor position - ''' - return (self._cursor - 1) > -1 - - def cursor_next(self) -> typing.Optional[Screenshot]: - ''' - get the next screenshot and increment the cursor - ''' - if self.has_next(): - self._cursor += 1 - return self[self._cursor] - - return None - - def cursor_prev(self) -> typing.Optional[Screenshot]: - ''' - get the previous screenshot and decrement the cursor - ''' - if self.has_previous(): - self._cursor -= 1 - return self[self._cursor] - - return None - - def cursor_current(self) -> typing.Optional[Screenshot]: - ''' - get the screenshot at the current cursor index - ''' - try: - return self._screenshots[self._cursor] - except IndexError: - return None - - def cursor_to_start(self): - '''move the cursor to index 0''' - self._cursor = 0 - - def cursor_to_end(self): - '''move the cursor to the last (highest) index''' - self._cursor = len(self._screenshots) - 1 +__all__ = [ + "Screenshot", + "ScreenshotCollection", +] diff --git a/src/gscreenshot/screenshot/effects/__init__.py b/src/gscreenshot/screenshot/effects/__init__.py index f817aca..ed9b70a 100644 --- a/src/gscreenshot/screenshot/effects/__init__.py +++ b/src/gscreenshot/screenshot/effects/__init__.py @@ -1,66 +1,10 @@ -''' -Provides a common API class for adding basic -editing to screenshots. -This is not intended for full image editing -support - just basics like crop and adding -simple overlays. Gscreenshot is not, and never -should be, GIMP or Krita. -''' -import typing -from PIL import Image +from .screenshot_effect import ScreenshotEffect +from .crop import CropEffect +from .stamp import StampEffect -class ScreenshotEffect(): - ''' - A simple manipulation for a screenshot - ''' - _enabled: bool - _alias: typing.Optional[str] - _meta: dict - - def __init__(self): - self._meta = {} - self._alias = None - self._enabled = True - - def set_alias(self, alias: typing.Optional[str]): - ''' - Add or change a name to this effect for identification - to the user. - ''' - self._alias = alias - - def enable(self): - ''' - Set this effect to enabled (it will be applied to - the screenshot) - ''' - self._enabled = True - - def disable(self): - ''' - Set this effect to disabled (it will NOT be applied - to the screenshot) - ''' - self._enabled = False - - def apply_to(self, screenshot: Image.Image) -> Image.Image: - ''' - Applies this effect to a provided image - ''' - return screenshot - - @property - def enabled(self) -> bool: - '''Returns whether this effect is enabled''' - return self._enabled - - @property - def alias(self) -> typing.Optional[str]: - '''Returns the alias of this effect''' - return self._alias - - @property - def meta(self) -> typing.Dict[str, str]: - '''Returns the metadata (if any)''' - return self._meta +__all__ = [ + "CropEffect", + "ScreenshotEffect", + "StampEffect", +] diff --git a/src/gscreenshot/screenshot/effects/crop.py b/src/gscreenshot/screenshot/effects/crop.py index bee58bc..081e570 100644 --- a/src/gscreenshot/screenshot/effects/crop.py +++ b/src/gscreenshot/screenshot/effects/crop.py @@ -3,7 +3,7 @@ ''' from PIL import Image -from gscreenshot.screenshot.effects import ScreenshotEffect +from .screenshot_effect import ScreenshotEffect class CropEffect(ScreenshotEffect): diff --git a/src/gscreenshot/screenshot/effects/screenshot_effect.py b/src/gscreenshot/screenshot/effects/screenshot_effect.py new file mode 100644 index 0000000..f817aca --- /dev/null +++ b/src/gscreenshot/screenshot/effects/screenshot_effect.py @@ -0,0 +1,66 @@ +''' +Provides a common API class for adding basic +editing to screenshots. +This is not intended for full image editing +support - just basics like crop and adding +simple overlays. Gscreenshot is not, and never +should be, GIMP or Krita. +''' +import typing +from PIL import Image + + +class ScreenshotEffect(): + ''' + A simple manipulation for a screenshot + ''' + _enabled: bool + _alias: typing.Optional[str] + _meta: dict + + def __init__(self): + self._meta = {} + self._alias = None + self._enabled = True + + def set_alias(self, alias: typing.Optional[str]): + ''' + Add or change a name to this effect for identification + to the user. + ''' + self._alias = alias + + def enable(self): + ''' + Set this effect to enabled (it will be applied to + the screenshot) + ''' + self._enabled = True + + def disable(self): + ''' + Set this effect to disabled (it will NOT be applied + to the screenshot) + ''' + self._enabled = False + + def apply_to(self, screenshot: Image.Image) -> Image.Image: + ''' + Applies this effect to a provided image + ''' + return screenshot + + @property + def enabled(self) -> bool: + '''Returns whether this effect is enabled''' + return self._enabled + + @property + def alias(self) -> typing.Optional[str]: + '''Returns the alias of this effect''' + return self._alias + + @property + def meta(self) -> typing.Dict[str, str]: + '''Returns the metadata (if any)''' + return self._meta diff --git a/src/gscreenshot/screenshot/effects/stamp.py b/src/gscreenshot/screenshot/effects/stamp.py index 7f64a40..dfa6d07 100644 --- a/src/gscreenshot/screenshot/effects/stamp.py +++ b/src/gscreenshot/screenshot/effects/stamp.py @@ -3,7 +3,7 @@ ''' import typing from PIL import Image -from gscreenshot.screenshot.effects import ScreenshotEffect +from .screenshot_effect import ScreenshotEffect class StampEffect(ScreenshotEffect): diff --git a/src/gscreenshot/screenshot/screenshot.py b/src/gscreenshot/screenshot/screenshot.py new file mode 100644 index 0000000..372eb5a --- /dev/null +++ b/src/gscreenshot/screenshot/screenshot.py @@ -0,0 +1,109 @@ +''' +Screenshot container classes for gscreenshot +''' +import os +import typing +from PIL import Image +from .effects import ScreenshotEffect + + +class Screenshot(object): + ''' + Represents a screenshot taken via Gscreenshot. + + This stores various runtime metadata about the + individual screenshot whose PIL.Image.Image it contains + ''' + + _image: Image.Image + _saved_to: typing.Optional[str] + _effects: typing.List[ScreenshotEffect] + + def __init__(self, image: Image.Image): + '''Constructor''' + self._image = image + self._saved_to = None + self._effects = [] + + def add_effect(self, effect: ScreenshotEffect): + ''' + Add another overlay effect to this screenshot + ''' + self._effects.append(effect) + + def remove_effect(self, effect: ScreenshotEffect): + ''' + Remove an overlay effect from this screenshot. + Note that effects can also be disabled without + being removed. + ''' + self._effects.remove(effect) + + def get_effects(self) -> typing.List[ScreenshotEffect]: + ''' + Provides the list of effects + ''' + return self._effects + + def get_image(self) -> Image.Image: + '''Gets the underlying PIL.Image.Image''' + image = self._image.copy() + + for effect in self._effects: + if effect.enabled: + image = effect.apply_to(image) + + return image.convert("RGB") + + def get_preview(self, width: int, height: int, with_border=False) -> Image.Image: + ''' + Gets a preview of the image. + + Params: + width: int + height: int + with_border: bool, whether to add a drop shadow for visibility + Returns: + Image + ''' + thumbnail = self.get_image().copy() + + antialias_algo = None + try: + antialias_algo = Image.Resampling.LANCZOS + except AttributeError: # PIL < 9.0 + antialias_algo = Image.ANTIALIAS + + thumbnail.thumbnail((width, height), antialias_algo) + + if with_border: + shadow = Image.new( + 'RGBA', + (int(thumbnail.size[0]+4), int(thumbnail.size[1]+4)), + (70, 70, 70, 50) + ) + shadow.paste(thumbnail, (1, 1)) + return shadow + + return thumbnail + + def set_saved_path(self, path: typing.Optional[str]): + '''Set the path this screenshot image was saved to''' + self._saved_to = path + + def get_saved_path(self) -> typing.Optional[str]: + '''Get the path this screenshot image was saved to''' + return self._saved_to + + def saved(self) -> bool: + '''Whether this screenshot image was saved''' + saved_path = self.get_saved_path() + + if saved_path is None: + return False + + return os.path.exists(saved_path) + + def __repr__(self) -> str: + return f'''{self.__class__.__name__}(image={self._image}) + ''' diff --git a/src/gscreenshot/screenshot/screenshot_collection.py b/src/gscreenshot/screenshot/screenshot_collection.py new file mode 100644 index 0000000..f6d5c1f --- /dev/null +++ b/src/gscreenshot/screenshot/screenshot_collection.py @@ -0,0 +1,130 @@ +''' +Screenshot container classes for gscreenshot +''' +import typing +from .screenshot import Screenshot + + +class ScreenshotCollection(object): + ''' + The collection of screenshots taken by gscreenshot + during the active session + ''' + + _screenshots: typing.List[Screenshot] + _cursor: int + + def __init__(self): + '''constructor''' + self._screenshots = [] + self._cursor = 0 + + def __len__(self) -> int: + '''length''' + return len(self._screenshots) + + def __getitem__(self, idx) -> Screenshot: + '''get the screenshot with the given index''' + return self._screenshots[idx] + + def __iter__(self): + yield from self._screenshots + + def cursor(self) -> int: + '''get the current cursor index''' + return self._cursor + + def append(self, item: Screenshot): + '''adds a screenshot to the end of the collection''' + self._screenshots.append(item) + + def remove(self, item: Screenshot): + '''removes a screenshot''' + self._screenshots.remove(item) + if not self.has_next(): + self.cursor_to_end() + elif not self.has_previous(): + self.cursor_to_start() + + def replace(self, replacement: Screenshot, idx: int = -2): + '''replaces a screenshot at the cursor or provided index''' + if idx == -2: + idx = self._cursor + + if len(self._screenshots) == 0: + self.append(replacement) + return + + try: + self._screenshots[idx] = replacement + except IndexError: + self._screenshots[self._cursor] = replacement + + def insert(self, screenshot: Screenshot): + ''' + Inserts a screenshot at the cursor + ''' + if len(self._screenshots) < 1: + self.append(screenshot) + return + + try: + self._screenshots = self._screenshots[:self._cursor + 1] + \ + [screenshot] + self._screenshots[self._cursor + 1:] + + self._cursor = self._cursor + 1 + + except IndexError: + self.append(screenshot) + self.cursor_to_end() + + def has_next(self) -> bool: + ''' + whether the collection has another screenshot + at the index+1 of the current cursor position + ''' + return (self._cursor + 1) < len(self._screenshots) + + def has_previous(self) -> bool: + ''' + whether the collection has another screenshot at + the index-1 of the current cursor position + ''' + return (self._cursor - 1) > -1 + + def cursor_next(self) -> typing.Optional[Screenshot]: + ''' + get the next screenshot and increment the cursor + ''' + if self.has_next(): + self._cursor += 1 + return self[self._cursor] + + return None + + def cursor_prev(self) -> typing.Optional[Screenshot]: + ''' + get the previous screenshot and decrement the cursor + ''' + if self.has_previous(): + self._cursor -= 1 + return self[self._cursor] + + return None + + def cursor_current(self) -> typing.Optional[Screenshot]: + ''' + get the screenshot at the current cursor index + ''' + try: + return self._screenshots[self._cursor] + except IndexError: + return None + + def cursor_to_start(self): + '''move the cursor to index 0''' + self._cursor = 0 + + def cursor_to_end(self): + '''move the cursor to the last (highest) index''' + self._cursor = len(self._screenshots) - 1 diff --git a/src/gscreenshot/selector/__init__.py b/src/gscreenshot/selector/__init__.py index 1b1f520..0856551 100644 --- a/src/gscreenshot/selector/__init__.py +++ b/src/gscreenshot/selector/__init__.py @@ -1,141 +1,9 @@ -''' -Classes and exceptions related to screen region selection -''' -import subprocess -import typing -from gscreenshot.util import GSCapabilities +from .factory import SelectorFactory +from .region_selector import RegionSelector -class SelectionError(BaseException): - '''Generic selection error''' -class SelectionExecError(BaseException): - '''Error executing selector''' - -class SelectionParseError(BaseException): - '''Error parsing selection output''' - -class SelectionCancelled(BaseException): - '''Selection cancelled error''' - -class NoSupportedSelectorError(BaseException): - '''No region selection tool available''' - -class RegionSelector(): - '''Region selection interface''' - - __utilityname__: str = "default" - - def __init__(self): - """ - constructor - """ - - def get_capabilities(self) -> typing.Dict[str, str]: - """ - Get the features this selector supports - """ - return { - GSCapabilities.WINDOW_SELECTION: self.__utilityname__, - GSCapabilities.REGION_SELECTION: self.__utilityname__, - GSCapabilities.REUSE_REGION: self.__utilityname__ - } - - def region_select(self) -> typing.Tuple[int, int, int, int]: - """ - Select an arbitrary region of the screen - - Returns: - (x top left, y top left, x bottom right, y bottom right) - """ - raise SelectionError("Not implemented") - - def window_select(self) -> typing.Tuple[int, int, int, int]: - """ - Selects a window from the screen - - Returns: - (x top left, y top left, x bottom right, y bottom right) - """ - raise SelectionError("Not implemented") - - @staticmethod - def can_run() -> bool: - """ - Returns whether this is capable of running. - """ - return False - - def _get_boundary_interactive(self, params: typing.List[str] - ) -> typing.Tuple[int, int, int, int]: - """ - Runs the selector and returns the parsed output. This accepts a list - that will be passed directly to subprocess.Popen and expects the - utility to be capable of returning a string parseable by _parse_selection_output. - """ - try: - with subprocess.Popen( - params, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) as selector_process: - - try: - stdout, stderr = selector_process.communicate(timeout=60) - except subprocess.TimeoutExpired: - selector_process.kill() - #pylint: disable=raise-missing-from - raise SelectionExecError(f"{params[0]} selection timed out") - - return_code = selector_process.returncode - - if return_code != 0: - selector_error = stderr.decode("UTF-8") - - if "cancelled" in selector_error: - raise SelectionCancelled("Selection was cancelled") - - raise SelectionExecError(selector_error) - - selector_output = stdout.decode("UTF-8").strip().split("\n") - - return self._parse_selection_output(selector_output) - except OSError: - #pylint: disable=raise-missing-from - raise SelectionExecError(f"{params[0]} was not found") #from exception - - def _parse_selection_output(self, region_output: typing.List[str] - ) -> typing.Tuple[int, int, int, int]: - ''' - Parses output from a region selection tool in the format - X=%x,Y=%y,W=%w,H=%h OR X=%x\nY=%x\nW=%w\nH=%h. - - Returns a tuple of the X and Y coordinates of the corners: - (X top left, Y top left, X bottom right, Y bottom right) - ''' - region_parsed = {} - # We iterate through the output so we're not reliant - # on the order or number of lines in the output - for line in region_output: - for comma_split in line.split(","): - if '=' in comma_split: - spl = comma_split.split("=") - region_parsed[spl[0]] = int(spl[1]) - - # (left, upper, right, lower) - try: - crop_box = ( - region_parsed['X'], - region_parsed['Y'], - region_parsed['X'] + region_parsed['W'], - region_parsed['Y'] + region_parsed['H'] - ) - except KeyError: - #pylint: disable=raise-missing-from - raise SelectionParseError("Unexpected output") #from exception - - return crop_box - - def __repr__(self) -> str: - return f'{self.__class__.__name__}()' +__all__ = [ + "RegionSelector", + "SelectorFactory", +] diff --git a/src/gscreenshot/selector/exceptions.py b/src/gscreenshot/selector/exceptions.py new file mode 100644 index 0000000..1003f9e --- /dev/null +++ b/src/gscreenshot/selector/exceptions.py @@ -0,0 +1,14 @@ +class SelectionError(BaseException): + '''Generic selection error''' + +class SelectionExecError(BaseException): + '''Error executing selector''' + +class SelectionParseError(BaseException): + '''Error parsing selection output''' + +class SelectionCancelled(BaseException): + '''Selection cancelled error''' + +class NoSupportedSelectorError(BaseException): + '''No region selection tool available''' diff --git a/src/gscreenshot/selector/factory.py b/src/gscreenshot/selector/factory.py index f09fc1a..8cf1854 100644 --- a/src/gscreenshot/selector/factory.py +++ b/src/gscreenshot/selector/factory.py @@ -3,11 +3,11 @@ ''' import typing -from gscreenshot.selector.slop import Slop -from gscreenshot.selector.slurp import Slurp -from gscreenshot.selector import NoSupportedSelectorError -from gscreenshot.selector import RegionSelector from gscreenshot.util import session_is_wayland +from .exceptions import NoSupportedSelectorError +from .region_selector import RegionSelector +from .slop import Slop +from .slurp import Slurp class SelectorFactory(object): @@ -16,11 +16,11 @@ class SelectorFactory(object): def __init__(self, screenselector:typing.Optional[RegionSelector]=None): self.screenselector:typing.Optional[RegionSelector] = screenselector self.xorg_selectors = [ - Slop + Slop, ] self.wayland_selectors = [ - Slurp + Slurp, ] self.selectors:list = [] diff --git a/src/gscreenshot/selector/region_selector.py b/src/gscreenshot/selector/region_selector.py new file mode 100644 index 0000000..dac81e1 --- /dev/null +++ b/src/gscreenshot/selector/region_selector.py @@ -0,0 +1,124 @@ +import subprocess +import typing +from gscreenshot.util import GSCapabilities +from .exceptions import SelectionError, SelectionExecError, SelectionCancelled, SelectionParseError + + +class RegionSelector(): + '''Region selection interface''' + + __utilityname__: str = "default" + + def __init__(self): + """ + constructor + """ + + def get_capabilities(self) -> typing.Dict[str, str]: + """ + Get the features this selector supports + """ + return { + GSCapabilities.WINDOW_SELECTION: self.__utilityname__, + GSCapabilities.REGION_SELECTION: self.__utilityname__, + GSCapabilities.REUSE_REGION: self.__utilityname__ + } + + def region_select(self) -> typing.Tuple[int, int, int, int]: + """ + Select an arbitrary region of the screen + + Returns: + (x top left, y top left, x bottom right, y bottom right) + """ + raise SelectionError("Not implemented") + + def window_select(self) -> typing.Tuple[int, int, int, int]: + """ + Selects a window from the screen + + Returns: + (x top left, y top left, x bottom right, y bottom right) + """ + raise SelectionError("Not implemented") + + @staticmethod + def can_run() -> bool: + """ + Returns whether this is capable of running. + """ + return False + + def _get_boundary_interactive(self, params: typing.List[str] + ) -> typing.Tuple[int, int, int, int]: + """ + Runs the selector and returns the parsed output. This accepts a list + that will be passed directly to subprocess.Popen and expects the + utility to be capable of returning a string parseable by _parse_selection_output. + """ + try: + with subprocess.Popen( + params, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) as selector_process: + + try: + stdout, stderr = selector_process.communicate(timeout=60) + except subprocess.TimeoutExpired: + selector_process.kill() + #pylint: disable=raise-missing-from + raise SelectionExecError(f"{params[0]} selection timed out") + + return_code = selector_process.returncode + + if return_code != 0: + selector_error = stderr.decode("UTF-8") + + if "cancelled" in selector_error: + raise SelectionCancelled("Selection was cancelled") + + raise SelectionExecError(selector_error) + + selector_output = stdout.decode("UTF-8").strip().split("\n") + + return self._parse_selection_output(selector_output) + except OSError: + #pylint: disable=raise-missing-from + raise SelectionExecError(f"{params[0]} was not found") #from exception + + def _parse_selection_output(self, region_output: typing.List[str] + ) -> typing.Tuple[int, int, int, int]: + ''' + Parses output from a region selection tool in the format + X=%x,Y=%y,W=%w,H=%h OR X=%x\nY=%x\nW=%w\nH=%h. + + Returns a tuple of the X and Y coordinates of the corners: + (X top left, Y top left, X bottom right, Y bottom right) + ''' + region_parsed = {} + # We iterate through the output so we're not reliant + # on the order or number of lines in the output + for line in region_output: + for comma_split in line.split(","): + if '=' in comma_split: + spl = comma_split.split("=") + region_parsed[spl[0]] = int(spl[1]) + + # (left, upper, right, lower) + try: + crop_box = ( + region_parsed['X'], + region_parsed['Y'], + region_parsed['X'] + region_parsed['W'], + region_parsed['Y'] + region_parsed['H'] + ) + except KeyError: + #pylint: disable=raise-missing-from + raise SelectionParseError("Unexpected output") #from exception + + return crop_box + + def __repr__(self) -> str: + return f'{self.__class__.__name__}()' diff --git a/src/gscreenshot/selector/slop.py b/src/gscreenshot/selector/slop.py index 615e001..3d9a8dc 100644 --- a/src/gscreenshot/selector/slop.py +++ b/src/gscreenshot/selector/slop.py @@ -2,8 +2,8 @@ Wrapper for the slop screen selector utility ''' import typing -from gscreenshot.selector import RegionSelector from gscreenshot.util import find_executable +from .region_selector import RegionSelector class Slop(RegionSelector): diff --git a/src/gscreenshot/selector/slurp.py b/src/gscreenshot/selector/slurp.py index 0a2a117..ad6a922 100644 --- a/src/gscreenshot/selector/slurp.py +++ b/src/gscreenshot/selector/slurp.py @@ -3,8 +3,8 @@ ''' from time import sleep import typing -from gscreenshot.selector import RegionSelector from gscreenshot.util import find_executable, GSCapabilities +from .region_selector import RegionSelector class Slurp(RegionSelector): diff --git a/test/gscreenshot/screenshooter/test_screenshooter.py b/test/gscreenshot/screenshooter/test_screenshooter.py index 2096ef8..0ba932b 100644 --- a/test/gscreenshot/screenshooter/test_screenshooter.py +++ b/test/gscreenshot/screenshooter/test_screenshooter.py @@ -2,7 +2,7 @@ from unittest.mock import Mock import mock -from gscreenshot.selector import SelectionCancelled, SelectionParseError +from gscreenshot.selector.exceptions import SelectionCancelled, SelectionParseError from src.gscreenshot.screenshooter import Screenshooter @@ -68,9 +68,9 @@ def test_grab_window(self): self.screenshooter.grab_window_() self.assertIsNotNone(self.screenshooter.image) - @mock.patch('src.gscreenshot.screenshooter.subprocess.check_output') - @mock.patch('src.gscreenshot.screenshooter.PIL') - @mock.patch('src.gscreenshot.screenshooter.os') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.subprocess.check_output') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.PIL') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.os') def test_call_screenshooter_success(self, mock_os, mock_pil, mock_subprocess): success = self.screenshooter._call_screenshooter('potato', ['pancake']) mock_subprocess.assert_called_once_with( @@ -78,9 +78,9 @@ def test_call_screenshooter_success(self, mock_os, mock_pil, mock_subprocess): ) self.assertTrue(success) - @mock.patch('src.gscreenshot.screenshooter.subprocess.check_output') - @mock.patch('src.gscreenshot.screenshooter.PIL') - @mock.patch('src.gscreenshot.screenshooter.os') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.subprocess.check_output') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.PIL') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.os') def test_call_screenshooter_subprocess_no_params(self, mock_os, mock_pil, mock_subprocess): mock_subprocess.side_effect = OSError() success = self.screenshooter._call_screenshooter('potato') @@ -89,9 +89,9 @@ def test_call_screenshooter_subprocess_no_params(self, mock_os, mock_pil, mock_s ) self.assertFalse(success) - @mock.patch('src.gscreenshot.screenshooter.subprocess.check_output') - @mock.patch('src.gscreenshot.screenshooter.PIL') - @mock.patch('src.gscreenshot.screenshooter.os') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.subprocess.check_output') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.PIL') + @mock.patch('src.gscreenshot.screenshooter.screenshooter.os') def test_call_screenshooter_subprocess_error(self, mock_os, mock_pil, mock_subprocess): mock_subprocess.side_effect = OSError() success = self.screenshooter._call_screenshooter('potato', ['pancake']) diff --git a/test/gscreenshot/selector/test_selector.py b/test/gscreenshot/selector/test_selector.py index 445f5e4..21b3911 100644 --- a/test/gscreenshot/selector/test_selector.py +++ b/test/gscreenshot/selector/test_selector.py @@ -1,10 +1,5 @@ import unittest -from unittest.mock import Mock -import mock -from PIL import Image -from PIL import ImageChops -from gscreenshot.selector import SelectionCancelled, SelectionParseError from src.gscreenshot.selector import RegionSelector\