summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--doc/ranger.117
-rw-r--r--doc/ranger.pod15
-rw-r--r--ranger/config/rc.conf19
-rw-r--r--ranger/container/settings.py3
-rw-r--r--ranger/ext/img_display.py182
5 files changed, 147 insertions, 89 deletions
diff --git a/doc/ranger.1 b/doc/ranger.1
index 1290cb58..f8f0bd8c 100644
--- a/doc/ranger.1
+++ b/doc/ranger.1
@@ -324,6 +324,23 @@ 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 at the moment to work. A nasty bug that can
+corrupt the terminal window when scrolling quicly many images, that can
+be solved by with the \f(CW\*(C`redraw_window\*(C'\fR command.
+.PP
+To enable this feature, set the option \f(CW\*(C`preview_images_method\*(C'\fR to kitty
+.PP
+\fIkitty-network\fR
+.IX Subsection "kitty-network"
+.PP
+The same as kitty, but uses a streaming method to allow previews remotely,
+for example in an ssh session. However the system is slighly more computationally taxing
+and the quality of the preview is reduced for bandwidth considerations.
 .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 4cac8ef9..79a61a98 100644
--- a/doc/ranger.pod
+++ b/doc/ranger.pod
@@ -236,6 +236,21 @@ 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 at the moment to work. A nasty bug that can
+corrupt the terminal window when scrolling quicly many images, that can
+be solved by with the C<redraw_window> command.
+
+To enable this feature, set the option C<preview_images_method> to kitty
+
+=head3 kitty-network
+
+The same as kitty, but uses a streaming method to allow previews remotely,
+for example in an ssh session. However the system is slighly more computationally taxing
+and the quality of the preview is reduced for bandwidth considerations.
+
 =head2 SELECTION
 
 The I<selection> is defined as "All marked files IF THERE ARE ANY, otherwise
diff --git a/ranger/config/rc.conf b/ranger/config/rc.conf
index bf858a50..b8bc00d8 100644
--- a/ranger/config/rc.conf
+++ b/ranger/config/rc.conf
@@ -68,7 +68,7 @@ set vcs_backend_bzr disabled
 set vcs_backend_svn disabled
 
 # Use one of the supported image preview protocols
-set preview_images true
+set preview_images false
 
 # Set the preview image method. Supported methods:
 #
@@ -93,7 +93,22 @@ set preview_images true
 # * urxvt-full:
 #   The same as urxvt but utilizing not only the preview pane but the
 #   whole terminal window.
-set preview_images_method kitty
+#
+# * kitty:
+#   Preview images in full color using kitty image protocol
+#   (https://github.com/kovidgoyal/kitty/blob/master/graphics-protocol.asciidoc),
+#   Requires python PIL or pillow library.
+#   In experimental stage: tmux support is untested, and a scrolling too fast a folder with many images may glitch ranger;
+#   Future improvements to kitty will ameliorate this issue, for now call the command 'redraw_window' to get rid of the garbage.
+#
+# * kitty-network
+#   Similar to base kitty, bit instead of local storage it uses kitty's protocol special feature to
+#   stream the whole image over standard input. More error prone, and more intensive since it scales down images,
+#   producing also worse quality previews.
+#   However it makes possible to see previews froma ranger instance over the network,
+#   so it makes sense to enable this on remote machines.
+#   Note that has been untested over an actual network.
+set preview_images_method w3m
 
 # Default iTerm2 font size (see: preview_images_method: iterm2)
 set iterm2_font_width 8
diff --git a/ranger/container/settings.py b/ranger/container/settings.py
index 170ace5a..dcadf8bf 100644
--- a/ranger/container/settings.py
+++ b/ranger/container/settings.py
@@ -100,7 +100,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', 'urxvt',
+                              'urxvt-full', 'kitty', 'kitty-network'],
     'vcs_backend_bzr': ['disabled', 'local', 'enabled'],
     'vcs_backend_git': ['enabled', 'disabled', 'local'],
     'vcs_backend_hg': ['disabled', 'local', 'enabled'],
diff --git a/ranger/ext/img_display.py b/ranger/ext/img_display.py
index f34315e3..122e3a15 100644
--- a/ranger/ext/img_display.py
+++ b/ranger/ext/img_display.py
@@ -25,7 +25,8 @@ import termios
 import select
 from contextlib import contextmanager
 import tty
-import fcntl
+import codecs
+from tempfile import NamedTemporaryFile
 
 from ranger.core.shared import FileManagerAware
 
@@ -472,10 +473,19 @@ class URXVTImageFSDisplayer(URXVTImageDisplayer):
 
 
 class KittyImageDisplayer(ImageDisplayer):
-    """TODO: Document here the relevant parts of the protocol"""
+    """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'\033_G'
+    protocol_end = b'\033\\'
     def __init__(self, stream=False, resize_height=720):
-        self.protocol_start = b'\033_G'
-        self.protocol_end = b'\033\\'
         self.image_id = 0
         self.temp_paths = []
         # parameter deciding if we're going to send the picture data
@@ -486,97 +496,98 @@ class KittyImageDisplayer(ImageDisplayer):
         if "screen" in os.environ['TERM']:
             # TODO: probably need to modify the preamble
             pass
-        # TODO: implement check if protocol terminal supports protocol
-
+        # TODO: implement check if protocol terminal supports kitty protocol
+        # Poissibbly automatically check if transfer via file is possible,
+        # and if negative switch to streaming mode?
+        self.backend = None
         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
+            self.backend = PIL.Image
             self.filter = PIL.Image.BILINEAR
         except ImportError:
-            sys.stderr.write("PIL not Found, trying ImageMagick")
-            # TODO: check for ImageMagick
-            pass
+            sys.stderr.write("PIL not Found")
+            # TODO: implement a wrapper class for Imagemagick process to
+            # replicate the functionality we use from im
 
     def draw(self, path, start_x, start_y, width, height):
         # dictionary to store the command arguments for kitty
         # a is the display command, with T going for immediate output
-        cmds = {'a': 'T', 'm': 1}
-
-        # let's open the image
-        if self.backend:
-            im = self.backend.open(path)
-            # first let's reduce the size of the image if we intend to stream it
-            aspect = im.width / im.height
-            if im.height > self.max_height:
-                im = im.resize((int(self.max_height * aspect), self.max_height), self.filter)
-            # since kitty streches the image to fill the view box
-            # we need to resize the box to not get distortion
-            cell_ratio = 0.5
-            dest_aspect = width * cell_ratio / height
-            mismatch_ratio = aspect/dest_aspect
-            if mismatch_ratio > 1.02:
-                new_h = height / mismatch_ratio
-                start_y += int((height - new_h) / 2)
-                height = int(new_h)
-            elif mismatch_ratio < 0.98:
-                new_w = width * mismatch_ratio
-                start_x += int((width - new_w) / 2)
-                width = int(new_w)
-            # encode image or just the filename and save the image
-        elif self.backend == "immgk":
-            pass
+        cmds = {'a': 'T'}
+        # sys.stderr.write('{}-{}@{}x{}\t'.format(start_x, start_y, width, height))
+        assert self.backend is not None  # sanity check if we actually have a backend
+        image = self.backend.open(path)
+        aspect = image.width / image.height
+        # first let's reduce the size of the image if we intend to stream it
+        if image.height > self.max_height:
+            image = image.resize((int(self.max_height * aspect), self.max_height), self.filter)
+        # since kitty streches the image to fill the view box
+        # we need to resize the box to not get distortion
+        mismatch_ratio = aspect / (width * 0.5 / height)
+        if mismatch_ratio > 1.05:
+            new_h = height / mismatch_ratio
+            start_y += int((height - new_h) / 2)
+            height = int(new_h)
+        elif mismatch_ratio < 0.95:
+            new_w = width * mismatch_ratio
+            start_x += int((width - new_w) / 2)
+            width = int(new_w)
 
         if self.stream:
-            if im.mode != 'RGB' or im.mode != 'RGBA':
-                im = im.convert('RGB')
-            cmds.update({'t': 'd', 'f': len(im.getbands()) * 8,
-                's': im.width, 'v': im.height,
-                'c': width, 'r': height})
-            raw = bytearray().join(map(bytes, im.getdata())) # TODO: check speed
-            payload = base64.standard_b64encode(raw)
+            # encode the whole image as base64
+            # TODO: implement z compression
+            # to possibly increase resolution in sent image
+            if image.mode != 'RGB' or 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,
+                         'c': width, 'r': height})
+            payload = base64.standard_b64encode(
+                bytearray().join(map(bytes, image.getdata())))
         else:
-            from tempfile import NamedTemporaryFile
+            # put the image in a temporary png file
+            # we need to find out the encoding for a path string, ascii won't cut it
             try:
-                fsenc = sys.getfilesystemencoding() or 'utf-8'
+                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 Exception:
+            except (LookupError, TypeError):
                 fsenc = 'utf-8'
+            # 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, 'c': width, 'r': height})
             with NamedTemporaryFile(prefix='rgr_thumb_', suffix='.png', delete=False) as tmpf:
-                im.save(tmpf, format='png', compress_level=0)
+                image.save(tmpf, format='png', compress_level=0)
                 self.temp_paths.append(tmpf.name)
                 payload = base64.standard_b64encode(os.path.abspath(tmpf.name).encode(fsenc))
 
         self.image_id += 1
+        # image handle we'll use with kitty
         cmds['i'] = self.image_id
-        # now for some good old retrocompatibility C protocols
         # save current cursor position
         curses.putp(curses.tigetstr("sc"))
         # we then can move the cursor to our desired spot
-        # for some reason none is using curses.move(y, x)
-        # but this convoluted method
-        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)
+        # we can't call window.move(y, x) since we don't have the curses win instance
+        sys.stdout.buffer.write(curses.tparm(curses.tigetstr("cup"), start_y, start_x))
 
-        #finally send the command
+        # finally send the command
         for cmd_str in self._format_cmd_str(cmds, payload=payload):
             sys.stdout.buffer.write(cmd_str)
         sys.stdout.flush()
         # to catch the incoming response (which breaks ranger)
         # a simple readline doesn't work, but this seems fine
-        with self.non_blocking_read() as fd:
-            while True:
-                rd = select.select([fd], [], [], 2 if self.stream else 0.1)[0]
-                if rd:
-                    data = sys.stdin.buffer.read()  #TODO: check if all is well
-                    break
-                else:
-                    break
+        # except when scrolling real fast. If kitty will implemnt a key to suppress
+        # responses this will be omitted
+        with self.non_blocking_read() as f_descr:
+            if select.select([f_descr], [], [], 2 if self.stream else 0.1)[0]:
+                sys.stdin.buffer.read()  # TODO: check if all is well
         # Restore cursor
         curses.putp(curses.tigetstr("rc"))
         sys.stdout.flush()
@@ -584,14 +595,16 @@ class KittyImageDisplayer(ImageDisplayer):
     def _format_cmd_str(self, cmd, payload=None, max_l=1024):
         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_l:
                 payload_blk, payload = payload[:max_l], payload[max_l:]
                 yield self.protocol_start + \
-                        central_blk + b',m=1;' + payload_blk + \
-                        self.protocol_end
-            yield self.protocol_start + \
-                    central_blk + b',m=0;' + payload + \
+                    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
 
@@ -599,41 +612,38 @@ class KittyImageDisplayer(ImageDisplayer):
     @contextmanager
     def non_blocking_read(src=sys.stdin):
         # not entirely sure what's going on here
-        fd = src.fileno()
+        # but sure it looks like tty black magic
+        f_handle = src.fileno()
         if src.isatty():
-            old = termios.tcgetattr(fd)
-            tty.setraw(fd)
-        oldfl = fcntl.fcntl(fd, fcntl.F_GETFL)
-        fcntl.fcntl(fd, fcntl.F_SETFL, oldfl | os.O_NONBLOCK)
-        yield fd
+            old = termios.tcgetattr(f_handle)
+            tty.setraw(f_handle)
+        oldfl = fcntl.fcntl(f_handle, fcntl.F_GETFL)
+        # this seems the juicy part were we actally set the non_blockingness
+        fcntl.fcntl(f_handle, fcntl.F_SETFL, oldfl | os.O_NONBLOCK)
+        yield f_handle
+        # after the with block is done we are resetting back to the old state
         if src.isatty():
-            termios.tcsetattr(fd, termios.TCSADRAIN, old)
-        fcntl.fcntl(fd, fcntl.F_SETFL, oldfl)
+            termios.tcsetattr(f_handle, termios.TCSADRAIN, old)
+        fcntl.fcntl(f_handle, fcntl.F_SETFL, oldfl)
 
     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):
             sys.stdout.buffer.write(cmd_str)
         sys.stdout.flush()
-#        with self.non_blocking_read() as fd:
-#            while True:
-#                rd = select.select([fd], [], [], 2 if self.stream else 0.3)[0]
-#                if rd:
-#                    data = sys.stdin.buffer.read()  #TODO: check if all is well
-#                    break
-#                else:
-#                    break
+        # 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 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)
-        while len(self.temp_paths) != 0:
+            self.clear(0, 0, 0, 0)
+        while self.temp_paths:
             try:
                 os.remove(self.temp_paths.pop())
             except FileNotFoundError:
                 continue
-