Creating a DropSpot App with Python and PyQt5

Dropspot.py in a nutshell: highlight and drag rather than copy and paste.

In this guide, we’ll build a desktop app called DropSpot using Python and PyQt5. This app lets users save text snippets by dragging and dropping them into a desktop window rather than copy & paste. It organizes the saved snippets, stores them in an SQLite database, and allows you to search, edit, and delete entries.

Double-click the window to open the saved drops.

py_core_python_dropspot

Let’s set up our environment and go through the code.

Setting Up a Virtual Environment

To keep the project’s dependencies organized, we’ll create a virtual environment. Here are the steps for both Windows and Linux:

Windows

Open a command prompt and enter:

cd C:\dropspot
python -m venv myenv

Activate the environment with:

myenv\Scripts\activate

Linux

In a terminal, navigate to your project directory and run:

cd ~/dropspot
python3 -m venv myenv

Activate it with:

source myenv/bin/activate

With the environment active, install PyQt5 and psutil, our essential dependencies:

pip install pyqt5 psutil

Now, we’re ready to start building the app.

Step 1: Database Setup with createdb.py

Our DropSpot app stores data in a local SQLite database called notepad_drops.db. We’ll use createdb.py to set up the database structure and provide a function to add new entries.

Code for createdb.py

import sqlite3
from datetime import datetime

def create_database():
    conn = sqlite3.connect("notepad_drops.db")
    cursor = conn.cursor()
    cursor.execute('''CREATE TABLE IF NOT EXISTS drops (
                        id INTEGER PRIMARY KEY,
                        content TEXT NOT NULL,
                        source TEXT,
                        timestamp TEXT NOT NULL
                    )''')
    conn.commit()
    conn.close()

def add_drop(content, source=None):
    conn = sqlite3.connect("notepad_drops.db")
    cursor = conn.cursor()
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    cursor.execute("INSERT INTO drops (content, source, timestamp) VALUES (?, ?, ?)", (content, source, timestamp))
    conn.commit()
    conn.close()

# Ensure the database is created
create_database()

# Example usage of add_drop
add_drop("This is a test drop", "Example Source")

Explanation

  • create_database(): Creates the database and a table called drops, with columns for idcontentsource, and timestamp.
  • add_drop(content, source=None): Inserts new entries into the database. If no source is provided, the default is set to None.

This script sets up the essential structure for our app to store snippets of text along with their source and timestamp.

Step 2: Building the Main Application with app.py

The main part of DropSpot is built in app.py. This code defines the main app window where users can drag and drop text, a view window to manage saved entries, and various supporting functions.

Code for app.py

Here’s an overview of the key functions and classes:

import sys
import re
import psutil
import ctypes
from ctypes import wintypes
from PyQt5.QtWidgets import (QApplication, QLabel, QMainWindow, QVBoxLayout, QWidget, QLineEdit,
                             QHBoxLayout, QPushButton, QScrollArea, QFrame, QInputDialog, QMessageBox)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon, QFont, QDesktopServices
from PyQt5.Qt import QUrl
import sqlite3
from datetime import datetime

This imports necessary modules for the app, including psutil and ctypes to capture the active window’s process.

The DropSpot Class

The DropSpot class represents the main app window where users drop text.

class DropSpot(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Notepad Drop Spot")
        self.setFixedSize(200, 200)
        self.setAcceptDrops(True)
        self.initUI()

    def initUI(self):
        self.label = QLabel("Drop Text Here", self)
        self.label.setAlignment(Qt.AlignCenter)
        self.setCentralWidget(self.label)
  • Constructor and initUI: Sets the window title, size, and a central label displaying “Drop Text Here.”

Handling Drag-and-Drop Events

    def dragEnterEvent(self, event):
        if event.mimeData().hasText():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event):
        text = event.mimeData().text()
        self.save_drop(text)
        self.label.setText("Text Saved!")
  • dragEnterEvent: Checks if the dropped item is text and accepts or ignores the drop.
  • dropEvent: Captures the dropped text and saves it using save_drop().

Capturing the Active Window

    def get_active_process_name(self):
        user32 = ctypes.WinDLL('user32', use_last_error=True)
        hwnd = user32.GetForegroundWindow()
        pid = wintypes.DWORD()
        user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
        
        try:
            process = psutil.Process(pid.value)
            return process.name()
        except psutil.NoSuchProcess:
            return "Unknown Process"
  • get_active_process_name(): Uses ctypes and psutil to capture the process name of the window in focus, storing this as the “source” in the database.

Viewing Saved Drops: The DropViewer Class

This class is responsible for displaying saved drops, providing search, edit, and delete functionalities.

Loading Drops

 def load_drops(self):
        self.scroll_content.deleteLater()
        self.scroll_content = QWidget()
        self.scroll_layout = QVBoxLayout(self.scroll_content)
        self.scroll_area.setWidget(self.scroll_content)

        conn = sqlite3.connect("notepad_drops.db")
        cursor = conn.cursor()
        search_query = f"%{self.search_bar.text()}%"
        cursor.execute("SELECT id, content, source, timestamp FROM drops WHERE content LIKE ? OR source LIKE ?", (search_query, search_query))
        rows = cursor.fetchall()
        conn.close()

        for record_id, content, source, timestamp in rows:
            item_layout = QHBoxLayout()
            content_display = self.format_text(content, source, timestamp)
            item_layout.addWidget(content_display)

            edit_button = QPushButton("Edit")
            edit_button.clicked.connect(lambda checked, record_id=record_id: self.edit_entry(record_id))
            item_layout.addWidget(edit_button)

            delete_button = QPushButton("Delete")
            delete_button.clicked.connect(lambda checked, record_id=record_id: self.delete_entry(record_id))
            item_layout.addWidget(delete_button)

            self.scroll_layout.addLayout(item_layout)

            separator = QFrame()
            separator.setFrameShape(QFrame.HLine)
            separator.setFrameShadow(QFrame.Sunken)
            self.scroll_layout.addWidget(separator)

load_drops: Retrieves saved entries from the database and displays them with buttons for editing and deletion. It includes a separator line for readability.

For reference, here is the entire app.py:

import sys
import re
import psutil  # Replacing win32gui with psutil
import ctypes
from ctypes import wintypes
from PyQt5.QtWidgets import (QApplication, QLabel, QMainWindow, QVBoxLayout, QWidget, QLineEdit,
                             QHBoxLayout, QPushButton, QScrollArea, QFrame, QInputDialog, QMessageBox)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon, QFont, QDesktopServices
from PyQt5.Qt import QUrl
import sqlite3
from datetime import datetime

class DropSpot(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Notepad Drop Spot")
        self.setFixedSize(200, 200)
        self.setAcceptDrops(True)
        self.initUI()

    def initUI(self):
        self.label = QLabel("Drop Text Here", self)
        self.label.setAlignment(Qt.AlignCenter)
        self.setCentralWidget(self.label)

    def dragEnterEvent(self, event):
        if event.mimeData().hasText():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event):
        text = event.mimeData().text()
        self.save_drop(text)
        self.label.setText("Text Saved!")

    def get_active_process_name(self):
        """Helper function to get the name of the currently active process."""
        user32 = ctypes.WinDLL('user32', use_last_error=True)
        hwnd = user32.GetForegroundWindow()
        
        # Get the process ID associated with the active window
        pid = wintypes.DWORD()
        user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
        
        # Get the process name using psutil
        try:
            process = psutil.Process(pid.value)
            return process.name()
        except psutil.NoSuchProcess:
            return "Unknown Process"

    def save_drop(self, content):
        source = self.get_active_process_name()  # Get active process name as source for db field
        conn = sqlite3.connect("notepad_drops.db")
        cursor = conn.cursor()
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        cursor.execute("INSERT INTO drops (content, source, timestamp) VALUES (?, ?, ?)", (content, source, timestamp))
        conn.commit()
        conn.close()

    def mouseDoubleClickEvent(self, event):
        self.open_viewer()

    def open_viewer(self):
        self.viewer = DropViewer()
        self.viewer.show()


class DropViewer(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Saved Drops")
        self.setGeometry(100, 100, 600, 400)
        self.layout = QVBoxLayout()
        self.setLayout(self.layout)
        
        self.search_bar = QLineEdit(self)
        self.search_bar.setPlaceholderText("Search...")
        self.search_bar.textChanged.connect(self.load_drops)  # Reload entries based on search input
        self.layout.addWidget(self.search_bar)
        
        self.scroll_area = QScrollArea(self)
        self.scroll_area.setWidgetResizable(True)
        self.scroll_content = QWidget()
        self.scroll_layout = QVBoxLayout(self.scroll_content)
        self.scroll_area.setWidget(self.scroll_content)
        self.layout.addWidget(self.scroll_area)
        
        self.load_drops()
        
    def clear_layout(self, layout):
        """ Helper function to clear all items from a layout. """
        while layout.count():
            item = layout.takeAt(0)
            widget = item.widget()
            if widget is not None:
                widget.deleteLater()

    def load_drops(self):
        # Clear existing scroll_content and scroll_layout
        self.scroll_content.deleteLater()
        
        # Recreate scroll_content and scroll_layout
        self.scroll_content = QWidget()
        self.scroll_layout = QVBoxLayout(self.scroll_content)
        self.scroll_area.setWidget(self.scroll_content)

        conn = sqlite3.connect("notepad_drops.db")
        cursor = conn.cursor()
        search_query = f"%{self.search_bar.text()}%"
        cursor.execute("SELECT id, content, source, timestamp FROM drops WHERE content LIKE ? OR source LIKE ?", (search_query, search_query))
        rows = cursor.fetchall()
        conn.close()

        for record_id, content, source, timestamp in rows:
            item_layout = QHBoxLayout()

            # Format content with hyperlink detection
            content_display = self.format_text(content, source, timestamp)
            item_layout.addWidget(content_display)

            # Edit Button
            edit_button = QPushButton("Edit")
            edit_button.clicked.connect(lambda checked, record_id=record_id: self.edit_entry(record_id))
            item_layout.addWidget(edit_button)

            # Delete Button
            delete_button = QPushButton("Delete")
            delete_button.clicked.connect(lambda checked, record_id=record_id: self.delete_entry(record_id))
            item_layout.addWidget(delete_button)

            # Add item layout to scroll layout
            self.scroll_layout.addLayout(item_layout)

            # Separator line
            separator = QFrame()
            separator.setFrameShape(QFrame.HLine)
            separator.setFrameShadow(QFrame.Sunken)
            self.scroll_layout.addWidget(separator)

    def format_text(self, content, source, timestamp):
        # Wrap text if over 100 characters
        formatted_content = content if len(content) <= 100 else '\n'.join([content[i:i+100] for i in range(0, len(content), 100)])
        label_text = f"{timestamp} - {formatted_content} ({source if source else 'No Source'})"

        label = QLabel(self)
        label.setWordWrap(True)
        label.setOpenExternalLinks(True)  # Make URLs clickable
        label.setFont(QFont("Arial", 10))

        # Enhanced URL pattern to capture more complex URLs
        url_pattern = r'(https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}(/[a-zA-Z0-9._~:/?#[\]@!$&\'()*+,;=%-]*)?)'

        # Replace matched URLs with clickable links
        formatted_label_text = re.sub(url_pattern, r'<a href="\1">\1</a>', label_text)

        label.setText(formatted_label_text)
        
        return label

    def edit_entry(self, record_id):
        conn = sqlite3.connect("notepad_drops.db")
        cursor = conn.cursor()
        cursor.execute("SELECT content FROM drops WHERE id = ?", (record_id,))
        old_content = cursor.fetchone()[0]
        conn.close()

        # Show input dialog to edit content
        new_content, ok = QInputDialog.getMultiLineText(self, "Edit Entry", "Edit content:", old_content)
        if ok and new_content:
            conn = sqlite3.connect("notepad_drops.db")
            cursor = conn.cursor()
            cursor.execute("UPDATE drops SET content = ? WHERE id = ?", (new_content, record_id))
            conn.commit()
            conn.close()
            self.load_drops()  # Refresh display to show updated entry

    def delete_entry(self, record_id):
        confirm = QMessageBox.question(self, "Delete Entry", "Are you sure you want to delete this entry?",
                                       QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if confirm == QMessageBox.Yes:
            conn = sqlite3.connect("notepad_drops.db")
            cursor = conn.cursor()
            cursor.execute("DELETE FROM drops WHERE id = ?", (record_id,))
            conn.commit()
            conn.close()
            self.load_drops()  # Refresh display

def main():
    app = QApplication(sys.argv)
    app.setWindowIcon(QIcon("dropicon.png"))
    drop_spot = DropSpot()
    drop_spot.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

Thank you for reading this article. We hope you found it helpful and informative. If you have any questions, or if you would like to suggest new Python code examples or topics for future tutorials/articles, please feel free to join and comment. Your feedback and suggestions are always welcome!

You can find the same tutorial on Medium.com.

Leave a Reply