├── .SRCINFO ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── PKGBUILD ├── README.rst ├── bin └── clipmanager ├── clipmanager.cer ├── clipmanager.desktop ├── clipmanager.install ├── clipmanager.iss ├── clipmanager.pfx ├── clipmanager.spec ├── clipmanager.version ├── clipmanager ├── __init__.py ├── app.py ├── clipboard.py ├── database.py ├── defs.py ├── hotkey │ ├── __init__.py │ ├── base.py │ ├── hook.py │ ├── win32.py │ └── x11.py ├── models.py ├── owner │ ├── __init__.py │ ├── win32.py │ └── x11.py ├── paste │ ├── __init__.py │ ├── win32.py │ └── x11.py ├── resources.py ├── settings.py ├── singleinstance.py ├── ui │ ├── __init__.py │ ├── dialogs │ │ ├── __init__.py │ │ ├── about.py │ │ ├── preview.py │ │ └── settings.py │ ├── historylist.py │ ├── icons.py │ ├── mainwindow.py │ ├── searchedit.py │ └── systemtray.py └── utils.py ├── data ├── application-exit.png ├── clipmanager.ico ├── clipmanager.png ├── document-print-preview.png ├── edit-paste.png ├── help-about.png ├── list-remove.png ├── preferences-system.png ├── resources.qrc └── search.png ├── pytest.ini ├── setup.py ├── setup_cxfreeze.py └── tests ├── __init__.py ├── conftest.py ├── helpers.py ├── test_clipboard.py ├── test_database.py ├── test_hotkey.py ├── test_models.py ├── test_owner.py ├── test_singleinstance.py ├── test_ui_dialogs.py ├── test_ui_historylist.py ├── test_ui_mainwindow.py ├── test_ui_systemtray.py └── test_utils.py /.SRCINFO: -------------------------------------------------------------------------------- 1 | pkgbase = clipmanager 2 | pkgdesc = Python Qt GUI clipboard manager 3 | pkgver = 0.5.0 4 | pkgrel = 1 5 | url = https://github.com/scottwernervt/clipmanager 6 | install = clipmanager.install 7 | arch = any 8 | license = BSD 9 | depends = python2 10 | depends = python2-setuptools 11 | depends = python2-pyside 12 | depends = python2-xlib 13 | optdepends = xdotool: paste into active window 14 | source = https://github.com/scottwernervt/clipmanager/archive/v0.5.0.tar.gz 15 | md5sums = 117fbd707f72659424ef221bd3bb1afc 16 | 17 | pkgname = clipmanager 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | var 11 | sdist 12 | develop-eggs 13 | .installed.cfg 14 | lib 15 | lib64 16 | __pycache__ 17 | testing 18 | MANIFEST 19 | .gnupg 20 | 21 | # Debian 22 | debian/clipmanager/* 23 | debian/clipmanager.debhelper.log 24 | debian/clipmanager.substvars 25 | 26 | # SQL databases 27 | *.db 28 | ### Qt template 29 | # C++ objects and libs 30 | 31 | *.slo 32 | *.lo 33 | *.o 34 | *.a 35 | *.la 36 | *.lai 37 | *.so 38 | *.dll 39 | *.dylib 40 | 41 | # Qt-es 42 | 43 | object_script.*.Release 44 | object_script.*.Debug 45 | *_plugin_import.cpp 46 | /.qmake.cache 47 | /.qmake.stash 48 | *.pro.user 49 | *.pro.user.* 50 | *.qbs.user 51 | *.qbs.user.* 52 | *.moc 53 | moc_*.cpp 54 | moc_*.h 55 | qrc_*.cpp 56 | ui_*.h 57 | *.qmlc 58 | *.jsc 59 | Makefile* 60 | *build-* 61 | 62 | # Qt unit tests 63 | target_wrapper.* 64 | 65 | # QtCreator 66 | 67 | *.autosave 68 | 69 | # QtCtreator Qml 70 | *.qmlproject.user 71 | *.qmlproject.user.* 72 | 73 | # QtCtreator CMake 74 | CMakeLists.txt.user* 75 | 76 | ### Python template 77 | # Byte-compiled / optimized / DLL files 78 | __pycache__/ 79 | *$py.class 80 | 81 | # C extensions 82 | 83 | # Distribution / packaging 84 | .Python 85 | build/ 86 | develop-eggs/ 87 | dist/ 88 | downloads/ 89 | eggs/ 90 | .eggs/ 91 | lib/ 92 | lib64/ 93 | parts/ 94 | sdist/ 95 | var/ 96 | wheels/ 97 | *.egg-info/ 98 | 99 | # PyInstaller 100 | # Usually these files are written by a python script from a template 101 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 102 | *.manifest 103 | 104 | # Installer logs 105 | pip-log.txt 106 | pip-delete-this-directory.txt 107 | 108 | # Unit test / coverage reports 109 | htmlcov/ 110 | .tox/ 111 | .coverage 112 | .coverage.* 113 | .cache 114 | nosetests.xml 115 | coverage.xml 116 | *.cover 117 | .hypothesis/ 118 | 119 | # Translations 120 | *.mo 121 | *.pot 122 | 123 | # Django stuff: 124 | *.log 125 | .static_storage/ 126 | .media/ 127 | local_settings.py 128 | 129 | # Flask stuff: 130 | instance/ 131 | .webassets-cache 132 | 133 | # Scrapy stuff: 134 | .scrapy 135 | 136 | # Sphinx documentation 137 | docs/_build/ 138 | 139 | # PyBuilder 140 | target/ 141 | 142 | # Jupyter Notebook 143 | .ipynb_checkpoints 144 | 145 | # pyenv 146 | .python-version 147 | 148 | # celery beat schedule file 149 | celerybeat-schedule 150 | 151 | # SageMath parsed files 152 | *.sage.py 153 | 154 | # Environments 155 | .env 156 | .venv 157 | env/ 158 | venv/ 159 | ENV/ 160 | env.bak/ 161 | venv.bak/ 162 | 163 | # Spyder project settings 164 | .spyderproject 165 | .spyproject 166 | 167 | # Rope project settings 168 | .ropeproject 169 | 170 | # mkdocs documentation 171 | /site 172 | 173 | # mypy 174 | .mypy_cache/ 175 | 176 | # pytest 177 | .pytest_cache 178 | 179 | # JetBrains 180 | .idea 181 | 182 | # signtool 183 | *.pvk 184 | 185 | # inno setup 186 | /src -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - '2.7' 5 | 6 | cache: 7 | - pip 8 | 9 | sudo: required 10 | 11 | before_install: 12 | - sudo apt-get -qq update 13 | # http://pyside.readthedocs.io/en/latest/building/linux.html 14 | - sudo apt-get install build-essential git cmake libqt4-dev libxml2-dev libxslt1-dev libqt4-sql-sqlite 15 | - sudo apt-get install xvfb 16 | 17 | install: 18 | - pip install -U pip setuptools wheel 19 | - pip install pyside pytest pytest-qt python-xlib 20 | 21 | script: 22 | - xvfb-run python setup.py test -a --verbose 23 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | Authors 3 | ******* 4 | 5 | Lead 6 | ==== 7 | 8 | * Scott Werner `@scottwernervt `_ 9 | 10 | Contributors 11 | ============ 12 | 13 | .. * 14 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | v0.5.0 (2018-05-03) 5 | +++++++++++++++++++ 6 | 7 | * Drop QtWebKit dependency. (`#13 `_) 8 | 9 | v0.4.0 (2018-02-27) 10 | +++++++++++++++++++ 11 | 12 | * Major code refactor, overhaul, and PEP8 / Pyside style applied. 13 | * Deleting entries is now faster. (`#7 `_) 14 | * Replace python-keybinder with Xlib calls. (`#3 `_). 15 | 16 | v0.3 (2013-06-24) 17 | +++++++++++++++++ 18 | 19 | * Imported from BitBucket repo. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Scott Werner 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | 3. All advertising materials mentioning features or use of this software 12 | must display the following acknowledgement: 13 | This product includes software developed by the Scott Werner. 14 | 4. Neither the name of the Scott Werner nor the 15 | names of its contributors may be used to endorse or promote products 16 | derived from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY SCOTT WERNER ''AS IS'' AND ANY 19 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL SCOTT WERNER BE LIABLE FOR ANY 22 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include clipmanager.desktop 2 | 3 | recursive-include tests * 4 | recursive-include data *.* 5 | 6 | prune build 7 | 8 | prune dist 9 | global-exclude *.pyc *.ini *.json .git* *.gnupg -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Scott Werner 2 | pkgname=clipmanager 3 | pkgver=0.5.0 4 | pkgrel=1 5 | pkgdesc="Python Qt GUI clipboard manager" 6 | arch=('any') 7 | url="https://github.com/scottwernervt/clipmanager" 8 | license=('BSD') 9 | depends=('python2' 'python2-setuptools' 'python2-pyside' 'python2-xlib') 10 | optdepends=('xdotool: paste into active window') 11 | install=$pkgname.install 12 | source=("https://github.com/scottwernervt/${pkgname}/archive/v${pkgver}.tar.gz") 13 | md5sums=('117fbd707f72659424ef221bd3bb1afc') 14 | 15 | package() { 16 | cd $pkgname-$pkgver 17 | 18 | python2 ./setup.py install --root="$pkgdir/" 19 | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 20 | } 21 | 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ClipManager 2 | =========== 3 | 4 | .. image:: https://travis-ci.org/scottwernervt/clipmanager.svg?branch=master 5 | :target: https://travis-ci.org/scottwernervt/clipmanager 6 | 7 | .. image:: https://img.shields.io/badge/license-BSD-blue.svg 8 | :target: /LICENSE 9 | 10 | .. image:: https://api.codeclimate.com/v1/badges/80e08076df90e2c5e23a/maintainability 11 | :target: https://codeclimate.com/github/scottwernervt/clipmanager/maintainability 12 | 13 | Cross-platform (Windows and Linux) GUI application to manage the system's 14 | clipboard history. 15 | 16 | .. image:: https://i.imgur.com/NSVFd3b.png 17 | :alt: Main application screenshot 18 | :target: https://i.imgur.com/NSVFd3b.png 19 | 20 | Requirements 21 | ------------ 22 | 23 | * Python 2.7 24 | * PySide 25 | * python-xlib (linux) or pywin32 (windows) 26 | * PyInstaller (optional: win32 executable) 27 | * Inno Setup (optional: win32 installer package) 28 | 29 | Installation 30 | ------------ 31 | 32 | **Arch Linux** 33 | 34 | `clipmanager `_ 35 | 36 | **Windows** 37 | 38 | * `clipmanager-setup-v0.5.0.exe `_ 39 | * `clipmanager-setup-v0.4.0.exe `_ 40 | 41 | 42 | Development 43 | ----------- 44 | 45 | **Application Icon** 46 | 47 | #. Navigate to `fa2png.io `_ 48 | #. Icon = ``feather-clipboard`` 49 | #. Color = ``#ececec`` 50 | #. Background = ``transparent`` 51 | #. Size = ``256px`` 52 | #. Padding = ``24px`` 53 | 54 | **Build Resources** 55 | 56 | ``pyside-rcc -o data/resource_rc.py clipmanager/resource.qrc`` 57 | 58 | **Package Arch AUR** 59 | 60 | .. code:: bash 61 | 62 | $ makepkg -g >> PKGBUILD 63 | $ namcap PKGBUILD 64 | $ makepkg -f 65 | $ namcap clipmanager--1-any.pkg.tar.xz 66 | $ makepkg -si 67 | $ makepkg --printsrcinfo > .SRCINFO 68 | 69 | $ git add PKGBUILD .SRCINFO 70 | $ git commit -m "useful commit message" 71 | $ git push 72 | 73 | **Package Win32 Executable** 74 | 75 | .. code:: bash 76 | 77 | > pyinstaller --noconfirm --clean clipmanager.spec 78 | > "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\signtool.exe" sign -f clipmanager.pfx -t http://timestamp.comodoca.com -p dist\clipmanager\clipmanager.exe 79 | > "C:\Program Files\Inno Setup 5\iscc.exe" "clipmanager.iss" 80 | > "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\signtool.exe" sign -f clipmanager.pfx -t http://timestamp.comodoca.com -p dist\clipmanager-setup-.exe 81 | 82 | Help 83 | ---- 84 | 85 | **PyWin32: DLL load failed on Python 3.4b1** 86 | 87 | `#661 DLL load failed on Python 3.4b1 `_ 88 | 89 | .. code:: bash 90 | 91 | > copy C:\Python27\lib\site-packages\pywin32_system32\py*.dll C:\Python27\lib\site-packages\win32 92 | 93 | **ClipManager is not using my GTK theme** 94 | 95 | .. code:: bash 96 | 97 | $ ln -s icon/theme/directory $HOME/.icons/hicolor 98 | 99 | Icons 100 | ----- 101 | 102 | * `ClipManager application icon `_ 103 | * `Menu icons `_ 104 | 105 | Inspiration 106 | ----------- 107 | 108 | * `Ditto Clipboard Manager `_ 109 | * `Glipper `_ 110 | * `Clipit `_ 111 | -------------------------------------------------------------------------------- /bin/clipmanager: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | """ 3 | ClipManager - Manage clipboard history 4 | 5 | Copyright (c) 2018, Scott Werner 6 | All rights reserved. 7 | """ 8 | 9 | if __name__ == '__main__': 10 | from clipmanager.app import main 11 | 12 | main() 13 | -------------------------------------------------------------------------------- /clipmanager.cer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/clipmanager.cer -------------------------------------------------------------------------------- /clipmanager.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=0.5.0 3 | Name=ClipManager 4 | GenericName=Clipboard manager 5 | Comment=Manage clipboard history 6 | Icon=clipmanager 7 | Terminal=false 8 | Type=Application 9 | Exec=clipmanager 10 | Categories=Utility; -------------------------------------------------------------------------------- /clipmanager.install: -------------------------------------------------------------------------------- 1 | post_install() { 2 | update-desktop-database -q 3 | } -------------------------------------------------------------------------------- /clipmanager.iss: -------------------------------------------------------------------------------- 1 | ; Script generated by the Inno Setup Script Wizard. 2 | ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! 3 | 4 | #define MyAppName "ClipManager" 5 | #define MyAppVersion "0.5.0" 6 | #define MyAppPublisher "Werner" 7 | #define MyAppURL "https://github.com/scottwernervt/clipmanager" 8 | #define MyAppExeName "clipmanager.exe" 9 | 10 | [Setup] 11 | ; NOTE: The value of AppId uniquely identifies this application. 12 | ; Do not use the same AppId value in installers for other applications. 13 | ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) 14 | AppId={{3B5A17D5-C21D-4701-9AF0-0B599D7AAB4B} 15 | AppName={#MyAppName} 16 | AppVersion={#MyAppVersion} 17 | AppVerName={#MyAppName} {#MyAppVersion} 18 | AppPublisher={#MyAppPublisher} 19 | AppPublisherURL={#MyAppURL} 20 | AppSupportURL={#MyAppURL} 21 | AppUpdatesURL={#MyAppURL} 22 | DefaultDirName={pf}\{#MyAppName} 23 | DisableProgramGroupPage=yes 24 | LicenseFile=LICENSE 25 | OutputDir=dist 26 | OutputBaseFilename=clipmanager-setup-v{#MyAppVersion} 27 | SetupIconFile=data\clipmanager.ico 28 | Compression=lzma 29 | SolidCompression=yes 30 | ; http://www.jrsoftware.org/iskb.php?startup 31 | PrivilegesRequired=admin 32 | ; http://www.jrsoftware.org/iskb.php?mutexsessions 33 | AppMutex=Werner.ClipManager,Global\Werner.ClipManager 34 | UninstallDisplayIcon={app}\{#MyAppExeName} 35 | SetupLogging=yes 36 | 37 | [Languages] 38 | Name: "english"; MessagesFile: "compiler:Default.isl" 39 | 40 | [Tasks] 41 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked 42 | 43 | [Files] 44 | Source: "dist\clipmanager\clipmanager.exe"; DestDir: "{app}"; Flags: ignoreversion 45 | Source: "dist\clipmanager\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs 46 | ; NOTE: Don't use "Flags: ignoreversion" on any shared system files 47 | 48 | [Icons] 49 | Name: "{commonprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" 50 | Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon 51 | 52 | [Run] 53 | Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent 54 | 55 | [UninstallDelete] 56 | Type: files; Name: "{commonstartup}\{#MyAppName}" -------------------------------------------------------------------------------- /clipmanager.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/clipmanager.pfx -------------------------------------------------------------------------------- /clipmanager.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | import os 4 | import sys 5 | 6 | block_cipher = None 7 | 8 | python_install_dir, _ = os.path.split(sys.executable) 9 | site_packages_path = os.path.join(python_install_dir, 'Lib', 'site-packages') 10 | 11 | a = Analysis(['bin\\clipmanager'], 12 | pathex=[], 13 | binaries=[ 14 | ( 15 | os.path.join(site_packages_path, 'PySide', 'plugins', 16 | 'sqldrivers', 'qsqlite4.dll'), 17 | os.path.join('qt4_plugins', 'sqldrivers'), 18 | ), 19 | ], 20 | datas=[], 21 | hiddenimports=[], 22 | hookspath=[], 23 | runtime_hooks=[], 24 | excludes=[], 25 | win_no_prefer_redirects=False, 26 | win_private_assemblies=False, 27 | cipher=block_cipher) 28 | pyz = PYZ(a.pure, a.zipped_data, 29 | cipher=block_cipher) 30 | exe = EXE(pyz, 31 | a.scripts, 32 | exclude_binaries=True, 33 | name='clipmanager', 34 | debug=False, 35 | strip=False, 36 | upx=True, 37 | console=False, 38 | icon='data\\clipmanager.ico', 39 | version='clipmanager.version') 40 | coll = COLLECT(exe, 41 | a.binaries, 42 | a.zipfiles, 43 | a.datas, 44 | strip=False, 45 | upx=True, 46 | name='clipmanager') 47 | -------------------------------------------------------------------------------- /clipmanager.version: -------------------------------------------------------------------------------- 1 | # UTF-8 2 | # 3 | # For more details about fixed file info 'ffi' see: 4 | # http://msdn.microsoft.com/en-us/library/ms646997.aspx 5 | VSVersionInfo( 6 | ffi=FixedFileInfo( 7 | # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) 8 | # Set not needed items to zero 0. 9 | filevers=(0, 5, 0, 0), 10 | prodvers=(0, 5, 0, 0), 11 | # Contains a bitmask that specifies the valid bits 'flags'r 12 | mask=0x3f, 13 | # Contains a bitmask that specifies the Boolean attributes of the file. 14 | flags=0x0, 15 | # The operating system for which this file was designed. 16 | # 0x4 - NT and there is no need to change it. 17 | OS=0x40004, 18 | # The general type of file. 19 | # 0x1 - the file is an application. 20 | fileType=0x1, 21 | # The function of the file. 22 | # 0x0 - the function is not defined for this fileType 23 | subtype=0x0, 24 | # Creation date and time stamp. 25 | date=(0, 0) 26 | ), 27 | kids=[ 28 | StringFileInfo( 29 | [ 30 | StringTable( 31 | u'040904B0', 32 | [StringStruct(u'CompanyName', u'Werner'), 33 | StringStruct(u'FileDescription', u'GUI clipboard manager.'), 34 | StringStruct(u'FileVersion', u'0.5.0.0'), 35 | StringStruct(u'InternalName', u'ClipManager'), 36 | StringStruct(u'LegalCopyright', u'© 2018 Scott Werner.'), 37 | StringStruct(u'OriginalFilename', u'clipmanager.exe'), 38 | StringStruct(u'ProductName', u'ClipManager'), 39 | StringStruct(u'ProductVersion', u'0.5.0.0')]) 40 | ]), 41 | VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /clipmanager/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'ClipManager' 2 | __version__ = '0.5.0' 3 | __author__ = 'Scott Werner' 4 | __license__ = 'BSD' 5 | __copyright__ = 'Copyright 2018 Scott Werner' 6 | __org__ = 'Werner' 7 | __email__ = 'scott.werner.vt@gmail.com' 8 | __description__ = "Manage the system's clipboard history." 9 | __url__ = 'https://github.com/scottwernervt/clipmanager' 10 | -------------------------------------------------------------------------------- /clipmanager/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | import logging 4 | import optparse 5 | import os 6 | import sys 7 | from logging.handlers import RotatingFileHandler 8 | 9 | from PySide.QtCore import QCoreApplication, QDir, QEvent, Slot 10 | from PySide.QtGui import QApplication 11 | 12 | from clipmanager import __org__, __title__, __url__, __version__ 13 | from clipmanager.singleinstance import SingleInstance 14 | from clipmanager.ui.mainwindow import MainWindow 15 | 16 | 17 | def _setup_logger(logging_level='INFO'): 18 | log_path = os.path.join(QDir.tempPath(), __title__.lower() + '.log') 19 | formatter = logging.Formatter( 20 | '%(asctime)s - %(levelname)s - %(name)s - %(message)s') 21 | 22 | logger = logging.getLogger('clipmanager') 23 | logger.setLevel(logging_level) 24 | 25 | stream_handler = logging.StreamHandler(sys.stdout) 26 | stream_handler.setLevel(logging_level) 27 | stream_handler.setFormatter(formatter) 28 | logger.addHandler(stream_handler) 29 | 30 | file_handler = RotatingFileHandler(log_path, maxBytes=1048576) 31 | file_handler.setLevel(logging_level) 32 | file_handler.setFormatter(formatter) 33 | logger.addHandler(file_handler) 34 | 35 | 36 | class Application(QApplication): 37 | """Application event loop which spawns the main window.""" 38 | 39 | def __init__(self, app_args): 40 | """Initialize application and launch main window. 41 | 42 | :param app_args: 'minimize' on launch 43 | :type app_args: list 44 | """ 45 | super(Application, self).__init__(app_args) 46 | 47 | self.setApplicationName(__url__) 48 | self.setOrganizationName(__org__) 49 | self.setApplicationName(__title__) 50 | self.setApplicationVersion(__version__) 51 | 52 | # prevent application from exiting if minimized "closed" 53 | self.setQuitOnLastWindowClosed(False) 54 | 55 | if 'minimize' in app_args: 56 | self.mw = MainWindow(minimize=True) 57 | else: 58 | self.mw = MainWindow(minimize=False) 59 | 60 | self.aboutToQuit.connect(self.destroy) 61 | 62 | @Slot() 63 | def destroy(self): 64 | """Clean up and set clipboard contents to OS clipboard. 65 | 66 | The basic concept behind this is that by default copying something 67 | into the clipboard only copies a reference/pointer to the source 68 | application. Then when another application wants to paste the data 69 | from the clipboard it requests the data from the source application. 70 | 71 | :return: None 72 | :rtype: None 73 | """ 74 | clipboard = QApplication.clipboard() 75 | event = QEvent(QEvent.Clipboard) 76 | QApplication.sendEvent(clipboard, event) 77 | 78 | self.mw.destroy() 79 | 80 | 81 | def main(): 82 | parser = optparse.OptionParser() 83 | parser.add_option('-l', '--logging-level', default='INFO', 84 | help='Logging level') 85 | (options, args) = parser.parse_args() 86 | 87 | _setup_logger(options.logging_level) 88 | 89 | single_instance = SingleInstance() 90 | if single_instance.is_running(): 91 | sys.exit(1056) 92 | 93 | QCoreApplication.setOrganizationName(__org__) 94 | QCoreApplication.setApplicationName(__title__) 95 | QCoreApplication.setApplicationVersion(__version__) 96 | 97 | app = Application(sys.argv) 98 | sys.exit(app.exec_()) 99 | 100 | 101 | if __name__ == '__main__': 102 | main() 103 | -------------------------------------------------------------------------------- /clipmanager/clipboard.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PySide.QtCore import QMimeData, QObject, Signal, Slot 4 | from PySide.QtGui import QApplication, QClipboard 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ClipboardManager(QObject): 10 | """Handles communication between all clipboards and main window. 11 | 12 | Source: http://bazaar.launchpad.net/~glipper-drivers/glipper/Clipboards.py 13 | """ 14 | new_item = Signal(QMimeData) 15 | 16 | def __init__(self, parent=None): 17 | super(ClipboardManager, self).__init__(parent) 18 | 19 | self.primary_clipboard = Clipboard(QApplication.clipboard(), 20 | self.new_item) 21 | 22 | def get_primary_clipboard_text(self): 23 | """Get primary clipboard contents. 24 | 25 | :return: Current clipboard contents. 26 | :rtype: QMimeData 27 | """ 28 | return self.primary_clipboard.get_text() 29 | 30 | def set_text(self, mime_data): 31 | """Set clipboard contents. 32 | 33 | :param mime_data: Data to set to global clipboard. 34 | :type mime_data: QMimeData 35 | 36 | :return: None 37 | :rtype: None 38 | """ 39 | self.primary_clipboard.set_text(mime_data) 40 | self.new_item.emit(mime_data) 41 | 42 | def clear_text(self): 43 | self.primary_clipboard.clear_text() 44 | 45 | 46 | class Clipboard(QObject): 47 | """Monitor's clipboard for changes. 48 | 49 | :param clipboard: Clipboard reference. 50 | :type clipboard: QClipboard 51 | 52 | :param callback: Function to call on content change. 53 | :type callback: func 54 | 55 | :param mode: 56 | :type mode: QClipboard.Mode.Clipboard 57 | """ 58 | 59 | def __init__(self, clipboard, callback, mode=QClipboard.Clipboard): 60 | super(Clipboard, self).__init__() 61 | 62 | self.clipboard = clipboard 63 | self.callback = callback 64 | self.mode = mode 65 | 66 | self.clipboard.dataChanged.connect(self.on_data_changed) 67 | 68 | def get_text(self): 69 | """Get clipboard contents. 70 | 71 | :return: Current clipboard contents. 72 | :rtype: QMimeData 73 | """ 74 | return self.clipboard.mimeData(self.mode) 75 | 76 | def set_text(self, mime_data): 77 | """Set clipboard contents. 78 | 79 | :param mime_data: Data to set to global clipboard. 80 | :type mime_data: QMimeData 81 | 82 | :return: None 83 | :rtype: None 84 | """ 85 | self.clipboard.setMimeData(mime_data, self.mode) 86 | 87 | def clear_text(self): 88 | """Clear clipboard contents. 89 | 90 | :return: None 91 | :rtype: None 92 | """ 93 | self.clipboard.clear(mode=self.mode) 94 | 95 | @Slot() 96 | def on_data_changed(self): 97 | """Add new clipboard item using callback. 98 | 99 | :return: None 100 | :rtype: None 101 | """ 102 | self.callback.emit(self.get_text()) 103 | -------------------------------------------------------------------------------- /clipmanager/database.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from PySide.QtCore import QDir, QObject 5 | from PySide.QtGui import QDesktopServices 6 | from PySide.QtSql import QSqlDatabase, QSqlQuery 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Database(QObject): 12 | """Database connection helper. 13 | 14 | Database file can be found in: 15 | * Windows 16 | - XP: C:\Documents and Settings\\Local Settings\Application Data\ 17 | - 7/8: C:\Users\\AppData\Local\Werner\ClipManager 18 | * Linux: /home//.local/share/data/Werner/ClipManager 19 | """ 20 | _main_table_sql = """CREATE TABLE IF NOT EXISTS main( 21 | id INTEGER PRIMARY KEY AUTOINCREMENT, 22 | title TEXT, 23 | title_short TEXT, 24 | checksum TEXT, 25 | keep INTEGER DEFAULT 0, 26 | created_at TIMESTAMP 27 | );""" 28 | _data_table_sql = """CREATE TABLE IF NOT EXISTS data( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, 30 | parent_id INTEGER, 31 | mime_format TEXT, 32 | byte_data BLOB, 33 | FOREIGN KEY(parent_id) REFERENCES main(id) 34 | );""" 35 | 36 | def __init__(self, parent=None): 37 | super(Database, self).__init__(parent) 38 | 39 | storage_path = QDesktopServices.storageLocation( 40 | QDesktopServices.DataLocation) 41 | storage_dir = QDir(storage_path) 42 | if not storage_dir.exists(): 43 | storage_dir.mkpath('.') 44 | 45 | db_path = os.path.join(storage_path, 'contents.db') 46 | logger.info(db_path) 47 | 48 | # noinspection PyTypeChecker,PyCallByClass 49 | db = QSqlDatabase.addDatabase('QSQLITE') 50 | db.setDatabaseName(db_path) 51 | 52 | if not db.open(): 53 | logger.error(db.lastError()) 54 | 55 | self.connection = db 56 | 57 | def create_tables(self): 58 | """Create main and data table (one to many). 59 | 60 | :return: None 61 | :rtype: None 62 | """ 63 | query_main = QSqlQuery() 64 | query_main.exec_(self._main_table_sql) 65 | query_main.finish() 66 | if query_main.lastError().isValid(): 67 | logger.error(query_main.lastError().text()) 68 | 69 | query_data = QSqlQuery() 70 | query_data.exec_(self._data_table_sql) 71 | query_data.finish() 72 | if query_data.lastError().isValid(): 73 | logger.error(query_data.lastError().text()) 74 | 75 | return True 76 | 77 | def open(self): 78 | """Alias for QSqlDatabase.open() 79 | 80 | :return: True if connection open. 81 | :rtype: bool 82 | """ 83 | return self.connection.open() 84 | 85 | def close(self): 86 | """Perform vacuum on database and then close. 87 | 88 | :return: None 89 | :rtype: None 90 | """ 91 | query_data = QSqlQuery() 92 | query_data.exec_('VACUUM') 93 | query_data.finish() 94 | 95 | if query_data.lastError().isValid(): 96 | logger.warning(query_data.lastError().text()) 97 | 98 | self.connection.close() 99 | -------------------------------------------------------------------------------- /clipmanager/defs.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Formats to check and save with from OS clipboard 4 | MIME_SUPPORTED = [ 5 | 'text/html', 6 | 'text/html;charset=utf-8', 7 | 'text/plain', 8 | 'text/plain;charset=utf-8', 9 | 'text/richtext', 10 | 'application/x-qt-windows-mime;value="Rich Text Format"', 11 | 'text/uri-list', 12 | # 'application/x-qt-image' 13 | ] 14 | 15 | if os.name == 'posix': 16 | MIME_SUPPORTED.append('x-special/gnome-copied-files') 17 | -------------------------------------------------------------------------------- /clipmanager/hotkey/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def initialize(): 5 | if os.name == 'nt': 6 | from clipmanager.hotkey.win32 import GlobalHotkeyManagerWin 7 | return GlobalHotkeyManagerWin() 8 | elif os.name == 'posix': 9 | from clipmanager.hotkey.x11 import GlobalHotkeyManagerX11 10 | return GlobalHotkeyManagerX11() 11 | -------------------------------------------------------------------------------- /clipmanager/hotkey/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Source: https://github.com/rokups/paste2box 3 | License: GNU General Public License v3.0 4 | """ 5 | 6 | from PySide.QtCore import Qt, Signal 7 | from PySide.QtGui import QKeySequence 8 | 9 | 10 | class GlobalHotkeyManagerBase(object): 11 | keyPressed = Signal(int) 12 | keyReleased = Signal(int) 13 | 14 | def __init__(self): 15 | self.shortcuts = {} 16 | 17 | def register(self, sequence, callback, winid): 18 | if not isinstance(sequence, QKeySequence): 19 | sequence = QKeySequence(sequence) 20 | assert isinstance(sequence, QKeySequence), 'Invalid sequence type' 21 | k, m = self._sequence_to_native(sequence) 22 | return self._register_shortcut(callback, k, m, winid) 23 | 24 | def unregister(self, sequence=None, callback=None, winid=None): 25 | if sequence is not None: 26 | assert callback is not None, 'Invalid parameter' 27 | if isinstance(sequence, str): 28 | sequence = QKeySequence(sequence) 29 | assert isinstance(sequence, QKeySequence), 'Invalid sequence type' 30 | k, m = self._sequence_to_native(sequence) 31 | self._unregister_shortcut(k, m, winid) 32 | elif callback is not None: 33 | assert sequence is not None, 'Invalid parameter' 34 | for (k, m), cb in self.shortcuts.items(): 35 | if callback == cb: 36 | self._unregister_shortcut(k, m, winid) 37 | else: 38 | for (k, m), cb in self.shortcuts.copy().items(): 39 | self._unregister_shortcut(k, m, winid) 40 | 41 | def _sequence_to_native(self, sequence): 42 | all_mods = int(Qt.ShiftModifier | 43 | Qt.ControlModifier | 44 | Qt.AltModifier | 45 | Qt.MetaModifier) 46 | assert not sequence.isEmpty() 47 | key = Qt.Key((sequence[0] ^ all_mods) & sequence[0]) 48 | mods = Qt.KeyboardModifiers(sequence[0] ^ int(key)) 49 | return self._native_keycode(key), self._native_modifiers(mods) 50 | 51 | def _native_modifiers(self, modifiers): 52 | raise NotImplemented() 53 | 54 | def _native_keycode(self, key): 55 | raise NotImplemented() 56 | 57 | def _register_shortcut(self, receiver, native_key, native_mods, winid): 58 | raise NotImplemented() 59 | 60 | def _unregister_shortcut(self, native_key, native_mods, winid): 61 | raise NotImplemented() 62 | 63 | def stop(self): 64 | raise NotImplemented 65 | -------------------------------------------------------------------------------- /clipmanager/hotkey/hook.py: -------------------------------------------------------------------------------- 1 | """ 2 | Source: https://github.com/rokups/paste2box 3 | License: GNU General Public License v3.0 4 | """ 5 | 6 | import struct 7 | from ctypes import byref, c_char_p, c_size_t, c_void_p, cast, windll 8 | from ctypes.wintypes import DWORD 9 | 10 | PAGE_EXECUTE_READWRITE = 0x40 11 | 12 | 13 | def hotpatch(source, destination): 14 | source = cast(source, c_void_p).value 15 | destination = cast(destination, c_void_p).value 16 | old = DWORD() 17 | if windll.kernel32.VirtualProtect(source - 5, 8, PAGE_EXECUTE_READWRITE, 18 | byref(old)): 19 | try: 20 | written = c_size_t() 21 | jmp_code = struct.pack('> 16) & 0xFFFF 165 | modifiers = msg.lParam & 0xFFFF 166 | key = (keycode, modifiers) 167 | if key in self.shortcuts: 168 | QTimer.singleShot(0, self.shortcuts[key]) 169 | return False 170 | 171 | return self._TranslateMessageReal(pmsg) 172 | 173 | @staticmethod 174 | def stop(): 175 | unhotpatch(ctypes.windll.user32.TranslateMessage) 176 | 177 | def _native_modifiers(self, modifiers): 178 | native = 0 179 | if modifiers & Qt.ShiftModifier: 180 | native |= MOD_SHIFT 181 | if modifiers & Qt.ControlModifier: 182 | native |= MOD_CONTROL 183 | if modifiers & Qt.AltModifier: 184 | native |= MOD_ALT 185 | if modifiers & Qt.MetaModifier: 186 | native |= MOD_WIN 187 | return native 188 | 189 | def _native_keycode(self, key): 190 | if key in self._qt_key_to_vk: 191 | return self._qt_key_to_vk[key] 192 | elif key in self._qt_key_numbers: 193 | return key 194 | elif key in self._qt_key_letters: 195 | return key 196 | return 0 197 | 198 | @staticmethod 199 | def _unwrap_window_id(window_id): 200 | try: 201 | return int(window_id) 202 | except Exception as err: 203 | ctypes.pythonapi.PyCObject_AsVoidPtr.restype = ctypes.c_void_p 204 | ctypes.pythonapi.PyCObject_AsVoidPtr.argtypes = [ctypes.py_object] 205 | return int(ctypes.pythonapi.PyCObject_AsVoidPtr(window_id)) 206 | 207 | def _register_shortcut(self, receiver, native_key, native_mods, window_id): 208 | result = ctypes.windll.user32.RegisterHotKey( 209 | self._unwrap_window_id(window_id), 210 | int(native_mods) ^ int(native_key), 211 | native_mods, int(native_key) 212 | ) 213 | if result: 214 | self.shortcuts[(native_key, native_mods)] = receiver 215 | 216 | return result 217 | 218 | def _unregister_shortcut(self, native_key, native_mods, window_id): 219 | result = ctypes.windll.user32.UnregisterHotKey( 220 | self._unwrap_window_id(window_id), 221 | int(native_mods) ^ int(native_key) 222 | ) 223 | try: 224 | del self.shortcuts[(native_key, native_mods)] 225 | except KeyError: 226 | pass 227 | 228 | return result 229 | -------------------------------------------------------------------------------- /clipmanager/hotkey/x11.py: -------------------------------------------------------------------------------- 1 | """ 2 | Source: https://github.com/rokups/paste2box 3 | License: GNU General Public License v3.0 4 | 5 | Source: https://github.com/SavinaRoja/PyKeyboard 6 | License: WTFPL 7 | """ 8 | 9 | from PySide.QtCore import ( 10 | QThread, 11 | QTimer, 12 | Qt, 13 | Signal, 14 | ) 15 | from PySide.QtGui import QKeySequence 16 | from Xlib import X, XK 17 | from Xlib.display import Display 18 | from Xlib.ext import record 19 | from Xlib.protocol import rq 20 | 21 | from clipmanager.hotkey.base import GlobalHotkeyManagerBase 22 | 23 | 24 | class X11EventPoller(QThread): 25 | keyPressed = Signal(object, object) 26 | 27 | def __init__(self, display=None): 28 | QThread.__init__(self) 29 | 30 | self.display = Display(display) 31 | self.display2 = Display(display) 32 | self.context = self.display2.record_create_context( 33 | 0, 34 | [record.AllClients], 35 | [{ 36 | 'core_requests': (0, 0), 37 | 'core_replies': (0, 0), 38 | 'ext_requests': (0, 0, 0, 0), 39 | 'ext_replies': (0, 0, 0, 0), 40 | 'delivered_events': (0, 0), 41 | 'device_events': (X.KeyPress, X.KeyRelease), 42 | 'errors': (0, 0), 43 | 'client_started': False, 44 | 'client_died': False, 45 | }]) 46 | 47 | def run(self): 48 | self.display2.record_enable_context(self.context, self.record_callback) 49 | self.display2.record_free_context(self.context) 50 | 51 | def record_callback(self, reply): 52 | if reply.category != record.FromServer: 53 | return 54 | if reply.client_swapped: 55 | # received swapped protocol data, cowardly ignored 56 | return 57 | if not len(reply.data) or reply.data[0] < 2: 58 | # not an event 59 | return 60 | 61 | data = reply.data 62 | while len(data): 63 | event, data = rq.EventField(None).parse_binary_value( 64 | data, 65 | self.display.display, 66 | None, None) 67 | self.keyPressed.emit(event, data) 68 | 69 | def stop(self): 70 | self.display.record_disable_context(self.context) 71 | self.display.flush() 72 | 73 | self.display2.record_disable_context(self.context) 74 | self.display2.flush() 75 | 76 | 77 | class GlobalHotkeyManagerX11(GlobalHotkeyManagerBase): 78 | def __init__(self, display=None): 79 | self._text_to_native = { 80 | '-': 'minus', 81 | '+': 'plus', 82 | '=': 'equal', 83 | '[': 'bracketleft', 84 | ']': 'bracketright', 85 | '|': 'bar', 86 | ';': 'semicolon', 87 | '\'': 'quoteright', 88 | ',': 'comma', 89 | '.': 'period', 90 | '/': 'slash', 91 | '\\': 'backslash', 92 | '`': 'asciitilde', 93 | } 94 | GlobalHotkeyManagerBase.__init__(self) 95 | 96 | self.display = Display(display) 97 | self._error = False 98 | 99 | self._poller = X11EventPoller(display) 100 | self._poller.keyPressed.connect(self.x11_event) 101 | self._poller.start() 102 | 103 | def stop(self): 104 | self._poller.stop() 105 | 106 | # noinspection PyUnusedLocal 107 | def x11_event(self, event, data): 108 | if event.type == X.KeyPress: 109 | key = (event.detail, int(event.state) & ( 110 | X.ShiftMask | X.ControlMask | X.Mod1Mask | X.Mod4Mask)) 111 | if key in self.shortcuts: 112 | # noinspection PyCallByClass,PyTypeChecker 113 | QTimer.singleShot(0, self.shortcuts[key]) 114 | return False 115 | 116 | def _native_modifiers(self, modifiers): 117 | # ShiftMask, LockMask, ControlMask, Mod1Mask, Mod2Mask, Mod3Mask, 118 | # Mod4Mask, and Mod5Mask 119 | native = 0 120 | modifiers = int(modifiers) 121 | 122 | if modifiers & Qt.ShiftModifier: 123 | native |= X.ShiftMask 124 | if modifiers & Qt.ControlModifier: 125 | native |= X.ControlMask 126 | if modifiers & Qt.AltModifier: 127 | native |= X.Mod1Mask 128 | if modifiers & Qt.MetaModifier: 129 | native |= X.Mod4Mask 130 | 131 | # TODO: resolve these? 132 | # if (modifiers & Qt.MetaModifier) 133 | # if (modifiers & Qt.KeypadModifier) 134 | # if (modifiers & Qt.GroupSwitchModifier) 135 | return native 136 | 137 | def _native_keycode(self, key): 138 | keysym = QKeySequence(key).toString() 139 | if keysym in self._text_to_native: 140 | keysym = self._text_to_native[keysym] 141 | return self.display.keysym_to_keycode(XK.string_to_keysym(keysym)) 142 | 143 | # noinspection PyUnusedLocal 144 | def _on_error(self, e, data): 145 | if e.code in (X.BadAccess, X.BadValue, X.BadWindow): 146 | if e.major_opcode in (33, 34): # X_GrabKey, X_UngrabKey 147 | self._error = True 148 | return 0 149 | 150 | def _register_shortcut(self, receiver, native_key, native_mods, winid=None): 151 | window = self.display.screen().root 152 | self._error = False 153 | 154 | window.grab_key(native_key, native_mods, True, X.GrabModeAsync, 155 | X.GrabModeAsync, self._on_error) 156 | self.display.sync() 157 | 158 | if not self._error: 159 | self.shortcuts[(native_key, native_mods)] = receiver 160 | 161 | return not self._error 162 | 163 | def _unregister_shortcut(self, native_key, native_mods, window_id): 164 | display = Display() 165 | window = display.screen().root 166 | self._error = False 167 | 168 | window.ungrab_key(native_key, native_mods, self._on_error) 169 | display.sync() 170 | 171 | try: 172 | del self.shortcuts[(native_key, native_mods)] 173 | except KeyError: 174 | pass 175 | 176 | return not self._error 177 | -------------------------------------------------------------------------------- /clipmanager/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PySide.QtCore import QDateTime, Qt 4 | from PySide.QtSql import QSqlQuery, QSqlTableModel 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class MainSqlTableModel(QSqlTableModel): 10 | """Main table model that has children in Data table. 11 | 12 | Todo: 13 | Look into returning Qt.UserRole+1, Qt.UserRole+n, for each column 14 | instead of forcing me to create a new index requesting the specified 15 | column. User role can be used on model().data(index, Qt.UserRole) 16 | 17 | Get PySide model test working. 18 | 19 | http://stackoverflow.com/questions/13055423/virtual-column-in-qtableview 20 | """ 21 | ID, TITLE, TITLE_SHORT, CHECKSUM, KEEP, CREATED_AT = range(6) 22 | 23 | def __init__(self, parent=None): 24 | super(MainSqlTableModel, self).__init__(parent) 25 | 26 | self.setTable('main') 27 | self.setSort(self.CREATED_AT, Qt.DescendingOrder) 28 | # submitAll() to make changes, needed for max entries / date check 29 | self.setEditStrategy(QSqlTableModel.OnManualSubmit) 30 | 31 | self.select() 32 | 33 | self.setHeaderData(self.ID, Qt.Horizontal, 'id') 34 | self.setHeaderData(self.TITLE, Qt.Horizontal, 'title') 35 | self.setHeaderData(self.TITLE_SHORT, Qt.Horizontal, 'title_short') 36 | self.setHeaderData(self.CHECKSUM, Qt.Horizontal, 'checksum') 37 | self.setHeaderData(self.CREATED_AT, Qt.Horizontal, 'created_at') 38 | 39 | def select(self): 40 | """Load all records before returning if there is a selection. 41 | 42 | QSortFilterProxyModel does not show all matching records due to 43 | records not being loaded until scrolled. 44 | 45 | References: 46 | http://qtsimplify.blogspot.com/2013/05/eager-loading.html 47 | 48 | :return: 49 | :rtype: bool 50 | """ 51 | while self.canFetchMore(): 52 | self.fetchMore() 53 | 54 | return super(MainSqlTableModel, self).select() 55 | 56 | def data(self, index, role=Qt.DisplayRole): 57 | """Override QSqlTableModel.data() 58 | 59 | :param index: Row and column of data entry. 60 | :type index: QModelIndex 61 | 62 | :param role: 63 | :type role: Qt.DisplayRole 64 | 65 | :return: Row column data from table. 66 | :rtype: str, int, or None 67 | """ 68 | if not index.isValid(): 69 | return None 70 | 71 | row = index.row() 72 | 73 | if role == Qt.DisplayRole: 74 | return QSqlTableModel.data(self, index) 75 | elif role == Qt.ToolTipRole: 76 | date_index = self.index(row, self.CREATED_AT) 77 | time_stamp = QDateTime() 78 | time_stamp.setMSecsSinceEpoch(QSqlTableModel.data(self, date_index)) 79 | date_string = time_stamp.toString(Qt.SystemLocaleShortDate) 80 | return 'Last used: {!s}'.format(date_string) 81 | elif role == Qt.DecorationRole: # future image icon 82 | return None 83 | 84 | return None 85 | 86 | def flags(self, index): 87 | """Return item's Qt.ItemFlags in history list view. 88 | 89 | :param index: Row and column of data entry. 90 | :type index: QModelIndex 91 | 92 | :return: 93 | :rtype: Qt.ItemFlags 94 | """ 95 | if not index.isValid(): 96 | return Qt.ItemFlags() 97 | 98 | return Qt.ItemFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) 99 | 100 | @staticmethod 101 | def create(title, title_short, checksum, created_at): 102 | """Insert new row into the main table. 103 | 104 | :param title: Full title of clipboard contents. 105 | :type title: str 106 | 107 | :param title_short: Shorten title for truncating. 108 | :type title_short: str 109 | 110 | :param checksum: CRC32 checksum of entity. 111 | :type checksum: int 112 | 113 | :param created_at: UTC in milliseconds. 114 | :type created_at: int 115 | 116 | :return: Row id from SQL INSERT. 117 | :rtype: int 118 | """ 119 | insert_query = QSqlQuery() 120 | insert_query.prepare('INSERT OR FAIL INTO main (title, title_short, ' 121 | 'checksum, created_at) VALUES (:title, ' 122 | ':title_short, :checksum, :created_at)') 123 | insert_query.bindValue(':title', title) 124 | insert_query.bindValue(':title_short', title_short) 125 | insert_query.bindValue(':checksum', checksum) 126 | insert_query.bindValue(':created_at', created_at) 127 | insert_query.exec_() 128 | 129 | if insert_query.lastError().isValid(): 130 | logger.error(insert_query.lastError().text()) 131 | 132 | row_id = insert_query.lastInsertId() 133 | insert_query.finish() 134 | 135 | return row_id 136 | 137 | 138 | class DataSqlTableModel(QSqlTableModel): 139 | ID, PARENT_ID, MIME_FORMAT, BYTE_DATA = range(4) 140 | 141 | def __init__(self, parent=None): 142 | super(DataSqlTableModel, self).__init__(parent) 143 | 144 | self.setTable('data') 145 | self.setEditStrategy(QSqlTableModel.OnManualSubmit) 146 | 147 | self.select() 148 | 149 | self.setHeaderData(self.ID, Qt.Horizontal, 'id') 150 | self.setHeaderData(self.PARENT_ID, Qt.Horizontal, 'parent_id') 151 | self.setHeaderData(self.MIME_FORMAT, Qt.Horizontal, 'mime_format') 152 | self.setHeaderData(self.BYTE_DATA, Qt.Horizontal, 'byte_data') 153 | 154 | @staticmethod 155 | def create(parent_id, mime_format, byte_data): 156 | """Insert blob into the data table. 157 | 158 | :param parent_id: Row ID from main table. 159 | :type parent_id: int 160 | 161 | :param mime_format: Mime data format, i.e 'text/html'. 162 | :type mime_format: str 163 | 164 | :param byte_data: Mime data based on format converted to QByteArray. 165 | :type byte_data: QByteArray 166 | 167 | :return: Row ID from SQL INSERT. 168 | :rtype: int 169 | """ 170 | insert_query = QSqlQuery() 171 | insert_query.prepare('INSERT OR FAIL INTO data VALUES (NULL, ' 172 | ':parent_id, :mime_format, :byte_data)') 173 | insert_query.bindValue(':parent_id', parent_id) 174 | insert_query.bindValue(':mime_format', mime_format) 175 | insert_query.bindValue(':byte_data', byte_data) 176 | insert_query.exec_() 177 | 178 | if insert_query.lastError().isValid(): 179 | logger.error(insert_query.lastError().text()) 180 | 181 | row_id = insert_query.lastInsertId() 182 | insert_query.finish() 183 | 184 | return row_id 185 | 186 | @staticmethod 187 | def read(parent_id): 188 | """Get blob from data table. 189 | 190 | :param parent_id: Main table row ID. 191 | :type parent_id: int 192 | 193 | :return: [['text/html','blob'],['text/plain','bytes']] 194 | :rtype: list[list[str,str]] 195 | """ 196 | query = QSqlQuery() 197 | query.prepare('SELECT * FROM Data WHERE parent_id=:parent_id') 198 | query.bindValue(':parent_id', parent_id) 199 | query.exec_() 200 | 201 | mime_list = [] # [[mime_format, byte_data]] 202 | 203 | while query.next(): 204 | mime_format = query.value(2) 205 | byte_data = query.value(3) 206 | mime_list.append([mime_format, byte_data]) 207 | 208 | if query.lastError().isValid(): 209 | logger.error(query.lastError().text()) 210 | continue 211 | 212 | query.finish() 213 | return mime_list 214 | 215 | @staticmethod 216 | def delete(parent_ids): 217 | """Delete blob from data table. 218 | 219 | :param parent_ids: Row id of main table. 220 | :type parent_ids: list[int] 221 | 222 | :return: None 223 | :rtype: None 224 | """ 225 | query = QSqlQuery() 226 | 227 | for parent_id in parent_ids: 228 | query.prepare('DELETE FROM data WHERE parent_id=:parent_id') 229 | query.bindValue(':parent_id', parent_id) 230 | 231 | query.exec_() 232 | 233 | if query.lastError().isValid(): 234 | logger.error(query.lastError().text()) 235 | 236 | query.finish() 237 | -------------------------------------------------------------------------------- /clipmanager/owner/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def initialize(): 5 | if os.name == 'nt': 6 | from clipmanager.owner.win32 import get_win32_owner 7 | return get_win32_owner 8 | elif os.name == 'posix': 9 | from clipmanager.owner.x11 import get_x11_owner 10 | return get_x11_owner 11 | -------------------------------------------------------------------------------- /clipmanager/owner/win32.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from ctypes import c_char, windll 4 | 5 | from win32gui import EnumWindows, GetWindowText 6 | from win32process import GetWindowThreadProcessId 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | MAX_PATH = 260 11 | PROCESS_TERMINATE = 0x0001 12 | PROCESS_QUERY_INFORMATION = 0x0400 13 | 14 | 15 | def get_hwnds_for_pid(pid): 16 | """Get HWND's for particular PID. 17 | 18 | Source: 19 | http://timgolden.me.uk/python/win32_how_do_i/find-the-window-for-my-subprocess.html 20 | 21 | :param pid: Process identifier to look up. 22 | :type pid: int 23 | 24 | :return: List of window handles 25 | :rtype: list 26 | """ 27 | 28 | def callback(hwnd, hwnds): 29 | _, found_pid = GetWindowThreadProcessId(hwnd) 30 | if found_pid == pid: 31 | hwnds.append(hwnd) 32 | return True 33 | 34 | hwnds = [] 35 | EnumWindows(callback, hwnds) 36 | return hwnds 37 | 38 | 39 | def get_win32_owner(): 40 | """Get clipboard owner window details. 41 | 42 | Sources: 43 | https://sjohannes.wordpress.com/2012/03/23/win32-python-getting-all-window-titles/ 44 | http://nullege.com/codes/show/src%40w%40i%40winappdbg-1.4%40winappdbg%40system.py/5058/win32.GetProcessImageFileName/python 45 | http://stackoverflow.com/questions/6980246/how-can-i-find-a-process-by-name-and-kill-using-ctypes 46 | http://msdn.microsoft.com/en-us/library/windows/desktop/ms648709(v=vs.85).aspx 47 | 48 | :return: List of clipboard owner's binary name and window titles/classes. 49 | :rtype: list[str] 50 | """ 51 | owner_names = [] 52 | 53 | # HWND WINAPI GetClipboardOwner(void); 54 | owner_hwnd = windll.user32.GetClipboardOwner() 55 | 56 | # DWORD WINAPI GetWindowThreadProcessId( 57 | # _In_ HWND hWnd, 58 | # _Out_opt_ LPDWORD lpdwProcessId 59 | # ); 60 | _, owner_process_id = GetWindowThreadProcessId(owner_hwnd) 61 | 62 | for hwnd in get_hwnds_for_pid(owner_process_id): 63 | window_text = GetWindowText(hwnd) 64 | if window_text: 65 | window_title = window_text.split('-')[-1].strip() 66 | owner_names.extend([window_text, window_title]) 67 | 68 | # HANDLE WINAPI OpenProcess( 69 | # _In_ DWORD dwDesiredAccess, 70 | # _In_ BOOL bInheritHandle, 71 | # _In_ DWORD dwProcessId 72 | # ); 73 | h_process = windll.kernel32.OpenProcess(PROCESS_TERMINATE | 74 | PROCESS_QUERY_INFORMATION, False, 75 | owner_process_id) 76 | 77 | ImageFileName = (c_char * MAX_PATH)() 78 | 79 | try: 80 | # DWORD WINAPI GetProcessImageFileName( 81 | # _In_ HANDLE hProcess, 82 | # _Out_ LPTSTR lpImageFileName, 83 | # _In_ DWORD nSize 84 | # ); 85 | if windll.psapi.GetProcessImageFileNameA(h_process, ImageFileName, 86 | MAX_PATH) > 0: 87 | binary_name = os.path.basename(ImageFileName.value) 88 | if binary_name: 89 | owner_names.append(binary_name) 90 | except (AttributeError, WindowsError) as err: 91 | logger.exception(err) 92 | 93 | # BOOL WINAPI CloseHandle( 94 | # _In_ HANDLE hObject 95 | # ); 96 | windll.kernel32.CloseHandle(h_process) 97 | 98 | return owner_names 99 | -------------------------------------------------------------------------------- /clipmanager/owner/x11.py: -------------------------------------------------------------------------------- 1 | """ 2 | Source: https://github.com/ActivityWatch/aw-watcher-x11/aw_watcher_x11/xprop.py 3 | License: None 4 | """ 5 | import logging 6 | import os 7 | import re 8 | import subprocess 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def readlink_binary(pid): 14 | cmd = ['readlink', '-f', '/proc/{}/exe'.format(pid)] 15 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, close_fds=True) 16 | return p.stdout.read() 17 | 18 | 19 | def xprop_id(window_id): 20 | cmd = ['xprop', '-id', window_id] 21 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, close_fds=True) 22 | return p.stdout.read() 23 | 24 | 25 | def xprop_root(): 26 | cmd = ['xprop', '-root'] 27 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, close_fds=True) 28 | return p.stdout.read() 29 | 30 | 31 | def get_active_window_id(): 32 | lines = xprop_root().split('\n') 33 | client_list = next( 34 | iter(filter(lambda x: '_NET_ACTIVE_WINDOW(' in x, lines)) 35 | ) 36 | wid = re.findall("0x[0-9a-f]*", client_list)[0] 37 | return wid 38 | 39 | 40 | def get_window_ids(): 41 | lines = xprop_root().split('\n') 42 | client_list = next(iter(filter(lambda x: '_NET_CLIENT_LIST(' in x, lines))) 43 | window_ids = re.findall('0x[0-9a-f]*', client_list) 44 | return window_ids 45 | 46 | 47 | def _extract_xprop_field(line): 48 | return ''.join(line.split('=')[1:]).strip(' \n') 49 | 50 | 51 | def get_xprop_field(fieldname, xprop_output): 52 | return list( 53 | map(_extract_xprop_field, re.findall(fieldname + '.*\n', xprop_output))) 54 | 55 | 56 | def get_xprop_field_str(fieldname, xprop_output): 57 | return get_xprop_field(fieldname, xprop_output)[0].strip('"') 58 | 59 | 60 | def get_xprop_field_strlist(fieldname, xprop_output): 61 | return [s.strip('"') for s in get_xprop_field(fieldname, xprop_output)] 62 | 63 | 64 | def get_xprop_field_class(xprop_output): 65 | return [c.strip('", ') for c in 66 | get_xprop_field('WM_CLASS', xprop_output)[0].split(',')] 67 | 68 | 69 | def get_xprop_field_int(fieldname, xprop_output): 70 | return int(get_xprop_field(fieldname, xprop_output)[0]) 71 | 72 | 73 | def get_window(wid, active_window=False): 74 | s = xprop_id(wid) 75 | window = { 76 | 'id': wid, 77 | 'active': active_window, 78 | 'name': get_xprop_field_str('WM_NAME', s), 79 | 'class': get_xprop_field_class(s), 80 | 'desktop': get_xprop_field_int('WM_DESKTOP', s), 81 | 'command': get_xprop_field('WM_COMMAND', s), 82 | 'role': get_xprop_field_strlist('WM_WINDOW_ROLE', s), 83 | 'pid': get_xprop_field_int('WM_PID', s), 84 | } 85 | 86 | return window 87 | 88 | 89 | def get_windows(wids, active_window_id=None): 90 | return [get_window(wid, active_window=(wid == active_window_id)) for wid in 91 | wids] 92 | 93 | 94 | def get_x11_owner(): 95 | owner_names = [] 96 | 97 | wid = get_active_window_id() 98 | window = get_window(wid, True) 99 | 100 | window_classes = window.get('class', []) 101 | owner_names.extend(window_classes) 102 | 103 | window_name = window.get('name', '') 104 | if window_name: 105 | application_name = window_name.split('-')[-1].strip() 106 | owner_names.extend([window_name, application_name]) 107 | 108 | binary_path = readlink_binary(window['pid']) 109 | binary_name = os.path.basename(binary_path.strip()) 110 | if binary_name: 111 | owner_names.append(binary_name) 112 | 113 | return owner_names 114 | -------------------------------------------------------------------------------- /clipmanager/paste/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def initialize(): 5 | if os.name == 'nt': 6 | from clipmanager.paste.win32 import paste_action 7 | return paste_action 8 | elif os.name == 'posix': 9 | from clipmanager.paste.x11 import paste_action 10 | return paste_action 11 | -------------------------------------------------------------------------------- /clipmanager/paste/win32.py: -------------------------------------------------------------------------------- 1 | """Source: http://stackoverflow.com/questions/13289777/""" 2 | import ctypes 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | SendInput = ctypes.windll.user32.SendInput 8 | PUL = ctypes.POINTER(ctypes.c_ulong) 9 | 10 | 11 | class KeyBdInput(ctypes.Structure): 12 | _fields_ = [("wVk", ctypes.c_ushort), 13 | ("wScan", ctypes.c_ushort), 14 | ("dwFlags", ctypes.c_ulong), 15 | ("time", ctypes.c_ulong), 16 | ("dwExtraInfo", PUL)] 17 | 18 | 19 | class HardwareInput(ctypes.Structure): 20 | _fields_ = [("uMsg", ctypes.c_ulong), 21 | ("wParamL", ctypes.c_short), 22 | ("wParamH", ctypes.c_ushort)] 23 | 24 | 25 | class MouseInput(ctypes.Structure): 26 | _fields_ = [("dx", ctypes.c_long), 27 | ("dy", ctypes.c_long), 28 | ("mouseData", ctypes.c_ulong), 29 | ("dwFlags", ctypes.c_ulong), 30 | ("time", ctypes.c_ulong), 31 | ("dwExtraInfo", PUL)] 32 | 33 | 34 | class Input_I(ctypes.Union): 35 | _fields_ = [("ki", KeyBdInput), 36 | ("mi", MouseInput), 37 | ("hi", HardwareInput)] 38 | 39 | 40 | class Input(ctypes.Structure): 41 | _fields_ = [("type", ctypes.c_ulong), 42 | ("ii", Input_I)] 43 | 44 | 45 | def _press_key(hex_key_code): 46 | extra = ctypes.c_ulong(0) 47 | ii_ = Input_I() 48 | ii_.ki = KeyBdInput(hex_key_code, 0x48, 0, 0, ctypes.pointer(extra)) 49 | x = Input(ctypes.c_ulong(1), ii_) 50 | ctypes.windll.user32.SendInput(1, ctypes.pointer(x), ctypes.sizeof(x)) 51 | 52 | 53 | def _release_key(hex_key_code): 54 | extra = ctypes.c_ulong(0) 55 | ii_ = Input_I() 56 | ii_.ki = KeyBdInput(hex_key_code, 0x48, 0x0002, 0, ctypes.pointer(extra)) 57 | x = Input(ctypes.c_ulong(1), ii_) 58 | ctypes.windll.user32.SendInput(1, ctypes.pointer(x), ctypes.sizeof(x)) 59 | 60 | 61 | def paste_action(): 62 | _press_key(0x11) # CTRL 63 | _press_key(0x56) # V 64 | _release_key(0x56) 65 | _release_key(0x11) 66 | -------------------------------------------------------------------------------- /clipmanager/paste/x11.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | 4 | from Xlib import X, XK 5 | from Xlib.display import Display 6 | from Xlib.error import DisplayError, XError 7 | from Xlib.protocol import event 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | PASTE_KEY = "v" 12 | 13 | 14 | def _get_keycode(key, display): 15 | keysym = XK.string_to_keysym(key) 16 | keycode = display.keysym_to_keycode(keysym) 17 | return keycode 18 | 19 | 20 | def _paste_x11(): 21 | display = Display() 22 | root = display.screen().root 23 | window = display.get_input_focus().focus 24 | 25 | window.grab_keyboard(False, X.GrabModeAsync, X.GrabModeAsync, X.CurrentTime) 26 | display.flush() 27 | 28 | keycode = _get_keycode(PASTE_KEY, display) 29 | 30 | key_press = event.KeyPress(detail=keycode, 31 | time=X.CurrentTime, 32 | root=root, 33 | window=window, 34 | child=X.NONE, 35 | root_x=0, 36 | root_y=0, 37 | event_x=0, 38 | event_y=0, 39 | state=X.ControlMask, 40 | same_screen=1) 41 | key_release = event.KeyRelease(detail=keycode, 42 | time=X.CurrentTime, 43 | root=root, 44 | window=window, 45 | child=X.NONE, 46 | root_x=0, 47 | root_y=0, 48 | event_x=0, 49 | event_y=0, 50 | state=X.ControlMask, 51 | same_screen=1) 52 | 53 | window.send_event(key_press) 54 | window.send_event(key_release) 55 | display.ungrab_keyboard(X.CurrentTime) 56 | 57 | display.flush() 58 | display.close() 59 | return True 60 | 61 | 62 | def _paste_xdotool(): 63 | cmd = ['xdotool', 'key', '--delay', '100', 'ctrl+v'] 64 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, close_fds=True) 65 | return p.returncode 66 | 67 | 68 | def paste_action(): 69 | """Execute paste key shortcut, CTRL+V. 70 | 71 | If X11 fails, fallback to xdotool. 72 | 73 | :return: None 74 | :rtype: None 75 | """ 76 | try: 77 | _paste_x11() 78 | except (DisplayError, XError) as e: 79 | logger.exception(e) 80 | _paste_xdotool() 81 | -------------------------------------------------------------------------------- /clipmanager/settings.py: -------------------------------------------------------------------------------- 1 | from PySide.QtCore import QObject, QPoint, QSettings, QSize 2 | 3 | 4 | class Settings(QObject): 5 | """Allows other modules to access application settings. 6 | 7 | Todo: 8 | Possibly change to requesting settings by string instead of defining 9 | functions for each. 10 | """ 11 | 12 | def __init__(self, parent=None): 13 | super(QObject, self).__init__(parent) 14 | 15 | self.q_settings = QSettings() 16 | 17 | def filename(self): 18 | """Wrapper around QSettings.filename() 19 | 20 | :return: Path to app's setting ini file. 21 | :rtype: str 22 | """ 23 | return self.q_settings.fileName() 24 | 25 | def sync(self): 26 | """Sync settings to storage method. 27 | """ 28 | self.q_settings.sync() 29 | 30 | def get_disconnect(self): 31 | """Get disconnect from clipboard setting. 32 | 33 | :return: True if enabled, False if disabled. 34 | :rtype: int 35 | """ 36 | return int(self.q_settings.value('disconnect', 0)) 37 | 38 | def set_disconnect(self, value): 39 | """Set disconnect setting value. 40 | 41 | :param value: True if disconnected or false if connected. 42 | :type value: bool 43 | 44 | :return: None 45 | :rtype: None 46 | """ 47 | self.q_settings.setValue('disconnect', int(value)) 48 | 49 | def get_exclude(self): 50 | """Get application exclusion string list. 51 | 52 | :return: String list separated by a semicolon, keepassxc;chromium. 53 | :rtype: str 54 | """ 55 | return self.q_settings.value('exclude_app', str('')) 56 | 57 | def set_exclude(self, value): 58 | """Set application exclusion string list. 59 | 60 | :param value: String list separated by a semicolon, keepassxc;chromium. 61 | :type value: str 62 | 63 | :return: None 64 | :rtype: None 65 | """ 66 | applications = [app.strip() for app in value.split(';') if app] 67 | exclude = ';'.join(applications) 68 | if len(exclude) != 0 and not exclude.endswith(';'): 69 | exclude += ';' 70 | self.q_settings.setValue('exclude_app', str(exclude)) 71 | 72 | def get_global_hot_key(self): 73 | """Get global hoy key shortcut. 74 | 75 | :return: Defaults to Ctrl+Shift+H. 76 | :rtype: str 77 | """ 78 | return str(self.q_settings.value('global_hot_key', 'Ctrl+Shift+H')) 79 | 80 | def set_global_hot_key(self, value): 81 | self.q_settings.setValue('global_hot_key', str(value)) 82 | 83 | def get_lines_to_display(self): 84 | return int(self.q_settings.value('line_count', 4)) 85 | 86 | def set_lines_to_display(self, value): 87 | self.q_settings.setValue('line_count', int(value)) 88 | 89 | def get_open_window_at(self): 90 | return int(self.q_settings.value('open_at', 0)) 91 | 92 | def set_open_window_at(self, value): 93 | self.q_settings.setValue('open_at', int(value)) 94 | 95 | def get_send_paste(self): 96 | return int(self.q_settings.value('send_paste', 1)) 97 | 98 | def set_send_paste(self, value): 99 | self.q_settings.setValue('send_paste', int(value)) 100 | 101 | def set_window_pos(self, value): 102 | self.q_settings.setValue('window_position', value) 103 | 104 | def get_window_pos(self): 105 | return self.q_settings.value('window_position', QPoint(0, 0)) 106 | 107 | def set_window_size(self, value): 108 | self.q_settings.setValue('window_size', value) 109 | 110 | def get_window_size(self): 111 | return self.q_settings.value('window_size', QSize(275, 230)) 112 | 113 | def get_max_entries_value(self): 114 | return int(self.q_settings.value('max_entries', 300)) 115 | 116 | def set_max_entries_value(self, value): 117 | self.q_settings.setValue('max_entries', int(value)) 118 | 119 | def get_expire_value(self): 120 | return int(self.q_settings.value('expire_at', 14)) 121 | 122 | def set_expire_value(self, value): 123 | self.q_settings.setValue('expire_at', int(value)) 124 | 125 | def clear(self): 126 | self.q_settings.clear() 127 | -------------------------------------------------------------------------------- /clipmanager/singleinstance.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import tempfile 4 | 5 | from clipmanager import __name__, __org__ 6 | 7 | if os.name == 'nt': 8 | from win32event import CreateMutex 9 | from win32api import CloseHandle, GetLastError 10 | from winerror import ERROR_ALREADY_EXISTS 11 | else: 12 | import commands 13 | import os 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class SingleInstance: 19 | """Limits application to a single instance. 20 | 21 | References 22 | - https://github.com/josephturnerjr/boatshoes/blob/master/boatshoes/SingleInstance.py 23 | - https://github.com/csuarez/emesene-1.6.3-fixed/blob/master/SingleInstance.py 24 | """ 25 | 26 | def __init__(self): 27 | self.last_error = False 28 | self.pid_path = os.path.normpath( 29 | os.path.join( 30 | tempfile.gettempdir(), 31 | '{}-{}.lock'.format(__name__.lower(), self._get_username()) 32 | ) 33 | ) 34 | 35 | if os.name == 'nt': 36 | # HANDLE WINAPI CreateMutex( 37 | # _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes, 38 | # _In_ BOOL bInitialOwner, 39 | # _In_opt_ LPCTSTR lpName 40 | # ); 41 | # 42 | # DWORD WINAPI GetLastError(void); 43 | self.mutex_name = '{}.{}'.format(__org__, __name__) 44 | self.mutex = CreateMutex(None, False, self.mutex_name) 45 | self.last_error = GetLastError() 46 | else: 47 | if os.path.exists(self.pid_path): 48 | pid = open(self.pid_path, 'r').read().strip() 49 | pid_running = commands.getoutput( 50 | 'ls /proc | grep {}'.format(pid) 51 | ) 52 | 53 | if pid_running: 54 | self.last_error = True 55 | 56 | if not self.last_error: 57 | f = open(self.pid_path, 'w') 58 | f.write(str(os.getpid())) 59 | f.close() 60 | 61 | def __del__(self): 62 | self.destroy() 63 | 64 | @staticmethod 65 | def _get_username(): 66 | return os.getenv('USERNAME') or os.getenv('USER') 67 | 68 | def is_running(self): 69 | """Check if application is running. 70 | 71 | :return: True if instance is running. 72 | :rtype: bool 73 | """ 74 | if os.name == 'nt': 75 | # ERROR_ALREADY_EXISTS 76 | # 183 (0xB7) 77 | # Cannot create a file when that file already exists. 78 | return self.last_error == ERROR_ALREADY_EXISTS 79 | else: 80 | return self.last_error 81 | 82 | def destroy(self): 83 | """Close mutex handle or delete pid file. 84 | 85 | :return: None 86 | :rtype: None 87 | """ 88 | if os.name == 'nt' and self.mutex: 89 | # BOOL WINAPI CloseHandle( 90 | # _In_ HANDLE hObject 91 | # ); 92 | CloseHandle(self.mutex) 93 | else: 94 | try: 95 | os.unlink(self.pid_path) 96 | except OSError as err: 97 | logger.error(err) 98 | -------------------------------------------------------------------------------- /clipmanager/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/clipmanager/ui/__init__.py -------------------------------------------------------------------------------- /clipmanager/ui/dialogs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/clipmanager/ui/dialogs/__init__.py -------------------------------------------------------------------------------- /clipmanager/ui/dialogs/about.py: -------------------------------------------------------------------------------- 1 | from PySide.QtCore import QCoreApplication, Qt, Slot 2 | from PySide.QtGui import QDialog, QDialogButtonBox, QGridLayout, QLabel 3 | 4 | from clipmanager import __license__, __url__ 5 | from clipmanager.ui.icons import get_icon 6 | 7 | 8 | class AboutDialog(QDialog): 9 | """About dialog that displays information about application.""" 10 | 11 | def __init__(self, parent=None): 12 | super(AboutDialog, self).__init__(parent) 13 | 14 | self.parent = parent 15 | 16 | self.setWindowTitle('About') 17 | self.setWindowIcon(get_icon('clipmanager.ico')) 18 | self.setAttribute(Qt.WA_DeleteOnClose) 19 | 20 | app_name = QCoreApplication.applicationName() 21 | app_version = QCoreApplication.applicationVersion() 22 | 23 | app_homepage = QLabel('{0}'.format(__url__)) 24 | app_homepage.setTextFormat(Qt.RichText) 25 | app_homepage.setTextInteractionFlags(Qt.TextBrowserInteraction) 26 | app_homepage.setOpenExternalLinks(True) 27 | 28 | self.button_box = QDialogButtonBox(QDialogButtonBox.Close) 29 | self.button_box.setFocus() 30 | 31 | layout = QGridLayout() 32 | layout.addWidget(QLabel('Name:'), 0, 0) 33 | layout.addWidget(QLabel(app_name), 0, 1) 34 | layout.addWidget(QLabel('Version:'), 1, 0) 35 | layout.addWidget(QLabel(app_version), 1, 1) 36 | layout.addWidget(QLabel('License:'), 2, 0) 37 | layout.addWidget(QLabel(__license__), 2, 1) 38 | layout.addWidget(QLabel('Homepage:'), 4, 0) 39 | layout.addWidget(app_homepage, 4, 1) 40 | layout.addWidget(self.button_box, 5, 0, 1, 4) 41 | self.setLayout(layout) 42 | 43 | self.button_box.rejected.connect(self.close) 44 | 45 | @Slot() 46 | def close(self): 47 | """Close dialog. 48 | 49 | :return: None 50 | :rtype: None 51 | """ 52 | self.done(True) 53 | -------------------------------------------------------------------------------- /clipmanager/ui/dialogs/preview.py: -------------------------------------------------------------------------------- 1 | from PySide.QtCore import QSize, Qt, Slot 2 | from PySide.QtGui import ( 3 | QDialog, 4 | QDialogButtonBox, 5 | QGridLayout, 6 | QTextCursor, 7 | QTextEdit, 8 | ) 9 | 10 | from clipmanager.ui.icons import get_icon 11 | 12 | 13 | class PreviewDialog(QDialog): 14 | """Display preview of item.""" 15 | 16 | def __init__(self, mime_data, parent=None): 17 | """Preview display is determined by mime format. 18 | 19 | :param mime_data: 20 | :type mime_data: QMimeData 21 | 22 | :param parent: 23 | :type parent: 24 | """ 25 | super(PreviewDialog, self).__init__(parent) 26 | 27 | self.parent = parent 28 | 29 | self.setWindowIcon(get_icon('clipmanager.ico')) 30 | self.setWindowTitle('Preview') 31 | self.setAttribute(Qt.WA_DeleteOnClose) 32 | self.resize(QSize(500, 300)) 33 | 34 | doc = QTextEdit(self) 35 | 36 | if mime_data.hasHtml(): 37 | html = mime_data.html() 38 | doc.setHtml(html) 39 | else: 40 | if mime_data.hasUrls(): 41 | text = 'Copied File(s): ' 42 | for url in mime_data.urls(): 43 | text += url.toLocalFile() + '\n' 44 | doc.setPlainText(text) 45 | elif doc.canInsertFromMimeData(mime_data): 46 | doc.insertFromMimeData(mime_data) 47 | else: 48 | doc.setPlainText( 49 | 'Invalid data formats: {}'.format( 50 | ','.join(mime_data.formats()) 51 | ) 52 | ) 53 | 54 | doc.moveCursor(QTextCursor.Start) # scroll to top 55 | doc.ensureCursorVisible() 56 | 57 | self.button_box = QDialogButtonBox(QDialogButtonBox.Close) 58 | self.button_box.setFocus() 59 | 60 | layout = QGridLayout(self) 61 | layout.addWidget(doc, 0, 0) 62 | layout.addWidget(self.button_box, 1, 0) 63 | self.setLayout(layout) 64 | 65 | self.button_box.rejected.connect(self.close) 66 | 67 | @Slot() 68 | def close(self): 69 | """Close dialog. 70 | 71 | :return: None 72 | :rtype: None 73 | """ 74 | self.done(True) 75 | -------------------------------------------------------------------------------- /clipmanager/ui/dialogs/settings.py: -------------------------------------------------------------------------------- 1 | from PySide.QtCore import Qt, Slot 2 | from PySide.QtGui import ( 3 | QCheckBox, 4 | QComboBox, 5 | QDialog, 6 | QDialogButtonBox, 7 | QFormLayout, 8 | QGroupBox, 9 | QKeySequence, 10 | QLineEdit, 11 | QSizePolicy, 12 | QSpinBox, 13 | QVBoxLayout, 14 | ) 15 | 16 | from clipmanager.settings import Settings 17 | from clipmanager.ui.icons import get_icon 18 | 19 | 20 | def _qcheckbox_state(state): 21 | """Toggle QCheckBox based on boolean state. 22 | 23 | :param state: Enabled or disabled. 24 | :type state: bool 25 | 26 | :return: Checked or unchecked. 27 | :rtype: Qt.Checked/Qt.Unchecked 28 | """ 29 | return Qt.Checked if state else Qt.Unchecked 30 | 31 | 32 | class SettingsDialog(QDialog): 33 | """Settings dialog for changing application preferences.""" 34 | 35 | def __init__(self, parent=None): 36 | super(SettingsDialog, self).__init__(parent) 37 | 38 | self.settings = Settings() 39 | 40 | self.setWindowTitle('Settings') 41 | self.setWindowIcon(get_icon('clipmanager.ico')) 42 | self.setAttribute(Qt.WA_DeleteOnClose) 43 | 44 | self.key_combo_edit = HotKeyEdit(self) 45 | self.key_combo_edit.setText(self.settings.get_global_hot_key()) 46 | 47 | self.line_count_spin = QSpinBox(self) 48 | self.line_count_spin.setRange(1, 10) 49 | self.line_count_spin.setValue(self.settings.get_lines_to_display()) 50 | 51 | self.open_at_pos_combo = QComboBox(self) 52 | self.open_at_pos_combo.addItem('Mouse cursor', 0) 53 | self.open_at_pos_combo.addItem('Last position', 1) 54 | self.open_at_pos_combo.addItem('System tray', 2) 55 | 56 | global_form = QFormLayout() 57 | global_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint) 58 | global_form.addRow('Global shortcut:', self.key_combo_edit) 59 | global_form.addRow('Open window at:', self.open_at_pos_combo) 60 | global_form.addRow('Lines to display:', self.line_count_spin) 61 | 62 | self.paste_check = QCheckBox('Paste in active window after selection') 63 | self.paste_check.setCheckState( 64 | _qcheckbox_state(self.settings.get_send_paste()) 65 | ) 66 | 67 | self.entries_edit = QLineEdit(self) 68 | self.entries_edit.setText(str(self.settings.get_max_entries_value())) 69 | self.entries_edit.setToolTip('Ignored if set to 0 days.') 70 | self.entries_edit.setFixedWidth(50) 71 | 72 | self.expire_edit = QSpinBox(self) 73 | self.expire_edit.setRange(0, 60) 74 | self.expire_edit.setSuffix(' days') 75 | self.expire_edit.setToolTip('Ignored if set to 0 days.') 76 | self.expire_edit.setValue(self.settings.get_expire_value()) 77 | 78 | manage_form = QFormLayout() 79 | manage_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint) 80 | manage_form.addRow('Maximum entries:', self.entries_edit) 81 | manage_form.addRow('Expire after:', self.expire_edit) 82 | 83 | manage_box = QGroupBox('Manage history:') 84 | manage_box.setAlignment(Qt.AlignLeft) 85 | manage_box.setLayout(manage_form) 86 | 87 | ignore_box = QGroupBox('Ignore the following applications:') 88 | self.exclude_edit = QLineEdit(self) 89 | self.exclude_edit.setPlaceholderText('BinaryName;WindowTitle;') 90 | self.exclude_edit.setText(self.settings.get_exclude()) 91 | 92 | ignore_layout = QVBoxLayout() 93 | ignore_layout.addWidget(self.exclude_edit) 94 | ignore_box.setLayout(ignore_layout) 95 | 96 | self.button_box = QDialogButtonBox( 97 | QDialogButtonBox.Save | QDialogButtonBox.Cancel) 98 | 99 | main_layout = QVBoxLayout(self) 100 | main_layout.addLayout(global_form) 101 | main_layout.addWidget(self.paste_check) 102 | main_layout.addWidget(manage_box) 103 | main_layout.addWidget(ignore_box) 104 | main_layout.addWidget(self.button_box) 105 | self.setLayout(main_layout) 106 | 107 | # X11: Give focus for window managers, e.g. i3. 108 | self.setFocus(Qt.PopupFocusReason) 109 | 110 | self.button_box.accepted.connect(self.save) 111 | self.button_box.rejected.connect(self.cancel) 112 | 113 | @Slot() 114 | def save(self): 115 | """Save settings and and close the dialog. 116 | 117 | :return: None 118 | :rtype: None 119 | """ 120 | self.settings.set_global_hot_key(self.key_combo_edit.text()) 121 | self.settings.set_lines_to_display(self.line_count_spin.value()) 122 | self.settings.set_send_paste(self.paste_check.isChecked()) 123 | self.settings.set_exclude(self.exclude_edit.text()) 124 | self.settings.set_max_entries_value(self.entries_edit.text()) 125 | self.settings.set_expire_value(self.expire_edit.value()) 126 | 127 | open_at_index = self.open_at_pos_combo.currentIndex() 128 | open_at_value = self.open_at_pos_combo.itemData(open_at_index) 129 | self.settings.set_open_window_at(open_at_value) 130 | 131 | self.settings.sync() 132 | self.done(True) 133 | 134 | @Slot() 135 | def cancel(self): 136 | """Do not save settings and close the dialog. 137 | 138 | :return: None 139 | :rtype: None 140 | """ 141 | self.done(False) 142 | 143 | 144 | class HotKeyEdit(QLineEdit): 145 | """Capture key presses for setting global hot key. 146 | 147 | Source: https://github.com/rokups/paste2box 148 | License: GNU General Public License v3.0 149 | """ 150 | special_key_whitelist = [ 151 | Qt.Key_Print, 152 | Qt.Key_ScrollLock, 153 | Qt.Key_Pause, 154 | ] 155 | 156 | special_key_with_modifiers = [ 157 | Qt.Key_Tab, 158 | Qt.Key_CapsLock, 159 | Qt.Key_Escape, 160 | Qt.Key_Backspace, 161 | Qt.Key_Insert, 162 | Qt.Key_Delete, 163 | Qt.Key_Home, 164 | Qt.Key_End, 165 | Qt.Key_PageUp, 166 | Qt.Key_PageDown, 167 | Qt.Key_NumLock, 168 | Qt.UpArrow, 169 | Qt.RightArrow, 170 | Qt.DownArrow, 171 | Qt.LeftArrow, 172 | ] 173 | 174 | def __init__(self, *args, **kwargs): 175 | QLineEdit.__init__(self, *args, **kwargs) 176 | 177 | self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) 178 | self.setToolTip('Press ESC to clear_text.') 179 | 180 | def keyPressEvent(self, event): 181 | """Capture key and modifier presses and insert then. 182 | 183 | References: 184 | http://stackoverflow.com/questions/6647970/how-can-i-capture-qkey_sequence-from-qkeyevent-depending-on-current-keyboard-layo 185 | 186 | :param event: 187 | :type event: QKeyEvent 188 | 189 | :return: 190 | :rtype: QLineEdit.keyPressEvent 191 | """ 192 | key = event.key() 193 | 194 | key_sequence = 0 195 | mod = event.modifiers() 196 | 197 | if mod & Qt.MetaModifier: 198 | key_sequence |= int(Qt.META) 199 | if mod & Qt.ShiftModifier: 200 | key_sequence |= int(Qt.SHIFT) 201 | if mod & Qt.ControlModifier: 202 | key_sequence |= int(Qt.CTRL) 203 | if mod & Qt.AltModifier: 204 | key_sequence |= int(Qt.ALT) 205 | 206 | if key in self.special_key_with_modifiers and not mod: 207 | if event.key() == Qt.Key_Escape: 208 | self.clear() 209 | 210 | return 211 | 212 | # Empty means a special key like F5, Delete, etc 213 | if event.text() == '' and key not in self.special_key_whitelist: 214 | return 215 | 216 | key_sequence |= int(key) 217 | 218 | self.setText( 219 | QKeySequence(key_sequence).toString(QKeySequence.NativeText) 220 | ) 221 | -------------------------------------------------------------------------------- /clipmanager/ui/historylist.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from itertools import groupby 3 | from operator import itemgetter 4 | 5 | from PySide.QtCore import ( 6 | QCoreApplication, 7 | QModelIndex, 8 | QSize, 9 | Qt, 10 | Signal, 11 | Slot, 12 | ) 13 | from PySide.QtGui import ( 14 | QAbstractItemView, 15 | QAction, 16 | QKeySequence, 17 | QListView, 18 | QMenu, 19 | QPen, 20 | QStyle, 21 | QStyledItemDelegate, 22 | QTextDocument, 23 | QTextOption, 24 | ) 25 | 26 | from clipmanager.ui.icons import get_icon 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class HistoryListView(QListView): 32 | """Clipboard history list.""" 33 | set_clipboard = Signal(QModelIndex) 34 | open_preview = Signal(QModelIndex) 35 | 36 | def __init__(self, parent=None): 37 | super(HistoryListView, self).__init__(parent) 38 | 39 | self.parent = parent 40 | 41 | self.setLayoutMode(QListView.SinglePass) 42 | self.setSelectionMode(QAbstractItemView.ExtendedSelection) 43 | self.setEditTriggers(QAbstractItemView.NoEditTriggers) 44 | self.setDragEnabled(False) 45 | self.setAcceptDrops(False) 46 | self.setAlternatingRowColors(True) 47 | self.setViewMode(QListView.ListMode) 48 | self.setResizeMode(QListView.Adjust) 49 | self.setStyleSheet('QListView::item {padding:10px;}') 50 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 51 | self.setItemDelegate(HistoryListItemDelegate(self)) 52 | 53 | self.doubleClicked.connect(self.emit_set_clipboard) 54 | 55 | self.menu = QMenu(self) 56 | 57 | self.paste_action = QAction(get_icon('edit-paste.png'), 'Paste', self) 58 | self.paste_action.setShortcut(QKeySequence(Qt.Key_Return)) 59 | self.paste_action.triggered.connect(self.emit_set_clipboard) 60 | 61 | self.preview_action = QAction( 62 | get_icon('document-print-preview.png'), 63 | 'Preview', 64 | self 65 | ) 66 | self.preview_action.setShortcut(QKeySequence(Qt.Key_F11)) 67 | self.preview_action.triggered.connect(self.emit_open_preview) 68 | 69 | self.delete_action = QAction(get_icon('list-remove.png'), 'Delete', self) 70 | self.delete_action.setShortcut(QKeySequence.Delete) 71 | self.delete_action.triggered.connect(self.delete_item) 72 | 73 | exit_action = QAction(get_icon('application-exit.png'), 'Quit', self) 74 | exit_action.triggered.connect(QCoreApplication.quit) 75 | 76 | self.menu.addAction(self.paste_action) 77 | self.menu.addAction(self.preview_action) 78 | self.menu.addAction(self.delete_action) 79 | self.menu.addSeparator() 80 | self.menu.addAction(exit_action) 81 | 82 | # keyboard shortcuts work on selected items without menu 83 | self.addAction(self.paste_action) 84 | self.addAction(self.preview_action) 85 | self.addAction(self.delete_action) 86 | 87 | def contextMenuEvent(self, event): 88 | """Open context menu. 89 | 90 | :param event: Event. 91 | :type event: QEvent 92 | 93 | :return: None 94 | :rtype: None 95 | """ 96 | selection_count = len(self.selectionModel().selectedIndexes()) 97 | if selection_count == 0: 98 | self.paste_action.setDisabled(True) 99 | self.preview_action.setDisabled(True) 100 | self.delete_action.setDisabled(True) 101 | elif selection_count == 1: 102 | self.paste_action.setDisabled(False) 103 | self.preview_action.setDisabled(False) 104 | self.delete_action.setDisabled(False) 105 | else: 106 | self.paste_action.setDisabled(True) 107 | self.preview_action.setDisabled(True) 108 | self.delete_action.setDisabled(False) 109 | 110 | self.menu.exec_(event.globalPos()) 111 | 112 | def keyPressEvent(self, event): 113 | """Automatically set focus to search box when typing. 114 | 115 | :param event: 116 | :type event: QEvent 117 | 118 | :return: 119 | :rtype: QListView.keyPressEvent() 120 | """ 121 | # Select all (Ctrl+A) 122 | if (event.modifiers() == Qt.ControlModifier) and ( 123 | event.key() == Qt.Key_A): 124 | return QListView.keyPressEvent(self, event) 125 | # Scroll list view to the right (word wrap disabled) 126 | elif event.key() == Qt.Key_Right: 127 | value = self.horizontalScrollBar().value() 128 | self.horizontalScrollBar().setValue(value + 10) 129 | # Scroll list view to the left (word wrap disabled) 130 | elif event.key() == Qt.Key_Left: 131 | value = self.horizontalScrollBar().value() 132 | self.horizontalScrollBar().setValue(value - 10) 133 | # Give focus to search box if user starts typing letters 134 | elif event.text(): 135 | self.parent.search_box.setText( 136 | self.parent.search_box.text() + event.text()) 137 | self.parent.search_box.setFocus(Qt.ActiveWindowFocusReason) 138 | 139 | return QListView.keyPressEvent(self, event) 140 | 141 | @Slot() 142 | def emit_set_clipboard(self): 143 | """Send set clipboard signal with current selection. 144 | 145 | :return: None 146 | :rtype: None 147 | """ 148 | indexes = self.selectionModel().selectedIndexes() 149 | if len(indexes) == 1: 150 | self.set_clipboard.emit(indexes[0]) 151 | 152 | @Slot() 153 | def emit_open_preview(self): 154 | """Send open preview signal with selection index. 155 | 156 | :return: None 157 | :rtype: None 158 | """ 159 | indexes = self.selectionModel().selectedIndexes() 160 | if len(indexes) == 1: 161 | self.open_preview.emit(indexes[0]) 162 | 163 | @Slot() 164 | def delete_item(self): 165 | """Delete selected rows. 166 | 167 | CTRL+A on list view selects hidden columns. So even if user deselects 168 | an item, it will still be deleted since the hidden column is still 169 | selected. 170 | 171 | :return: None 172 | :rtype: None 173 | """ 174 | self.setCursor(Qt.BusyCursor) 175 | 176 | selection_model = self.selectionModel() 177 | selection_rows = set(idx.row() for idx in 178 | selection_model.selectedIndexes()) 179 | 180 | # delete from data table 181 | parent_indexes = [self.model().index(row, 0) for row in selection_rows] 182 | parent_ids = filter(lambda p: p is not None, 183 | [self.model().data(idx) for idx in parent_indexes]) 184 | self.parent.data_model.delete(parent_ids) 185 | 186 | # delete from main table and view 187 | for k, g in groupby(enumerate(selection_rows), lambda (i, x): i - x): 188 | rows = map(itemgetter(1), g) 189 | self.model().removeRows(min(rows), len(rows)) 190 | 191 | self.model().sourceModel().submitAll() 192 | self.unsetCursor() 193 | 194 | 195 | class HistoryListItemDelegate(QStyledItemDelegate): 196 | """Subclass painting and style of QListView items.""" 197 | 198 | def __init__(self, parent=None): 199 | super(HistoryListItemDelegate, self).__init__(parent) 200 | 201 | def paint(self, painter, option, index): 202 | """Subclass of paint function. 203 | 204 | References: 205 | http://pydoc.net/Python/gayeogi/0.6/gayeogi.plugins.player/ 206 | 207 | :param painter: 208 | :type painter: QPainter 209 | 210 | :param option: 211 | :type option: QStyleOptionViewItem 212 | 213 | :param index: 214 | :type index: QModelIndex 215 | 216 | :return: 217 | :rtype: QStyledItemDelegate.paint() 218 | """ 219 | if not index.isValid(): 220 | return QStyledItemDelegate.paint(self, painter, option, index) 221 | 222 | painter.save() 223 | 224 | # draw selection highlight 225 | if option.state & QStyle.State_Selected: 226 | painter.setPen(QPen(option.palette.highlightedText(), 0)) 227 | painter.fillRect(option.rect, option.palette.highlight()) 228 | 229 | # Set alignment and enable word wrap if applicable 230 | text_option = QTextOption() 231 | text_option.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) 232 | text_option.setWrapMode(QTextOption.NoWrap) 233 | 234 | # add left and right padding to text 235 | text_rect = option.rect 236 | text_rect.setLeft(text_rect.left() + 5) 237 | text_rect.setRight(text_rect.right() - 5) 238 | 239 | painter.drawText(text_rect, index.data(), o=text_option) 240 | painter.restore() 241 | 242 | def sizeHint(self, option, index): 243 | """Calculate option size. 244 | 245 | Calculated by creating a QTextDocument with modified text and 246 | determining the dimensions. 247 | 248 | Todo: 249 | * Look into using font metrics bounding rect. 250 | * Handle lines to display in relation to word wrap. 251 | 252 | References: 253 | http://qt-project.org/forums/viewthread/12186 254 | 255 | :param option: 256 | :type option: QStyleOptionViewItem 257 | 258 | :param index: 259 | :type index: QModelIndex 260 | 261 | :return: 262 | :rtype: QSize 263 | """ 264 | if not index.isValid(): 265 | return QStyledItemDelegate.sizeHint(self, option, index) 266 | 267 | # WARNING: Inserting self creates a memory leak! 268 | doc = QTextDocument() 269 | 270 | text_option = QTextOption() 271 | text_option.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) 272 | text_option.setWrapMode(QTextOption.NoWrap) 273 | 274 | doc.setDefaultTextOption(text_option) 275 | doc.setPlainText(index.data()) 276 | 277 | # add padding to each item 278 | return QSize(doc.size().width(), doc.size().height() + 5) 279 | 280 | # def sizeHint(self, option, index): 281 | # if not index.isValid(): 282 | # return QStyledItemDelegate.sizeHint(self, option, index) 283 | 284 | # fake_text = 'Line1\nLine2\nLine3\n' 285 | # fake_fm = option.fontMetrics 286 | # fake_font_rect = fake_fm.boundingRect(option.rect, Qt.AlignLeft|Qt.AlignTop|Qt.TextWordWrap, fake_text) 287 | 288 | # real_text = index.data() 289 | # real_fm = option.fontMetrics 290 | # real_font_rect = real_fm.boundingRect(option.rect, Qt.AlignLeft|Qt.AlignTop|Qt.TextWordWrap, real_text) 291 | 292 | # if real_font_rect.height() < fake_font_rect.height(): 293 | # height = real_font_rect.height() 294 | # else: 295 | # height = fake_font_rect.height() 296 | 297 | # return QSize(real_font_rect.width(), height+10) 298 | 299 | # def flags(self, index): 300 | # """Sublass of flags method. 301 | 302 | # Args: 303 | # index: QModelIndex 304 | # """ 305 | # if not index.isValid(): 306 | # return Qt.ItemFlags() 307 | 308 | # return Qt.ItemFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) 309 | -------------------------------------------------------------------------------- /clipmanager/ui/icons.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from PySide.QtGui import QIcon 4 | 5 | from clipmanager import resources 6 | 7 | 8 | def get_icon(name): 9 | """Helper to load QIcon from theme or QResources. 10 | 11 | :param name: Filename of the icon: search.png. 12 | :type name: str 13 | 14 | :return: Qt QIcon class. 15 | :rtype: QIcon 16 | """ 17 | basename = os.path.splitext(name)[0] 18 | 19 | icon = QIcon.fromTheme(basename) 20 | if not icon.isNull(): 21 | return icon 22 | 23 | return QIcon(':/icons/{}'.format(name)) 24 | -------------------------------------------------------------------------------- /clipmanager/ui/mainwindow.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import zlib 4 | 5 | from PySide.QtCore import ( 6 | QDateTime, 7 | QMimeData, 8 | QModelIndex, 9 | QTextCodec, 10 | QTextEncoder, 11 | Qt, 12 | Signal, 13 | Slot, 14 | ) 15 | from PySide.QtGui import ( 16 | QCursor, 17 | QDesktopWidget, 18 | QGridLayout, 19 | QItemSelectionModel, 20 | QMainWindow, 21 | QMessageBox, 22 | QPushButton, 23 | QSystemTrayIcon, 24 | QWidget, 25 | ) 26 | 27 | from clipmanager import __title__, hotkey, owner, paste 28 | from clipmanager.clipboard import ClipboardManager 29 | from clipmanager.database import Database 30 | from clipmanager.defs import MIME_SUPPORTED 31 | from clipmanager.models import DataSqlTableModel, MainSqlTableModel 32 | from clipmanager.settings import Settings 33 | from clipmanager.ui.dialogs.preview import PreviewDialog 34 | from clipmanager.ui.dialogs.settings import SettingsDialog 35 | from clipmanager.ui.historylist import HistoryListView 36 | from clipmanager.ui.icons import get_icon 37 | from clipmanager.ui.searchedit import SearchEdit, SearchFilterProxyModel 38 | from clipmanager.ui.systemtray import SystemTrayIcon 39 | from clipmanager.utils import format_title, truncate_lines 40 | 41 | logger = logging.getLogger(__name__) 42 | 43 | 44 | class MainWindow(QMainWindow): 45 | """Main window container for main widget. 46 | 47 | :param minimize: True, minimize to system tray, False, bring to front. 48 | :type minimize: bool 49 | """ 50 | 51 | def __init__(self, minimize=False): 52 | super(MainWindow, self).__init__() 53 | 54 | self.setWindowTitle(__title__) 55 | self.setWindowIcon(get_icon('clipmanager.ico')) 56 | 57 | # hide minimize and maximize in window title 58 | self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowCloseButtonHint) 59 | 60 | if not QSystemTrayIcon.isSystemTrayAvailable(): 61 | QMessageBox.critical( 62 | self, 63 | 'System Tray', 64 | 'Cannot find a system tray.' 65 | ) 66 | 67 | self.system_tray = SystemTrayIcon(self) 68 | self.system_tray.activated.connect(self.system_tray_activate) 69 | self.system_tray.show() 70 | 71 | self.settings = Settings() 72 | logger.info(self.settings.filename()) 73 | 74 | self.main_widget = MainWidget(self) 75 | self.setCentralWidget(self.main_widget) 76 | 77 | # Return OS specific global hot key binder and set it 78 | self.hotkey = hotkey.initialize() 79 | self.paste = paste.initialize() 80 | 81 | if not minimize: 82 | self.toggle_window() 83 | 84 | self.register_hot_key() 85 | 86 | self.system_tray.toggle_window.connect(self.toggle_window) 87 | self.system_tray.open_settings.connect(self.open_settings) 88 | 89 | self.main_widget.open_settings.connect(self.open_settings) 90 | self.main_widget.paste_clipboard.connect(self.paste_clipboard) 91 | 92 | def closeEvent(self, event): 93 | """Capture close event and hide main window. 94 | 95 | Hide main window instead of exiting. Only method to quit is from right 96 | click menu on system tray icon or view widget. 97 | 98 | :param event: Exit event signal from user clicking "close". 99 | :type event: QCloseEvent 100 | 101 | :return: None 102 | :rtype: None 103 | """ 104 | event.ignore() 105 | 106 | self.settings.set_window_pos(self.pos()) 107 | self.settings.set_window_size(self.size()) 108 | 109 | self.hide() 110 | 111 | def register_hot_key(self): 112 | """Helper function to bind global hot key to OS specific binder class. 113 | 114 | If binding fails then display a message in system tray notification 115 | tray. 116 | """ 117 | key_sequence = self.settings.get_global_hot_key() # Ctrl+Shift+h 118 | if key_sequence: 119 | self.hotkey.unregister(winid=self.winId()) 120 | return self.hotkey.register(key_sequence, self.toggle_window, 121 | self.winId()) 122 | else: 123 | self.system_tray.showMessage( 124 | 'Global Hot Key', 125 | 'Failed to bind global hot key {}.'.format(hotkey), 126 | icon=QSystemTrayIcon.Warning, 127 | msecs=10000 128 | ) 129 | return False 130 | 131 | def destroy(self): 132 | """Perform cleanup before exiting the application. 133 | 134 | :return: None 135 | :rtype: None 136 | """ 137 | self.main_widget.destroy() 138 | 139 | if self.hotkey: 140 | self.hotkey.unregister(winid=self.winId()) 141 | self.hotkey.stop() 142 | 143 | self.settings.set_window_pos(self.pos()) 144 | self.settings.set_window_size(self.size()) 145 | self.settings.sync() 146 | 147 | @Slot(QSystemTrayIcon.ActivationReason) 148 | def system_tray_activate(self, activation_reason): 149 | """Toggle window when system tray icon is clicked. 150 | 151 | :param activation_reason: Clicked or double clicked. 152 | :type activation_reason: QSystemTrayIcon.ActivationReason.Trigger 153 | 154 | :return: None 155 | :rtype: None 156 | """ 157 | if activation_reason in (QSystemTrayIcon.Trigger, 158 | QSystemTrayIcon.DoubleClick): 159 | self.toggle_window() 160 | 161 | @Slot() 162 | def open_settings(self): 163 | """Launch settings dialog. 164 | 165 | Before opening, unbind global hot key and rebind after dialog is closed. 166 | Model view is also updated to reflect lines to display. 167 | 168 | :return: 169 | :rtype: 170 | """ 171 | # Windows allow's the user to open extra settings dialogs from system 172 | # tray menu even though dialog is modal 173 | self.hotkey.unregister(winid=self.winId()) 174 | 175 | settings_dialog = SettingsDialog() 176 | settings_dialog.exec_() 177 | 178 | self.setCursor(Qt.BusyCursor) 179 | 180 | # Attempt to set new hot key 181 | self.register_hot_key() 182 | 183 | self.main_widget.main_model.select() 184 | self.unsetCursor() 185 | 186 | @Slot() 187 | def toggle_window(self): 188 | """Show and hide main window. 189 | 190 | If visible, then hide the window. If not visible, then open window 191 | based on position settings at: mouse cursor, system tray, or last 192 | position. Adjust's the window position based on desktop dimensions to 193 | prevent main window going off screen. 194 | 195 | :return: None 196 | :rtype: None 197 | """ 198 | window_size = self.settings.get_window_size() 199 | 200 | # Hide window if visible and leave function 201 | if self.isVisible(): 202 | self.settings.set_window_pos(self.pos()) 203 | self.settings.set_window_size(self.size()) 204 | self.hide() 205 | else: 206 | # Desktop number based on cursor 207 | desktop = QDesktopWidget() 208 | 209 | # Determine global coordinates by summing screen(s) coordinates 210 | x_max = 0 211 | y_max = 999999 212 | for screen in range(0, desktop.screenCount()): 213 | x_max += desktop.availableGeometry(screen).width() 214 | 215 | y_screen = desktop.availableGeometry(screen).height() 216 | if y_screen < y_max: 217 | y_max = y_screen 218 | 219 | # Minimum x and y screen coordinates 220 | x_min, y_min, __, __ = desktop.availableGeometry().getCoords() 221 | 222 | open_window_at = self.settings.get_open_window_at() 223 | if open_window_at == 2: # 2: System tray 224 | x = self.system_tray.geometry().x() 225 | y = self.system_tray.geometry().y() 226 | elif open_window_at == 1: # 1: Last position 227 | x = self.settings.get_window_pos().x() 228 | y = self.settings.get_window_pos().y() 229 | else: # 0: At mouse cursor 230 | x = QCursor().pos().x() 231 | y = QCursor().pos().y() 232 | 233 | # Readjust window's position if offscreen 234 | if x < x_min: 235 | x = x_min 236 | elif x + window_size.width() > x_max: 237 | x = x_max - window_size.width() 238 | 239 | if y < y_min: 240 | y = y_min 241 | elif y + window_size.height() > y_max: 242 | y = y_max - window_size.height() 243 | 244 | # Clear search box from last interaction 245 | if len(self.main_widget.search_box.text()) != 0: 246 | self.main_widget.search_box.clear() 247 | 248 | # Reposition and resize the main window 249 | self.move(x, y) 250 | self.resize(window_size.width(), window_size.height()) 251 | 252 | self.show() 253 | self.activateWindow() 254 | self.main_widget.check_selection() 255 | 256 | @Slot() 257 | def paste_clipboard(self): 258 | self.paste() 259 | 260 | 261 | class MainWidget(QWidget): 262 | """Main widget container for main window.""" 263 | open_settings = Signal() 264 | paste_clipboard = Signal() 265 | 266 | def __init__(self, parent=None): 267 | super(MainWidget, self).__init__(parent) 268 | 269 | self.parent = parent 270 | 271 | self.settings = Settings() 272 | 273 | self.database = Database(self) 274 | self.database.create_tables() 275 | 276 | self.ignore_created = False 277 | 278 | self.clipboard_manager = ClipboardManager(self) 279 | self.window_owner = owner.initialize() 280 | 281 | self.history_view = HistoryListView(self) 282 | 283 | self.main_model = MainSqlTableModel(self) 284 | self.data_model = DataSqlTableModel(self) 285 | 286 | self.search_proxy = SearchFilterProxyModel(self) 287 | self.search_proxy.setSourceModel(self.main_model) 288 | 289 | self.history_view.setModel(self.search_proxy) 290 | self.history_view.setModelColumn(self.main_model.TITLE_SHORT) 291 | 292 | self.search_box = SearchEdit(self.history_view, self.search_proxy) 293 | 294 | settings_button = QPushButton(self) 295 | settings_button.setIcon(get_icon('preferences-system.png')) 296 | settings_button.setToolTip('Settings...') 297 | settings_button.clicked.connect(self.emit_open_settings) 298 | 299 | layout = QGridLayout(self) 300 | 301 | layout.addWidget(self.search_box, 0, 0) 302 | layout.addWidget(settings_button, 0, 1) 303 | layout.addWidget(self.history_view, 1, 0, 1, 2) 304 | 305 | self.setLayout(layout) 306 | 307 | self.clipboard_manager.new_item.connect(self.new_item) 308 | 309 | self.search_box.returnPressed.connect(self.set_clipboard) 310 | self.search_box.textChanged.connect( 311 | self.search_proxy.setFilterFixedString) 312 | self.search_box.textChanged.connect(self.check_selection) 313 | 314 | self.history_view.set_clipboard.connect(self.set_clipboard) 315 | self.history_view.open_preview.connect(self.open_preview) 316 | 317 | def create_item_title(self, mime_data): 318 | """Create full title from clipboard mime data. 319 | 320 | Extract a title from QMimeData using urls, html, or text. 321 | 322 | :param mime_data: 323 | :type mime_data: QMimeData 324 | 325 | :return: Full title or None if it did not have any text/html/url. 326 | :rtype: str or None 327 | """ 328 | if mime_data.hasUrls(): 329 | urls = [url.toString() for url in mime_data.urls()] 330 | return 'Copied File(s):\n' + '\n'.join(urls) 331 | elif mime_data.hasText(): 332 | return mime_data.text() 333 | elif mime_data.hasHtml(): # last resort 334 | return mime_data.html() 335 | else: 336 | self.parent.system_tray.showMessage( 337 | 'Clipboard', 338 | 'Failed to get clipboard mime data formats.', 339 | icon=QSystemTrayIcon.Warning, 340 | msecs=5000 341 | ) 342 | return None 343 | 344 | def get_item_checksum(self, mime_data): 345 | """Calculate CRC checksum based on urls, html, or text. 346 | 347 | :param mime_data: Data from clipboard. 348 | :type mime_data: QMimeData 349 | 350 | :return: CRC32 checksum. 351 | :rtype: int 352 | """ 353 | if mime_data.hasUrls(): 354 | checksum_str = str(mime_data.urls()) 355 | elif mime_data.hasHtml(): 356 | checksum_str = mime_data.html() 357 | elif mime_data.hasText(): 358 | checksum_str = mime_data.text() 359 | else: 360 | self.parent.system_tray.showMessage( 361 | 'Clipboard', 362 | 'Failed to get clipboard text/html/urls.', 363 | icon=QSystemTrayIcon.Warning, 364 | msecs=5000 365 | ) 366 | return None 367 | 368 | # encode unicode characters for crc library 369 | codec = QTextCodec.codecForName('UTF-8') 370 | encoder = QTextEncoder(codec) 371 | byte_array = encoder.fromUnicode(checksum_str) # QByteArray 372 | 373 | checksum = zlib.crc32(byte_array) 374 | return checksum 375 | 376 | def destroy(self): 377 | self.main_model.submitAll() 378 | self.database.close() 379 | 380 | def find_duplicate(self, checksum): 381 | """Checks for a duplicate row in Main table. 382 | 383 | :param checksum: CRC32 string to search for. 384 | :type checksum: int 385 | 386 | :return: True if duplicate found and False if not found. 387 | :rtype: bool 388 | """ 389 | for row in range(self.main_model.rowCount()): 390 | source_index = self.main_model.index(row, self.main_model.CHECKSUM) 391 | checksum_source = self.main_model.data(source_index) 392 | 393 | # update row created_at timestamp 394 | if str(checksum) == str(checksum_source): 395 | self.main_model.setData( 396 | self.main_model.index(row, self.main_model.CREATED_AT), 397 | QDateTime.currentMSecsSinceEpoch() 398 | ) 399 | self.main_model.submitAll() 400 | return True 401 | 402 | return False 403 | 404 | def purge_expired_entries(self): 405 | """Remove entries that have expired. 406 | 407 | Starting at the bottom of the list, compare each item's date to user 408 | set expire in X days. If item is older than setting, remove it from 409 | database. 410 | 411 | :return: None 412 | :rtype: None 413 | """ 414 | expire_at = self.settings.get_expire_value() 415 | if int(expire_at) == 0: 416 | return 417 | 418 | entries = range(0, self.main_model.rowCount()) 419 | entries.reverse() # sort by oldest 420 | 421 | for row in entries: 422 | index = self.main_model.index(row, self.main_model.CREATED_AT) 423 | created_at = self.main_model.data(index) 424 | 425 | # Convert from ms to s 426 | time = datetime.datetime.fromtimestamp(created_at / 1000) 427 | today = datetime.datetime.today() 428 | delta = today - time 429 | 430 | if delta.days > expire_at: 431 | index = self.main_model.index(row, self.main_model.ID) 432 | parent_id = self.main_model.data(index) 433 | 434 | self.data_model.delete([parent_id]) 435 | self.main_model.removeRow(row) 436 | else: 437 | break 438 | 439 | self.main_model.submitAll() 440 | 441 | def purge_max_entries(self): 442 | """Remove extra entries. 443 | 444 | Count total number of items in history, and if greater than user 445 | setting for maximum entries, delete_item them. 446 | 447 | :return: None 448 | :rtype: None 449 | """ 450 | max_entries = self.settings.get_max_entries_value() 451 | if max_entries == 0: 452 | return 453 | 454 | row_count = self.main_model.rowCount() + 1 455 | 456 | if row_count > max_entries: 457 | for row in range(max_entries - 1, row_count): 458 | index_id = self.main_model.index(row, self.main_model.ID) 459 | parent_id = self.main_model.data(index_id) 460 | 461 | self.data_model.delete(parent_id) 462 | self.main_model.removeRow(row) 463 | 464 | self.main_model.submitAll() 465 | 466 | @Slot(str) 467 | def check_selection(self): 468 | """Prevent user selection from disappearing during a proxy filter. 469 | 470 | :return: None 471 | :rtype: None 472 | """ 473 | selection_model = self.history_view.selectionModel() 474 | indexes = selection_model.selectedIndexes() 475 | 476 | if not indexes: 477 | index = self.search_proxy.index(0, self.main_model.TITLE_SHORT) 478 | selection_model.select(index, QItemSelectionModel.Select) 479 | selection_model.setCurrentIndex(index, 480 | QItemSelectionModel.Select) 481 | 482 | @Slot(QMimeData) 483 | def new_item(self, mime_data): 484 | """Append clipboard contents to database. 485 | 486 | :param mime_data: Clipboard contents mime data 487 | :type mime_data: QMimeData 488 | 489 | :return: True, if successfully added. 490 | :rtype: bool 491 | """ 492 | if self.settings.get_disconnect(): 493 | return False 494 | elif self.ignore_created: 495 | self.ignore_created = False 496 | return False 497 | 498 | # Check if process that set clipboard is on exclude list 499 | window_names = self.window_owner() 500 | logger.debug('%s', window_names) 501 | 502 | ignore_list = self.settings.get_exclude().lower().split(';') 503 | if any(str(i) in window_names for i in ignore_list): 504 | logger.info('Ignoring copy from application.') 505 | return False 506 | 507 | title = self.create_item_title(mime_data) 508 | if not title: 509 | self.parent.system_tray.showMessage( 510 | 'Clipboard', 511 | 'Failed to get clipboard contents.', 512 | icon=QSystemTrayIcon.Warning, 513 | msecs=5000 514 | ) 515 | return None 516 | 517 | title_short = format_title(title) 518 | title_short = truncate_lines(title_short, 519 | self.settings.get_lines_to_display()) 520 | created_at = QDateTime.currentMSecsSinceEpoch() 521 | 522 | checksum = self.get_item_checksum(mime_data) 523 | if checksum and self.find_duplicate(checksum): 524 | return None 525 | 526 | parent_id = self.main_model.create(title=title, 527 | title_short=title_short, 528 | checksum=checksum, 529 | created_at=created_at) 530 | 531 | for mime_format in MIME_SUPPORTED: 532 | if mime_data.hasFormat(mime_format): 533 | byte_data = mime_data.data(mime_format) 534 | self.data_model.create(parent_id, mime_format, byte_data) 535 | 536 | self.purge_max_entries() 537 | self.purge_expired_entries() 538 | 539 | # Highlight top item and then insert mime data 540 | self.main_model.select() # Update view 541 | index = QModelIndex( 542 | self.history_view.model().index(0, self.main_model.TITLE_SHORT) 543 | ) 544 | self.history_view.setCurrentIndex(index) 545 | 546 | return True 547 | 548 | @Slot(QModelIndex) 549 | def open_preview(self, selection_index): 550 | """"Open preview dialog for selected item. 551 | 552 | :param selection_index: Selected row index from history list view. 553 | :type selection_index: QModelIndex 554 | 555 | :return: None 556 | :rtype: None 557 | """ 558 | source_index = self.search_proxy.mapToSource(selection_index) 559 | source_row = source_index.row() 560 | model_index = self.main_model.index(source_row, self.main_model.ID) 561 | parent_id = self.main_model.data(model_index) 562 | 563 | mime_data = QMimeData() 564 | for mime_format, byte_data in self.data_model.read(parent_id): 565 | mime_data.setData(mime_format, byte_data) 566 | 567 | preview_dialog = PreviewDialog(mime_data, parent=self) 568 | preview_dialog.exec_() 569 | del preview_dialog 570 | 571 | @Slot(QModelIndex) 572 | def set_clipboard(self, selection_index): 573 | """Set selection item to clipboard. 574 | 575 | :param selection_index: 576 | :type selection_index: 577 | 578 | :return: 579 | :rtype: 580 | """ 581 | self.window().hide() 582 | self.ignore_created = True 583 | 584 | source_index = self.search_proxy.mapToSource(selection_index) 585 | source_row = source_index.row() 586 | model_index = self.main_model.index(source_row, self.main_model.ID) 587 | parent_id = self.main_model.data(model_index) 588 | 589 | mime_data = QMimeData() 590 | for mime_type, byte_data in self.data_model.read(parent_id): 591 | mime_data.setData(mime_type, byte_data) 592 | 593 | self.clipboard_manager.set_text(mime_data) 594 | 595 | if self.settings.get_send_paste(): 596 | self.paste_clipboard.emit() 597 | 598 | self.main_model.setData( 599 | self.main_model.index(model_index.row(), 600 | self.main_model.CREATED_AT), 601 | QDateTime.currentMSecsSinceEpoch()) 602 | self.main_model.submitAll() 603 | 604 | @Slot() 605 | def emit_open_settings(self): 606 | """Emit signal to open settings dialog. 607 | 608 | :return: None 609 | :rtype: None 610 | """ 611 | self.open_settings.emit() 612 | -------------------------------------------------------------------------------- /clipmanager/ui/searchedit.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PySide.QtCore import Qt, Slot 4 | from PySide.QtGui import QLineEdit, QSortFilterProxyModel 5 | 6 | from clipmanager.models import MainSqlTableModel 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class SearchFilterProxyModel(QSortFilterProxyModel): 12 | """Search database using fixed string.""" 13 | 14 | def __init__(self, parent=None): 15 | super(SearchFilterProxyModel, self).__init__(parent) 16 | 17 | self.setFilterKeyColumn(MainSqlTableModel.TITLE) 18 | self.setDynamicSortFilter(True) 19 | self.setFilterCaseSensitivity(Qt.CaseInsensitive) 20 | 21 | @Slot(str) 22 | def setFilterFixedString(self, *args): 23 | """Fetch rows from source model before filtering. 24 | 25 | :param args: Filter string. 26 | :type args: tuple[str] 27 | 28 | :return: None 29 | :rtype: None 30 | """ 31 | while self.sourceModel().canFetchMore(): 32 | self.sourceModel().fetchMore() 33 | QSortFilterProxyModel.setFilterFixedString(self, *args) 34 | 35 | 36 | class SearchEdit(QLineEdit): 37 | """Search box for history view and main table model.""" 38 | 39 | def __init__(self, view, proxy, parent=None): 40 | super(SearchEdit, self).__init__(parent) 41 | 42 | self.view = view # QListView 43 | self.proxy = proxy # QSortFilterProxyModel 44 | self.parent = parent 45 | 46 | self.setPlaceholderText('Start typing to search history...') 47 | 48 | def keyPressEvent(self, event): 49 | """Allow arrow selection on history list from search box. 50 | 51 | Override QLineEdit.keyPressEvent and check for up and down arrow key 52 | for changing selection. If conditional checks not met, return original 53 | keyPressEvent. 54 | 55 | :param event: 56 | :type event: Qt.Event 57 | 58 | :return: None 59 | :rtype: None 60 | """ 61 | if event.key() == Qt.Key_Up: 62 | if self.view.currentIndex().row() >= 1: 63 | current_row = self.view.currentIndex().row() 64 | index = self.proxy.index(current_row - 1, 65 | MainSqlTableModel.TITLE_SHORT) 66 | self.view.setCurrentIndex(index) 67 | else: 68 | # keep selection at top 69 | index = self.proxy.index(0, MainSqlTableModel.TITLE_SHORT) 70 | self.view.setCurrentIndex(index) 71 | elif event.key() == Qt.Key_Down: 72 | current_row = self.view.currentIndex().row() 73 | index = self.proxy.index(current_row + 1, 74 | MainSqlTableModel.TITLE_SHORT) 75 | self.view.setCurrentIndex(index) 76 | elif event.key() == Qt.Key_Escape: 77 | self.clear() 78 | 79 | return QLineEdit.keyPressEvent(self, event) 80 | -------------------------------------------------------------------------------- /clipmanager/ui/systemtray.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PySide.QtCore import QCoreApplication, Signal, Slot 4 | from PySide.QtGui import QAction, QMenu, QSystemTrayIcon 5 | 6 | from clipmanager import __title__ 7 | from clipmanager.settings import Settings 8 | from clipmanager.ui.dialogs.about import AboutDialog 9 | from clipmanager.ui.icons import get_icon 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class SystemTrayIcon(QSystemTrayIcon): 15 | """Application system tray icon with right click menu.""" 16 | toggle_window = Signal() 17 | open_settings = Signal() 18 | 19 | def __init__(self, parent=None): 20 | super(SystemTrayIcon, self).__init__(parent) 21 | 22 | self.parent = parent 23 | 24 | self.setToolTip(__title__) 25 | self.setIcon(get_icon('clipmanager.ico')) 26 | 27 | self.settings = Settings() 28 | 29 | menu = QMenu(parent) 30 | 31 | toggle_action = QAction('&Toggle', self) 32 | toggle_action.triggered.connect(self.emit_toggle_window) 33 | 34 | settings_action = QAction(get_icon('preferences-system.png'), 35 | '&Settings', 36 | self) 37 | settings_action.triggered.connect(self.emit_open_settings) 38 | 39 | about_action = QAction(get_icon('help-about.png'), '&About', self) 40 | about_action.triggered.connect(self.open_about) 41 | 42 | exit_action = QAction(get_icon('application-exit.png'), '&Quit', self) 43 | exit_action.triggered.connect(QCoreApplication.quit) 44 | 45 | disconnect_action = QAction('&Private mode', self) 46 | disconnect_action.setCheckable(True) 47 | disconnect_action.setChecked(self.settings.get_disconnect()) 48 | disconnect_action.triggered.connect(self.toggle_private) 49 | 50 | menu.addAction(toggle_action) 51 | menu.addSeparator() 52 | menu.addAction(disconnect_action) 53 | menu.addAction(settings_action) 54 | menu.addAction(about_action) 55 | menu.addSeparator() 56 | menu.addAction(exit_action) 57 | 58 | self.setContextMenu(menu) 59 | 60 | @Slot() 61 | def toggle_private(self): 62 | """Toggle and save private self.settings. 63 | 64 | :return: None 65 | :rtype: None 66 | """ 67 | self.settings.set_disconnect(not self.settings.get_disconnect()) 68 | 69 | @Slot() 70 | def open_about(self): 71 | """Open about dialog. 72 | 73 | :return: None 74 | :rtype: None 75 | """ 76 | about = AboutDialog() 77 | about.exec_() 78 | del about 79 | 80 | @Slot() 81 | def emit_toggle_window(self): 82 | """Emit signal to toggle the main window. 83 | 84 | :return: None 85 | :rtype: None 86 | """ 87 | self.toggle_window.emit() 88 | 89 | @Slot() 90 | def emit_open_settings(self): 91 | """Emit signal to open the settings dialog. 92 | 93 | :return: None 94 | :rtype: None 95 | """ 96 | self.open_settings.emit() 97 | -------------------------------------------------------------------------------- /clipmanager/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import textwrap 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def format_title(title): 8 | """Format clipboard text for display in history view list. 9 | 10 | :param title: Title to format. 11 | :type title: str 12 | 13 | :return: Formatted text. 14 | :rtype: str 15 | """ 16 | modified = textwrap.dedent(title) 17 | return modified.replace('\t', ' ') 18 | 19 | 20 | def truncate_lines(text, count): 21 | """Truncate string based on line count. 22 | 23 | Counts number of line breaks in text and removes extra lines 24 | based on line_count value. If lines are removed, appends '...' 25 | to end of text to inform user of truncation. 26 | 27 | :param text: Single or multi-line string. 28 | :type text: str 29 | 30 | :param count: Number of lines to return. 31 | :type count: int 32 | 33 | :return: Truncated text string. 34 | :rtype: str 35 | """ 36 | lines = [line for line in text.splitlines() if line.strip()] 37 | text = '\n'.join(lines[:count]) 38 | if len(lines) > count: 39 | text += '...' 40 | return text 41 | -------------------------------------------------------------------------------- /data/application-exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/application-exit.png -------------------------------------------------------------------------------- /data/clipmanager.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/clipmanager.ico -------------------------------------------------------------------------------- /data/clipmanager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/clipmanager.png -------------------------------------------------------------------------------- /data/document-print-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/document-print-preview.png -------------------------------------------------------------------------------- /data/edit-paste.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/edit-paste.png -------------------------------------------------------------------------------- /data/help-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/help-about.png -------------------------------------------------------------------------------- /data/list-remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/list-remove.png -------------------------------------------------------------------------------- /data/preferences-system.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/preferences-system.png -------------------------------------------------------------------------------- /data/resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | application-exit.png 4 | clipmanager.ico 5 | clipmanager.png 6 | document-print-preview.png 7 | edit-paste.png 8 | help-about.png 9 | list-remove.png 10 | preferences-system.png 11 | search.png 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/data/search.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | qt_api=pyside -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | import os 4 | import re 5 | import sys 6 | 7 | from setuptools import find_packages, setup 8 | from setuptools.command.test import test as TestCommand 9 | 10 | ROOT = os.path.abspath(os.path.dirname(__file__)) 11 | VERSION_RE = re.compile(r'''__version__ = ['"]([0-9.]+)['"]''') 12 | 13 | 14 | class PyTest(TestCommand): 15 | user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")] 16 | 17 | def initialize_options(self): 18 | TestCommand.initialize_options(self) 19 | self.pytest_args = '' 20 | 21 | def run_tests(self): 22 | import shlex 23 | # import here, cause outside the eggs aren't loaded 24 | import pytest 25 | errno = pytest.main(shlex.split(self.pytest_args)) 26 | sys.exit(errno) 27 | 28 | 29 | install_requires = ['pyside'] 30 | data_files = [] 31 | 32 | if os.name == 'nt': 33 | install_requires.append('pywin32') 34 | elif os.name == 'posix': 35 | install_requires.append('python-xlib') 36 | data_files.extend([ 37 | ('/usr/share/applications', ['clipmanager.desktop']), 38 | ('/usr/share/pixmaps', ['data/clipmanager.png']), 39 | ('/usr/share/licenses/clipmanager', ['LICENSE']), 40 | ]) 41 | 42 | 43 | def get_version(): 44 | init = open(os.path.join(ROOT, 'clipmanager', '__init__.py')).read() 45 | return VERSION_RE.search(init).group(1) 46 | 47 | 48 | download_url = 'https://github.com/scottwernervt/clipmanager' \ 49 | 'archive/%s.tar.gz' % get_version() 50 | 51 | setup( 52 | name='clipmanager', 53 | version=get_version(), 54 | author='Scott Werner', 55 | author_email='scott.werner.vt@gmail.com', 56 | description="Manage the system's clipboard history.", 57 | long_description=open('README.rst').read(), 58 | license='BSD', 59 | platforms='Posix; Windows', 60 | keywords=' '.join([ 61 | 'clipboard', 62 | 'manager', 63 | 'history', 64 | ]), 65 | url='https://github.com/scottwernervt/clipmanager', 66 | download_url=download_url, 67 | scripts=['bin/clipmanager'], 68 | install_requires=install_requires, 69 | extras_require={ 70 | 'win32': [ 71 | 'PyInstaller', # GPL 72 | ], 73 | }, 74 | setup_requires=[ 75 | 'pytest-runner', # MIT 76 | ], 77 | tests_require=[ 78 | 'pytest', # MIT 79 | 'pytest-qt', # MIT 80 | ], 81 | test_suite='tests', 82 | packages=find_packages(exclude=['contrib', 'tests*']), 83 | include_package_data=True, 84 | data_files=data_files, 85 | cmdclass={'test': PyTest}, 86 | classifiers=[ 87 | 'Development Status :: 5 - Production/Stable', 88 | 'Environment :: Win32 (MS Windows)', 89 | 'Environment :: X11 Applications', 90 | 'Intended Audience :: End Users/Desktop', 91 | 'License :: OSI Approved :: BSD License', 92 | 'Operating System :: POSIX', 93 | 'Operating System :: Unix', 94 | 'Programming Language :: Python', 95 | 'Programming Language :: Python :: 2', 96 | 'Topic :: Utilities', 97 | ] 98 | ) 99 | -------------------------------------------------------------------------------- /setup_cxfreeze.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | from cx_Freeze import Executable, setup 6 | 7 | ROOT = os.path.abspath(os.path.dirname(__file__)) 8 | VERSION_RE = re.compile(r'''__version__ = ['"]([0-9.]+)['"]''') 9 | 10 | python_install_dir, _ = os.path.split(sys.executable) 11 | site_packages_path = os.path.join(python_install_dir, 'Lib', 'site-packages') 12 | 13 | 14 | def get_version(): 15 | init = open(os.path.join(ROOT, 'clipmanager', '__init__.py')).read() 16 | return VERSION_RE.search(init).group(1) 17 | 18 | 19 | build_options = dict( 20 | optimize=2, 21 | include_msvcr=False, # Microsoft Visual C runtime DLLs 22 | include_files=[ 23 | ( 24 | os.path.join(site_packages_path, 25 | 'PySide\plugins\sqldrivers\qsqlite4.dll'), 26 | os.path.join('sqldrivers', 'qsqlite4.dll') 27 | ), 28 | ], 29 | # packages to include, which includes all submodules in the package 30 | packages=[ 31 | 'ctypes', 32 | 'datetime', 33 | 'itertools', 34 | 'logging', 35 | 'operator', 36 | 'optparse', 37 | 'os', 38 | 're', 39 | 'struct', 40 | 'subprocess', 41 | 'sys', 42 | 'tempfile', 43 | 'textwrap', 44 | 'win32api', 45 | 'win32event', 46 | 'win32gui', 47 | 'win32process', 48 | 'winerror', 49 | 'zlib', 50 | ], 51 | # modules to include 52 | includes=[ 53 | 'PySide.QtCore', 54 | 'PySide.QtGui', 55 | 'PySide.QtNetwork', 56 | 'PySide.QtSql', 57 | ], 58 | # modules to exclude 59 | excludes=[ 60 | 'json', 61 | 'Tkinter', 62 | 'unittest', 63 | 'Xlib', 64 | 'xml', 65 | ]) 66 | 67 | executables = [ 68 | Executable( 69 | 'bin/clipmanager', 70 | base='Win32GUI', 71 | targetName='clipmanager.exe', 72 | icon='clipmanager/icons/clipmanager.ico' 73 | ) 74 | ] 75 | 76 | setup( 77 | name='clipmanager', 78 | version=get_version(), 79 | description="Manage the system's clipboard history.", 80 | options=dict(build_exe=build_options), 81 | executables=executables 82 | ) 83 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottwernervt/clipmanager/34e9f45f7d9a3cef423d9d54df5d220aed5fd821/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PySide.QtCore import QAbstractListModel, QModelIndex, Qt 3 | 4 | from clipmanager import __org__, __title__ 5 | 6 | 7 | @pytest.fixture(scope='session', autouse=True) 8 | def config_app_settings(qapp): 9 | qapp.setOrganizationName('%s-pytest' % __org__.lower()) 10 | qapp.setApplicationName('%s-pytest' % __title__.lower()) 11 | 12 | 13 | # noinspection PyShadowingNames 14 | @pytest.fixture(scope='function') 15 | def model(): 16 | class Model(QAbstractListModel): 17 | def __init__(self, data, columns, parent=None): 18 | QAbstractListModel.__init__(self, parent) 19 | self._data = data 20 | self._columns = columns 21 | 22 | def rowCount(self, parent=QModelIndex()): 23 | return len(self._data) 24 | 25 | def columnCount(self, parent): 26 | return len(self._columns) 27 | 28 | def headerData(self, section, orientation, role=Qt.DisplayRole): 29 | if orientation == Qt.Horizontal and role == Qt.DisplayRole: 30 | return None 31 | return self._columns[section] 32 | 33 | def data(self, index, role=Qt.DisplayRole): 34 | """Override QSqlTableModel.data() 35 | 36 | :param index: Row and column of data entry. 37 | :type index: QModelIndex 38 | 39 | :param role: 40 | :type role: Qt.DisplayRole 41 | 42 | :return: Row column data from table. 43 | :rtype: str, int, or None 44 | """ 45 | if not index.isValid(): 46 | return None 47 | 48 | row = index.row() 49 | column = index.column() 50 | 51 | if role == Qt.DisplayRole: 52 | return self._data[row][column] 53 | elif role == Qt.ToolTipRole: 54 | return 'Tooltip' 55 | elif role == Qt.DecorationRole: 56 | return None 57 | 58 | return None 59 | 60 | def flags(self, index): 61 | if not index.isValid(): 62 | return Qt.ItemFlags() 63 | return Qt.ItemFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) 64 | 65 | model = Model([['1', 'A'], ['2', 'B'], ['3', 'C']], ['ID', 'TITLE']) 66 | yield model 67 | del model 68 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | def menu_action(menu, name): 2 | for action in menu.actions(): 3 | if action.text() == name: 4 | return action 5 | -------------------------------------------------------------------------------- /tests/test_clipboard.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PySide.QtCore import QMimeData 3 | 4 | from clipmanager.clipboard import ClipboardManager 5 | 6 | 7 | @pytest.fixture(scope='function') 8 | def clipboard_manager(): 9 | cbm = ClipboardManager() 10 | yield cbm 11 | cbm.clear_text() 12 | 13 | 14 | @pytest.fixture(scope='function') 15 | def text_data(): 16 | text = QMimeData() 17 | text.setText('test') 18 | return text 19 | 20 | 21 | @pytest.fixture(scope='function') 22 | def html_data(): 23 | html = QMimeData() 24 | html.setHtml('

test

') 25 | return html 26 | 27 | 28 | class TestClipboardManager: 29 | def test_set_text(self, clipboard_manager, text_data): 30 | clipboard_manager.set_text(text_data) 31 | contents = clipboard_manager.get_primary_clipboard_text() 32 | assert contents.text() == text_data.text() 33 | 34 | def test_set_html(self, clipboard_manager, html_data): 35 | clipboard_manager.set_text(html_data) 36 | contents = clipboard_manager.get_primary_clipboard_text() 37 | assert contents.html() == html_data.html() 38 | 39 | def test_clear_text(self, clipboard_manager): 40 | clipboard_manager.clear_text() 41 | contents = clipboard_manager.get_primary_clipboard_text() 42 | assert not contents.text() 43 | 44 | def test_new_item_signal(self, qtbot, clipboard_manager): 45 | with qtbot.waitSignal(clipboard_manager.new_item, 1000, True): 46 | clipboard_manager.set_text(QMimeData()) 47 | -------------------------------------------------------------------------------- /tests/test_database.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clipmanager.database import Database 4 | 5 | 6 | @pytest.fixture(scope='function') 7 | def database(): 8 | db = Database() 9 | yield db 10 | db.close() 11 | 12 | 13 | class TestDatabase: 14 | def test_open(self, database): 15 | assert database.open() 16 | assert not database.connection.isOpenError() 17 | 18 | def test_close(self, database): 19 | assert not database.close() 20 | 21 | def test_driver(self, database): 22 | assert database.connection.isDriverAvailable('QSQLITE') 23 | assert database.connection.driverName() == 'QSQLITE' 24 | 25 | def test_create_tables(self, database): 26 | assert database.create_tables() 27 | assert database.connection.isValid() 28 | -------------------------------------------------------------------------------- /tests/test_hotkey.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PySide.QtCore import Qt 3 | from PySide.QtGui import QMainWindow 4 | 5 | from clipmanager import hotkey 6 | 7 | 8 | @pytest.fixture() 9 | def main_window(qtbot): 10 | mw = QMainWindow() 11 | mw.showMaximized() 12 | 13 | qtbot.addWidget(mw) 14 | qtbot.waitForWindowShown(mw) 15 | 16 | hk = hotkey.initialize() 17 | yield qtbot, mw, hk 18 | hk.unregister(winid=mw.winId()) 19 | hk.stop() 20 | 21 | 22 | def callback(): 23 | return True 24 | 25 | 26 | class TestGlobalHotKey: 27 | def test_register(self, main_window): 28 | qtbot, mw, hk = main_window 29 | assert hk.register('Ctrl+Return', callback, mw.winId()) 30 | qtbot.keyPress(mw, Qt.Key_Return, modifier=Qt.ControlModifier) 31 | 32 | def test_unregister(self, main_window): 33 | qtbot, mw, hk = main_window 34 | hk.unregister(winid=mw.winId()) 35 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zlib 3 | 4 | import pytest 5 | from PySide.QtCore import QDateTime, QMimeData, Qt 6 | 7 | from clipmanager.database import Database 8 | from clipmanager.models import DataSqlTableModel, MainSqlTableModel 9 | 10 | 11 | @pytest.fixture() 12 | def main_table(): 13 | db = Database() 14 | db.create_tables() 15 | 16 | main = MainSqlTableModel() 17 | r = main.record() 18 | r.setValue('title', 'A') 19 | r.setValue('short_title', 'a') 20 | r.setValue('checksum', zlib.crc32('A')) 21 | r.setValue('created_at', QDateTime.currentMSecsSinceEpoch()) 22 | if not main.insertRecord(-1, r): 23 | assert False 24 | 25 | main.submitAll() 26 | 27 | yield main 28 | 29 | db.close() 30 | os.unlink(db.connection.databaseName()) 31 | 32 | 33 | @pytest.fixture() 34 | def data_table(): 35 | db = Database() 36 | db.create_tables() 37 | 38 | data = DataSqlTableModel() 39 | 40 | mime_data = QMimeData() 41 | mime_data.setData('text/plain', 'plain-text') 42 | 43 | r = data.record() 44 | r.setValue('parent_id', 1) 45 | r.setValue('mime_format', 'text/plain') 46 | r.setValue('byte_data', mime_data.data('text/plain')) 47 | if not data.insertRecord(-1, r): 48 | assert False 49 | 50 | data.submitAll() 51 | 52 | yield data 53 | 54 | db.close() 55 | os.unlink(db.connection.databaseName()) 56 | 57 | 58 | class TestMainSqlTableModel: 59 | def test_create(self, main_table): 60 | row_id = main_table.create('title', 'short-title', 724990059, 61 | QDateTime.currentMSecsSinceEpoch()) 62 | main_table.submitAll() 63 | 64 | assert row_id 65 | 66 | def test_data_display_role(self, main_table): 67 | index = main_table.index(0, main_table.TITLE) 68 | title = main_table.data(index.sibling(index.row(), main_table.TITLE), 69 | role=Qt.DisplayRole) 70 | 71 | assert isinstance(title, unicode) 72 | assert title == 'A' 73 | 74 | def test_data_tooltip_role(self, main_table): 75 | index = main_table.index(0, main_table.TITLE_SHORT) 76 | tooltip = main_table.data(index.sibling(index.row(), 77 | main_table.TITLE_SHORT), 78 | role=Qt.ToolTipRole) 79 | 80 | assert isinstance(tooltip, str) 81 | assert 'last used' in tooltip.lower() 82 | 83 | 84 | class TestDataSqlTableModel: 85 | def test_create(self, data_table): 86 | mime_data = QMimeData() 87 | mime_data.setData('text/plain', 'plain-text') 88 | byte_data = mime_data.data('plain-text') 89 | 90 | row_id = data_table.create(1, 'plain-text', byte_data) 91 | data_table.submitAll() 92 | 93 | assert row_id 94 | 95 | def test_read(self, data_table): 96 | mime_data = data_table.read(1) 97 | assert len(mime_data) > 0 98 | 99 | format_type = mime_data[0][0] 100 | byte_array = mime_data[0][1] 101 | 102 | mime_data = QMimeData() 103 | mime_data.setData(format_type, byte_array) 104 | 105 | assert format_type == 'text/plain' 106 | assert mime_data.text() == 'plain-text' 107 | 108 | def test_delete(self, data_table): 109 | data_table.delete([1]) 110 | mime_data = data_table.read(1) 111 | 112 | assert len(mime_data) == 0 113 | -------------------------------------------------------------------------------- /tests/test_owner.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PySide.QtGui import QMainWindow, QWidget 3 | 4 | from clipmanager import owner 5 | 6 | 7 | @pytest.mark.skip(reason='BUG: get_active_window_id() fails.') 8 | def test_owner(qtbot): 9 | window = QMainWindow() 10 | window.setWindowTitle('owner-package-test') 11 | window.setCentralWidget(QWidget()) 12 | window.show() 13 | 14 | qtbot.addWidget(window) 15 | qtbot.waitForWindowShown(window) 16 | 17 | current_app = owner.initialize() 18 | window_names = current_app() 19 | 20 | assert len(window_names) > 0 21 | assert 'owner-package-test' in window_names 22 | -------------------------------------------------------------------------------- /tests/test_singleinstance.py: -------------------------------------------------------------------------------- 1 | from clipmanager.singleinstance import SingleInstance 2 | 3 | 4 | def test_single_instance(): 5 | app = SingleInstance() 6 | assert not app.is_running() 7 | app.destroy() 8 | 9 | 10 | def test_duplicate_instance(): 11 | app_a, app_b = SingleInstance(), SingleInstance() 12 | assert not app_a.is_running() 13 | assert app_b.is_running() 14 | app_a.destroy() 15 | app_b.destroy() 16 | -------------------------------------------------------------------------------- /tests/test_ui_dialogs.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PySide.QtCore import QMimeData, Qt 3 | from PySide.QtGui import QDialogButtonBox 4 | 5 | from clipmanager.settings import Settings 6 | from clipmanager.ui.dialogs.about import AboutDialog 7 | from clipmanager.ui.dialogs.preview import PreviewDialog 8 | from clipmanager.ui.dialogs.settings import SettingsDialog 9 | 10 | 11 | @pytest.fixture() 12 | def about_dialog(qtbot): 13 | dialog = AboutDialog() 14 | dialog.show() 15 | qtbot.addWidget(dialog) 16 | return qtbot, dialog 17 | 18 | 19 | @pytest.fixture() 20 | def preview_dialog(qtbot): 21 | mime_data = QMimeData() 22 | mime_data.setData('text/plain', 'text') 23 | dialog = PreviewDialog(mime_data) 24 | dialog.show() 25 | qtbot.addWidget(dialog) 26 | return qtbot, dialog 27 | 28 | 29 | @pytest.fixture() 30 | def settings_dialog(qtbot): 31 | dialog = SettingsDialog() 32 | dialog.show() 33 | qtbot.addWidget(dialog) 34 | return qtbot, dialog 35 | 36 | 37 | class TestAboutDialog: 38 | def test_close(self, about_dialog): 39 | qtbot, dialog = about_dialog 40 | with qtbot.waitSignal(dialog.accepted, 1000, True): 41 | button = dialog.button_box.button(QDialogButtonBox.Close) 42 | qtbot.mouseClick(button, Qt.LeftButton) 43 | 44 | 45 | class TestPreviewDialog: 46 | def test_close(self, preview_dialog): 47 | qtbot, dialog = preview_dialog 48 | with qtbot.waitSignal(dialog.accepted, 1000, True): 49 | button = dialog.button_box.button(QDialogButtonBox.Close) 50 | qtbot.mouseClick(button, Qt.LeftButton) 51 | 52 | 53 | class TestSettingsDialog: 54 | def setup(self): 55 | self.settings = Settings() 56 | self.settings.clear() 57 | 58 | @staticmethod 59 | def _cancel(qtbot, dialog): 60 | button = dialog.button_box.button(QDialogButtonBox.Cancel) 61 | qtbot.mouseClick(button, Qt.LeftButton) 62 | 63 | def test_save(self, settings_dialog): 64 | qtbot, dialog = settings_dialog 65 | with qtbot.waitSignal(dialog.accepted, 1000, True): 66 | button = dialog.button_box.button(QDialogButtonBox.Save) 67 | qtbot.mouseClick(button, Qt.LeftButton) 68 | 69 | def test_cancel(self, settings_dialog): 70 | qtbot, dialog = settings_dialog 71 | with qtbot.waitSignal(dialog.rejected, 1000, True): 72 | button = dialog.button_box.button(QDialogButtonBox.Cancel) 73 | qtbot.mouseClick(button, Qt.LeftButton) 74 | 75 | def test_valid_global_shortcut(self, settings_dialog): 76 | qtbot, dialog = settings_dialog 77 | qtbot.keyPress(dialog.key_combo_edit, 'H', Qt.ControlModifier) 78 | assert dialog.key_combo_edit.text().lower() == 'ctrl+h' 79 | 80 | def test_invalid_global_shortcut(self, settings_dialog): 81 | qtbot, dialog = settings_dialog 82 | qtbot.keyPress(dialog.key_combo_edit, Qt.Key_F5, Qt.NoModifier) 83 | assert dialog.key_combo_edit.text().lower() == 'ctrl+shift+h' # default 84 | 85 | def test_exclude_application(self, settings_dialog): 86 | qtbot, dialog = settings_dialog 87 | 88 | dialog.exclude_edit.setText('app1') 89 | button = dialog.button_box.button(QDialogButtonBox.Save) 90 | qtbot.mouseClick(button, Qt.LeftButton) 91 | assert self.settings.get_exclude() == 'app1;' 92 | 93 | dialog.exclude_edit.setText('app1;app2;') 94 | button = dialog.button_box.button(QDialogButtonBox.Save) 95 | qtbot.mouseClick(button, Qt.LeftButton) 96 | assert self.settings.get_exclude() == 'app1;app2;' 97 | -------------------------------------------------------------------------------- /tests/test_ui_historylist.py: -------------------------------------------------------------------------------- 1 | from clipmanager.ui.historylist import HistoryListView 2 | 3 | 4 | def test_history_list(qtbot, model): 5 | history_view = HistoryListView() 6 | history_view.setModel(model) 7 | history_view.show() 8 | qtbot.addWidget(history_view) 9 | 10 | assert history_view.model().rowCount() > 0 11 | -------------------------------------------------------------------------------- /tests/test_ui_mainwindow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PySide.QtGui import QCloseEvent, QSystemTrayIcon 3 | 4 | from clipmanager.ui.mainwindow import MainWidget, MainWindow 5 | 6 | pytestmark = pytest.mark.skip('MainWindow tests lock up.') 7 | 8 | 9 | @pytest.fixture() 10 | def main_window(qtbot): 11 | mw = MainWindow() 12 | mw.show() 13 | qtbot.addWidget(mw) 14 | return qtbot, mw 15 | 16 | 17 | @pytest.fixture() 18 | def main_widget(qtbot): 19 | mw = MainWidget() 20 | mw.show() 21 | qtbot.addWidget(mw) 22 | 23 | return qtbot, mw 24 | 25 | 26 | class TestMainWindow: 27 | def test_close_event(self, main_window): 28 | qtbot, window = main_window 29 | window.show() 30 | event = QCloseEvent() 31 | window.closeEvent(event) 32 | assert not window.isVisible() 33 | 34 | def test_system_tray_icon(self, main_window): 35 | qtbot, window = main_window 36 | window.hide() 37 | window.system_tray_activate(QSystemTrayIcon.Trigger) 38 | assert window.isVisible() 39 | -------------------------------------------------------------------------------- /tests/test_ui_systemtray.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clipmanager.ui.systemtray import SystemTrayIcon 4 | 5 | 6 | @pytest.fixture() 7 | def systemtray(): 8 | tray = SystemTrayIcon() 9 | tray.show() 10 | return tray 11 | 12 | 13 | class TestSystemTrayIcon: 14 | def test_is_visible(self, systemtray): 15 | assert systemtray.isVisible() 16 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clipmanager.utils import format_title, truncate_lines 4 | 5 | 6 | @pytest.mark.parametrize('title,expected', [ 7 | ('plain-text', 'plain-text'), 8 | ('\tplain-text', 'plain-text'), 9 | ('\nplain-text', '\nplain-text'), 10 | ]) 11 | def test_format_title(title, expected): 12 | assert format_title(title) == expected 13 | 14 | 15 | @pytest.mark.parametrize('title,count,expected', [ 16 | ('line-one', 1, 'line-one'), 17 | ('line-one', 2, 'line-one'), 18 | ('line-one\nline-two', 2, 'line-one\nline-two'), 19 | ('line-one\nline-two\nline-three', 2, 'line-one\nline-two...'), 20 | ]) 21 | def test_truncate_lines(title, count, expected): 22 | assert truncate_lines(title, count) == expected 23 | --------------------------------------------------------------------------------