summary refs log tree commit diff stats
diff options
context:
space:
mode:
authortoonn <toonn@toonn.io>2018-06-07 14:36:51 +0200
committertoonn <toonn@toonn.io>2018-06-07 14:36:51 +0200
commit9c0092a7e0a9c23e6342beea3d79f35026b31747 (patch)
tree371d34d653da1d93544a530b16745c292abbe65e
parente461aaf0cb12fe72338f6d0ec101718e1ff02e66 (diff)
parent3d699f88b0d4e7bf6bccd27360cc841831c3bd5d (diff)
downloadranger-9c0092a7e0a9c23e6342beea3d79f35026b31747.tar.gz
Merge branch 'kitty-img-preview'
Adding kitty image previews to ranger : )
-rw-r--r--doc/ranger.117
-rw-r--r--doc/ranger.pod13
-rw-r--r--doc/rifle.14
-rw-r--r--ranger/config/rc.conf13
-rw-r--r--ranger/container/settings.py3
-rw-r--r--ranger/core/fm.py12
-rw-r--r--ranger/core/main.py2
-rw-r--r--ranger/ext/img_display.py279
-rw-r--r--ranger/gui/widgets/pager.py3
9 files changed, 292 insertions, 54 deletions
diff --git a/doc/ranger.1 b/doc/ranger.1
index 1290cb58..8d373a64 100644
--- a/doc/ranger.1
+++ b/doc/ranger.1
@@ -129,7 +129,7 @@
 .\" ========================================================================
 .\"
 .IX Title "RANGER 1"
-.TH RANGER 1 "ranger-1.9.1" "2018-05-14" "ranger manual"
+.TH RANGER 1 "ranger-1.9.1" "05/29/2018" "ranger manual"
 .\" For nroff, turn off justification.  Always turn off hyphenation; it makes
 .\" way too many mistakes in technical documents.
 .if n .ad l
@@ -307,6 +307,13 @@ This feature relies on the dimensions of the terminal's font.  By default, a
 width of 8 and height of 11 are used.  To use other values, set the options
 \&\f(CW\*(C`iterm2_font_width\*(C'\fR and \f(CW\*(C`iterm2_font_height\*(C'\fR to the desired values.
 .PP
+\fIterminology\fR
+.IX Subsection "terminology"
+.PP
+This only works in terminology. It can render vector graphics, but works only locally.
+.PP
+To enable this feature, set the option \f(CW\*(C`preview_images_method\*(C'\fR to terminology.
+.PP
 \fIurxvt\fR
 .IX Subsection "urxvt"
 .PP
@@ -324,6 +331,14 @@ The same as urxvt but utilizing not only the preview pane but the whole terminal
 window.
 .PP
 To enable this feature, set the option \f(CW\*(C`preview_images_method\*(C'\fR to urxvt-full.
+.PP
+\fIkitty\fR
+.IX Subsection "kitty"
+.PP
+This only works on Kitty. It requires \s-1PIL\s0 (or pillow) to work.
+Allows remote image previews, for example in an ssh session.
+.PP
+To enable this feature, set the option \f(CW\*(C`preview_images_method\*(C'\fR to kitty.
 .SS "\s-1SELECTION\s0"
 .IX Subsection "SELECTION"
 The \fIselection\fR is defined as \*(L"All marked files \s-1IF THERE ARE ANY,\s0 otherwise
diff --git a/doc/ranger.pod b/doc/ranger.pod
index 4471a153..24df0fc9 100644
--- a/doc/ranger.pod
+++ b/doc/ranger.pod
@@ -220,6 +220,12 @@ This feature relies on the dimensions of the terminal's font.  By default, a
 width of 8 and height of 11 are used.  To use other values, set the options
 C<iterm2_font_width> and C<iterm2_font_height> to the desired values.
 
+=head3 terminology
+
+This only works in terminology. It can render vector graphics, but works only locally.
+
+To enable this feature, set the option C<preview_images_method> to terminology.
+
 =head3 urxvt
 
 This only works in urxvt compiled with pixbuf support. Does not work over ssh.
@@ -236,6 +242,13 @@ window.
 
 To enable this feature, set the option C<preview_images_method> to urxvt-full.
 
+=head3 kitty
+
+This only works on Kitty. It requires PIL (or pillow) to work.
+Allows remote image previews, for example in an ssh session.
+
+To enable this feature, set the option C<preview_images_method> to kitty.
+
 =head2 SELECTION
 
 The I<selection> is defined as "All marked files IF THERE ARE ANY, otherwise
diff --git a/doc/rifle.1 b/doc/rifle.1
index ad32e4f4..c53a6f39 100644
--- a/doc/rifle.1
+++ b/doc/rifle.1
@@ -1,4 +1,4 @@
-.\" Automatically generated by Pod::Man 4.07 (Pod::Simple 3.32)
+.\" Automatically generated by Pod::Man 4.09 (Pod::Simple 3.35)
 .\"
 .\" Standard preamble:
 .\" ========================================================================
@@ -129,7 +129,7 @@
 .\" ========================================================================
 .\"
 .IX Title "RIFLE 1"
-.TH RIFLE 1 "rifle-1.9.1" "05.03.2018" "rifle manual"
+.TH RIFLE 1 "rifle-1.9.1" "05/29/2018" "rifle manual"
 .\" For nroff, turn off justification.  Always turn off hyphenation; it makes
 .\" way too many mistakes in technical documents.
 .if n .ad l
diff --git a/ranger/config/rc.conf b/ranger/config/rc.conf
index 1369bb27..37a570b5 100644
--- a/ranger/config/rc.conf
+++ b/ranger/config/rc.conf
@@ -86,6 +86,10 @@ set preview_images false
 #   width of 8 and height of 11 are used.  To use other values, set the options
 #   iterm2_font_width and iterm2_font_height to the desired values.
 #
+# * terminology:
+#   Previews images in full color in the terminology terminal emulator.
+#   Supports a wide variety of formats, even vector graphics like svg.
+#
 # * urxvt:
 #   Preview images in full color using urxvt image backgrounds. This
 #   requires using urxvt compiled with pixbuf support.
@@ -93,6 +97,15 @@ set preview_images false
 # * urxvt-full:
 #   The same as urxvt but utilizing not only the preview pane but the
 #   whole terminal window.
+#
+# * kitty:
+#   Preview images in full color using kitty image protocol.
+#   Requires python PIL or pillow library.
+#   If ranger does not share the local filesystem with kitty
+#   the transfer method is changed to encode the whole image;
+#   while slower, this allows remote previews,
+#   for example during an ssh session.
+#   Tmux is unsupported.
 set preview_images_method w3m
 
 # Delay in seconds before displaying an image with the w3m method.
diff --git a/ranger/container/settings.py b/ranger/container/settings.py
index d0ff2f5e..9002fded 100644
--- a/ranger/container/settings.py
+++ b/ranger/container/settings.py
@@ -102,7 +102,8 @@ ALLOWED_VALUES = {
     'confirm_on_delete': ['multiple', 'always', 'never'],
     'line_numbers': ['false', 'absolute', 'relative'],
     'one_indexed': [False, True],
-    'preview_images_method': ['w3m', 'iterm2', 'urxvt', 'urxvt-full'],
+    'preview_images_method': ['w3m', 'iterm2', 'terminology',
+                              'urxvt', 'urxvt-full', 'kitty'],
     'vcs_backend_bzr': ['disabled', 'local', 'enabled'],
     'vcs_backend_git': ['enabled', 'disabled', 'local'],
     'vcs_backend_hg': ['disabled', 'local', 'enabled'],
diff --git a/ranger/core/fm.py b/ranger/core/fm.py
index d85dd48c..61b3cb11 100644
--- a/ranger/core/fm.py
+++ b/ranger/core/fm.py
@@ -24,7 +24,9 @@ from ranger.container.bookmarks import Bookmarks
 from ranger.core.runner import Runner
 from ranger.ext.img_display import (W3MImageDisplayer, ITerm2ImageDisplayer,
                                     TerminologyImageDisplayer,
-                                    URXVTImageDisplayer, URXVTImageFSDisplayer, ImageDisplayer)
+                                    URXVTImageDisplayer, URXVTImageFSDisplayer,
+                                    KittyImageDisplayer,
+                                    ImageDisplayer)
 from ranger.core.metadata import MetadataManager
 from ranger.ext.rifle import Rifle
 from ranger.container.directory import Directory
@@ -223,7 +225,7 @@ class FM(Actions,  # pylint: disable=too-many-instance-attributes
             for line in entry.splitlines():
                 yield line
 
-    def _get_image_displayer(self):
+    def _get_image_displayer(self):  # pylint: disable=too-many-return-statements
         if self.settings.preview_images_method == "w3m":
             return W3MImageDisplayer()
         elif self.settings.preview_images_method == "iterm2":
@@ -234,6 +236,8 @@ class FM(Actions,  # pylint: disable=too-many-instance-attributes
             return URXVTImageDisplayer()
         elif self.settings.preview_images_method == "urxvt-full":
             return URXVTImageFSDisplayer()
+        elif self.settings.preview_images_method == "kitty":
+            return KittyImageDisplayer()
         return ImageDisplayer()
 
     def _get_thisfile(self):
@@ -424,5 +428,5 @@ class FM(Actions,  # pylint: disable=too-many-instance-attributes
             if not ranger.args.clean and self.settings.save_tabs_on_exit and len(self.tabs) > 1:
                 with open(self.datapath('tabs'), 'a') as fobj:
                     # Don't save active tab since launching ranger changes the active tab
-                    fobj.write('\0'.join(v.path for t, v in self.tabs.items()) +
-                               '\0\0')
+                    fobj.write('\0'.join(v.path for t, v in self.tabs.items())
+                               + '\0\0')
diff --git a/ranger/core/main.py b/ranger/core/main.py
index b5d0af77..598ce243 100644
--- a/ranger/core/main.py
+++ b/ranger/core/main.py
@@ -363,7 +363,7 @@ def load_settings(  # pylint: disable=too-many-locals,too-many-branches,too-many
 
         # Load custom commands
         def import_file(name, path):  # From https://stackoverflow.com/a/67692
-            # pragma pylint: disable=no-name-in-module,import-error,no-member
+            # pragma pylint: disable=no-name-in-module,import-error,no-member, deprecated-method
             if sys.version_info >= (3, 5):
                 import importlib.util as util
                 spec = util.spec_from_file_location(name, path)
diff --git a/ranger/ext/img_display.py b/ranger/ext/img_display.py
index 4f447f39..f78e170b 100644
--- a/ranger/ext/img_display.py
+++ b/ranger/ext/img_display.py
@@ -19,9 +19,13 @@ import imghdr
 import os
 import struct
 import sys
+import warnings
 from subprocess import Popen, PIPE
 
 import termios
+from contextlib import contextmanager
+import codecs
+from tempfile import NamedTemporaryFile
 
 from ranger.core.shared import FileManagerAware
 
@@ -35,6 +39,28 @@ W3MIMGDISPLAY_PATHS = [
     '/usr/local/libexec/w3m/w3mimgdisplay',
 ]
 
+# Helper functions shared between the previewers (make them static methods of the base class?)
+
+
+@contextmanager
+def temporarily_moved_cursor(to_y, to_x):
+    """Common boilerplate code to move the cursor to a drawing area. Use it as:
+        with temporarily_moved_cursor(dest_y, dest_x):
+            your_func_here()"""
+    curses.putp(curses.tigetstr("sc"))
+    move_cur(to_y, to_x)
+    yield
+    curses.putp(curses.tigetstr("rc"))
+    sys.stdout.flush()
+
+
+# this is excised since Terminology needs to move the cursor multiple times
+def move_cur(to_y, to_x):
+    tparm = curses.tparm(curses.tigetstr("cup"), to_y, to_x)
+    # on python2 stdout is already in binary mode, in python3 is accessed via buffer
+    bin_stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+    bin_stdout.write(tparm)
+
 
 class ImageDisplayError(Exception):
     pass
@@ -218,15 +244,8 @@ class ITerm2ImageDisplayer(ImageDisplayer, FileManagerAware):
     """
 
     def draw(self, path, start_x, start_y, width, height):
-        curses.putp(curses.tigetstr("sc"))
-        tparm = curses.tparm(curses.tigetstr("cup"), start_y, start_x)
-        if sys.version_info[0] < 3:
-            sys.stdout.write(tparm)
-        else:
-            sys.stdout.buffer.write(tparm)  # pylint: disable=no-member
-        sys.stdout.write(self._generate_iterm2_input(path, width, height))
-        curses.putp(curses.tigetstr("rc"))
-        sys.stdout.flush()
+        with temporarily_moved_cursor(start_y, start_x):
+            sys.stdout.write(self._generate_iterm2_input(path, width, height))
 
     def clear(self, start_x, start_y, width, height):
         self.fm.ui.win.redrawwin()
@@ -335,44 +354,23 @@ class TerminologyImageDisplayer(ImageDisplayer, FileManagerAware):
         self.close_protocol = "\000"
 
     def draw(self, path, start_x, start_y, width, height):
-        # Save cursor
-        curses.putp(curses.tigetstr("sc"))
-
-        y = start_y
-        # Move to drawing zone
-        self._move_to(start_x, y)
-
-        # Write intent
-        sys.stdout.write("%s}ic#%d;%d;%s%s" % (
-            self.display_protocol,
-            width, height,
-            path,
-            self.close_protocol))
-
-        # Write Replacement commands ('#')
-        for _ in range(0, height):
-            sys.stdout.write("%s}ib%s%s%s}ie%s" % (
-                self.display_protocol,
-                self.close_protocol,
-                "#" * width,
+        with temporarily_moved_cursor(start_y, start_x):
+            # Write intent
+            sys.stdout.write("%s}ic#%d;%d;%s%s" % (
                 self.display_protocol,
+                width, height,
+                path,
                 self.close_protocol))
-            y = y + 1
-            self._move_to(start_x, y)
 
-        # Restore cursor
-        curses.putp(curses.tigetstr("rc"))
-
-        sys.stdout.flush()
-
-    @staticmethod
-    def _move_to(x, y):
-        # curses.move(y, x)
-        tparm = curses.tparm(curses.tigetstr("cup"), y, x)
-        if sys.version_info[0] < 3:
-            sys.stdout.write(tparm)
-        else:
-            sys.stdout.buffer.write(tparm)  # pylint: disable=no-member
+            # Write Replacement commands ('#')
+            for y in range(0, height):
+                move_cur(start_y + y, start_x)
+                sys.stdout.write("%s}ib%s%s%s}ie%s\n" % (  # needs a newline to work
+                    self.display_protocol,
+                    self.close_protocol,
+                    "#" * width,
+                    self.display_protocol,
+                    self.close_protocol))
 
     def clear(self, start_x, start_y, width, height):
         self.fm.ui.win.redrawwin()
@@ -473,3 +471,196 @@ class URXVTImageFSDisplayer(URXVTImageDisplayer):
     def _get_offsets(self):
         """Center the image."""
         return self._get_centered_offsets()
+
+
+class KittyImageDisplayer(ImageDisplayer):
+    """Implementation of ImageDisplayer for kitty (https://github.com/kovidgoyal/kitty/)
+    terminal. It uses the built APC to send commands and data to kitty,
+    which in turn renders the image. The APC takes the form
+    '\033_Gk=v,k=v...;bbbbbbbbbbbbbb\033\\'
+       |   ---------- --------------  |
+    escape code  |             |    escape code
+                 |  base64 encoded payload
+        key: value pairs as parameters
+    For more info please head over to :
+        https://github.com/kovidgoyal/kitty/blob/master/graphics-protocol.asciidoc"""
+    protocol_start = b'\x1b_G'
+    protocol_end = b'\x1b\\'
+    # we are going to use stdio in binary mode a lot, so due to py2 -> py3
+    # differnces is worth to do this:
+    stdbout = getattr(sys.stdout, 'buffer', sys.stdout)
+    stdbin = getattr(sys.stdin, 'buffer', sys.stdin)
+    # counter for image ids on kitty's end
+    image_id = 0
+    # we need to find out the encoding for a path string, ascii won't cut it
+    try:
+        fsenc = sys.getfilesystemencoding()  # returns None if standard utf-8 is used
+        # throws LookupError if can't find the codec, TypeError if fsenc is None
+        codecs.lookup(fsenc)
+    except (LookupError, TypeError):
+        fsenc = 'utf-8'
+
+    def __init__(self):
+        # the rest of the initializations that require reading stdio or raising exceptions
+        # are delayed to the first draw call, since curses
+        # and ranger exception handler are not online at __init__() time
+        self.needs_late_init = True
+        # to init in _late_init()
+        self.backend = None
+        self.stream = None
+        self.pix_row, self.pix_col = (0, 0)
+
+    def _late_init(self):
+        # tmux
+        if 'kitty' not in os.environ['TERM']:
+            # this doesn't seem to work, ranger freezes...
+            # commenting out the response check does nothing
+            # self.protocol_start = b'\033Ptmux;\033' + self.protocol_start
+            # self.protocol_end += b'\033\\'
+            raise ImgDisplayUnsupportedException(
+                'kitty previews only work in'
+                + ' kitty and outside tmux. '
+                + 'Make sure your TERM contains the string "kitty"')
+
+        # automatic check if we share the filesystem using a dummy file
+        with NamedTemporaryFile() as tmpf:
+            tmpf.write(bytearray([0xFF] * 3))
+            tmpf.flush()
+            for cmd in self._format_cmd_str(
+                    {'a': 'q', 'i': 1, 'f': 24, 't': 'f', 's': 1, 'v': 1, 'S': 3},
+                    payload=base64.standard_b64encode(tmpf.name.encode(self.fsenc))):
+                self.stdbout.write(cmd)
+            sys.stdout.flush()
+            resp = b''
+            while resp[-2:] != self.protocol_end:
+                resp += self.stdbin.read(1)
+        # set the transfer method based on the response
+        # if resp.find(b'OK') != -1:
+        if b'OK' in resp:
+            self.stream = False
+        elif b'EBADF' in resp:
+            self.stream = True
+        else:
+            raise ImgDisplayUnsupportedException(
+                'kitty replied an unexpected response: {}'.format(resp))
+
+        # get the image manipulation backend
+        try:
+            # pillow is the default since we are not going
+            # to spawn other processes, so it _should_ be faster
+            import PIL.Image
+            self.backend = PIL.Image
+        except ImportError:
+            raise ImageDisplayError("Image previews in kitty require PIL (pillow)")
+            # TODO: implement a wrapper class for Imagemagick process to
+            # replicate the functionality we use from im
+
+        # get dimensions of a cell in pixels
+        ret = fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ,
+                          struct.pack('HHHH', 0, 0, 0, 0))
+        n_cols, n_rows, x_px_tot, y_px_tot = struct.unpack('HHHH', ret)
+        self.pix_row, self.pix_col = x_px_tot // n_rows, y_px_tot // n_cols
+        self.needs_late_init = False
+
+    def draw(self, path, start_x, start_y, width, height):
+        self.image_id += 1
+        # dictionary to store the command arguments for kitty
+        # a is the display command, with T going for immediate output
+        # i is the id entifier for the image
+        cmds = {'a': 'T', 'i': self.image_id}
+        # sys.stderr.write('{}-{}@{}x{}\t'.format(start_x, start_y, width, height))
+
+        # finish initialization if it is the first call
+        if self.needs_late_init:
+            self._late_init()
+
+        with warnings.catch_warnings(record=True):  # as warn:
+            warnings.simplefilter('ignore', self.backend.DecompressionBombWarning)
+            image = self.backend.open(path)
+            # TODO: find a way to send a message to the user that
+            # doesn't stop the image from displaying
+            # if warn:
+            #     raise ImageDisplayError(str(warn[-1].message))
+        box = (width * self.pix_row, height * self.pix_col)
+
+        if image.width > box[0] or image.height > box[1]:
+            scale = min(box[0] / image.width, box[1] / image.height)
+            image = image.resize((int(scale * image.width), int(scale * image.height)),
+                                 self.backend.LANCZOS)
+
+        # start_x += ((box[0] - image.width) // 2) // self.pix_row
+        # start_y += ((box[1] - image.height) // 2) // self.pix_col
+        if self.stream:
+            # encode the whole image as base64
+            # TODO: implement z compression
+            # to possibly increase resolution in sent image
+            if image.mode != 'RGB' and image.mode != 'RGBA':
+                image = image.convert('RGB')
+            # t: transmissium medium, 'd' for embedded
+            # f: size of a pixel fragment (8bytes per color)
+            # s, v: size of the image to recompose the flattened data
+            # c, r: size in cells of the viewbox
+            cmds.update({'t': 'd', 'f': len(image.getbands()) * 8,
+                         's': image.width, 'v': image.height, })
+            payload = base64.standard_b64encode(
+                bytearray().join(map(bytes, image.getdata())))
+        else:
+            # put the image in a temporary png file
+            # t: transmissium medium, 't' for temporary file (kitty will delete it for us)
+            # f: size of a pixel fragment (100 just mean that the file is png encoded,
+            #       the only format except raw RGB(A) bitmap that kitty understand)
+            # c, r: size in cells of the viewbox
+            cmds.update({'t': 't', 'f': 100, })
+            with NamedTemporaryFile(prefix='ranger_thumb_', suffix='.png', delete=False) as tmpf:
+                image.save(tmpf, format='png', compress_level=0)
+                payload = base64.standard_b64encode(tmpf.name.encode(self.fsenc))
+
+        with temporarily_moved_cursor(int(start_y), int(start_x)):
+            for cmd_str in self._format_cmd_str(cmds, payload=payload):
+                self.stdbout.write(cmd_str)
+        # catch kitty answer before the escape codes corrupt the console
+        resp = b''
+        while resp[-2:] != self.protocol_end:
+            resp += self.stdbin.read(1)
+        if b'OK' in resp:
+            return
+        else:
+            raise ImageDisplayError('kitty replied "{}"'.format(resp))
+
+    def clear(self, start_x, start_y, width, height):
+        # let's assume that every time ranger call this
+        # it actually wants just to remove the previous image
+        # TODO: implement this using the actual x, y, since the protocol supports it
+        cmds = {'a': 'd', 'i': self.image_id}
+        for cmd_str in self._format_cmd_str(cmds):
+            self.stdbout.write(cmd_str)
+        self.stdbout.flush()
+        # kitty doesn't seem to reply on deletes, checking like we do in draw()
+        # will slows down scrolling with timeouts from select
+        self.image_id -= 1
+
+    def _format_cmd_str(self, cmd, payload=None, max_slice_len=2048):
+        central_blk = ','.join(["{}={}".format(k, v) for k, v in cmd.items()]).encode('ascii')
+        if payload is not None:
+            # we add the m key to signal a multiframe communication
+            # appending the end (m=0) key to a single message has no effect
+            while len(payload) > max_slice_len:
+                payload_blk, payload = payload[:max_slice_len], payload[max_slice_len:]
+                yield self.protocol_start + \
+                    central_blk + b',m=1;' + payload_blk + \
+                    self.protocol_end
+            yield self.protocol_start + \
+                central_blk + b',m=0;' + payload + \
+                self.protocol_end
+        else:
+            yield self.protocol_start + central_blk + b';' + self.protocol_end
+
+    def quit(self):
+        # clear all remaining images, then check if all files went through or are orphaned
+        while self.image_id >= 1:
+            self.clear(0, 0, 0, 0)
+        # for k in self.temp_paths:
+        #     try:
+        #         os.remove(self.temp_paths[k])
+        #     except (OSError, IOError):
+        #         continue
diff --git a/ranger/gui/widgets/pager.py b/ranger/gui/widgets/pager.py
index 42adf1e9..9afbfd15 100644
--- a/ranger/gui/widgets/pager.py
+++ b/ranger/gui/widgets/pager.py
@@ -109,8 +109,9 @@ class Pager(Widget):  # pylint: disable=too-many-instance-attributes
             try:
                 self.fm.image_displayer.draw(self.image, self.x, self.y,
                                              self.wid, self.hei)
-            except ImgDisplayUnsupportedException:
+            except ImgDisplayUnsupportedException as ex:
                 self.fm.settings.preview_images = False
+                self.fm.notify(ex, bad=True)
             except Exception as ex:  # pylint: disable=broad-except
                 self.fm.notify(ex, bad=True)
             else: