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