1# This file is part of ranger, the console file manager. 2# License: GNU GPL version 3, see the file "AUTHORS" for details. 3# Author: Emanuel Guevel, 2013 4# Author: Delisa Mason, 2015 5 6"""Interface for drawing images into the console 7 8This module provides functions to draw images in the terminal using supported 9implementations, which are currently w3m, iTerm2 and urxvt. 10""" 11 12from __future__ import (absolute_import, division, print_function) 13 14import base64 15import curses 16import errno 17import fcntl 18import imghdr 19import os 20import struct 21import sys 22import warnings 23import json 24import threading 25from subprocess import Popen, PIPE 26from collections import defaultdict 27 28import termios 29from contextlib import contextmanager 30import codecs 31from tempfile import NamedTemporaryFile 32 33from ranger.core.shared import FileManagerAware 34 35W3MIMGDISPLAY_ENV = "W3MIMGDISPLAY_PATH" 36W3MIMGDISPLAY_OPTIONS = [] 37W3MIMGDISPLAY_PATHS = [ 38 '/usr/local/libexec/w3m/w3mimgdisplay', 39] 40 41# Helper functions shared between the previewers (make them static methods of the base class?) 42 43 44@contextmanager 45def temporarily_moved_cursor(to_y, to_x): 46 """Common boilerplate code to move the cursor to a drawing area. Use it as: 47 with temporarily_moved_cursor(dest_y, dest_x): 48 your_func_here()""" 49 curses.putp(curses.tigetstr("sc")) 50 move_cur(to_y, to_x) 51 yield 52 curses.putp(curses.tigetstr("rc")) 53 sys.stdout.flush() 54 55 56# this is excised since Terminology needs to move the cursor multiple times 57def move_cur(to_y, to_x): 58 tparm = curses.tparm(curses.tigetstr("cup"), to_y, to_x) 59 # on python2 stdout is already in binary mode, in python3 is accessed via buffer 60 bin_stdout = getattr(sys.stdout, 'buffer', sys.stdout) 61 bin_stdout.write(tparm) 62 63 64class ImageDisplayError(Exception): 65 pass 66 67 68class ImgDisplayUnsupportedException(Exception): 69 pass 70 71 72def fallback_image_displayer(): 73 """Simply makes some noise when chosen. Temporary fallback behavior.""" 74 75 raise ImgDisplayUnsupportedException 76 77 78IMAGE_DISPLAYER_REGISTRY = defaultdict(fallback_image_displayer) 79 80 81def register_image_displayer(nickname=None): 82 """Register an ImageDisplayer by nickname if available.""" 83 84 def decorator(image_displayer_class): 85 if nickname: 86 registry_key = nickname 87 else: 88 registry_key = image_displayer_class.__name__ 89 IMAGE_DISPLAYER_REGISTRY[registry_key] = image_displayer_class 90 return image_displayer_class 91 return decorator 92 93 94def get_image_displayer(registry_key): 95 image_displayer_class = IMAGE_DISPLAYER_REGISTRY[registry_key] 96 return image_displayer_class() 97 98 99class ImageDisplayer(object): 100 """Image display provider functions for drawing images in the terminal""" 101 102 working_dir = os.environ.get('XDG_RUNTIME_DIR', os.path.expanduser("~") or None) 103 104 def draw(self, path, start_x, start_y, width, height): 105 """Draw an image at the given coordinates.""" 106 pass 107 108 def clear(self, start_x, start_y, width, height): 109 """Clear a part of terminal display.""" 110 pass 111 112 def quit(self): 113 """Cleanup and close""" 114 pass 115 116 117@register_image_displayer("w3m") 118class W3MImageDisplayer(ImageDisplayer, FileManagerAware): 119 """Implementation of ImageDisplayer using w3mimgdisplay, an utilitary 120 program from w3m (a text-based web browser). w3mimgdisplay can display 121 images either in virtual tty (using linux framebuffer) or in a Xorg session. 122 Does not work over ssh. 123 124 w3m need to be installed for this to work. 125 """ 126 is_initialized = False 127 128 def __init__(self): 129 self.binary_path = None 130 self.process = None 131 132 def initialize(self): 133 """start w3mimgdisplay""" 134 self.binary_path = None 135 self.binary_path = self._find_w3mimgdisplay_executable() # may crash 136 self.process = Popen([self.binary_path] + W3MIMGDISPLAY_OPTIONS, cwd=self.working_dir, 137 stdin=PIPE, stdout=PIPE, universal_newlines=True) 138 self.is_initialized = True 139 140 @staticmethod 141 def _find_w3mimgdisplay_executable(): 142 paths = [os.environ.get(W3MIMGDISPLAY_ENV, None)] + W3MIMGDISPLAY_PATHS 143 for path in paths: 144 if path is not None and os.path.exists(path): 145 return path 146 raise ImageDisplayError("No w3mimgdisplay executable found. Please set " 147 "the path manually by setting the %s environment variable. (see " 148 "man page)" % W3MIMGDISPLAY_ENV) 149 150 def _get_font_dimensions(self): 151 # Get the height and width of a character displayed in the terminal in 152 # pixels. 153 if self.binary_path is None: 154 self.binary_path = self._find_w3mimgdisplay_executable() 155 farg = struct.pack("HHHH", 0, 0, 0, 0) 156 fd_stdout = sys.stdout.fileno() 157 fretint = fcntl.ioctl(fd_stdout, termios.TIOCGWINSZ, farg) 158 rows, cols, xpixels, ypixels = struct.unpack("HHHH", fretint) 159 if xpixels == 0 and ypixels == 0: 160 process = Popen([self.binary_path, "-test"], stdout=PIPE, universal_newlines=True) 161 output, _ = process.communicate() 162 output = output.split() 163 xpixels, ypixels = int(output[0]), int(output[1]) 164 # adjust for misplacement 165 xpixels += 2 166 ypixels += 2 167 168 return (xpixels // cols), (ypixels // rows) 169 170 def draw(self, path, start_x, start_y, width, height): 171 if not self.is_initialized or self.process.poll() is not None: 172 self.initialize() 173 try: 174 input_gen = self._generate_w3m_input(path, start_x, start_y, width, height) 175 except ImageDisplayError: 176 raise 177 178 # Mitigate the issue with the horizontal black bars when 179 # selecting some images on some systems. 2 milliseconds seems 180 # enough. Adjust as necessary. 181 if self.fm.settings.w3m_delay > 0: 182 from time import sleep 183 sleep(self.fm.settings.w3m_delay) 184 185 self.process.stdin.write(input_gen) 186 self.process.stdin.flush() 187 self.process.stdout.readline() 188 self.quit() 189 self.is_initialized = False 190 191 def clear(self, start_x, start_y, width, height): 192 if not self.is_initialized or self.process.poll() is not None: 193 self.initialize() 194 195 fontw, fonth = self._get_font_dimensions() 196 197 cmd = "6;{x};{y};{w};{h}\n4;\n3;\n".format( 198 x=int((start_x - 0.2) * fontw), 199 y=start_y * fonth, 200 # y = int((start_y + 1) * fonth), # (for tmux top status bar) 201 w=int((width + 0.4) * fontw), 202 h=height * fonth + 1, 203 # h = (height - 1) * fonth + 1, # (for tmux top status bar) 204 ) 205 206 try: 207 self.fm.ui.win.redrawwin() 208 self.process.stdin.write(cmd) 209 except IOError as ex: 210 if ex.errno == errno.EPIPE: 211 return 212 raise 213 self.process.stdin.flush() 214 self.process.stdout.readline() 215 216 def _generate_w3m_input(self, path, start_x, start_y, max_width, max_height): 217 """Prepare the input string for w3mimgpreview 218 219 start_x, start_y, max_height and max_width specify the drawing area. 220 They are expressed in number of characters. 221 """ 222 fontw, fonth = self._get_font_dimensions() 223 if fontw == 0 or fonth == 0: 224 raise ImgDisplayUnsupportedException 225 226 max_width_pixels = max_width * fontw 227 max_height_pixels = max_height * fonth - 2 228 # (for tmux top status bar) 229 # max_height_pixels = (max_height - 1) * fonth - 2 230 231 # get image size 232 cmd = "5;{}\n".format(path) 233 234 self.process.stdin.write(cmd) 235 self.process.stdin.flush() 236 output = self.process.stdout.readline().split() 237 238 if len(output) != 2: 239 raise ImageDisplayError('Failed to execute w3mimgdisplay', output) 240 241 width = int(output[0]) 242 height = int(output[1]) 243 244 # get the maximum image size preserving ratio 245 if width > max_width_pixels: 246 height = (height * max_width_pixels) // width 247 width = max_width_pixels 248 if height > max_height_pixels: 249 width = (width * max_height_pixels) // height 250 height = max_height_pixels 251 252 start_x = int((start_x - 0.2) * fontw) + self.fm.settings.w3m_offset 253 start_y = (start_y * fonth) + self.fm.settings.w3m_offset 254 255 return "0;1;{x};{y};{w};{h};;;;;{filename}\n4;\n3;\n".format( 256 x=start_x, 257 y=start_y, 258 # y = (start_y + 1) * fonth, # (for tmux top status bar) 259 w=width, 260 h=height, 261 filename=path, 262 ) 263 264 def quit(self): 265 if self.is_initialized and self.process and self.process.poll() is None: 266 self.process.kill() 267 268# TODO: remove FileManagerAwareness, as stuff in ranger.ext should be 269# ranger-independent libraries. 270 271 272@register_image_displayer("iterm2") 273class ITerm2ImageDisplayer(ImageDisplayer, FileManagerAware): 274 """Implementation of ImageDisplayer using iTerm2 image display support 275 (http://iterm2.com/images.html). 276 277 Ranger must be running in iTerm2 for this to work. 278 """ 279 280 def draw(self, path, start_x, start_y, width, height): 281 with temporarily_moved_cursor(start_y, start_x): 282 sys.stdout.write(self._generate_iterm2_input(path, width, height)) 283 284 def clear(self, start_x, start_y, width, height): 285 self.fm.ui.win.redrawwin() 286 self.fm.ui.win.refresh() 287 288 def quit(self): 289 self.clear(0, 0, 0, 0) 290 291 def _generate_iterm2_input(self, path, max_cols, max_rows): 292 """Prepare the image content of path for image display in iTerm2""" 293 image_width, image_height = self._get_image_dimensions(path) 294 if max_cols == 0 or max_rows == 0 or image_width == 0 or image_height == 0: 295 return "" 296 image_width = self._fit_width( 297 image_width, image_height, max_cols, max_rows) 298 content = self._encode_image_content(path) 299 display_protocol = "\033" 300 close_protocol = "\a" 301 if "screen" in os.environ['TERM']: 302 display_protocol += "Ptmux;\033\033" 303 close_protocol += "\033\\" 304 305 text = "{0}]1337;File=inline=1;preserveAspectRatio=0;size={1};width={2}px:{3}{4}\n".format( 306 display_protocol, 307 str(len(content)), 308 str(int(image_width)), 309 content, 310 close_protocol) 311 return text 312 313 def _fit_width(self, width, height, max_cols, max_rows): 314 max_width = self.fm.settings.iterm2_font_width * max_cols 315 max_height = self.fm.settings.iterm2_font_height * max_rows 316 if height > max_height: 317 if width > max_width: 318 width_scale = max_width / width 319 height_scale = max_height / height 320 min_scale = min(width_scale, height_scale) 321 max_scale = max(width_scale, height_scale) 322 if width * max_scale <= max_width and height * max_scale <= max_height: 323 return width * max_scale 324 return width * min_scale 325 326 scale = max_height / height 327 return width * scale 328 elif width > max_width: 329 scale = max_width / width 330 return width * scale 331 332 return width 333 334 @staticmethod 335 def _encode_image_content(path): 336 """Read and encode the contents of path""" 337 with open(path, 'rb') as fobj: 338 return base64.b64encode(fobj.read()).decode('utf-8') 339 340 @staticmethod 341 def _get_image_dimensions(path): 342 """Determine image size using imghdr""" 343 file_handle = open(path, 'rb') 344 file_header = file_handle.read(24) 345 image_type = imghdr.what(path) 346 if len(file_header) != 24: 347 file_handle.close() 348 return 0, 0 349 if image_type == 'png': 350 check = struct.unpack('>i', file_header[4:8])[0] 351 if check != 0x0d0a1a0a: 352 file_handle.close() 353 return 0, 0 354 width, height = struct.unpack('>ii', file_header[16:24]) 355 elif image_type == 'gif': 356 width, height = struct.unpack('<HH', file_header[6:10]) 357 elif image_type == 'jpeg': 358 unreadable = IOError if sys.version_info[0] < 3 else OSError 359 try: 360 file_handle.seek(0) 361 size = 2 362 ftype = 0 363 while not 0xc0 <= ftype <= 0xcf: 364 file_handle.seek(size, 1) 365 byte = file_handle.read(1) 366 while ord(byte) == 0xff: 367 byte = file_handle.read(1) 368 ftype = ord(byte) 369 size = struct.unpack('>H', file_handle.read(2))[0] - 2 370 file_handle.seek(1, 1) 371 height, width = struct.unpack('>HH', file_handle.read(4)) 372 except unreadable: 373 height, width = 0, 0 374 else: 375 file_handle.close() 376 return 0, 0 377 file_handle.close() 378 return width, height 379 380 381@register_image_displayer("terminology") 382class TerminologyImageDisplayer(ImageDisplayer, FileManagerAware): 383 """Implementation of ImageDisplayer using terminology image display support 384 (https://github.com/billiob/terminology). 385 386 Ranger must be running in terminology for this to work. 387 Doesn't work with TMUX :/ 388 """ 389 390 def __init__(self): 391 self.display_protocol = "\033" 392 self.close_protocol = "\000" 393 394 def draw(self, path, start_x, start_y, width, height): 395 with temporarily_moved_cursor(start_y, start_x): 396 # Write intent 397 sys.stdout.write("%s}ic#%d;%d;%s%s" % ( 398 self.display_protocol, 399 width, height, 400 path, 401 self.close_protocol)) 402 403 # Write Replacement commands ('#') 404 for y in range(0, height): 405 move_cur(start_y + y, start_x) 406 sys.stdout.write("%s}ib%s%s%s}ie%s\n" % ( # needs a newline to work 407 self.display_protocol, 408 self.close_protocol, 409 "#" * width, 410 self.display_protocol, 411 self.close_protocol)) 412 413 def clear(self, start_x, start_y, width, height): 414 self.fm.ui.win.redrawwin() 415 self.fm.ui.win.refresh() 416 417 def quit(self): 418 self.clear(0, 0, 0, 0) 419 420 421@register_image_displayer("urxvt") 422class URXVTImageDisplayer(ImageDisplayer, FileManagerAware): 423 """Implementation of ImageDisplayer working by setting the urxvt 424 background image "under" the preview pane. 425 426 Ranger must be running in urxvt for this to work. 427 428 """ 429 430 def __init__(self): 431 self.display_protocol = "\033" 432 self.close_protocol = "\a" 433 if "screen" in os.environ['TERM']: 434 self.display_protocol += "Ptmux;\033\033" 435 self.close_protocol += "\033\\" 436 self.display_protocol += "]20;" 437 438 @staticmethod 439 def _get_max_sizes(): 440 """Use the whole terminal.""" 441 pct_width = 100 442 pct_height = 100 443 return pct_width, pct_height 444 445 @staticmethod 446 def _get_centered_offsets(): 447 """Center the image.""" 448 pct_x = 50 449 pct_y = 50 450 return pct_x, pct_y 451 452 def _get_sizes(self): 453 """Return the width and height of the preview pane in relation to the 454 whole terminal window. 455 456 """ 457 if self.fm.ui.pager.visible: 458 return self._get_max_sizes() 459 460 total_columns_ratio = sum(self.fm.settings.column_ratios) 461 preview_column_ratio = self.fm.settings.column_ratios[-1] 462 pct_width = int((100 * preview_column_ratio) / total_columns_ratio) 463 pct_height = 100 # As much as possible while preserving the aspect ratio. 464 return pct_width, pct_height 465 466 def _get_offsets(self): 467 """Return the offsets of the image center.""" 468 if self.fm.ui.pager.visible: 469 return self._get_centered_offsets() 470 471 pct_x = 100 # Right-aligned. 472 pct_y = 2 # TODO: Use the font size to calculate this offset. 473 return pct_x, pct_y 474 475 def draw(self, path, start_x, start_y, width, height): 476 # The coordinates in the arguments are ignored as urxvt takes 477 # the coordinates in a non-standard way: the position of the 478 # image center as a percentage of the terminal size. As a 479 # result all values below are in percents. 480 481 pct_x, pct_y = self._get_offsets() 482 pct_width, pct_height = self._get_sizes() 483 484 sys.stdout.write( 485 self.display_protocol 486 + path 487 + ";{pct_width}x{pct_height}+{pct_x}+{pct_y}:op=keep-aspect".format( 488 pct_width=pct_width, pct_height=pct_height, pct_x=pct_x, pct_y=pct_y 489 ) 490 + self.close_protocol 491 ) 492 sys.stdout.flush() 493 494 def clear(self, start_x, start_y, width, height): 495 sys.stdout.write( 496 self.display_protocol 497 + ";100x100+1000+1000" 498 + self.close_protocol 499 ) 500 sys.stdout.flush() 501 502 def quit(self): 503 self.clear(0, 0, 0, 0) # dummy assignments 504 505 506@register_image_displayer("urxvt-full") 507class URXVTImageFSDisplayer(URXVTImageDisplayer): 508 """URXVTImageDisplayer that utilizes the whole terminal.""" 509 510 def _get_sizes(self): 511 """Use the whole terminal.""" 512 return self._get_max_sizes() 513 514 def _get_offsets(self): 515 """Center the image.""" 516 return self._get_centered_offsets() 517 518 519@register_image_displayer("kitty") 520class KittyImageDisplayer(ImageDisplayer, FileManagerAware): 521 """Implementation of ImageDisplayer for kitty (https://github.com/kovidgoyal/kitty/) 522 terminal. It uses the built APC to send commands and data to kitty, 523 which in turn renders the image. The APC takes the form 524 '\033_Gk=v,k=v...;bbbbbbbbbbbbbb\033\\' 525 | ---------- -------------- | 526 escape code | | escape code 527 | base64 encoded payload 528 key: value pairs as parameters 529 For more info please head over to : 530 https://github.com/kovidgoyal/kitty/blob/master/graphics-protocol.asciidoc""" 531 protocol_start = b'\x1b_G' 532 protocol_end = b'\x1b\\' 533 # we are going to use stdio in binary mode a lot, so due to py2 -> py3 534 # differnces is worth to do this: 535 stdbout = getattr(sys.stdout, 'buffer', sys.stdout) 536 stdbin = getattr(sys.stdin, 'buffer', sys.stdin) 537 # counter for image ids on kitty's end 538 image_id = 0 539 # we need to find out the encoding for a path string, ascii won't cut it 540 try: 541 fsenc = sys.getfilesystemencoding() # returns None if standard utf-8 is used 542 # throws LookupError if can't find the codec, TypeError if fsenc is None 543 codecs.lookup(fsenc) 544 except (LookupError, TypeError): 545 fsenc = 'utf-8' 546 547 def __init__(self): 548 # the rest of the initializations that require reading stdio or raising exceptions 549 # are delayed to the first draw call, since curses 550 # and ranger exception handler are not online at __init__() time 551 self.needs_late_init = True 552 # to init in _late_init() 553 self.backend = None 554 self.stream = None 555 self.pix_row, self.pix_col = (0, 0) 556 557 def _late_init(self): 558 # tmux 559 if 'kitty' not in os.environ['TERM']: 560 # this doesn't seem to work, ranger freezes... 561 # commenting out the response check does nothing 562 # self.protocol_start = b'\033Ptmux;\033' + self.protocol_start 563 # self.protocol_end += b'\033\\' 564 raise ImgDisplayUnsupportedException( 565 'kitty previews only work in' 566 + ' kitty and outside tmux. ' 567 + 'Make sure your TERM contains the string "kitty"') 568 569 # automatic check if we share the filesystem using a dummy file 570 with NamedTemporaryFile() as tmpf: 571 tmpf.write(bytearray([0xFF] * 3)) 572 tmpf.flush() 573 for cmd in self._format_cmd_str( 574 {'a': 'q', 'i': 1, 'f': 24, 't': 'f', 's': 1, 'v': 1, 'S': 3}, 575 payload=base64.standard_b64encode(tmpf.name.encode(self.fsenc))): 576 self.stdbout.write(cmd) 577 sys.stdout.flush() 578 resp = b'' 579 while resp[-2:] != self.protocol_end: 580 resp += self.stdbin.read(1) 581 # set the transfer method based on the response 582 # if resp.find(b'OK') != -1: 583 if b'OK' in resp: 584 self.stream = False 585 elif b'EBADF' in resp: 586 self.stream = True 587 else: 588 raise ImgDisplayUnsupportedException( 589 'kitty replied an unexpected response: {}'.format(resp)) 590 591 # get the image manipulation backend 592 try: 593 # pillow is the default since we are not going 594 # to spawn other processes, so it _should_ be faster 595 import PIL.Image 596 self.backend = PIL.Image 597 except ImportError: 598 raise ImageDisplayError("Image previews in kitty require PIL (pillow)") 599 # TODO: implement a wrapper class for Imagemagick process to 600 # replicate the functionality we use from im 601 602 # get dimensions of a cell in pixels 603 ret = fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, 604 struct.pack('HHHH', 0, 0, 0, 0)) 605 n_cols, n_rows, x_px_tot, y_px_tot = struct.unpack('HHHH', ret) 606 self.pix_row, self.pix_col = x_px_tot // n_rows, y_px_tot // n_cols 607 self.needs_late_init = False 608 609 def draw(self, path, start_x, start_y, width, height): 610 self.image_id += 1 611 # dictionary to store the command arguments for kitty 612 # a is the display command, with T going for immediate output 613 # i is the id entifier for the image 614 cmds = {'a': 'T', 'i': self.image_id} 615 # sys.stderr.write('{}-{}@{}x{}\t'.format(start_x, start_y, width, height)) 616 617 # finish initialization if it is the first call 618 if self.needs_late_init: 619 self._late_init() 620 621 with warnings.catch_warnings(record=True): # as warn: 622 warnings.simplefilter('ignore', self.backend.DecompressionBombWarning) 623 image = self.backend.open(path) 624 # TODO: find a way to send a message to the user that 625 # doesn't stop the image from displaying 626 # if warn: 627 # raise ImageDisplayError(str(warn[-1].message)) 628 box = (width * self.pix_row, height * self.pix_col) 629 630 if image.width > box[0] or image.height > box[1]: 631 scale = min(box[0] / image.width, box[1] / image.height) 632 image = image.resize((int(scale * image.width), int(scale * image.height)), 633 self.backend.LANCZOS) 634 635 if image.mode != 'RGB' and image.mode != 'RGBA': 636 image = image.convert('RGB') 637 # start_x += ((box[0] - image.width) // 2) // self.pix_row 638 # start_y += ((box[1] - image.height) // 2) // self.pix_col 639 if self.stream: 640 # encode the whole image as base64 641 # TODO: implement z compression 642 # to possibly increase resolution in sent image 643 # t: transmissium medium, 'd' for embedded 644 # f: size of a pixel fragment (8bytes per color) 645 # s, v: size of the image to recompose the flattened data 646 # c, r: size in cells of the viewbox 647 cmds.update({'t': 'd', 'f': len(image.getbands()) * 8, 648 's': image.width, 'v': image.height, }) 649 payload = base64.standard_b64encode( 650 bytearray().join(map(bytes, image.getdata()))) 651 else: 652 # put the image in a temporary png file 653 # t: transmissium medium, 't' for temporary file (kitty will delete it for us) 654 # f: size of a pixel fragment (100 just mean that the file is png encoded, 655 # the only format except raw RGB(A) bitmap that kitty understand) 656 # c, r: size in cells of the viewbox 657 cmds.update({'t': 't', 'f': 100, }) 658 with NamedTemporaryFile(prefix='ranger_thumb_', suffix='.png', delete=False) as tmpf: 659 image.save(tmpf, format='png', compress_level=0) 660 payload = base64.standard_b64encode(tmpf.name.encode(self.fsenc)) 661 662 with temporarily_moved_cursor(int(start_y), int(start_x)): 663 for cmd_str in self._format_cmd_str(cmds, payload=payload): 664 self.stdbout.write(cmd_str) 665 # catch kitty answer before the escape codes corrupt the console 666 resp = b'' 667 while resp[-2:] != self.protocol_end: 668 resp += self.stdbin.read(1) 669 if b'OK' in resp: 670 return 671 else: 672 raise ImageDisplayError('kitty replied "{}"'.format(resp)) 673 674 def clear(self, start_x, start_y, width, height): 675 # let's assume that every time ranger call this 676 # it actually wants just to remove the previous image 677 # TODO: implement this using the actual x, y, since the protocol supports it 678 cmds = {'a': 'd', 'i': self.image_id} 679 for cmd_str in self._format_cmd_str(cmds): 680 self.stdbout.write(cmd_str) 681 self.stdbout.flush() 682 # kitty doesn't seem to reply on deletes, checking like we do in draw() 683 # will slows down scrolling with timeouts from select 684 self.image_id -= 1 685 self.fm.ui.win.redrawwin() 686 self.fm.ui.win.refresh() 687 688 def _format_cmd_str(self, cmd, payload=None, max_slice_len=2048): 689 central_blk = ','.join(["{}={}".format(k, v) for k, v in cmd.items()]).encode('ascii') 690 if payload is not None: 691 # we add the m key to signal a multiframe communication 692 # appending the end (m=0) key to a single message has no effect 693 while len(payload) > max_slice_len: 694 payload_blk, payload = payload[:max_slice_len], payload[max_slice_len:] 695 yield self.protocol_start + \ 696 central_blk + b',m=1;' + payload_blk + \ 697 self.protocol_end 698 yield self.protocol_start + \ 699 central_blk + b',m=0;' + payload + \ 700 self.protocol_end 701 else: 702 yield self.protocol_start + central_blk + b';' + self.protocol_end 703 704 def quit(self): 705 # clear all remaining images, then check if all files went through or are orphaned 706 while self.image_id >= 1: 707 self.clear(0, 0, 0, 0) 708 # for k in self.temp_paths: 709 # try: 710 # os.remove(self.temp_paths[k]) 711 # except (OSError, IOError): 712 # continue 713 714 715@register_image_displayer("ueberzug") 716class UeberzugImageDisplayer(ImageDisplayer): 717 """Implementation of ImageDisplayer using ueberzug. 718 Ueberzug can display images in a Xorg session. 719 Does not work over ssh. 720 """ 721 IMAGE_ID = 'preview' 722 is_initialized = False 723 724 def __init__(self): 725 self.process = None 726 727 def initialize(self): 728 """start ueberzug""" 729 if (self.is_initialized and self.process.poll() is None 730 and not self.process.stdin.closed): 731 return 732 733 self.process = Popen(['ueberzug', 'layer', '--silent'], cwd=self.working_dir, 734 stdin=PIPE, universal_newlines=True) 735 self.is_initialized = True 736 737 def _execute(self, **kwargs): 738 self.initialize() 739 self.process.stdin.write(json.dumps(kwargs) + '\n') 740 self.process.stdin.flush() 741 742 def draw(self, path, start_x, start_y, width, height): 743 self._execute( 744 action='add', 745 identifier=self.IMAGE_ID, 746 x=start_x, 747 y=start_y, 748 max_width=width, 749 max_height=height, 750 path=path 751 ) 752 753 def clear(self, start_x, start_y, width, height): 754 if self.process and not self.process.stdin.closed: 755 self._execute(action='remove', identifier=self.IMAGE_ID) 756 757 def quit(self): 758 if self.is_initialized and self.process.poll() is None: 759 timer_kill = threading.Timer(1, self.process.kill, []) 760 try: 761 self.process.terminate() 762 timer_kill.start() 763 self.process.communicate() 764 finally: 765 timer_kill.cancel() 766