test/native_resize.py
Ref: Size: 13.6 KiB History
#!/usr/bin/env python3
"""Real SDL divider input, three daemon PTYs, and nonforcing frame oracles."""
import fcntl
import os
from pathlib import Path
import shlex
import struct
import sys
import termios
sys.dont_write_bytecode = True
from native_tiling import Rig, colour_counts, eventually, require
class ResizeRig(Rig):
float_size = (1000, 650)
float_scale = 2
def __init__(self, mux, muxg):
super().__init__(mux, muxg)
self.env['MUXG_TEST_RETAIN_FRAME'] = '1'
self.tty_paths = {}
def logical(self, state, x, y):
return (x * state['logical_width'] / state['width'],
y * state['logical_height'] / state['height'])
def point(self, state, x, y):
x, y = self.logical(state, x, y)
return f'{x:.4f},{y:.4f}'
def focus(self, pane_id):
state = self.state()
pane = by_id(state)[pane_id]
rect = pane['header']
self.send('click:' + self.point(state, rect['x'] + rect['w'] / 2,
rect['y'] + rect['h'] / 2))
self.wait_state(lambda s: s['focus'] == pane_id)
def drag(self, direction, dx=0, dy=0, pad=0):
state = self.state()
divider = one_divider(state, direction)
rect = divider['rect']
x, y = rect['x'] + rect['w'] / 2, rect['y'] + rect['h'] / 2
# Avoid the nested divider intersection. Pad probes the visual rail's
# surrounding logical-pixel grab area without changing the target delta.
if direction == 'beside':
y = rect['y'] + rect['h'] / 4
x += pad * state['width'] / state['logical_width']
else:
x = rect['x'] + rect['w'] * 3 / 4
y += pad * state['height'] / state['logical_height']
self.send('mousedown:' + self.point(state, x, y),
'mousemove:' + self.point(state, x + dx, y + dy),
'mouseup:' + self.point(state, x + dx, y + dy))
return state
def last_pixels(self):
raw = self.artifact('capture-last', '.ppm').read_bytes()
magic, dims, maximum, pixels = raw.split(b'\n', 3)
require(magic == b'P6' and maximum == b'255', 'invalid last-frame capture')
width, height = map(int, dims.split())
require(len(pixels) == width * height * 3, 'truncated last-frame capture')
return width, height, pixels
def by_id(state):
return {pane['id']: pane for pane in state['panes']}
def one_divider(state, direction):
found = [d for d in state['dividers'] if d['direction'] == direction]
require(len(found) == 1, 'expected one ' + direction + ' divider')
return found[0]
def edge(state, direction):
return one_divider(state, direction)['rect']['x' if direction == 'beside' else 'y']
def unchanged_ids(state, identities):
require([(p['id'], p['generation']) for p in state['panes']] == identities,
'resizing replaced, reordered or reattached a pane')
require(all(p['cols'] >= 2 and p['rows'] >= 2 for p in state['panes']),
'resizing violated a terminal minimum')
def pty_sizes(rig, sessions, identities):
state = rig.state()
unchanged_ids(state, identities)
require([(d['id'], d['direction']) for d in state['dividers']] == rig.divider_ids,
'resizing replaced or reordered divider identities')
for pane_id, (sock, session) in sessions.items():
pane = by_id(state)[pane_id]
expected = (pane['content']['h'] // state['cell_h'],
pane['content']['w'] // state['cell_w'])
require(expected == (pane['rows'], pane['cols']), 'geometry and cell claim disagree')
actual = None
def kernel_size():
nonlocal actual
fd = os.open(rig.tty_paths[pane_id], os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
try:
actual = struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))[:2]
return actual == expected
finally:
os.close(fd)
try:
eventually(kernel_size, f'{session} kernel PTY size did not become {expected}')
except AssertionError as error:
raise AssertionError(f'{error}; last observed {actual}') from error
# Both observations are read-only. Neither sends terminal input or
# attaches at a new size, which could mask a missing resize notification.
observed = rig.status(sock, session)
require((observed['rows'], observed['cols']) == expected, 'daemon dimension mismatch')
def drag_and_keys(rig, identities, sessions):
state = rig.state()
dx = state['cell_w'] * 5
before = rig.drag('beside', dx=dx, pad=2)
state = rig.wait_state(lambda s: abs(edge(s, 'beside') - edge(before, 'beside') - dx) <= 1)
require(edge(state, 'stacked') == edge(before, 'stacked'), 'outer drag moved inner horizontal rail')
old, new = by_id(before), by_id(state)
for pane_id in (identities[1][0], identities[2][0]):
require(abs(new[pane_id]['outer']['x'] - old[pane_id]['outer']['x'] - dx) <= 1,
'outer drag did not move both descendants')
rig.send('mousemove:0,0')
require(edge(rig.state(), 'beside') == edge(state, 'beside'),
'pointer motion after release continued resizing')
dy = -state['cell_h'] * 3
before = rig.drag('stacked', dy=dy, pad=2)
state = rig.wait_state(lambda s: abs(edge(s, 'stacked') - edge(before, 'stacked') - dy) <= 1)
require(by_id(state)[identities[0][0]]['outer'] == by_id(before)[identities[0][0]]['outer'],
'nested horizontal drag changed the independent left pane')
pty_sizes(rig, sessions, identities)
rig.ok('real divider drags resize descendants on both axes, including the wider grab area')
rig.focus(identities[2][0])
before = rig.state()
rig.chord('r')
rig.wait_state(lambda s: s['resize_mode'])
rig.key('left')
state = rig.wait_state(lambda s: abs(edge(s, 'beside') - edge(before, 'beside') + s['cell_w']) <= 1)
require(edge(state, 'stacked') == edge(before, 'stacked'), 'horizontal key chose wrong ancestor')
before = state
rig.key('j')
state = rig.wait_state(lambda s: abs(edge(s, 'stacked') - edge(before, 'stacked') - s['cell_h']) <= 1)
require(edge(state, 'beside') == edge(before, 'beside'), 'vertical key moved outer divider')
rig.key('escape')
rig.wait_state(lambda s: not s['resize_mode'])
pty_sizes(rig, sessions, identities)
rig.ok('keyboard resize chooses the nearest matching-axis divider and moves it one cell')
def modal_leakage(rig, identities):
pane_id = identities[2][0]
for exit_key in ('enter', 'escape'):
rig.focus(pane_id)
root = rig.root / ('modal-' + exit_key)
ready, received, done = [Path(str(root) + suffix) for suffix in ('.ready', '.bytes', '.done')]
command = ('stty -echo -icanon min 1 time 0; : > ' + shlex.quote(str(ready)) +
'; dd bs=1 count=1 2>/dev/null | od -An -tx1 > ' + shlex.quote(str(received)) +
'; stty sane; : > ' + shlex.quote(str(done)))
rig.shell(command)
eventually(ready.exists, 'raw terminal fixture did not arm')
rig.send('key:prefix', 'key:r', 'text:r', 'key:l', 'text:l',
'key:j', 'text:j', 'key:left', 'key:up', 'text:MODAL-LEAK',
'key:' + exit_key, 'text:Q')
eventually(done.exists, 'terminal did not receive post-resize sentinel')
require(received.read_text().split() == ['51'], 'resize mode leaked input before sentinel Q')
rig.wait_state(lambda s: not s['resize_mode'])
rig.ok('resize keys and associated text stay modal; Enter and Esc release ordinary input')
def clamps(rig, identities, sessions):
rig.drag('beside', dx=10000)
rig.wait_state(lambda s: all(by_id(s)[pane_id]['cols'] == 2
for pane_id in (identities[1][0], identities[2][0])))
rig.drag('stacked', dy=10000)
rig.wait_state(lambda s: by_id(s)[identities[2][0]]['rows'] == 2)
pty_sizes(rig, sessions, identities)
rig.drag('beside', dx=-10000)
rig.wait_state(lambda s: by_id(s)[identities[0][0]]['cols'] == 2)
rig.drag('stacked', dy=-10000)
state = rig.wait_state(lambda s: by_id(s)[identities[1][0]]['rows'] == 2)
unchanged_ids(state, identities)
pty_sizes(rig, sessions, identities)
before_tiny = state
rig.send('resize:8x8')
if rig.env['SDL_VIDEO_DRIVER'] == 'wayland':
state = rig.wait_state(lambda s: s['logical_width'] <= 100 and s['logical_height'] <= 100)
if (state['logical_width'], state['logical_height']) != (8, 8):
print('resize note: Sway minimum clamped requested 8x8 to '
f"{state['logical_width']}x{state['logical_height']}; exact 8x8 covered offscreen", flush=True)
else:
state = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (8, 8))
unchanged_ids(state, identities)
pty_sizes(rig, sessions, identities)
rig.send('key:prefix', 'key:r', 'key:l', 'key:j', 'key:escape')
require([p['outer'] for p in rig.state()['panes']] == [p['outer'] for p in state['panes']],
'explicit resizing changed an undersized tree')
restored_size = (before_tiny['logical_width'], before_tiny['logical_height'])
rig.send(f'resize:{restored_size[0]}x{restored_size[1]}')
restored = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == restored_size)
require([p['outer'] for p in restored['panes']] == [p['outer'] for p in before_tiny['panes']],
'tiny-window clipping rewrote split proportions')
# Restore usable widths/heights through the same real drag path.
state = rig.state()
rig.drag('beside', dx=state['width'] * .45 - edge(state, 'beside'))
state = rig.wait_state(lambda s: by_id(s)[identities[0][0]]['cols'] > 20)
rig.drag('stacked', dy=state['height'] * .5 - edge(state, 'stacked'))
rig.wait_state(lambda s: by_id(s)[identities[1][0]]['rows'] > 5)
pty_sizes(rig, sessions, identities)
rig.ok('minimum clamps and tiny-window clipping preserve every identity and nonzero PTY')
def pixels_and_batch(rig, identities, sessions):
for index, (pane_id, (sock, session)) in enumerate(sessions.items()):
rig.focus(pane_id)
colour = ('255;0;0', '0;255;0', '0;0;255')[index]
marker = 'COLOUR-' + session
rig.shell("printf '\\033[?25l\\033[48;2;" + colour + "m\\033[2J\\033[HCOLOUR-%s\\n' " + shlex.quote(session))
rig.wait_marker(sock, session, marker)
rig.wait_state(lambda s: marker in by_id(s)[pane_id]['painted_text'])
def clean_regions():
state = rig.state()
capture = rig.last_pixels()
for index, (pane_id, _) in enumerate(identities):
inside, outside = colour_counts(capture, by_id(state)[pane_id]['content'], ('red', 'green', 'blue')[index])
if inside < 100 or outside != 0:
return False
return True
eventually(clean_regions, 'initial nonforcing framebuffer colours are misplaced')
frames, _ = rig.frames()
rig.drag('beside', dx=-rig.state()['cell_w'] * 7)
eventually(lambda: rig.frames()[0] > frames, 'divider drag did not cause a completed frame')
eventually(clean_regions, 'old coloured pixels survived the divider moving, or crossed neighbours')
rig.ok('nonforcing framebuffer reads show old pixels erased after divider motion')
# One FIFO write places release, window resize, and terminal input into the
# normal SDL queue together. The focused terminal asks its OS for the size
# that accompanied this command, not a later observer-triggered correction.
rig.focus(identities[2][0])
state = rig.state()
rail = one_divider(state, 'stacked')['rect']
x, y = rail['x'] + rail['w'] * .75, rail['y'] + rail['h'] / 2
output = rig.root / 'batched-pty-size'
rig.send('mousedown:' + rig.point(state, x, y),
'mousemove:' + rig.point(state, x, y + state['cell_h']),
'mouseup:' + rig.point(state, x, y + state['cell_h']),
'resize:800x520', 'text:stty size > ' + shlex.quote(str(output)), 'key:enter')
eventually(lambda: output.exists() and output.stat().st_size > 0, 'batched terminal input did not run')
state = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (800, 520))
focused = by_id(state)[identities[2][0]]
require(tuple(map(int, output.read_text().split())) == (focused['rows'], focused['cols']),
'batched resize/input ran against stale PTY dimensions')
pty_sizes(rig, sessions, identities)
rig.ok('batched drag release, window resize and input preserve final PTY size ordering')
rig.send('resize:640x400')
rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (640, 400))
pty_sizes(rig, sessions, identities)
eventually(clean_regions, 'window shrink misplaced coloured pane pixels')
rig.focus(identities[0][0])
rig.shell("printf '\\033[0m\\033[2J\\033[H'")
def cleared():
state = rig.state()
capture = rig.last_pixels()
if sum(colour_counts(capture, by_id(state)[identities[0][0]]['content'], 'red')):
return False
for index, colour in ((1, 'green'), (2, 'blue')):
inside, outside = colour_counts(capture, by_id(state)[identities[index][0]]['content'], colour)
if inside < 100 or outside:
return False
return True
eventually(cleared, 'clear after window shrink left old pixels or erased a neighbour')
rig.send('resize:800x520')
rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (800, 520))
pty_sizes(rig, sessions, identities)
rig.ok('window shrink and terminal clear erase old pixels without erasing neighbours')