diff --git a/kittens/tui/images.py b/kittens/tui/images.py index 8c951075f..9bd88bf9b 100644 --- a/kittens/tui/images.py +++ b/kittens/tui/images.py @@ -3,11 +3,15 @@ # License: GPL v3 Copyright: 2018, Kovid Goyal import codecs +import os import shutil +import subprocess import sys import tempfile from base64 import standard_b64encode +from kitty.utils import screen_size_function + from .operations import serialize_gr_command try: @@ -15,6 +19,37 @@ try: codecs.lookup(fsenc) except Exception: fsenc = 'utf-8' +screen_size = screen_size_function() + + +class ImageData: + + def __init__(self, fmt, width, height, mode): + self.width, self.height, self.fmt, self.mode = width, height, fmt, mode + self.transmit_fmt = str(24 if self.mode == 'rgb' else 32) + + +class OpenFailed(ValueError): + + def __init__(self, path, message): + ValueError.__init__( + self, 'Failed to open: {} with error: {}'.format(path, message) + ) + self.path = path + + +def run_imagemagick(path, cmd, keep_stdout=True): + p = subprocess.run(cmd, stdout=subprocess.PIPE if keep_stdout else subprocess.DEVNULL, stderr=subprocess.PIPE) + if p.returncode != 0: + raise OpenFailed(path, p.stderr.decode('utf-8')) + return p + + +def identify(path): + p = run_imagemagick(path, ['identify', '-format', '%m %w %h %A', path]) + parts = tuple(filter(None, p.stdout.decode('utf-8').split())) + mode = 'rgb' if parts[3].lower() == 'false' else 'rgba' + return ImageData(parts[0].lower(), int(parts[1]), int(parts[2]), mode) def can_display_images(): @@ -31,6 +66,11 @@ class ImageManager: self.handler = handler self.sent_ids = set() self.filesystem_ok = None + self.image_data = {} + self.converted_images = {} + self.sent_images = {} + self.image_id_map = {} + self.transmission_status = {} def __enter__(self): self.tdir = tempfile.mkdtemp(prefix='kitten-images-') @@ -63,3 +103,61 @@ class ImageManager: if image_id == 1: self.filesystem_ok = payload == 'OK' return + + def send_image(self, path, max_cols=None, max_rows=None, scale_up=False): + path = os.path.abspath(path) + if path not in self.image_data: + self.image_data[path] = identify(path) + m = self.image_data[path] + ss = screen_size() + if max_cols is None: + max_cols = ss.cols + if max_rows is None: + max_rows = ss.rows + available_width = max_cols * ss.cell_width + available_height = max_rows * ss.cell_height + key = path, available_width, available_height + skey = self.converted_images.get(key) + if skey is None: + self.converted_images[key] = skey = self.convert_image(path, available_width, available_height, m, scale_up) + final_width, final_height = skey[1:] + if final_width == 0: + return 0, 0, 0 + image_id = self.sent_images.get(skey) + if image_id is None: + image_id = self.sent_image[skey] = self.transmit_image(m, key, *skey) + return image_id, skey[0], skey[1] + + def convert_image(self, path, available_width, available_height, image_data, scale_up=False): + from kitty.icat import convert + try: + rgba_path, width, height = convert(path, image_data, available_width, available_height, scale_up, tdir=self.tdir, exc_class=ValueError) + except ValueError: + rgba_path = None + width = height = 0 + return rgba_path, width, height + + def transmit_image(self, image_data, key, rgba_path, width, height): + image_id = len(self.sent_ids) + 1 + self.image_id_map[key] = image_id + self.sent_ids.add(image_id) + self.transmission_status[image_id] = 0 + cmd = {'a': 't', 'f': image_data.transmit_fmt, 's': width, 'v': height, 'i': image_id} + if self.filesystem_ok: + cmd['t'] = 'f' + self.handler.write(serialize_gr_command( + cmd, standard_b64encode(rgba_path.encode(fsenc)))) + else: + import zlib + data = open(rgba_path, 'rb').read() + cmd['S'] = len(data) + data = zlib.compress(data) + cmd['o'] = 'z' + data = standard_b64encode(data) + while data: + chunk, data = data[:4096], data[4096:] + m = 1 if data else 0 + cmd['m'] = m + self.handler.write(serialize_gr_command(cmd, chunk)) + cmd.clear() + return image_id diff --git a/kitty/icat.py b/kitty/icat.py index 869cdedaa..2ef38d7c7 100755 --- a/kitty/icat.py +++ b/kitty/icat.py @@ -12,12 +12,12 @@ import sys import zlib from base64 import standard_b64encode from collections import namedtuple -from math import ceil, floor +from math import ceil from tempfile import NamedTemporaryFile from kitty.cli import parse_args from kitty.constants import appname -from kitty.utils import read_with_timeout, screen_size_function +from kitty.utils import fit_image, read_with_timeout, screen_size_function screen_size = screen_size_function() try: @@ -109,20 +109,6 @@ def write_gr_cmd(cmd, payload=None): sys.stdout.flush() -def fit_image(width, height, pwidth, pheight): - if height > pheight: - corrf = pheight / float(height) - width, height = floor(corrf * width), pheight - if width > pwidth: - corrf = pwidth / float(width) - width, height = pwidth, floor(corrf * height) - if height > pheight: - corrf = pheight / float(height) - width, height = floor(corrf * width), pheight - - return int(width), int(height) - - def calculate_in_cell_x_offset(width, cell_width, align): if align == 'left': return 0 @@ -220,7 +206,7 @@ def identify(path): return ImageData(parts[0].lower(), int(parts[1]), int(parts[2]), mode) -def convert(path, m, available_width, available_height, scale_up): +def convert(path, m, available_width, available_height, scale_up, tdir=None, err_class=SystemExit): width, height = m.width, m.height cmd = ['convert', '-background', 'none', path] if scale_up: @@ -230,7 +216,7 @@ def convert(path, m, available_width, available_height, scale_up): if width > available_width or height > available_height: width, height = fit_image(width, height, available_width, available_height) cmd += ['-resize', '{}x{}'.format(width, height)] - with NamedTemporaryFile(prefix='icat-', suffix='.' + m.mode, delete=False) as outfile: + with NamedTemporaryFile(prefix='icat-', suffix='.' + m.mode, delete=False, dir=tdir) as outfile: run_imagemagick(path, cmd + [outfile.name]) # ImageMagick sometimes generated rgba images smaller than the specified # size. See https://github.com/kovidgoyal/kitty/issues/276 for examples @@ -240,7 +226,7 @@ def convert(path, m, available_width, available_height, scale_up): if sz < expected_size: missing = expected_size - sz if missing % (bytes_per_pixel * width) != 0: - raise SystemExit('ImageMagick failed to convert {} correctly, it generated {} < {} of data'.format(path, sz, expected_size)) + raise err_class('ImageMagick failed to convert {} correctly, it generated {} < {} of data'.format(path, sz, expected_size)) height -= missing // (bytes_per_pixel * width) return outfile.name, width, height diff --git a/kitty/utils.py b/kitty/utils.py index 4d310a02a..dbb6ebc6f 100644 --- a/kitty/utils.py +++ b/kitty/utils.py @@ -83,13 +83,15 @@ def screen_size_function(): import array import fcntl import termios - Size = namedtuple('Size', 'rows cols width height') + Size = namedtuple('Size', 'rows cols width height cell_width cell_height') def screen_size(): if screen_size.changed: buf = array.array('H', [0, 0, 0, 0]) fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, buf) - screen_size.ans = Size(*buf) + rows, cols, width, height = tuple(buf) + cell_width, cell_height = width // cols, height // rows + screen_size.ans = Size(rows, cols, width, height, cell_width, cell_height) screen_size.changed = False return screen_size.ans screen_size.changed = True @@ -99,6 +101,21 @@ def screen_size_function(): return ans +def fit_image(width, height, pwidth, pheight): + from math import floor + if height > pheight: + corrf = pheight / float(height) + width, height = floor(corrf * width), pheight + if width > pwidth: + corrf = pwidth / float(width) + width, height = pwidth, floor(corrf * height) + if height > pheight: + corrf = pheight / float(height) + width, height = floor(corrf * width), pheight + + return int(width), int(height) + + def set_primary_selection(text): if is_macos or is_wayland: return # There is no primary selection