scottwernervt/clipmanager

View on GitHub
clipmanager/ui/mainwindow.py

Summary

Maintainability
D
2 days
Test Coverage
import datetime
import logging
import zlib

from PySide.QtCore import (
    QDateTime,
    QMimeData,
    QModelIndex,
    QTextCodec,
    QTextEncoder,
    Qt,
    Signal,
    Slot,
)
from PySide.QtGui import (
    QCursor,
    QDesktopWidget,
    QGridLayout,
    QItemSelectionModel,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QSystemTrayIcon,
    QWidget,
)

from clipmanager import __title__, hotkey, owner, paste
from clipmanager.clipboard import ClipboardManager
from clipmanager.database import Database
from clipmanager.defs import MIME_SUPPORTED
from clipmanager.models import DataSqlTableModel, MainSqlTableModel
from clipmanager.settings import Settings
from clipmanager.ui.dialogs.preview import PreviewDialog
from clipmanager.ui.dialogs.settings import SettingsDialog
from clipmanager.ui.historylist import HistoryListView
from clipmanager.ui.icons import get_icon
from clipmanager.ui.searchedit import SearchEdit, SearchFilterProxyModel
from clipmanager.ui.systemtray import SystemTrayIcon
from clipmanager.utils import format_title, truncate_lines

logger = logging.getLogger(__name__)


class MainWindow(QMainWindow):
    """Main window container for main widget.

    :param minimize: True, minimize to system tray, False, bring to front.
    :type minimize: bool
    """

    def __init__(self, minimize=False):
        super(MainWindow, self).__init__()

        self.setWindowTitle(__title__)
        self.setWindowIcon(get_icon('clipmanager.ico'))

        # hide minimize and maximize in window title
        self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowCloseButtonHint)

        if not QSystemTrayIcon.isSystemTrayAvailable():
            QMessageBox.critical(
                self,
                'System Tray',
                'Cannot find a system tray.'
            )

        self.system_tray = SystemTrayIcon(self)
        self.system_tray.activated.connect(self.system_tray_activate)
        self.system_tray.show()

        self.settings = Settings()
        logger.info(self.settings.filename())

        self.main_widget = MainWidget(self)
        self.setCentralWidget(self.main_widget)

        # Return OS specific global hot key binder and set it
        self.hotkey = hotkey.initialize()
        self.paste = paste.initialize()

        if not minimize:
            self.toggle_window()

        self.register_hot_key()

        self.system_tray.toggle_window.connect(self.toggle_window)
        self.system_tray.open_settings.connect(self.open_settings)

        self.main_widget.open_settings.connect(self.open_settings)
        self.main_widget.paste_clipboard.connect(self.paste_clipboard)

    def closeEvent(self, event):
        """Capture close event and hide main window.

        Hide main window instead of exiting. Only method to quit is from right
        click menu on system tray icon or view widget.

        :param event: Exit event signal from user clicking "close".
        :type event: QCloseEvent

        :return: None
        :rtype: None
        """
        event.ignore()

        self.settings.set_window_pos(self.pos())
        self.settings.set_window_size(self.size())

        self.hide()

    def register_hot_key(self):
        """Helper function to bind global hot key to OS specific binder class.

        If binding fails then display a message in system tray notification
        tray.
        """
        key_sequence = self.settings.get_global_hot_key()  # Ctrl+Shift+h
        if key_sequence:
            self.hotkey.unregister(winid=self.winId())
            return self.hotkey.register(key_sequence, self.toggle_window,
                                        self.winId())
        else:
            self.system_tray.showMessage(
                'Global Hot Key',
                'Failed to bind global hot key {}.'.format(hotkey),
                icon=QSystemTrayIcon.Warning,
                msecs=10000
            )
            return False

    def destroy(self):
        """Perform cleanup before exiting the application.

        :return: None
        :rtype: None
        """
        self.main_widget.destroy()

        if self.hotkey:
            self.hotkey.unregister(winid=self.winId())
            self.hotkey.stop()

        self.settings.set_window_pos(self.pos())
        self.settings.set_window_size(self.size())
        self.settings.sync()

    @Slot(QSystemTrayIcon.ActivationReason)
    def system_tray_activate(self, activation_reason):
        """Toggle window when system tray icon is clicked.

        :param activation_reason: Clicked or double clicked.
        :type activation_reason: QSystemTrayIcon.ActivationReason.Trigger

        :return: None
        :rtype: None
        """
        if activation_reason in (QSystemTrayIcon.Trigger,
                                 QSystemTrayIcon.DoubleClick):
            self.toggle_window()

    @Slot()
    def open_settings(self):
        """Launch settings dialog.

        Before opening, unbind global hot key and rebind after dialog is closed.
        Model view is also updated to reflect lines to display.

        :return:
        :rtype:
        """
        # Windows allow's the user to open extra settings dialogs from system
        # tray menu even though dialog is modal
        self.hotkey.unregister(winid=self.winId())

        settings_dialog = SettingsDialog()
        settings_dialog.exec_()

        self.setCursor(Qt.BusyCursor)

        # Attempt to set new hot key
        self.register_hot_key()

        self.main_widget.main_model.select()
        self.unsetCursor()

    @Slot()
    def toggle_window(self):
        """Show and hide main window.

        If visible, then hide the window. If not visible, then open window
        based on position settings at: mouse cursor, system tray, or last
        position. Adjust's the window position based on desktop dimensions to
        prevent main window going off screen.

        :return: None
        :rtype: None
        """
        window_size = self.settings.get_window_size()

        # Hide window if visible and leave function
        if self.isVisible():
            self.settings.set_window_pos(self.pos())
            self.settings.set_window_size(self.size())
            self.hide()
        else:
            # Desktop number based on cursor
            desktop = QDesktopWidget()

            # Determine global coordinates by summing screen(s) coordinates
            x_max = 0
            y_max = 999999
            for screen in range(0, desktop.screenCount()):
                x_max += desktop.availableGeometry(screen).width()

                y_screen = desktop.availableGeometry(screen).height()
                if y_screen < y_max:
                    y_max = y_screen

            # Minimum x and y screen coordinates
            x_min, y_min, __, __ = desktop.availableGeometry().getCoords()

            open_window_at = self.settings.get_open_window_at()
            if open_window_at == 2:  # 2: System tray
                x = self.system_tray.geometry().x()
                y = self.system_tray.geometry().y()
            elif open_window_at == 1:  # 1: Last position
                x = self.settings.get_window_pos().x()
                y = self.settings.get_window_pos().y()
            else:  # 0: At mouse cursor
                x = QCursor().pos().x()
                y = QCursor().pos().y()

            # Readjust window's position if offscreen
            if x < x_min:
                x = x_min
            elif x + window_size.width() > x_max:
                x = x_max - window_size.width()

            if y < y_min:
                y = y_min
            elif y + window_size.height() > y_max:
                y = y_max - window_size.height()

            # Clear search box from last interaction
            if len(self.main_widget.search_box.text()) != 0:
                self.main_widget.search_box.clear()

            # Reposition and resize the main window
            self.move(x, y)
            self.resize(window_size.width(), window_size.height())

            self.show()
            self.activateWindow()
            self.main_widget.check_selection()

    @Slot()
    def paste_clipboard(self):
        self.paste()


class MainWidget(QWidget):
    """Main widget container for main window."""
    open_settings = Signal()
    paste_clipboard = Signal()

    def __init__(self, parent=None):
        super(MainWidget, self).__init__(parent)

        self.parent = parent

        self.settings = Settings()

        self.database = Database(self)
        self.database.create_tables()

        self.ignore_created = False

        self.clipboard_manager = ClipboardManager(self)
        self.window_owner = owner.initialize()

        self.history_view = HistoryListView(self)

        self.main_model = MainSqlTableModel(self)
        self.data_model = DataSqlTableModel(self)

        self.search_proxy = SearchFilterProxyModel(self)
        self.search_proxy.setSourceModel(self.main_model)

        self.history_view.setModel(self.search_proxy)
        self.history_view.setModelColumn(self.main_model.TITLE_SHORT)

        self.search_box = SearchEdit(self.history_view, self.search_proxy)

        settings_button = QPushButton(self)
        settings_button.setIcon(get_icon('preferences-system.png'))
        settings_button.setToolTip('Settings...')
        settings_button.clicked.connect(self.emit_open_settings)

        layout = QGridLayout(self)

        layout.addWidget(self.search_box, 0, 0)
        layout.addWidget(settings_button, 0, 1)
        layout.addWidget(self.history_view, 1, 0, 1, 2)

        self.setLayout(layout)

        self.clipboard_manager.new_item.connect(self.new_item)

        self.search_box.returnPressed.connect(self.set_clipboard)
        self.search_box.textChanged.connect(
            self.search_proxy.setFilterFixedString)
        self.search_box.textChanged.connect(self.check_selection)

        self.history_view.set_clipboard.connect(self.set_clipboard)
        self.history_view.open_preview.connect(self.open_preview)

    def create_item_title(self, mime_data):
        """Create full title from clipboard mime data.

        Extract a title from QMimeData using urls, html, or text.

        :param mime_data:
        :type mime_data: QMimeData

        :return: Full title or None if it did not have any text/html/url.
        :rtype: str or None
        """
        if mime_data.hasUrls():
            urls = [url.toString() for url in mime_data.urls()]
            return 'Copied File(s):\n' + '\n'.join(urls)
        elif mime_data.hasText():
            return mime_data.text()
        elif mime_data.hasHtml():  # last resort
            return mime_data.html()
        else:
            self.parent.system_tray.showMessage(
                'Clipboard',
                'Failed to get clipboard mime data formats.',
                icon=QSystemTrayIcon.Warning,
                msecs=5000
            )
            return None

    def get_item_checksum(self, mime_data):
        """Calculate CRC checksum based on urls, html, or text.

        :param mime_data: Data from clipboard.
        :type mime_data: QMimeData

        :return: CRC32 checksum.
        :rtype: int
        """
        if mime_data.hasUrls():
            checksum_str = str(mime_data.urls())
        elif mime_data.hasHtml():
            checksum_str = mime_data.html()
        elif mime_data.hasText():
            checksum_str = mime_data.text()
        else:
            self.parent.system_tray.showMessage(
                'Clipboard',
                'Failed to get clipboard text/html/urls.',
                icon=QSystemTrayIcon.Warning,
                msecs=5000
            )
            return None

        # encode unicode characters for crc library
        codec = QTextCodec.codecForName('UTF-8')
        encoder = QTextEncoder(codec)
        byte_array = encoder.fromUnicode(checksum_str)  # QByteArray

        checksum = zlib.crc32(byte_array)
        return checksum

    def destroy(self):
        self.main_model.submitAll()
        self.database.close()

    def find_duplicate(self, checksum):
        """Checks for a duplicate row in Main table.

        :param checksum: CRC32 string to search for.
        :type checksum: int

        :return: True if duplicate found and False if not found.
        :rtype: bool
        """
        for row in range(self.main_model.rowCount()):
            source_index = self.main_model.index(row, self.main_model.CHECKSUM)
            checksum_source = self.main_model.data(source_index)

            # update row created_at timestamp
            if str(checksum) == str(checksum_source):
                self.main_model.setData(
                    self.main_model.index(row, self.main_model.CREATED_AT),
                    QDateTime.currentMSecsSinceEpoch()
                )
                self.main_model.submitAll()
                return True

        return False

    def purge_expired_entries(self):
        """Remove entries that have expired.

        Starting at the bottom of the list, compare each item's date to user
        set expire in X days. If item is older than setting, remove it from
        database.

        :return: None
        :rtype: None
        """
        expire_at = self.settings.get_expire_value()
        if int(expire_at) == 0:
            return

        entries = range(0, self.main_model.rowCount())
        entries.reverse()  # sort by oldest

        for row in entries:
            index = self.main_model.index(row, self.main_model.CREATED_AT)
            created_at = self.main_model.data(index)

            # Convert from ms to s
            time = datetime.datetime.fromtimestamp(created_at / 1000)
            today = datetime.datetime.today()
            delta = today - time

            if delta.days > expire_at:
                index = self.main_model.index(row, self.main_model.ID)
                parent_id = self.main_model.data(index)

                self.data_model.delete([parent_id])
                self.main_model.removeRow(row)
            else:
                break

        self.main_model.submitAll()

    def purge_max_entries(self):
        """Remove extra entries.

        Count total number of items in history, and if greater than user
        setting for maximum entries, delete_item them.

        :return: None
        :rtype: None
        """
        max_entries = self.settings.get_max_entries_value()
        if max_entries == 0:
            return

        row_count = self.main_model.rowCount() + 1

        if row_count > max_entries:
            for row in range(max_entries - 1, row_count):
                index_id = self.main_model.index(row, self.main_model.ID)
                parent_id = self.main_model.data(index_id)

                self.data_model.delete(parent_id)
                self.main_model.removeRow(row)

        self.main_model.submitAll()

    @Slot(str)
    def check_selection(self):
        """Prevent user selection from disappearing during a proxy filter.

        :return: None
        :rtype: None
        """
        selection_model = self.history_view.selectionModel()
        indexes = selection_model.selectedIndexes()

        if not indexes:
            index = self.search_proxy.index(0, self.main_model.TITLE_SHORT)
            selection_model.select(index, QItemSelectionModel.Select)
            selection_model.setCurrentIndex(index,
                                            QItemSelectionModel.Select)

    @Slot(QMimeData)
    def new_item(self, mime_data):
        """Append clipboard contents to database.

        :param mime_data: Clipboard contents mime data
        :type mime_data: QMimeData

        :return: True, if successfully added.
        :rtype: bool
        """
        if self.settings.get_disconnect():
            return False
        elif self.ignore_created:
            self.ignore_created = False
            return False

        # Check if process that set clipboard is on exclude list
        window_names = self.window_owner()
        logger.debug('%s', window_names)

        ignore_list = self.settings.get_exclude().lower().split(';')
        if any(str(i) in window_names for i in ignore_list):
            logger.info('Ignoring copy from application.')
            return False

        title = self.create_item_title(mime_data)
        if not title:
            self.parent.system_tray.showMessage(
                'Clipboard',
                'Failed to get clipboard contents.',
                icon=QSystemTrayIcon.Warning,
                msecs=5000
            )
            return None

        title_short = format_title(title)
        title_short = truncate_lines(title_short,
                                     self.settings.get_lines_to_display())
        created_at = QDateTime.currentMSecsSinceEpoch()

        checksum = self.get_item_checksum(mime_data)
        if checksum and self.find_duplicate(checksum):
            return None

        parent_id = self.main_model.create(title=title,
                                           title_short=title_short,
                                           checksum=checksum,
                                           created_at=created_at)

        for mime_format in MIME_SUPPORTED:
            if mime_data.hasFormat(mime_format):
                byte_data = mime_data.data(mime_format)
                self.data_model.create(parent_id, mime_format, byte_data)

        self.purge_max_entries()
        self.purge_expired_entries()

        # Highlight top item and then insert mime data
        self.main_model.select()  # Update view
        index = QModelIndex(
            self.history_view.model().index(0, self.main_model.TITLE_SHORT)
        )
        self.history_view.setCurrentIndex(index)

        return True

    @Slot(QModelIndex)
    def open_preview(self, selection_index):
        """"Open preview dialog for selected item.

        :param selection_index: Selected row index from history list view.
        :type selection_index: QModelIndex

        :return: None
        :rtype: None
        """
        source_index = self.search_proxy.mapToSource(selection_index)
        source_row = source_index.row()
        model_index = self.main_model.index(source_row, self.main_model.ID)
        parent_id = self.main_model.data(model_index)

        mime_data = QMimeData()
        for mime_format, byte_data in self.data_model.read(parent_id):
            mime_data.setData(mime_format, byte_data)

        preview_dialog = PreviewDialog(mime_data, parent=self)
        preview_dialog.exec_()
        del preview_dialog

    @Slot(QModelIndex)
    def set_clipboard(self, selection_index):
        """Set selection item to clipboard.

        :param selection_index:
        :type selection_index:

        :return:
        :rtype:
        """
        self.window().hide()
        self.ignore_created = True

        source_index = self.search_proxy.mapToSource(selection_index)
        source_row = source_index.row()
        model_index = self.main_model.index(source_row, self.main_model.ID)
        parent_id = self.main_model.data(model_index)

        mime_data = QMimeData()
        for mime_type, byte_data in self.data_model.read(parent_id):
            mime_data.setData(mime_type, byte_data)

        self.clipboard_manager.set_text(mime_data)

        if self.settings.get_send_paste():
            self.paste_clipboard.emit()

        self.main_model.setData(
            self.main_model.index(model_index.row(),
                                  self.main_model.CREATED_AT),
            QDateTime.currentMSecsSinceEpoch())
        self.main_model.submitAll()

    @Slot()
    def emit_open_settings(self):
        """Emit signal to open settings dialog.

        :return: None
        :rtype: None
        """
        self.open_settings.emit()