diff options
-rw-r--r-- | doc/ranger.1 | 17 | ||||
-rw-r--r-- | doc/ranger.pod | 13 | ||||
-rw-r--r-- | doc/rifle.1 | 4 | ||||
-rw-r--r-- | ranger/config/rc.conf | 13 | ||||
-rw-r--r-- | ranger/container/settings.py | 3 | ||||
-rw-r--r-- | ranger/core/fm.py | 12 | ||||
-rw-r--r-- | ranger/core/main.py | 2 | ||||
-rw-r--r-- | ranger/ext/img_display.py | 279 | ||||
-rw-r--r-- | ranger/gui/widgets/pager.py | 3 |
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: |