Files
kitty/kitty_tests/graphics.py
Matsumoto Kotaro cc2d7a1789 graphics: add memory-only storage for graphics data
Add a new graphics protocol key, N=1, to request that transmitted
image/frame data is kept only in memory and not written to the graphics
disk cache file.

This is useful for transient high-frequency updates such as video-like
streams, where the latest frame is the only useful data and persisting
each frame to the disk cache causes unnecessary write traffic.

The implementation keeps the existing graphics cache abstraction intact:
memory-only entries can still be read back by animation, composition, and
frame coalescing paths. Only persistence to the disk cache file is skipped.

The default behavior is unchanged when N is omitted or set to zero.
2026-05-30 18:46:24 +09:00

1358 lines
55 KiB
Python

#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import os
import random
import tempfile
import time
import unittest
import zlib
from contextlib import suppress
from dataclasses import dataclass
from io import BytesIO
from kitty.fast_data_types import base64_decode, base64_encode, has_avx2, has_sse4_2, load_png_data, shm_unlink, shm_write, test_xor64
from . import BaseTest, parse_bytes
try:
from PIL import Image
except ImportError:
Image = None
png_data = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==')
def send_command(screen, cmd, payload=b''):
cmd = '\033_G' + cmd
if payload:
if isinstance(payload, str):
payload = payload.encode('utf-8')
payload = base64_encode(payload).decode('ascii')
cmd += ';' + payload
cmd += '\033\\'
c = screen.callbacks
c.clear()
parse_bytes(screen, cmd.encode('ascii'))
return c.wtcbuf
def parse_response(res):
if not res:
return
return res.decode('ascii').partition(';')[2].partition('\033')[0]
def parse_response_with_ids(res):
if not res:
return
a, b = res.decode('ascii').split(';', 1)
code = b.partition('\033')[0].split(':', 1)[0]
a = a.split('G', 1)[1]
return code, a
@dataclass(frozen=True)
class Response:
code: str = 'OK'
msg: str = ''
image_id: int = 0
image_number: int = 0
frame_number: int = 0
def parse_full_response(res):
if not res:
return
a, b = res.decode('ascii').split(';', 1)
code = b.partition('\033')[0].split(':', 1)
if len(code) == 1:
code = code[0]
msg = ''
else:
code, msg = code
a = a.split('G', 1)[1]
ans = {'code': code, 'msg': msg}
for x in a.split(','):
k, _, v = x.partition('=')
ans[{'i': 'image_id', 'I': 'image_number', 'r': 'frame_number'}[k]] = int(v)
return Response(**ans)
all_bytes = bytes(bytearray(range(256)))
def byte_block(sz):
d, m = divmod(sz, len(all_bytes))
return (all_bytes * d) + all_bytes[:m]
def load_helpers(self):
s = self.create_screen()
g = s.grman
def pl(payload, **kw):
kw.setdefault('i', 1)
cmd = ','.join(f'{k}={v}' for k, v in kw.items())
res = send_command(s, cmd, payload)
return parse_response(res)
def sl(payload, **kw):
if isinstance(payload, str):
payload = payload.encode('utf-8')
data = kw.pop('expecting_data', payload)
cid = kw.setdefault('i', 1)
self.ae('OK', pl(payload, **kw))
img = g.image_for_client_id(cid)
self.assertIsNotNone(img, f'No image with id {cid} found')
self.ae(img['client_id'], cid)
self.ae(img['data'], data)
if 's' in kw:
self.ae((kw['s'], kw['v']), (img['width'], img['height']))
self.ae(img['is_4byte_aligned'], kw.get('f') != 24)
return img
return s, g, pl, sl
def put_helpers(self, cw, ch, cols=10, lines=5):
iid = 0
def create_screen():
s = self.create_screen(cols, lines, cell_width=cw, cell_height=ch)
return s, 2 / s.columns, 2 / s.lines
def put_cmd(
z=0, num_cols=0, num_lines=0, x_off=0, y_off=0, width=0, height=0, cell_x_off=0,
cell_y_off=0, placement_id=0, cursor_movement=0, unicode_placeholder=0, parent_id=0,
parent_placement_id=0, offset_from_parent_x=0, offset_from_parent_y=0,
):
return (
f'z={z},c={num_cols},r={num_lines},x={x_off},y={y_off},w={width},h={height},'
f'X={cell_x_off},Y={cell_y_off},p={placement_id},C={cursor_movement},'
f'U={unicode_placeholder},P={parent_id},Q={parent_placement_id},'
f'H={offset_from_parent_x},V={offset_from_parent_y}'
)
def put_image(screen, w, h, **kw):
nonlocal iid
iid += 1
imgid = kw.pop('id', None) or iid
no_id = kw.pop('no_id', False)
a = kw.pop('a', 'T')
if no_id:
cmd = f'a={a},f=24,s=%d,v=%d,%s' % (w, h, put_cmd(**kw))
else:
cmd = f'a={a},f=24,i=%d,s=%d,v=%d,%s' % (imgid, w, h, put_cmd(**kw))
data = b'x' * w * h * 3
res = send_command(screen, cmd, data)
return imgid, parse_response(res)
def put_ref(screen, **kw):
imgid = kw.pop('id', None) or iid
cmd = 'a=p,i=%d,%s' % (imgid, put_cmd(**kw))
return imgid, parse_response_with_ids(send_command(screen, cmd))
def layers(screen, scrolled_by=0, xstart=-1, ystart=1):
return screen.grman.update_layers(scrolled_by, xstart, ystart, dx, dy, screen.columns, screen.lines, cw, ch)
def rect_eq(r, left, top, right, bottom):
for side in 'left top right bottom'.split():
a, b = r[side], locals()[side]
if abs(a - b) > 0.0001:
self.ae(a, b, 'the %s side is not equal' % side)
s, dx, dy = create_screen()
return s, dx, dy, put_image, put_ref, layers, rect_eq
def make_send_command(screen):
def li(payload='abcdefghijkl'*3, s=4, v=3, f=24, a='f', i=1, **kw):
if s:
kw['s'] = s
if v:
kw['v'] = v
if f:
kw['f'] = f
if i:
kw['i'] = i
kw['a'] = a
cmd = ','.join(f'{k}={v}' for k, v in kw.items())
res = send_command(screen, cmd, payload)
return parse_full_response(res)
return li
class TestGraphics(BaseTest):
def test_xor_data(self):
base_data = b'\x01' * 64
key = b'\x02' * 64
sizes = []
if has_sse4_2:
sizes.append(2)
if has_avx2:
sizes.append(3)
sizes.append(0)
def t(key, data, align_offset=0):
expected = test_xor64(key, data, 1, 0)
for which_function in sizes:
actual = test_xor64(key, data, which_function, align_offset)
self.ae(expected, actual, f'{align_offset=} {len(data)=}')
t(key, b'')
for base in (b'abc', base_data):
for extra in range(len(base_data)):
for align_offset in range(64):
data = base + base_data[:extra]
t(key, data, align_offset)
def test_disk_cache(self):
s = self.create_screen()
dc = s.grman.disk_cache
dc.small_hole_threshold = 0
data = {}
def key_as_bytes(key):
if isinstance(key, int):
key = str(key)
if isinstance(key, str):
key = key.encode('utf-8')
return bytes(key)
def add(key, val):
bkey = key_as_bytes(key)
data[key] = key_as_bytes(val)
dc.add(bkey, data[key])
def remove(key):
bkey = key_as_bytes(key)
data.pop(key, None)
return dc.remove(bkey)
def check_data():
for key, val in data.items():
self.ae(dc.get(key_as_bytes(key)), val)
def reset(small_hole_threshold=0, defrag_factor=2):
nonlocal dc, data, s
s = self.create_screen()
dc = s.grman.disk_cache
dc.small_hole_threshold = small_hole_threshold
dc.defrag_factor = defrag_factor
data = {}
holes_to_create = 2, 4, 6, 8
for i in range(25):
self.assertIsNone(add(i, f'{i}' * i))
if i <= max(holes_to_create):
# We wait here to ensure data is written in order, otherwise the
# holes test below can fail
self.assertTrue(dc.wait_for_write())
self.assertEqual(dc.total_size, sum(map(len, data.values())))
self.assertTrue(dc.wait_for_write())
check_data()
sz = dc.end_of_data_offset()
self.assertEqual(sz, sum(map(len, data.values())))
self.assertFalse(dc.holes())
holes = set()
for x in holes_to_create:
remove(x)
holes.add(x)
check_data()
self.assertRaises(KeyError, dc.get, key_as_bytes(x))
self.assertEqual(sz, dc.end_of_data_offset())
self.assertEqual(holes, {x[1] for x in dc.holes()})
self.assertEqual(sz, dc.end_of_data_offset())
# fill holes largest first to ensure small one doesn't go into large accidentally causing fragmentation
for i, x in enumerate(sorted(holes, reverse=True)):
x = 'ABCDEFGH'[i] * x
add(x, x)
self.assertTrue(dc.wait_for_write())
check_data()
holes.discard(len(x))
self.assertEqual(holes, {x[1] for x in dc.holes()})
self.assertEqual(sz, dc.end_of_data_offset(), f'Disk cache has unexpectedly grown from {sz} to {dc.end_of_data_offset()} with data: {x!r}')
check_data()
dc.clear()
self.assertEqual(dc.end_of_data_offset(), 0)
data.clear()
for i in range(25):
self.assertIsNone(add(i, f'{i}' * i))
dc.wait_for_write()
check_data()
before = dc.end_of_data_offset()
while dc.total_size > before // 3:
key = random.choice(tuple(data))
self.assertTrue(remove(key))
check_data()
add('trigger defrag', 'XXX')
dc.wait_for_write()
self.assertLess(dc.end_of_data_offset(), before)
check_data()
dc.clear()
st = time.monotonic()
while dc.end_of_data_offset() and time.monotonic() - st < 20:
time.sleep(0.01)
self.assertEqual(dc.end_of_data_offset(), 0)
for frame in range(32):
add(f'1:{frame}', f'{frame:02d}' * 8)
dc.wait_for_write()
self.assertEqual(dc.end_of_data_offset(), 32 * 16)
self.assertEqual(dc.num_cached_in_ram(), 0)
num_in_ram = 0
for frame in range(32):
dc.get(key_as_bytes(f'1:{frame}'))
self.assertEqual(dc.num_cached_in_ram(), num_in_ram)
for frame in range(32):
dc.get(key_as_bytes(f'1:{frame}'), True)
num_in_ram += 1
self.assertEqual(dc.num_cached_in_ram(), num_in_ram)
def clear_predicate(key):
return key.startswith(b'1:')
dc.remove_from_ram(clear_predicate)
self.assertEqual(dc.num_cached_in_ram(), 0)
reset(small_hole_threshold=512, defrag_factor=20)
self.assertIsNone(add(1, '1' * 1024))
self.assertIsNone(add(2, '2' * 1024))
dc.wait_for_write()
sz = dc.end_of_data_offset()
remove(1)
self.ae(sz, dc.end_of_data_offset())
self.ae({x[1] for x in dc.holes()}, {1024})
self.assertIsNone(add(3, '3' * 800))
dc.wait_for_write()
self.assertFalse(dc.holes())
self.ae(sz, dc.end_of_data_offset())
self.assertIsNone(add(4, '4' * 100))
sz += 100
dc.wait_for_write()
self.ae(sz, dc.end_of_data_offset())
check_data()
self.assertFalse(dc.holes())
remove(4)
self.assertFalse(dc.holes())
self.assertIsNone(add(5, '5' * 10))
sz += 10
dc.wait_for_write()
self.ae(sz, dc.end_of_data_offset())
# test hole coalescing
reset(defrag_factor=20)
for i in range(1, 6):
self.assertIsNone(add(i, str(i)*i))
dc.wait_for_write()
remove(2)
remove(4)
self.assertEqual(dc.holes(), {(1, 2), (6, 4)})
remove(3)
self.assertEqual(dc.holes(), {(1, 9)})
def test_suppressing_gr_command_responses(self):
s, g, pl, sl = load_helpers(self)
self.ae(pl('abcd', s=10, v=10, q=1), 'ENODATA:Insufficient image data: 4 < 400')
self.ae(pl('abcd', s=10, v=10, q=2), None)
self.assertIsNone(pl('abcd', s=1, v=1, a='q', q=1))
# Test chunked load
self.assertIsNone(pl('abcd', s=2, v=2, m=1, q=1))
self.assertIsNone(pl('efgh', m=1))
self.assertIsNone(pl('ijkl', m=1))
self.assertIsNone(pl('mnop', m=0))
# errors
self.assertIsNone(pl('abcd', s=2, v=2, m=1, q=1))
self.ae(pl('mnop', m=0), 'ENODATA:Insufficient image data: 8 < 16')
self.assertIsNone(pl('abcd', s=2, v=2, m=1, q=2))
self.assertIsNone(pl('mnop', m=0))
# frames
s = self.create_screen()
li = make_send_command(s)
self.assertEqual(li().code, 'ENOENT')
self.assertIsNone(li(q=2))
self.assertIsNone(li(a='t', q=1))
self.assertIsNone(li(payload='2' * 12, z=77, m=1, q=1))
self.assertIsNone(li(payload='2' * 12, m=1))
self.assertIsNone(li(payload='2' * 12))
self.assertIsNone(li(payload='2' * 12, z=77, m=1, q=1))
self.ae(li(payload='2' * 12).code, 'ENODATA')
self.assertIsNone(li(payload='2' * 12, z=77, m=1, q=2))
self.assertIsNone(li(payload='2' * 12))
def test_no_disk_cache_graphics_image(self):
s, g, pl, sl = load_helpers(self)
self.assertEqual(g.disk_cache.end_of_data_offset(), 0)
self.ae(pl('abc', s=1, v=1, f=24, N=1), 'OK')
self.assertTrue(g.disk_cache.wait_for_write())
self.assertEqual(g.disk_cache.end_of_data_offset(), 0)
img = g.image_for_client_id(1)
self.assertIsNotNone(img)
self.ae(img['data'], b'abc')
self.ae(pl('def', s=1, v=1, f=24, i=2), 'OK')
self.assertTrue(g.disk_cache.wait_for_write())
self.assertGreater(g.disk_cache.end_of_data_offset(), 0)
def test_load_images(self):
s, g, pl, sl = load_helpers(self)
self.assertEqual(g.disk_cache.total_size, 0)
# Test load query
self.ae(pl('abcd', s=1, v=1, a='q'), 'OK')
self.ae(g.image_count, 0)
# Test simple load
for f in 32, 24:
p = 'abc' + ('d' if f == 32 else '')
img = sl(p, s=1, v=1, f=f)
self.ae(bool(img['is_4byte_aligned']), f == 32)
# Test chunked load
self.assertIsNone(pl('abcd', s=2, v=2, m=1))
self.assertIsNone(pl('efgh', m=1))
self.assertIsNone(pl('ijkl', m=1))
self.ae(pl('mnop', m=0), 'OK')
img = g.image_for_client_id(1)
self.ae(img['data'], b'abcdefghijklmnop')
# Test interrupted and retried chunked load
self.assertIsNone(pl('abcd', s=2, v=2, m=1))
self.assertIsNone(pl('efgh', m=1))
send_command(s, 'a=d') # delete command should clear partial transfer
self.assertIsNone(pl('abcd', s=2, v=2, m=1))
self.assertIsNone(pl('efgh', m=1))
self.assertIsNone(pl('ijkl', m=1))
self.ae(pl('1234', m=0), 'OK')
img = g.image_for_client_id(1)
self.ae(img['data'], b'abcdefghijkl1234')
random_data = byte_block(32 * 1024)
sl(
random_data,
s=1024,
v=8,
expecting_data=random_data
)
# Test compression
compressed_random_data = zlib.compress(random_data)
sl(
compressed_random_data,
s=1024,
v=8,
o='z',
expecting_data=random_data
)
# Test chunked + compressed
b = len(compressed_random_data) // 2
self.assertIsNone(pl(compressed_random_data[:b], s=1024, v=8, o='z', m=1))
self.ae(pl(compressed_random_data[b:], m=0), 'OK')
img = g.image_for_client_id(1)
self.ae(img['data'], random_data)
# Test loading from file
def load_temp(prefix='tty-graphics-protocol-'):
f = tempfile.NamedTemporaryFile(prefix=prefix)
f.write(random_data), f.flush()
sl(f.name, s=1024, v=8, t='f', expecting_data=random_data)
self.assertTrue(os.path.exists(f.name))
f.seek(0), f.truncate(), f.write(compressed_random_data), f.flush()
sl(f.name, s=1024, v=8, t='t', o='z', expecting_data=random_data)
return f
f = load_temp()
self.assertFalse(os.path.exists(f.name), f'Temp file at {f.name} was not deleted')
with suppress(FileNotFoundError):
f.close()
f = load_temp('')
self.assertTrue(os.path.exists(f.name), f'Temp file at {f.name} was deleted')
f.close()
# Test loading from POSIX SHM
name = '/kitty-test-shm'
shm_write(name, random_data)
sl(name, s=1024, v=8, t='s', expecting_data=random_data)
self.assertRaises(
FileNotFoundError, shm_unlink, name
) # check that file was deleted
s.reset()
self.assertEqual(g.disk_cache.total_size, 0)
@unittest.skipIf(Image is None, 'PIL not available, skipping PNG tests')
def test_load_png(self):
s, g, pl, sl = load_helpers(self)
w, h = 5, 3
rgba_data = byte_block(w * h * 4)
img = Image.frombytes('RGBA', (w, h), rgba_data)
rgb_data = img.convert('RGB').convert('RGBA').tobytes()
self.assertEqual(g.disk_cache.total_size, 0)
def png(mode='RGBA'):
buf = BytesIO()
i = img
if mode != i.mode:
i = img.convert(mode)
i.save(buf, 'PNG')
return buf.getvalue()
for mode in 'RGBA RGB'.split():
data = png(mode)
sl(data, f=100, expecting_data=rgb_data if mode == 'RGB' else rgba_data)
for m in 'LP':
img = img.convert(m)
rgba_data = img.convert('RGBA').tobytes()
data = png(m)
sl(data, f=100, expecting_data=rgba_data)
self.ae(pl(b'a' * 20, f=100, S=20).partition(':')[0], 'EBADPNG')
s.reset()
self.assertEqual(g.disk_cache.total_size, 0)
def test_load_png_simple(self):
# 1x1 transparent PNG
expected = b'\x00\xff\xff\x7f'
self.ae(load_png_data(png_data), (expected, 1, 1))
s, g, pl, sl = load_helpers(self)
sl(png_data, f=100, expecting_data=expected)
# test error handling for loading bad png data
self.assertRaisesRegex(ValueError, '[EBADPNG]', load_png_data, b'dsfsdfsfsfd')
# Test that a large PNG chunk sent via direct transmission doesn't crash
# when its size exceeds 2 * initial_buf_capacity. Without the fix in
# commit 48ab623, the buffer reallocation used MIN(2*buf_capacity, MAX_DATA_SZ)
# which could leave buf_capacity smaller than the payload size, causing a
# heap buffer overflow when the data was copied in.
# Initial buf_capacity for PNG without an explicit S= is 10, so 2*10=20;
# a 25-byte chunk previously caused a crash.
res = pl(b'x' * 25, f=100)
self.ae(res.partition(':')[0], 'EBADPNG')
def test_gr_operations_with_numbers(self):
s = self.create_screen()
g = s.grman
self.assertEqual(g.disk_cache.total_size, 0)
def li(payload, **kw):
cmd = ','.join(f'{k}={v}' for k, v in kw.items())
res = send_command(s, cmd, payload)
return parse_response_with_ids(res)
code, ids = li('abc', s=1, v=1, f=24, I=1, i=3)
self.ae(code, 'EINVAL')
code, ids = li('abc', s=1, v=1, f=24, I=1)
self.ae((code, ids), ('OK', 'i=1,I=1'))
img = g.image_for_client_number(1)
self.ae(img['client_number'], 1)
self.ae(img['client_id'], 1)
code, ids = li('abc', s=1, v=1, f=24, I=1)
self.ae((code, ids), ('OK', 'i=2,I=1'))
img = g.image_for_client_number(1)
self.ae(img['client_number'], 1)
self.ae(img['client_id'], 2)
code, ids = li('abc', s=1, v=1, f=24, I=1)
self.ae((code, ids), ('OK', 'i=3,I=1'))
code, ids = li('abc', s=1, v=1, f=24, i=5)
self.ae((code, ids), ('OK', 'i=5'))
code, ids = li('abc', s=1, v=1, f=24, I=3)
self.ae((code, ids), ('OK', 'i=4,I=3'))
# Test chunked load with number
self.assertIsNone(li('abcd', s=2, v=2, m=1, I=93))
self.assertIsNone(li('efgh', m=1))
self.assertIsNone(li('ijkx', m=1))
self.ae(li('mnop', m=0), ('OK', 'i=6,I=93'))
img = g.image_for_client_number(93)
self.ae(img['data'], b'abcdefghijkxmnop')
self.ae(img['client_id'], 6)
# test put with number
def put(**kw):
cmd = ','.join(f'{k}={v}' for k, v in kw.items())
cmd = 'a=p,' + cmd
return parse_response_with_ids(send_command(s, cmd))
code, idstr = put(c=2, r=2, I=93)
self.ae((code, idstr), ('OK', 'i=6,I=93'))
code, idstr = put(c=2, r=2, I=94)
self.ae(code, 'ENOENT')
# test delete with number
def delete(ac='N', **kw):
cmd = 'a=d'
if ac:
cmd += f',d={ac}'
if kw:
cmd += ',' + ','.join(f'{k}={v}' for k, v in kw.items())
send_command(s, cmd)
count = s.grman.image_count
put(i=1), put(i=2), put(i=3), put(i=4), put(i=5)
delete(I=94)
self.ae(s.grman.image_count, count)
delete(I=93)
self.ae(s.grman.image_count, count - 1)
delete(I=1)
self.ae(s.grman.image_count, count - 2)
cn = 1117
li('abc', s=1, v=1, f=24, I=cn)
first_id = g.image_for_client_number(cn)['internal_id']
li('abc', s=1, v=1, f=24, I=cn)
second_id = g.image_for_client_number(cn)['internal_id']
self.assertNotEqual(first_id, second_id)
count = s.grman.image_count
delete(I=cn)
self.ae(g.image_for_client_number(cn)['internal_id'], first_id)
self.ae(s.grman.image_count, count - 1)
s.reset()
self.assertEqual(g.disk_cache.total_size, 0)
def test_image_put(self):
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
self.ae(put_image(s, cw, ch)[1], 'OK')
l0 = layers(s)
self.ae(len(l0), 1)
rect_eq(l0[0]['src_rect'], 0, 0, 1, 1)
rect_eq(l0[0]['dest_rect'], -1, 1, -1 + dx, 1 - dy)
self.ae(l0[0]['group_count'], 1)
self.ae(s.cursor.x, 1), self.ae(s.cursor.y, 0)
src_width, src_height = 3, 5
iid, (code, idstr) = put_ref(s, num_cols=s.columns, num_lines=1, x_off=2, y_off=1, width=src_width, height=src_height,
cell_x_off=3, cell_y_off=1, z=-1, placement_id=17)
self.ae(idstr, f'i={iid},p=17')
l2 = layers(s)
self.ae(len(l2), 2)
self.ae(l2[1], l0[0])
rect_eq(l2[0]['src_rect'], 2 / 10, 1 / 20, (2 + 3) / 10, (1 + 5)/20)
self.ae(l2[0]['group_count'], 2)
left, top = -1 + dx + 3 * dx / cw, 1 - 1 * dy / ch
right = -1 + (1 + s.columns) * dx
bottom = 1 - dy
rect_eq(l2[0]['dest_rect'], left, top, right, bottom)
self.ae(s.cursor.x, 0), self.ae(s.cursor.y, 1)
self.ae(put_image(s, 10, 20, cursor_movement=1)[1], 'OK')
self.ae(s.cursor.x, 0), self.ae(s.cursor.y, 1)
s.reset()
self.assertEqual(s.grman.disk_cache.total_size, 0)
self.ae(put_image(s, 2*cw, 2*ch, num_cols=3)[1], 'OK')
self.ae((s.cursor.x, s.cursor.y), (3, 2))
rect_eq(layers(s)[0]['dest_rect'], -1, 1, -1 + 3 * dx, 1 - 3*dy)
def test_image_layer_grouping(self):
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
def group_counts():
return tuple(x['group_count'] for x in layers(s))
self.ae(put_image(s, 10, 20, id=1)[1], 'OK')
self.ae(group_counts(), (1,))
put_ref(s, id=1, num_cols=2, num_lines=1, placement_id=2)
put_ref(s, id=1, num_cols=2, num_lines=1, placement_id=3, z=-2)
put_ref(s, id=1, num_cols=2, num_lines=1, placement_id=4, z=-2)
self.ae(group_counts(), (4, 3, 2, 1))
self.ae(put_image(s, 8, 16, id=2, z=-1)[1], 'OK')
self.ae(group_counts(), (2, 1, 1, 2, 1))
def test_image_parents(self):
cw, ch = 10, 20
iw, ih = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
def positions():
ans = {}
def x(x):
return round(((x + 1)/2) * s.columns)
def y(y):
return int(((-y + 1)/2) * s.lines)
for i in layers(s):
d = i['dest_rect']
ans[(i['image_id'], i['ref_id'])] = {'x': x(d['left']), 'y': y(d['top'])}
return ans
def p(x, y=0):
return {'x':x, 'y': y}
self.ae(put_image(s, iw, ih, id=1)[1], 'OK')
self.ae(put_ref(s, id=1, placement_id=1), (1, ('OK', 'i=1,p=1')))
pos = {(1, 1): p(0), (1, 2): p(1)}
self.ae(positions(), pos)
# check that adding a reference to a non-existent parent fails
self.ae(put_ref(s, id=1, placement_id=33, parent_id=1, parent_placement_id=2), (1, ('ENOPARENT', 'i=1,p=33')))
self.ae(put_ref(s, id=1, placement_id=33, parent_id=33), (1, ('ENOPARENT', 'i=1,p=33')))
# check that we cannot add a reference that is its own parent
self.ae(put_ref(s, id=1, placement_id=1, parent_id=1, parent_placement_id=1), (1, ('EINVAL', 'i=1,p=1')))
self.ae(put_image(s, iw, ih, id=2)[1], 'OK')
pos[(2,1)] = p(2)
self.ae(positions(), pos)
# Add two children to the first placement of img2
before = s.cursor.x, s.cursor.y
self.ae(put_ref(s, id=1, placement_id=2, parent_id=2, offset_from_parent_y=3), (1, ('OK', 'i=1,p=2')))
self.ae(before, (s.cursor.x, s.cursor.y), 'Cursor must not move for child image')
pos[(1,3)] = p(2, 3)
self.ae(positions(), pos)
self.ae(put_ref(s, id=2, placement_id=3, parent_id=2, offset_from_parent_y=4), (2, ('OK', 'i=2,p=3')))
pos[(2,2)] = p(2, 4)
self.ae(positions(), pos)
# Add a grand child to the second child of img2
self.ae(put_ref(s, id=2, placement_id=4, parent_id=2, parent_placement_id=3, offset_from_parent_x=-1), (2, ('OK', 'i=2,p=4')))
pos[(2,3)] = p(pos[(2,2)]['x']-1, pos[(2,2)]['y'])
self.ae(positions(), pos)
# Check that creating a cycle is prevented
self.ae(put_ref(s, id=2, placement_id=3, parent_id=2, parent_placement_id=4), (2, ('ECYCLE', 'i=2,p=3')))
self.ae(positions(), pos)
# Check that depth is limited
for i in range(5, 12):
q = put_ref(s, id=2, placement_id=i, parent_id=2, parent_placement_id=i-1, offset_from_parent_x=-1)[1][0]
if q == 'ETOODEEP':
break
self.ae(q, 'OK')
else:
self.assertTrue(False, 'Failed to limit reference chain depth')
# Check that deleting a parent removes all descendants
send_command(s, 'a=d,d=i,i=2,p=3')
pos.pop((2,3)), pos.pop((2,2))
self.ae(positions(), pos)
# Check that deleting a parent deletes all descendants and also removes
# images with no remaining placements
self.ae(put_ref(s, id=2, placement_id=3, parent_id=2, offset_from_parent_y=4), (2, ('OK', 'i=2,p=3')))
pos[(2,11)] = p(2, 4)
self.ae(positions(), pos)
self.ae(put_image(s, iw, ih, id=3, placement_id=97, parent_id=2, parent_placement_id=3)[1], 'OK')
pos[(3,1)] = p(2, 4)
self.ae(positions(), pos)
send_command(s, 'a=d,d=i,i=2')
pos.pop((3,1)), pos.pop((2,11)), pos.pop((2,1)), pos.pop((1,3))
self.ae(positions(), pos)
# Check that virtual placements that try to be relative are rejected
self.ae(put_ref(s, id=1, placement_id=11, parent_id=1, unicode_placeholder=1), (1, ('EINVAL', 'i=1,p=11')))
# Check creation of children of a unicode placeholder based image
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
put_image(s, 20, 20, num_cols=4, num_lines=2, unicode_placeholder=1, id=42)
s.update_only_line_graphics_data()
self.assertFalse(positions()) # the reference is virtual
self.ae(put_ref(s, id=42, placement_id=11, parent_id=42, offset_from_parent_y=2, offset_from_parent_x=1), (42, ('OK', 'i=42,p=11')))
self.assertFalse(positions()) # the reference is virtual without any cell images so the child is invisible
s.apply_sgr("38;5;42")
# These two characters will become one 2x1 ref.
s.cursor.x = s.cursor.y = 1
s.draw("\U0010EEEE\u0305\u0305\U0010EEEE\u0305\u030D")
s.cursor.x = s.cursor.y = 0
s.draw("\U0010EEEE\u0305\u0305\U0010EEEE\u0305\u030D")
s.update_only_line_graphics_data()
pos = {(1, 2): p(1, 2), (1, 3): p(0), (1, 4): p(1)}
self.ae(positions(), pos)
s.cursor.x = s.cursor.y = 0
s.erase_in_display(0, False)
s.update_only_line_graphics_data()
self.assertFalse(positions()) # the reference is virtual without any cell images so the child is invisible
s.cursor.x = s.cursor.y = 2
s.draw("\U0010EEEE\u0305\u0305\U0010EEEE\u0305\u030D")
s.update_only_line_graphics_data()
self.ae(positions(), {(1, 5): {'x': 2, 'y': 2}, (1, 2): {'x': 3, 'y': 4}})
def test_unicode_placeholders(self):
# This test tests basic image placement using using unicode placeholders
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
# Upload two images.
put_image(s, 20, 20, num_cols=4, num_lines=2, unicode_placeholder=1, id=42)
put_image(s, 10, 20, num_cols=4, num_lines=2, unicode_placeholder=1, id=(42<<16) + (43<<8) + 44)
# The references are virtual, so no visible refs yet.
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 0)
# A reminder of row/column diacritics meaning (assuming 0-based):
# \u0305 -> 0
# \u030D -> 1
# \u030E -> 2
# \u0310 -> 3
# Now print the placeholders for the first image.
# Encode the id as an 8-bit color.
s.apply_sgr("38;5;42")
# These two characters will become one 2x1 ref.
s.draw("\U0010EEEE\u0305\u0305\U0010EEEE\u0305\u030D")
# These two characters will be two separate refs (not contiguous).
s.draw("\U0010EEEE\u0305\u0305\U0010EEEE\u0305\u030E")
s.cursor_move(4)
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 3)
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 0.5, 'bottom': 0.5})
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 0.25, 'bottom': 0.5})
self.ae(refs[2]['src_rect'], {'left': 0.5, 'top': 0.0, 'right': 0.75, 'bottom': 0.5})
# Erase the line.
s.erase_in_line(2)
# There must be 0 refs after the line is erased.
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 0)
# Now test encoding IDs with the 24-bit color.
# The first image, 1x1
s.apply_sgr("38;2;0;0;42")
s.draw("\U0010EEEE\u0305\u0305")
# The second image, 2x1
s.apply_sgr("38;2;42;43;44")
s.draw("\U0010EEEE\u0305\u030D\U0010EEEE\u0305\u030E")
s.cursor_move(2)
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 2)
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 0.25, 'bottom': 0.5})
# The second ref spans the whole widths of the second image because it's
# fit to height and centered in a 4x2 box (specified in put_image).
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 0.5})
# Erase the line.
s.erase_in_line(2)
# Now test implicit column numbers.
# We will mix implicit and explicit column/row specifications, but they
# will be combine into just two references.
s.apply_sgr("38;5;42")
# full row 0 of the first image
s.draw("\U0010EEEE\u0305\u0305\U0010EEEE\u0305\U0010EEEE\U0010EEEE\u0305")
# full row 1 of the first image
s.draw("\U0010EEEE\u030D\U0010EEEE\U0010EEEE\U0010EEEE\u030D\u0310")
s.cursor_move(8)
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 2)
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 0.5})
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.5, 'right': 1.0, 'bottom': 1.0})
# Now reset the screen, the images should be erased.
s.reset()
refs = layers(s)
self.ae(len(refs), 0)
def test_unicode_placeholders_3rd_combining_char(self):
# This test tests that we can use the 3rd diacritic for the most
# significant byte
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
# Upload two images.
put_image(s, 20, 20, num_cols=4, num_lines=2, unicode_placeholder=1, id=42)
put_image(s, 20, 10, num_cols=4, num_lines=1, unicode_placeholder=1, id=(42 << 24) + 43)
# This one will have id=43, which does not exist.
s.apply_sgr("38;2;0;0;43")
s.draw("\U0010EEEE\u0305\U0010EEEE\U0010EEEE\U0010EEEE")
s.cursor_move(4)
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 0)
s.erase_in_line(2)
# This one will have id=42. We explicitly specify that the most
# significant byte is 0 (third \u305). Specifying the zero byte like
# this is not necessary but is correct.
s.apply_sgr("38;2;0;0;42")
s.draw("\U0010EEEE\u0305\u0305\u0305\U0010EEEE\u0305\u030D\u0305")
# This is the second image.
# \u059C -> 42
s.apply_sgr("38;2;0;0;43")
s.draw("\U0010EEEE\u0305\u0305\u059C\U0010EEEE\u0305\u030D\u059C")
# Check that we can continue by using implicit row/column specification.
s.draw("\U0010EEEE\u0305\U0010EEEE")
s.cursor_move(6)
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 2)
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 0.5, 'bottom': 0.5})
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 1.0})
s.erase_in_line(2)
# Now test the 8-bit color mode. Using the third diacritic, we can
# specify 16 bits: the most significant byte and the least significant
# byte.
s.apply_sgr("38;5;42")
s.draw("\U0010EEEE\u0305\u0305\u0305\U0010EEEE")
s.apply_sgr("38;5;43")
s.draw("\U0010EEEE\u0305\u0305\u059C\U0010EEEE\U0010EEEE\u0305\U0010EEEE")
s.cursor_move(6)
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 2)
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 0.5, 'bottom': 0.5})
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 1.0})
def test_unicode_placeholders_multiple_placements(self):
# Here we test placement specification via underline color.
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
put_image(s, 20, 20, num_cols=1, num_lines=1, placement_id=1, unicode_placeholder=1, id=42)
put_ref(s, id=42, num_cols=2, num_lines=1, placement_id=22, unicode_placeholder=1)
put_ref(s, id=42, num_cols=4, num_lines=2, placement_id=44, unicode_placeholder=1)
# The references are virtual, so no visible refs yet.
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 0)
# Draw the first row of each placement.
s.apply_sgr("38;5;42")
s.apply_sgr("58;5;1")
s.draw("\U0010EEEE\u0305")
s.apply_sgr("58;5;22")
s.draw("\U0010EEEE\u0305\U0010EEEE\u0305")
s.apply_sgr("58;5;44")
s.draw("\U0010EEEE\u0305\U0010EEEE\u0305\U0010EEEE\u0305\U0010EEEE\u0305")
s.update_only_line_graphics_data()
refs = layers(s)
self.ae(len(refs), 3)
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 1.5})
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 1.0})
self.ae(refs[2]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 0.5})
def test_unicode_placeholders_scroll(self):
# Here we test scrolling of a region. We'll draw an image spanning 8
# rows and then scroll only the middle part of this image. Each
# reference corresponds to one row.
cw, ch = 5, 10
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch, lines=8)
put_image(s, 5, 80, num_cols=1, num_lines=8, unicode_placeholder=1, id=42)
s.apply_sgr("38;5;42")
s.cursor_position(1, 0)
s.draw("\U0010EEEE\u0305\n")
s.cursor_position(2, 0)
s.draw("\U0010EEEE\u030D\n")
s.cursor_position(3, 0)
s.draw("\U0010EEEE\u030E\n")
s.cursor_position(4, 0)
s.draw("\U0010EEEE\u0310\n")
s.cursor_position(5, 0)
s.draw("\U0010EEEE\u0312\n")
s.cursor_position(6, 0)
s.draw("\U0010EEEE\u033D\n")
s.cursor_position(7, 0)
s.draw("\U0010EEEE\u033E\n")
s.cursor_position(8, 0)
s.draw("\U0010EEEE\u033F")
# Each line will contain a part of the image.
s.update_only_line_graphics_data()
refs = layers(s)
refs = sorted(refs, key=lambda r: r['src_rect']['top'])
self.ae(len(refs), 8)
for i in range(8):
self.ae(refs[i]['src_rect'], {'left': 0.0, 'top': 0.125*i, 'right': 1.0, 'bottom': 0.125*(i + 1)})
self.ae(refs[i]['dest_rect']['top'], 1 - 0.25*i)
# Now set margins to lines 3 and 6.
s.set_margins(3, 6) # 1-based indexing
# Scroll two lines down (i.e. move lines 3..6 up).
# Lines 3 and 4 will be erased.
s.cursor_position(6, 0)
s.index()
s.index()
s.update_only_line_graphics_data()
refs = layers(s)
refs = sorted(refs, key=lambda r: r['src_rect']['top'])
self.ae(len(refs), 6)
# Lines 1 and 2 are outside of the region, not scrolled.
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 0.125})
self.ae(refs[0]['dest_rect']['top'], 1.0)
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.125*1, 'right': 1.0, 'bottom': 0.125*2})
self.ae(refs[1]['dest_rect']['top'], 1.0 - 0.25*1)
# Lines 3 and 4 are erased.
# Lines 5 and 6 are now higher.
self.ae(refs[2]['src_rect'], {'left': 0.0, 'top': 0.125*4, 'right': 1.0, 'bottom': 0.125*5})
self.ae(refs[2]['dest_rect']['top'], 1.0 - 0.25*2)
self.ae(refs[3]['src_rect'], {'left': 0.0, 'top': 0.125*5, 'right': 1.0, 'bottom': 0.125*6})
self.ae(refs[3]['dest_rect']['top'], 1.0 - 0.25*3)
# Lines 7 and 8 are outside of the region.
self.ae(refs[4]['src_rect'], {'left': 0.0, 'top': 0.125*6, 'right': 1.0, 'bottom': 0.125*7})
self.ae(refs[4]['dest_rect']['top'], 1.0 - 0.25*6)
self.ae(refs[5]['src_rect'], {'left': 0.0, 'top': 0.125*7, 'right': 1.0, 'bottom': 0.125*8})
self.ae(refs[5]['dest_rect']['top'], 1.0 - 0.25*7)
# Now scroll three lines up (i.e. move lines 5..6 down).
# Line 6 will be erased.
s.cursor_position(3, 0)
s.reverse_index()
s.reverse_index()
s.reverse_index()
s.update_only_line_graphics_data()
refs = layers(s)
refs = sorted(refs, key=lambda r: r['src_rect']['top'])
self.ae(len(refs), 5)
# Lines 1 and 2 are outside of the region, not scrolled.
self.ae(refs[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 0.125})
self.ae(refs[0]['dest_rect']['top'], 1.0)
self.ae(refs[1]['src_rect'], {'left': 0.0, 'top': 0.125*1, 'right': 1.0, 'bottom': 0.125*2})
self.ae(refs[1]['dest_rect']['top'], 1.0 - 0.25*1)
# Lines 3, 4 and 6 are erased.
# Line 5 is now lower.
self.ae(refs[2]['src_rect'], {'left': 0.0, 'top': 0.125*4, 'right': 1.0, 'bottom': 0.125*5})
self.ae(refs[2]['dest_rect']['top'], 1.0 - 0.25*5)
# Lines 7 and 8 are outside of the region.
self.ae(refs[3]['src_rect'], {'left': 0.0, 'top': 0.125*6, 'right': 1.0, 'bottom': 0.125*7})
self.ae(refs[3]['dest_rect']['top'], 1.0 - 0.25*6)
self.ae(refs[4]['src_rect'], {'left': 0.0, 'top': 0.125*7, 'right': 1.0, 'bottom': 0.125*8})
self.ae(refs[4]['dest_rect']['top'], 1.0 - 0.25*7)
def test_gr_scroll(self):
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
put_image(s, 10, 20, no_id=True) # a one cell image at (0, 0)
self.ae(len(layers(s)), 1)
for i in range(s.lines):
s.index()
self.ae(len(layers(s)), 0), self.ae(s.grman.image_count, 1)
for i in range(s.historybuf.ynum - 1):
s.index()
self.ae(len(layers(s)), 0), self.ae(s.grman.image_count, 1)
s.index()
self.ae(s.grman.image_count, 0)
# Now test with margins
s.reset()
# Test images outside page area untouched
put_image(s, cw, ch) # a one cell image at (0, 0)
for i in range(s.lines - 1):
s.index()
put_image(s, cw, ch) # a one cell image at (0, bottom)
s.set_margins(2, 4) # 1-based indexing
self.ae(s.grman.image_count, 2)
for i in range(s.lines + s.historybuf.ynum):
s.index()
self.ae(s.grman.image_count, 2)
for i in range(s.lines): # ensure cursor is at top margin
s.reverse_index()
# Test clipped scrolling during index
put_image(s, cw, 2*ch, z=-1, no_id=True) # 1x2 cell image
self.ae(s.grman.image_count, 3)
self.ae(layers(s)[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 1.0})
s.index(), s.index()
l0 = layers(s)
self.ae(len(l0), 3)
self.ae(layers(s)[0]['src_rect'], {'left': 0.0, 'top': 0.5, 'right': 1.0, 'bottom': 1.0})
s.index()
self.ae(s.grman.image_count, 2)
# Test clipped scrolling during reverse_index
for i in range(s.lines):
s.reverse_index()
put_image(s, cw, 2*ch, z=-1, no_id=True) # 1x2 cell image
self.ae(s.grman.image_count, 3)
self.ae(layers(s)[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 1.0})
while s.cursor.y != 1:
s.reverse_index()
s.reverse_index(), s.reverse_index()
self.ae(layers(s)[0]['src_rect'], {'left': 0.0, 'top': 0.0, 'right': 1.0, 'bottom': 0.5})
s.reverse_index()
self.ae(s.grman.image_count, 2)
s.reset()
self.assertEqual(s.grman.disk_cache.total_size, 0)
def test_gr_reset(self):
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
put_image(s, cw, ch) # a one cell image at (0, 0)
self.ae(len(layers(s)), 1)
s.reset()
self.ae(s.grman.image_count, 0)
put_image(s, cw, ch) # a one cell image at (0, 0)
self.ae(s.grman.image_count, 1)
for i in range(s.lines):
s.index()
s.reset()
self.ae(s.grman.image_count, 1)
def test_gr_delete(self):
cw, ch = 10, 20
s, dx, dy, put_image, put_ref, layers, rect_eq = put_helpers(self, cw, ch)
def delete(ac=None, **kw):
cmd = 'a=d'
if ac:
cmd += f',d={ac}'
if kw:
cmd += ',' + ','.join(f'{k}={v}' for k, v in kw.items())
send_command(s, cmd)
iid = put_image(s, cw, ch, a='t')[0]
self.ae(s.grman.image_count, 1)
delete('I', i=iid)
self.ae(s.grman.image_count, 0)
iid1 = put_image(s, cw, ch, a='t')[0]
iid2 = put_image(s, cw, ch, a='t')[0]
self.ae(s.grman.image_count, 2)
delete('R', x=iid1, y=iid2)
self.ae(s.grman.image_count, 0)
put_image(s, cw, ch)
delete()
self.ae(s.grman.image_count, 1)
self.ae(len(layers(s)), 0)
delete('A')
self.ae(s.grman.image_count, 1)
s.reset()
self.ae(s.grman.image_count, 0)
put_image(s, cw, ch)
self.ae(s.grman.image_count, 1)
delete('A')
self.ae(s.grman.image_count, 0)
self.assertEqual(s.grman.disk_cache.total_size, 0)
iid = put_image(s, cw, ch)[0]
delete('I', i=iid, p=7)
self.ae(s.grman.image_count, 1)
delete('I', i=iid)
self.ae(s.grman.image_count, 0)
self.assertEqual(s.grman.disk_cache.total_size, 0)
iid = put_image(s, cw, ch, placement_id=9)[0]
delete('I', i=iid, p=9)
self.ae(s.grman.image_count, 0)
self.assertEqual(s.grman.disk_cache.total_size, 0)
s.reset()
put_image(s, cw, ch)
put_image(s, cw, ch)
delete('C')
self.ae(s.grman.image_count, 2)
s.cursor_position(1, 1)
delete('C')
self.ae(s.grman.image_count, 1)
delete('P', x=2, y=1)
self.ae(s.grman.image_count, 0)
self.assertEqual(s.grman.disk_cache.total_size, 0)
put_image(s, cw, ch, z=9)
delete('Z', z=9)
self.ae(s.grman.image_count, 0)
put_image(s, cw, ch, id=1)
put_image(s, cw, ch, id=2)
put_image(s, cw, ch, id=3)
delete('R', y=2)
self.ae(s.grman.image_count, 1)
delete('R', x=3, y=3)
self.ae(s.grman.image_count, 0)
self.assertEqual(s.grman.disk_cache.total_size, 0)
# test put + delete + put
iid = 999999
self.ae(put_image(s, cw, ch, id=iid), (iid, 'OK'))
self.ae(put_ref(s, id=iid), (iid, ('OK', f'i={iid}')))
delete('i', i=iid)
self.ae(s.grman.image_count, 1)
self.ae(put_ref(s, id=iid), (iid, ('OK', f'i={iid}')))
delete('I', i=iid)
self.ae(put_ref(s, id=iid), (iid, ('ENOENT', f'i={iid}')))
self.ae(s.grman.image_count, 0)
self.assertEqual(s.grman.disk_cache.total_size, 0)
# test delete but not free
s.reset()
iid = 9999999
self.ae(put_image(s, cw, ch, id=iid), (iid, 'OK'))
self.ae(put_ref(s, id=iid), (iid, ('OK', f'i={iid}')))
self.ae(put_image(s, cw, ch, id=iid+1), (iid+1, 'OK'))
self.ae(put_ref(s, id=iid+1), (iid+1, ('OK', f'i={iid+1}')))
delete('i', i=iid)
self.ae(s.grman.image_count, 2)
delete('I', i=iid+1)
self.ae(s.grman.image_count, 1)
def test_animation_frame_loading(self):
s = self.create_screen()
g = s.grman
li = make_send_command(s)
def t(code='OK', image_id=1, frame_number=2, **kw):
res = li(**kw)
if code is not None:
self.assertEqual(code, res.code, f'{code} != {res.code}: {res.msg}')
if image_id is not None:
self.assertEqual(image_id, res.image_id)
if frame_number is not None:
self.assertEqual(frame_number, res.frame_number)
# test error on send frame for non-existent image
self.assertEqual(li().code, 'ENOENT')
# create image
self.assertEqual(li(a='t').code, 'OK')
self.assertEqual(g.disk_cache.total_size, 36)
# simple new frame (width=4, height=3)
self.assertIsNone(li(payload='2' * 12, z=77, m=1))
self.assertIsNone(li(payload='2' * 12, z=77, m=1))
t(payload='2' * 12, z=77)
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], ({'gap': 77, 'id': 2, 'data': b'2' * 36},))
# test editing a frame
t(payload='3' * 36, r=2)
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], ({'gap': 77, 'id': 2, 'data': b'3' * 36},))
# test editing part of a frame
t(payload='4' * 12, r=2, s=2, v=2)
img = g.image_for_client_id(1)
def expand(*rows):
ans = []
for r in rows:
ans.append(''.join(x * 3 for x in str(r)))
return ''.join(ans).encode('ascii')
self.assertEqual(img['extra_frames'], ({'gap': 77, 'id': 2, 'data': expand(4433, 4433, 3333)},))
t(payload='5' * 12, r=2, s=2, v=2, x=1, y=1)
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], ({'gap': 77, 'id': 2, 'data': expand(4433, 4553, 3553)},))
t(payload='3' * 36, r=2)
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], ({'gap': 77, 'id': 2, 'data': b'3' * 36},))
# test loading from previous frame
t(payload='4' * 12, c=2, s=2, v=2, z=101, frame_number=3)
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], (
{'gap': 77, 'id': 2, 'data': b'3' * 36},
{'gap': 101, 'id': 3, 'data': b'444444333333444444333333333333333333'},
))
# test changing gaps
img = g.image_for_client_id(1)
self.assertEqual(img['root_frame_gap'], 0)
self.assertIsNone(li(a='a', i=1, r=1, z=13))
img = g.image_for_client_id(1)
self.assertEqual(img['root_frame_gap'], 13)
self.assertIsNone(li(a='a', i=1, r=2, z=43))
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'][0]['gap'], 43)
# test changing current frame
img = g.image_for_client_id(1)
self.assertEqual(img['current_frame_index'], 0)
self.assertIsNone(li(a='a', i=1, c=2))
img = g.image_for_client_id(1)
self.assertEqual(img['current_frame_index'], 1)
# test delete of frames
t(payload='5' * 36, frame_number=4)
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], (
{'gap': 43, 'id': 2, 'data': b'3' * 36},
{'gap': 101, 'id': 3, 'data': b'444444333333444444333333333333333333'},
{'gap': 40, 'id': 4, 'data': b'5' * 36},
))
self.assertEqual(img['current_frame_index'], 1)
self.assertIsNone(li(a='d', d='f', i=1, r=1))
img = g.image_for_client_id(1)
self.assertEqual(img['current_frame_index'], 0)
self.assertEqual(img['data'], b'3' * 36)
self.assertEqual(img['extra_frames'], (
{'gap': 101, 'id': 3, 'data': b'444444333333444444333333333333333333'},
{'gap': 40, 'id': 4, 'data': b'5' * 36},
))
self.assertIsNone(li(a='a', i=1, c=3))
img = g.image_for_client_id(1)
self.assertEqual(img['current_frame_index'], 2)
self.assertIsNone(li(a='d', d='f', i=1, r=2))
img = g.image_for_client_id(1)
self.assertEqual(img['current_frame_index'], 1)
self.assertEqual(img['data'], b'3' * 36)
self.assertEqual(img['extra_frames'], (
{'gap': 40, 'id': 4, 'data': b'5' * 36},
))
self.assertIsNone(li(a='d', d='f', i=1))
img = g.image_for_client_id(1)
self.assertEqual(img['current_frame_index'], 0)
self.assertEqual(img['data'], b'5' * 36)
self.assertFalse(img['extra_frames'])
self.assertIsNone(li(a='d', d='f', i=1))
img = g.image_for_client_id(1)
self.assertEqual(img['data'], b'5' * 36)
self.ae(g.image_count, 1)
self.assertIsNone(li(a='d', d='F', i=1))
self.ae(g.image_count, 0)
self.assertEqual(g.disk_cache.total_size, 0)
# test frame composition
self.assertEqual(li(a='t').code, 'OK')
self.assertEqual(g.disk_cache.total_size, 36)
t(payload='2' * 36)
t(payload='3' * 36, frame_number=3)
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], (
{'gap': 40, 'id': 2, 'data': b'2' * 36},
{'gap': 40, 'id': 3, 'data': b'3' * 36},
))
self.assertEqual(li(a='c', i=11).code, 'ENOENT')
self.assertEqual(li(a='c', i=1, r=1, c=2).code, 'OK')
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], (
{'gap': 40, 'id': 2, 'data': b'abcdefghijkl'*3},
{'gap': 40, 'id': 3, 'data': b'3' * 36},
))
self.assertEqual(li(a='c', i=1, r=2, c=3, w=1, h=2, x=1, y=1).code, 'OK')
img = g.image_for_client_id(1)
self.assertEqual(img['extra_frames'], (
{'gap': 40, 'id': 2, 'data': b'abcdefghijkl'*3},
{'gap': 40, 'id': 3, 'data': b'3' * 12 + (b'333abc' + b'3' * 6) * 2},
))
# Test that compose commands with offset values that would overflow a 32-bit
# unsigned integer are correctly rejected with EINVAL instead of crashing.
# In the old code, UINT32_MAX + img->width wrapped around as uint32_t to a
# small value that bypassed the bounds check, causing a crash (fix: e9661f0).
for offset_param in ('x', 'y', 'X', 'Y'):
self.assertEqual(
li(payload=b'', a='c', i=1, r=1, c=2, s=0, v=0, f=0, **{offset_param: 0xFFFFFFFF}).code,
'EINVAL', f'Expected EINVAL for overflow in compose offset parameter {offset_param!r}'
)
def test_graphics_quota_enforcement(self):
s = self.create_screen()
g = s.grman
g.storage_limit = 36*2
li = make_send_command(s)
# test quota for simple images
self.assertEqual(li(a='T').code, 'OK')
self.assertEqual(li(a='T', i=2).code, 'OK')
self.assertEqual(g.disk_cache.total_size, g.storage_limit)
self.assertEqual(g.image_count, 2)
self.assertEqual(li(a='T', i=3).code, 'OK')
self.assertEqual(g.disk_cache.total_size, g.storage_limit)
self.assertEqual(g.image_count, 2)
# test quota for frames
for i in range(8):
self.assertEqual(li(payload=f'{i}' * 36, i=2).code, 'OK')
self.assertEqual(li(payload='x' * 36, i=2).code, 'ENOSPC')
# test editing should not trigger quota
self.assertEqual(li(payload='4' * 12, r=2, s=2, v=2, i=2).code, 'OK')
s.reset()
self.ae(g.image_count, 0)
self.assertEqual(g.disk_cache.total_size, 0)
@unittest.skipIf(Image is None, 'PIL not available, skipping PNG tests')
def test_cached_rgba_conversion(self):
from kitty.render_cache import ImageRenderCacheForTesting
w, h = 5, 3
rgba_data = byte_block(w * h * 4)
img = Image.frombytes('RGBA', (w, h), rgba_data)
buf = BytesIO()
img.save(buf, 'PNG')
png_data = buf.getvalue()
with tempfile.TemporaryDirectory() as cache_path:
irc = ImageRenderCacheForTesting(cache_path)
srcs, outputs = [], []
for i in range(2 * irc.max_entries):
with open(os.path.join(cache_path, f'{i}.png'), 'wb') as f:
f.write(png_data)
srcs.append(f.name)
outputs.append(irc.render(f.name))
entries = list(irc.entries())
self.assertLessEqual(len(entries), irc.max_entries)
self.ae(irc.num_of_renders, len(outputs))
remaining_outputs = outputs[-irc.max_entries:]
for x in remaining_outputs:
self.assertTrue(os.path.exists(x))
for x in outputs[:-irc.max_entries]:
self.assertFalse(os.path.exists(x))
self.assertLess(os.path.getmtime(remaining_outputs[0]), os.path.getmtime(remaining_outputs[1]))
remaining_srcs = srcs[-irc.max_entries:]
self.ae(irc.render(remaining_srcs[0]), remaining_outputs[0])
self.ae(irc.num_of_renders, len(outputs))
self.assertGreater(os.path.getmtime(remaining_outputs[0]), os.path.getmtime(remaining_outputs[1]))
width, height, fd = irc(remaining_srcs[-1])
with open(fd, 'rb') as f:
self.ae((width, height), (w, h))
f.seek(8)
self.ae(rgba_data, f.read())