'''
This is an example of how you can pan a sixel image that is taller than
the terminal window. It requires a terminal with support for the ANSI
paging sequences, as well as the DEC rectangular editing extension to
copy content between pages. It uses img2sixel to load the initial image.
Use the up/down arrow keys to pan over the image and `q` to quit.
'''
import math
import subprocess
import sys
class RawInputOutput:
def __init__(self):
import tty
import termios
self.fd = sys.stdin.fileno()
self.saved_attr = termios.tcgetattr(self.fd)
tty.setraw(self.fd)
def __del__(self):
import termios
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved_attr)
def read(self):
return sys.stdin.read(1)
def read_until(self, final_char):
s = ''
while not s or s[-1] != final_char:
s += self.read()
return s
def write(self, s):
sys.stdout.write(s)
sys.stdout.flush()
class ImageBuffer:
def __init__(self, filename, screen_size, cell_size):
cell_height,cell_width = cell_size
screen_height,screen_width = screen_size
# We want to scale the image up to fill the width of the screen except
# for two columns that are used by the viewer's frame.
width = (screen_width - 2) * cell_width
result = subprocess.run(['img2sixel', '-w', str(width), filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
output = result.stdout
error = result.stderr
if error:
print(error)
quit()
# We split the returned sixel into a header (containing the sequence
# introducer and palette definition), body (containing the actual image
# content), and footer (the sequence terminator). The body is also then
# split into an array of sixel rows.
start = output.rfind(';') + 1
while output[start] in '0123456789': start += 1
end = output.rfind('\033')
self.head = output[:start]
self.body = output[start:end].split('-')
self.foot = output[end:]
self.sixel_height = len(self.body)
self.height = self.sixel_height*6
def slice(self, start, end):
return self.head + '-'.join(self.body[start:end]) + self.foot
class ImageViewer:
def __init__(self, image, screen_size, cell_size, max_pages):
self.screen_height,self.screen_width = screen_size
cell_height,cell_width = cell_size
self.draw_frame()
# This is the smallest vertical span that is an exact multiple of the
# the row height and also an exact multiple of sixels, so we can split
# the image on multiples of this size, and each split is guaranteed to
# be on a text row boundary.
segment_size = math.lcm(cell_height,6)
rows_per_segment = segment_size // cell_height
sixels_per_segment = segment_size // 6
segments_per_page = self.screen_height // rows_per_segment
sixels_per_page = segments_per_page * sixels_per_segment
# Here we're splitting the image along segment boundaries that will fit
# on a page, and then loading each chunk into a background page. We start
# with page 2 since page 1 is the foreground page.
page = 2
for i in range(0, image.sixel_height, sixels_per_page):
self.load_image_slice(image, page, i, sixels_per_page)
page += 1
if page > max_pages: break
self.rows_per_page = segments_per_page * rows_per_segment
self.max_offset = ((image.height + cell_height - 1) // cell_height) - (self.screen_height - 2)
self.max_pages = max_pages
def draw_frame(self):
# This is just a little border around the edge of the page to show
# that we aren't just scrolling a full screen image.
raw.write('\033[H')
raw.write('\033[2J')
raw.write('\033(0')
raw.write('l' + 'q'*(self.screen_width - 2) + 'k\r\n')
for i in range(self.screen_height - 2):
raw.write('x\033[%dCx\r\n' % (self.screen_width - 2))
raw.write('m' + 'q'*(self.screen_width - 2) + 'j')
raw.write('\033(B')
def load_image_slice(self, image, page, first_sixel, sixel_count):
# This loads a portion of the sixel image onto the specified page.
# The synchronized updates shouldn't be necessary, but might help
# avoid flickering on terminals that don't support DECPCCM.
seq = '\033[?80h' # Enable sixel display mode to prevent scrolling
seq += '\033[?2026h' # Begin synchronized update
seq += '\033[%d P' % page # Move to the target page
seq += '\033[H' # Home the cursor
seq += '\033[J' # Clear the screen
seq += image.slice(first_sixel, first_sixel+sixel_count) # Load image content
seq += '\033[1 P' # Move back to page 1
seq += '\033[?2026l' # End synchronized update
seq += '\033[?80l' # Restore sixel scrolling
raw.write(seq)
def render_image(self, pan_offset):
# The area of the image that we're viewing will typically span two
# pages, so we need to copy it to the foreground page in two parts.
source_page = pan_offset // self.rows_per_page + 2
source_row = pan_offset % self.rows_per_page + 1
sequence,copied_rows = self.copy(source_page, source_row, 2, 2)
sequence += self.copy(source_page+1, 1, 2+copied_rows, 2)[0]
raw.write(sequence)
def copy(self, source_page, source_row, target_row, target_col):
# We're using a DECCRA (copy rectangular area) control to copy a slice
# of the image from one of the background pages onto the foreground page.
free_space = (self.screen_height - 2) - (target_row - 2)
width = self.screen_width - 2
height = min(self.rows_per_page - source_row + 1, free_space)
top = source_row
left = 1
bottom = source_row + height - 1
right = width
# If the page is out of range, we just fill the target area. We could
# potentially load more segments on demand, but that's more complicated.
if source_page > self.max_pages:
sequence = '\033[32;%d;%d;%d;%d$x' % (target_row,target_col,target_row+height-1,target_col+width-1)
else:
sequence = '\033[%d;%d;%d;%d;%d;%d;%d;%d$v' % (top,left,bottom,right,source_page,target_row,target_col,1)
copied_rows = bottom - top + 1
if copied_rows < 1: sequence = ''
return (sequence, copied_rows)
if len(sys.argv) < 2:
print('Usage: python %s <image filename>' % sys.argv[0])
quit()
raw = RawInputOutput()
try:
def parse_response(s, count):
# Strip CSI and final, split parms, grab last n values.
return tuple(int(n) for n in (s[2:-1].split(';')[-count:]))
# Query the window dimensions
raw.write('\033[18t')
screen_size = parse_response(raw.read_until('t'), 2)
# Query the cell pixel dimensions
raw.write('\033[16t')
cell_size = parse_response(raw.read_until('t'), 2)
# Disable page cursor coupling
raw.write('\033[?64l')
# Detect how many pages are accessible
raw.write('\033[100 P\033[?6n\033[1 P')
max_pages = parse_response(raw.read_until('R'), 1)[0]
if max_pages < 3:
print('This requires a terminal with paging support\r')
quit()
# Hide cursor
raw.write('\033[?25l')
image = ImageBuffer(sys.argv[1], screen_size, cell_size)
viewer = ImageViewer(image, screen_size, cell_size, max_pages)
pan_offset = 0
done = False
while not done:
viewer.render_image(pan_offset)
while True:
ch = raw.read()
if ch == 'q' or ch == chr(3):
done = True
break
elif ch == 'A' and pan_offset > 0:
pan_offset -= 1
break
elif ch == 'B' and pan_offset < viewer.max_offset:
pan_offset += 1
break
# Clear the screen and restore cursor visibility
raw.write('\033[H\033[J')
raw.write('\033[?25h')
finally:
del raw
Originally posted by @j4james in #7