├── README.md ├── roficlip.py └── shots ├── roficlip-1.png ├── roficlip-2.png ├── roficlip-3.png └── roficlip.apng /README.md: -------------------------------------------------------------------------------- 1 | # About # 2 | Clipboard history manager designed for using with [Rofi](https://davedavenport.github.io/rofi/). 3 | 4 | # Features # 5 | * Show runtime (ring) clipboard history. 6 | * Show/Create/Delete persistent notes from clipboard. 7 | * Define and use actions with clipboard contents. 8 | * Desktop notifications via D-Bus. 9 | 10 | # Shots 11 | ![roficlip in rofi screenshot](shots/roficlip.apng) 12 | 13 | # Requirements # 14 | * [pygobject](https://pypi.org/project/PyGObject/) 15 | * [pyyaml](https://pypi.org/project/PyYAML/) 16 | * [pyxdg](https://pypi.org/project/pyxdg/) 17 | * [docopt](https://pypi.org/project/docopt/) 18 | * [notify2](https://pypi.org/project/notify2/) 19 | 20 | # Installation # 21 | * Install requirements via your favorite package manager. 22 | * Clone this repository to preferred place. 23 | * Make link to roficlip.py and place it to directory listed in $PATH e.g.: `ln -s ~/bin/apps/roficlip/roficlip.py ~/bin` 24 | 25 | # Usage # 26 | Run clipboard watcher: 27 | ```bash 28 | roficlip.py --daemon & 29 | ``` 30 | 31 | Read the help: 32 | ```bash 33 | roficlip.py --help 34 | ``` 35 | 36 | Bind hotkey (combined mode): 37 | ```bash 38 | rofi -modi "clipboard:roficlip.py --show,persistent:roficlip.py --show --persistent,actions:roficlip.py --show --actions" -show clipboard 39 | ``` 40 | or (single mode) 41 | ```bash 42 | rofi -modi "clipboard:roficlip.py --show" -show clipboard 43 | ``` 44 | 45 | # Settings # 46 | Yaml config placed in `$XDG_CONFIG_HOME/roficlip/settings` Example: 47 | ```yaml 48 | settings: 49 | ring_size: 20 # maximum clips count. 50 | newline_char: '¬' # any character for using in preview as new line marker. 51 | notify: True # allow using desktop notifications. 52 | notify_timeout: 1 # notification timeout in seconds. 53 | show_comments_first: False # all text after last '#' moved to beginning of line (in persitent mode) 54 | colored_comments: False # all text after last '#' is grayed 55 | 56 | actions: 57 | 'open url via mpv player': 'mpv --geometry=720x405-20-20 %s' # %s will be replaced with current clipboard content. 58 | 'add persistent clip': 'roficlip.py --add' # save current clipboard in persistent history. 59 | 'remove persistent clip': 'roficlip.py --remove' # remove current clipboard from persistent history. 60 | 'clear clipboard': 'roficlip.py --clear' # clear clipboard history. 61 | ``` 62 | -------------------------------------------------------------------------------- /roficlip.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Rofi clipboard manager 4 | Usage: 5 | roficlip.py --daemon [-q | --quiet] 6 | roficlip.py --show [--persistent | --actions] [] [-q | --quiet] 7 | roficlip.py --clear [-q | --quiet] 8 | roficlip.py --add [-q | --quiet] 9 | roficlip.py --remove [-q | --quiet] 10 | roficlip.py --edit 11 | roficlip.py (-h | --help) 12 | roficlip.py (-v | --version) 13 | 14 | Arguments: 15 | Selected item passed by Rofi on second script run. 16 | Used with actions as index for dict. 17 | 18 | Commands: 19 | --daemon Run clipboard manager daemon. 20 | --show Show clipboard history. 21 | --persistent Select to show persistent history. 22 | --actions Select to show actions defined in config. 23 | --clear Clear clipboard history. 24 | --add Add current clipboard to persistent storage. 25 | --remove Remove current clipboard from persistent storage. 26 | --edit Edit persistent storage with text editor. 27 | -q, --quiet Do not notify, even if notification enabled in config. 28 | -h, --help Show this screen. 29 | -v, --version Show version. 30 | 31 | """ 32 | import errno 33 | import os 34 | import sys 35 | import stat 36 | import struct 37 | from subprocess import Popen, DEVNULL 38 | from tempfile import NamedTemporaryFile 39 | from html import escape 40 | 41 | try: 42 | # https://docs.gtk.org/gtk3/method.Clipboard.clear.html 43 | import gi 44 | gi.require_version("Gtk", "3.0") 45 | from gi.repository import Gtk, Gdk, GLib 46 | except ImportError: 47 | raise 48 | 49 | import yaml 50 | from docopt import docopt 51 | from xdg import BaseDirectory 52 | 53 | try: 54 | import notify2 55 | except ImportError: 56 | pass 57 | 58 | 59 | # Used for injecting hidden index for menu rows. Simulate dmenu behavior. 60 | # See rofi-script.5 for details 61 | ROFI_INFO = b'\0info\x1f'.decode('utf-8') 62 | 63 | # Used for pango markup 64 | ROFI_MARKUP = b'\0markup-rows\x1ftrue'.decode('utf-8') 65 | ROFI_COMMENT = '#{}' 66 | 67 | # Used with fifo as instruction for cleaing clipboard history 68 | CLEAR_CODE = b'\0clear'.decode('utf-8') 69 | 70 | 71 | class ClipboardManager(): 72 | def __init__(self): 73 | # Init databases and fifo 74 | name = 'roficlip' 75 | self.ring_db = '{0}/{1}'.format(BaseDirectory.save_data_path(name), 'ring.db') 76 | self.persist_db = '{0}/{1}'.format(BaseDirectory.save_data_path(name), 'persistent.db') 77 | self.fifo_path = '{0}/{1}.fifo'.format(BaseDirectory.get_runtime_dir(strict=False), name) 78 | self.config_path = '{0}/settings'.format(BaseDirectory.save_config_path(name)) 79 | if not os.path.isfile(self.ring_db): 80 | open(self.ring_db, "a+").close() 81 | if not os.path.isfile(self.persist_db): 82 | open(self.persist_db, "a+").close() 83 | if ( 84 | not os.path.exists(self.fifo_path) or 85 | not stat.S_ISFIFO(os.stat(self.fifo_path).st_mode) 86 | ): 87 | os.mkfifo(self.fifo_path) 88 | self.fifo = os.open(self.fifo_path, os.O_RDONLY | os.O_NONBLOCK) 89 | 90 | # Init clipboard and read databases 91 | self.cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) 92 | self.ring = self.read(self.ring_db) 93 | self.persist = self.read(self.persist_db) 94 | 95 | # Load settings 96 | self.load_config() 97 | 98 | # Init notifications 99 | if self.cfg['notify'] and 'notify2' in sys.modules: 100 | self.notify = notify2 101 | self.notify.init(name) 102 | else: 103 | self.cfg['notify'] = False 104 | 105 | def daemon(self): 106 | """ 107 | Clipboard Manager daemon. 108 | """ 109 | GLib.timeout_add(300, self.cb_watcher) 110 | GLib.timeout_add(300, self.fifo_watcher) 111 | Gtk.main() 112 | 113 | def cb_watcher(self): 114 | """ 115 | Callback function. 116 | Watch clipboard and write changes to ring database. 117 | Must return "True" for continuous operation. 118 | """ 119 | clip = self.cb.wait_for_text() 120 | if self.sync_items(clip, self.ring): 121 | self.ring = self.ring[0:self.cfg['ring_size']] 122 | self.write(self.ring_db, self.ring) 123 | return True 124 | 125 | def fifo_watcher(self): 126 | """ 127 | Callback function. 128 | Copy contents from fifo to clipboard. 129 | Can clear clipboard history by instruction. 130 | Must return "True" for continuous operation. 131 | """ 132 | try: 133 | fifo_in = os.read(self.fifo, 65536) 134 | except OSError as err: 135 | if err.errno == errno.EAGAIN or err.errno == errno.EWOULDBLOCK: 136 | fifo_in = None 137 | else: 138 | raise 139 | if fifo_in: 140 | if fifo_in.decode('utf-8') == CLEAR_CODE: 141 | self.cb.set_text('', -1) 142 | self.ring = [''] 143 | self.write(self.ring_db, self.ring) 144 | self.notify_send('Clipboard is cleaned') 145 | else: 146 | self.cb.set_text(fifo_in.decode('utf-8'), -1) 147 | self.notify_send('Copied to the clipboard') 148 | return True 149 | 150 | def sync_items(self, clip, items): 151 | """ 152 | Sync clipboard contents with specified items dict when needed. 153 | Return "True" if dict modified, otherwise "False". 154 | """ 155 | if clip and (not items or clip != items[0]): 156 | if clip in items: 157 | items.remove(clip) 158 | items.insert(0, clip) 159 | return True 160 | return False 161 | 162 | def clear_ring(self): 163 | """ 164 | Write to fifo instruction for cleaning clipboard history 165 | """ 166 | with open(self.fifo_path, "w") as file: 167 | file.write(CLEAR_CODE) 168 | file.close() 169 | 170 | def copy_item(self, index, items): 171 | """ 172 | Writes to fifo item that should be copied to clipboard. 173 | """ 174 | with open(self.fifo_path, "w") as file: 175 | file.write(items[index]) 176 | file.close() 177 | 178 | def show_items(self, items): 179 | """ 180 | Format and show contents of specified items dict (for rofi). 181 | """ 182 | print(ROFI_MARKUP) if self.cfg['colored_comments'] else None 183 | for index, clip in enumerate(items): 184 | if args['--actions']: 185 | print(clip) 186 | else: 187 | # Replace newline characters for joining string 188 | clip = clip.replace('\n', self.cfg['newline_char']) 189 | if (self.cfg['colored_comments'] or self.cfg['show_comments_first']) and '#' in clip: 190 | # Save index of last '#' 191 | idx = clip.rfind('#') 192 | body = escape(clip[:idx]) if self.cfg['colored_comments'] else clip[:idx] 193 | comment = ROFI_COMMENT.format( 194 | escape(clip[idx+1:]) 195 | ) if self.cfg['colored_comments'] else '#' + clip[idx+1:] 196 | if args['--persistent'] and self.cfg['show_comments_first']: 197 | # Move text after last '#' to beginning of string 198 | clip = '{} {}'.format(comment, body) 199 | else: 200 | clip = '{} {}'.format(body, comment) 201 | print('{}{}{}'.format(clip, ROFI_INFO, index)) 202 | 203 | def persistent_add(self): 204 | """ 205 | Add current clipboard to persistent storage. 206 | """ 207 | clip = self.cb.wait_for_text() 208 | if self.sync_items(clip, self.persist): 209 | self.write(self.persist_db, self.persist) 210 | self.notify_send('Added to persistent') 211 | 212 | def persistent_remove(self): 213 | """ 214 | Remove current clipboard from persistent storage. 215 | """ 216 | clip = self.cb.wait_for_text() 217 | if clip and clip in self.persist: 218 | self.persist.remove(clip) 219 | self.write(self.persist_db, self.persist) 220 | self.notify_send('Removed from persistent') 221 | 222 | def persistent_edit(self): 223 | """ 224 | Edit persistent storage with text editor. 225 | New line char will be used as separator. 226 | """ 227 | editor = os.getenv('EDITOR', default='vi') 228 | if self.persist and editor: 229 | try: 230 | tmp = NamedTemporaryFile(mode='w+') 231 | for clip in self.persist: 232 | clip = '{}\n'.format(clip.replace('\n', self.cfg['newline_char'])) 233 | tmp.write(clip) 234 | tmp.flush() 235 | except IOError as e: 236 | print("I/O error({0}): {1}".format(e.errno, e.strerror)) 237 | else: 238 | proc = Popen([editor, tmp.name]) 239 | ret = proc.wait() 240 | if ret == 0: 241 | tmp.seek(0, 0) 242 | clips = tmp.read().splitlines() 243 | if clips: 244 | self.persist = [] 245 | for clip in clips: 246 | clip = clip.replace('\n', '') 247 | clip = clip.replace(self.cfg['newline_char'], '\n') 248 | self.persist.append(clip) 249 | self.write(self.persist_db, self.persist) 250 | finally: 251 | tmp.close() 252 | 253 | def do_action(self, item): 254 | """ 255 | Run selected action on clipboard contents. 256 | """ 257 | clip = self.cb.wait_for_text() 258 | params = self.actions[item].split(' ') 259 | while '%s' in params: 260 | params[params.index('%s')] = clip 261 | proc = Popen(params, stdout=DEVNULL, stderr=DEVNULL) 262 | ret = proc.wait() 263 | if ret == 0: 264 | self.notify_send(item) 265 | 266 | def notify_send(self, text): 267 | """ 268 | Show desktop notification. 269 | """ 270 | if self.cfg['notify']: 271 | n = self.notify.Notification("Roficlip", text) 272 | n.timeout = self.cfg['notify_timeout'] * 1000 273 | n.show() 274 | 275 | def read(self, fd): 276 | """ 277 | Helper function. Binary reader. 278 | """ 279 | result = [] 280 | with open(fd, "rb") as file: 281 | bytes_read = file.read(4) 282 | while bytes_read: 283 | chunksize = struct.unpack('>i', bytes_read)[0] 284 | bytes_read = file.read(chunksize) 285 | result.append(bytes_read.decode('utf-8')) 286 | bytes_read = file.read(4) 287 | return result 288 | 289 | def write(self, fd, items): 290 | """ 291 | Helper function. Binary writer. 292 | """ 293 | with open(fd, 'wb') as file: 294 | for item in items: 295 | item = item.encode('utf-8') 296 | file.write(struct.pack('>i', len(item))) 297 | file.write(item) 298 | 299 | def load_config(self): 300 | """ 301 | Read config if exists, and/or provide defaults. 302 | """ 303 | # default settings 304 | settings = { 305 | 'settings': { 306 | 'ring_size': 20, 307 | 'newline_char': '¬', 308 | 'notify': True, 309 | 'notify_timeout': 1, 310 | 'show_comments_first': False, 311 | 'colored_comments': False 312 | }, 313 | 'actions': {} 314 | } 315 | if os.path.isfile(self.config_path): 316 | with open(self.config_path, "r") as file: 317 | config = yaml.safe_load(file) 318 | for key in {'settings', 'actions'}: 319 | if key in config: 320 | settings[key].update(config[key]) 321 | self.cfg = settings['settings'] 322 | self.actions = settings['actions'] 323 | 324 | 325 | if __name__ == "__main__": 326 | cm = ClipboardManager() 327 | args = docopt(__doc__, version='0.5') 328 | if args['--quiet']: 329 | cm.cfg['notify'] = False 330 | if args['--daemon']: 331 | cm.daemon() 332 | elif args['--clear']: 333 | cm.clear_ring() 334 | elif args['--add']: 335 | cm.persistent_add() 336 | elif args['--remove']: 337 | cm.persistent_remove() 338 | elif args['--edit']: 339 | cm.persistent_edit() 340 | elif args['--show']: 341 | # Parse variables passed from rofi. See rofi-script.5 for details. 342 | # We get index from selected row here. 343 | if os.getenv('ROFI_INFO') is not None: 344 | index = int(os.getenv('ROFI_INFO')) 345 | elif args[''] is not None: 346 | index = args[''] 347 | else: 348 | index = None 349 | # Show contents on first run 350 | if index is None: 351 | cm.show_items(cm.actions if args['--actions'] else cm.persist if args['--persistent'] else cm.ring) 352 | # Do actions on second run 353 | else: 354 | if args['--actions']: 355 | cm.do_action(index) 356 | else: 357 | cm.copy_item(index, cm.persist if args['--persistent'] else cm.ring) 358 | exit(0) 359 | -------------------------------------------------------------------------------- /shots/roficlip-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seamus-45/roficlip/2a00a022d7fec5659bf56280059491f74c17c56b/shots/roficlip-1.png -------------------------------------------------------------------------------- /shots/roficlip-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seamus-45/roficlip/2a00a022d7fec5659bf56280059491f74c17c56b/shots/roficlip-2.png -------------------------------------------------------------------------------- /shots/roficlip-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seamus-45/roficlip/2a00a022d7fec5659bf56280059491f74c17c56b/shots/roficlip-3.png -------------------------------------------------------------------------------- /shots/roficlip.apng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seamus-45/roficlip/2a00a022d7fec5659bf56280059491f74c17c56b/shots/roficlip.apng --------------------------------------------------------------------------------