Source code for jsktoolbox.tktool.tools

# -*- coding: UTF-8 -*-
"""
Author:  Jacek 'Szumak' Kotlarski --<szumak@virthost.pl>
Created: 2024-10-05

Purpose: Provide multi-platform clipboard helpers that wrap GUI-specific APIs.

The module exposes per-platform tool adapters that normalise clipboard access,
allowing the higher-level tool to select an available backend at runtime.
"""

import ctypes
import os
import platform
import tkinter as tk
import time

from abc import ABC, abstractmethod
from inspect import currentframe
from typing import Callable, Optional, Union
from types import MethodType

from ..basetool import BData
from ..attribtool import ReadOnlyClass
from ..raisetool import Raise
from .base import TkBase


class _IClip(ABC):
    """Abstract clipboard interface.

    Defines the contract for platform-specific clipboard helpers that provide
    consistent get and set operations across environments.
    """

    @abstractmethod
    def get_clipboard(self) -> str:
        """Retrieve the current clipboard text.

        Implementations must return Unicode text or an empty string when the
        clipboard is empty or inaccessible.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Text currently stored on the clipboard.
        """

    @abstractmethod
    def set_clipboard(self, value: str) -> None:
        """Store text data in the clipboard.

        ### Arguments:
        * value: str - Text payload that should be placed on the clipboard.

        ### Returns:
        None - This method performs side effects only.
        """

    @property
    @abstractmethod
    def is_tool(self) -> bool:
        """Report whether the clipboard backend is available.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        bool - True when the backend is operational.

        ### Raises:
        * None: Availability checks do not raise exceptions.
        """


class _Keys(object, metaclass=ReadOnlyClass):
    """Immutable key container.

    Stores keyword constants and platform identifiers used by clipboard helpers.
    """

    COPY: str = "_copy_"
    DARWIN: str = "Darwin"
    LINUX: str = "Linux"
    MAC: str = "mac"
    NT: str = "nt"
    PASTE: str = "_paste_"
    POSIX: str = "posix"
    TOOL: str = "_tool_"
    WINDOWS: str = "Windows"


class _BClip(BData, _IClip):
    """Base clipboard storage helper.

    Provides default get/set implementations that dispatch to backend handlers
    collected by subclasses.
    """

    def get_clipboard(self) -> str:
        """Get clipboard content.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard text or an empty string when no backend is available.

        ### Raises:
        * None: Backend availability is verified before access.
        """
        if self.is_tool:
            return self._get_data(key=_Keys.PASTE)()  # type: ignore
        return ""

    def set_clipboard(self, value: str) -> None:
        """Set clipboard content.

        ### Arguments:
        * value: str - Text payload to push onto the clipboard.

        ### Returns:
        None - This method performs side effects only.

        ### Raises:
        * None: Backend availability is verified before access.
        """
        if self.is_tool:
            self._get_data(key=_Keys.COPY)(value)  # type: ignore

    @property
    def is_tool(self) -> bool:
        """Return True if the tool is available.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        bool - True when both copy and paste callbacks are registered.

        ### Raises:
        * None: Availability checks do not raise exceptions.
        """
        return (
            self._get_data(key=_Keys.COPY) is not None
            and self._get_data(key=_Keys.PASTE) is not None
        )


class _WinClip(_BClip):
    """Windows clipboard class.

    Provides ctypes-based access to the native Windows clipboard APIs.
    """

    _CF_UNICODETEXT: int = 13
    _GMEM_MOVEABLE: int = 0x0002
    _UNICODE_NULL: int = 2  # two bytes for UTF-16LE terminator

    def __init__(self) -> None:
        """Initialise clipboard helpers for the Windows platform.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        None - Performs backend registration side effects.

        ### Raises:
        * None: Failures are handled by skipping backend registration.
        """
        # https://stackoverflow.com/questions/101128/how-do-i-read-text-from-the-windows-clipboard-in-python

        if os.name == _Keys.NT or platform.system() == _Keys.WINDOWS:
            get_cb = self.__win_get_clipboard
            set_cb = self.__win_set_clipboard
            self._set_data(
                key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
            )
            self._set_data(
                key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
            )

    def __win_get_clipboard(self) -> str:
        """Return Unicode clipboard contents from the Windows clipboard.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard content decoded from UTF-16LE memory blocks.

        ### Raises:
        * RuntimeError: Raised when Windows clipboard APIs fail.
        """
        if not ctypes.windll.user32.OpenClipboard(0):  # type: ignore
            raise Raise.error(
                "Unable to open Windows clipboard.",
                RuntimeError,
                self._c_name,
                currentframe(),
            )
        try:
            handle = ctypes.windll.user32.GetClipboardData(self._CF_UNICODETEXT)  # type: ignore
            if not handle:
                return ""
            pointer = ctypes.windll.kernel32.GlobalLock(handle)  # type: ignore
            if not pointer:
                return ""
            try:
                text = ctypes.wstring_at(pointer)
                return text or ""
            finally:
                ctypes.windll.kernel32.GlobalUnlock(handle)  # type: ignore
        finally:
            ctypes.windll.user32.CloseClipboard()  # type: ignore

    def __win_set_clipboard(self, text: str) -> None:
        """Store Unicode clipboard data on Windows.

        ### Arguments:
        * text: str - Text payload encoded as UTF-16LE before storing.

        ### Returns:
        None - Performs clipboard update side effects.

        ### Raises:
        * MemoryError: Raised when global memory allocation fails.
        * RuntimeError: Raised when Windows clipboard APIs fail.
        """
        value = str(text)
        encoded = value.encode("utf-16-le")
        size = len(encoded) + self._UNICODE_NULL
        if not ctypes.windll.user32.OpenClipboard(0):  # type: ignore
            raise Raise.error(
                "Unable to open Windows clipboard.",
                RuntimeError,
                self._c_name,
                currentframe(),
            )
        try:
            ctypes.windll.user32.EmptyClipboard()  # type: ignore
            handle = ctypes.windll.kernel32.GlobalAlloc(self._GMEM_MOVEABLE, size)  # type: ignore
            if not handle:
                raise Raise.error(
                    "Unable to allocate global memory for clipboard.",
                    MemoryError,
                    self._c_name,
                    currentframe(),
                )
            pointer = ctypes.windll.kernel32.GlobalLock(handle)  # type: ignore
            if not pointer:
                ctypes.windll.kernel32.GlobalFree(handle)  # type: ignore
                raise Raise.error(
                    "Unable to lock global memory for clipboard.",
                    RuntimeError,
                    self._c_name,
                    currentframe(),
                )
            try:
                ctypes.memmove(pointer, encoded, len(encoded))  # type: ignore
                ctypes.memset(pointer + len(encoded), 0, self._UNICODE_NULL)  # type: ignore
            finally:
                ctypes.windll.kernel32.GlobalUnlock(handle)  # type: ignore
            ctypes.windll.user32.SetClipboardData(self._CF_UNICODETEXT, handle)  # type: ignore
        finally:
            ctypes.windll.user32.CloseClipboard()  # type: ignore


class _MacClip(_BClip):
    """macOS clipboard class.

    Wraps the pbcopy/pbpaste utilities to interoperate with the system clipboard.
    """

    def __init__(self) -> None:
        """Initialise the macOS clipboard backend.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        None - Performs backend registration side effects.

        ### Raises:
        * None: Backend remains unavailable if initialisation fails.
        """
        if os.name == _Keys.MAC or platform.system() == _Keys.DARWIN:
            get_cb = self.__mac_get_clipboard
            set_cb = self.__mac_set_clipboard
            self._set_data(
                key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
            )
            self._set_data(
                key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
            )

    def __mac_set_clipboard(self, text: str) -> None:
        """Set macOS clipboard data.

        ### Arguments:
        * text: str - Text payload forwarded to the `pbcopy` command.

        ### Returns:
        None - Performs clipboard update side effects.

        ### Raises:
        * None: Errors surface via the underlying shell command.
        """
        text = str(text)
        out_f: os._wrap_close = os.popen("pbcopy", "w")
        out_f.write(text)
        out_f.close()

    def __mac_get_clipboard(self) -> str:
        """Get macOS clipboard data.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard content produced by the `pbpaste` command.

        ### Raises:
        * None: Errors surface via the underlying shell command.
        """
        out_f: os._wrap_close = os.popen("pbpaste", "r")
        content: str = out_f.read()
        out_f.close()
        return content


class _XClip(_BClip):
    """X11 clipboard class.

    Integrates with the `xclip` command-line tool when it is available.
    """

    def __init__(self) -> None:
        """Initialise the xclip backend.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        None - Performs backend registration side effects.

        ### Raises:
        * None: Backend remains unavailable when the command is missing.
        """
        if os.name == _Keys.POSIX or platform.system() == _Keys.LINUX:
            if os.system("which xclip > /dev/null") == 0:
                get_cb = self.__xclip_get_clipboard
                set_cb = self.__xclip_set_clipboard
                self._set_data(
                    key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
                )
                self._set_data(
                    key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
                )

    def __xclip_set_clipboard(self, text: str) -> None:
        """Set xclip clipboard data.

        ### Arguments:
        * text: str - Text payload forwarded to the `xclip` command.

        ### Returns:
        None - Performs clipboard update side effects.

        ### Raises:
        * None: Errors surface via the underlying shell command.
        """
        text = str(text)
        out_f: os._wrap_close = os.popen("xclip -selection c", "w")
        out_f.write(text)
        out_f.close()

    def __xclip_get_clipboard(self) -> str:
        """Get xclip clipboard data.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard content produced by the `xclip` command.

        ### Raises:
        * None: Errors surface via the underlying shell command.
        """
        out_f: os._wrap_close = os.popen("xclip -selection c -o", "r")
        content: str = out_f.read()
        out_f.close()
        return content


class _XSel(_BClip):
    """X11 clipboard class.

    Integrates with the `xsel` command-line tool when it is available.
    """

    def __init__(self) -> None:
        """Initialise the xsel backend.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        None - Performs backend registration side effects.

        ### Raises:
        * None: Backend remains unavailable when the command is missing.
        """
        if os.name == _Keys.POSIX or platform.system() == _Keys.LINUX:
            if os.system("which xsel > /dev/null") == 0:
                get_cb = self.__xsel_get_clipboard
                set_cb = self.__xsel_set_clipboard
                self._set_data(
                    key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
                )
                self._set_data(
                    key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
                )

    def __xsel_set_clipboard(self, text: str) -> None:
        """Set xsel clipboard data.

        ### Arguments:
        * text: str - Text payload forwarded to the `xsel` command.

        ### Returns:
        None - Performs clipboard update side effects.

        ### Raises:
        * None: Errors surface via the underlying shell command.
        """
        text = str(text)
        out_f: os._wrap_close = os.popen("xsel -b -i", "w")
        out_f.write(text)
        out_f.close()

    def __xsel_get_clipboard(self) -> str:
        """Get xsel clipboard data.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard content produced by the `xsel` command.

        ### Raises:
        * None: Errors surface via the underlying shell command.
        """
        out_f: os._wrap_close = os.popen("xsel -b -o", "r")
        content: str = out_f.read()
        out_f.close()
        return content


class _GtkClip(_BClip):
    """Gtk clipboard class.

    Uses the Gtk clipboard API when the bindings are available.
    """

    def __init__(self) -> None:
        """Initialise the Gtk backend.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        None - Performs backend registration side effects.

        ### Raises:
        * None: Backend remains unavailable when Gtk cannot be imported.
        """
        try:
            import gtk  # type: ignore

            get_cb = self.__gtk_get_clipboard
            set_cb = self.__gtk_set_clipboard
            self._set_data(
                key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
            )
            self._set_data(
                key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
            )
        except Exception:
            pass

    def __gtk_get_clipboard(self) -> str:
        """Get GTK clipboard data.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard text retrieved via the Gtk clipboard.
        """
        return gtk.Clipboard().wait_for_text()  # type: ignore

    def __gtk_set_clipboard(self, text: str) -> None:
        """Set GTK clipboard data.

        ### Arguments:
        * text: str - Text payload to store via the Gtk clipboard.

        ### Returns:
        None - Performs clipboard update side effects.
        """
        global cb
        text = str(text)
        cb = gtk.Clipboard()  # type: ignore
        cb.set_text(text)
        cb.store()


class _QtClip(_BClip):
    """Qt clipboard class.

    Creates or reuses a QApplication and routes clipboard operations through Qt APIs.
    """

    __q_app = None
    __q_cb = None

    def __init__(self) -> None:
        """Initialise the Qt clipboard backend.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        None - Performs backend registration side effects.

        ### Raises:
        * None: Backend remains unavailable when Qt bindings cannot be imported.
        """
        try:
            # TODO: PyQt5
            # example: https://pythonprogramminglanguage.com/pyqt-clipboard/
            from PyQt5.QtCore import (
                QCoreApplication,
            )  # pyright: ignore[reportMissingImports]
            from PyQt5.QtWidgets import (
                QApplication,
            )  # pyright: ignore[reportMissingImports]
            from PyQt5.QtGui import QClipboard  # pyright: ignore[reportMissingImports]

            # QApplication is a singleton
            if not QApplication.instance():
                self.__q_app: Optional[Union[QApplication, QCoreApplication]] = (
                    QApplication([])  # pyright: ignore[reportRedeclaration]
                )
            else:
                self.__q_app = QApplication.instance()

            self.__q_cb: Optional[QClipboard] = (
                QApplication.clipboard()
            )  # pyright: ignore[reportRedeclaration]
            get_cb = self.__qt_get_clipboard
            set_cb = self.__qt_set_clipboard
            self._set_data(
                key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
            )
            self._set_data(
                key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
            )
        except Exception:
            try:
                from PyQt6.QtCore import QCoreApplication
                from PyQt6.QtWidgets import QApplication
                from PyQt6.QtGui import QClipboard

                # QApplication is a singleton
                if not QApplication.instance():
                    self.__q_app: Optional[Union[QApplication, QCoreApplication]] = (
                        QApplication([])
                    )
                else:
                    self.__q_app = QApplication.instance()

                self.__q_cb: Optional[QClipboard] = QApplication.clipboard()
                get_cb = self.__qt_get_clipboard
                set_cb = self.__qt_set_clipboard
                self._set_data(
                    key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
                )
                self._set_data(
                    key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
                )
            except Exception:
                pass

    def __qt_get_clipboard(self) -> str:
        """Get Qt clipboard data.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard text retrieved from the Qt clipboard instance.

        ### Raises:
        * None: Errors propagate from Qt when they occur.
        """
        if self.__q_cb:
            return str(self.__q_cb.text())
        return ""

    def __qt_set_clipboard(self, text: str) -> None:
        """Set Qt clipboard data.

        ### Arguments:
        * text: str - Text payload to store on the Qt clipboard.

        ### Returns:
        None - Performs clipboard update side effects.

        ### Raises:
        * None: Errors propagate from Qt when they occur.
        """
        if self.__q_cb:
            text = str(text)
            self.__q_cb.setText(text)


class _TkClip(_BClip, TkBase):
    """Tkinter-based clipboard helper with hidden root management.

    Owns a hidden Tk root window to synchronise clipboard operations without exposing UI elements.
    """

    __tw: Optional[tk.Tk] = None
    __owns_root: bool = False

    def __init__(self) -> None:
        """Initialise Tk clipboard access or mark as unavailable.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        None - Performs backend registration side effects.

        ### Raises:
        * None: Backend remains unavailable when Tk cannot initialise.
        """
        try:
            self.__tw = tk.Tk()
            self.__tw.withdraw()
            self.__owns_root = True
        except tk.TclError:
            self.__tw = None
            return

        if self.__tw:
            get_cb = self.__tkinter_get_clipboard
            set_cb = self.__tkinter_set_clipboard
            self._set_data(
                key=_Keys.COPY, value=set_cb, set_default_type=Optional[MethodType]
            )
            self._set_data(
                key=_Keys.PASTE, value=get_cb, set_default_type=Optional[MethodType]
            )

    def __del__(self) -> None:  # pragma: no cover - destructor depends on GC
        if self.__owns_root and self.__tw is not None:
            try:
                self.__tw.destroy()
            except tk.TclError:
                pass
            self.__tw = None

    def __tkinter_get_clipboard(self) -> str:
        """Return clipboard text via Tkinter APIs.

        ### Arguments:
        * None: No public arguments.

        ### Returns:
        str - Clipboard text retrieved from the Tkinter root or an empty string when unavailable.

        ### Raises:
        * None: Tkinter exceptions are handled internally.
        """
        if self.__tw is None:
            return ""
        try:
            return self.__tw.clipboard_get()  # type: ignore[no-any-return]
        except tk.TclError:
            return ""

    def __tkinter_set_clipboard(self, text: str) -> None:
        """Store clipboard text via Tkinter APIs.

        ### Arguments:
        * text: str - Text payload to place on the clipboard.

        ### Returns:
        None - Performs clipboard update side effects.

        ### Raises:
        * RuntimeError: Raised when Tkinter cannot set the clipboard content.
        """
        if self.__tw is None:
            return
        value = str(text)
        try:
            self.__tw.clipboard_clear()
            self.__tw.clipboard_append(value)
            self.__tw.update_idletasks()
            self.__tw.update()
            for _ in range(5):
                time.sleep(0.1)
                self.__tw.update()
                try:
                    if self.__tw.clipboard_get() == value:
                        break
                except tk.TclError:
                    continue
        except tk.TclError as exc:  # pragma: no cover - rare runtime failure
            raise Raise.error(
                f"Unable to set Tk clipboard content: {exc}",
                RuntimeError,
                self._c_name,
                currentframe(),
            )


[docs] class ClipBoard(BData): """System clipboard tool. Selects the first available platform backend and exposes copy/paste callables for clients. """ __error: str = ( "ClipBoard requires the xclip or the xsel command or gtk or PyQt4 module installed." )
[docs] def __init__(self) -> None: """Initialise the aggregated clipboard tool. ### Arguments: * None: No public arguments. ### Returns: None - Performs backend detection side effects. ### Raises: * None: Backend discovery failures are reported via messages. """ for tool in (_XClip(), _XSel(), _GtkClip(), _QtClip(), _WinClip(), _MacClip()): if tool.is_tool: self._set_data(key=_Keys.TOOL, value=tool) break if not self.is_tool: print( Raise.message( self.__error, self._c_name, currentframe(), ) )
@property def is_tool(self) -> bool: """Return True if the tool is available. ### Arguments: * None: No public arguments. ### Returns: bool - True when at least one backend is registered. ### Raises: * None: Availability checks do not raise exceptions. """ if self._get_data(key=_Keys.TOOL, default_value=None): tool: _IClip = self._get_data(key=_Keys.TOOL) # type: ignore return tool.is_tool return False @property def copy(self) -> Callable: """Return copy handler. ### Arguments: * None: No public arguments. ### Returns: Callable - Function that copies text to the system clipboard. ### Raises: * None: Missing backends fall back to a no-op lambda. """ if self.is_tool: return self._get_data(key=_Keys.TOOL).set_clipboard # type: ignore print( Raise.message( self.__error, self._c_name, currentframe(), ) ) return lambda: "" @property def paste(self) -> Callable: """Return paste handler. ### Arguments: * None: No public arguments. ### Returns: Callable - Function that retrieves text from the system clipboard. ### Raises: * None: Missing backends fall back to a no-op lambda. """ if self.is_tool: return self._get_data(key=_Keys.TOOL).get_clipboard # type: ignore print( Raise.message( self.__error, self._c_name, currentframe(), ) ) return lambda: ""
# #[EOF]#######################################################################