diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-14 13:55:41 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-15 17:24:42 -0300 |
| commit | 95681ff7017b8a9e4c8f9fa6a7371d1233432f2f (patch) | |
| tree | 34787d6bb62944cc778624685387db7376ccf71b /test/window_opacity.py | |
| parent | 682e237df7e8b22f820d14a4adee58e6e2f84268 (diff) | |
| download | pardes-95681ff7017b8a9e4c8f9fa6a7371d1233432f2f.tar.gz pardes-95681ff7017b8a9e4c8f9fa6a7371d1233432f2f.zip | |
9p: use cloud9 protocol sessions and transports
Diffstat (limited to 'test/window_opacity.py')
| -rw-r--r-- | test/window_opacity.py | 225 |
1 files changed, 225 insertions, 0 deletions
diff --git a/test/window_opacity.py b/test/window_opacity.py new file mode 100644 index 00000000..8abe8677 --- /dev/null +++ b/test/window_opacity.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Check native Wayland background opacity in an owned live SDL window. + +Real composited pixels must show see-through backgrounds while full-coverage +comment, syntax, and terminal glyph pixels stay opaque. Niri/grim captures are +restricted to this process's window and never use the screenshot clipboard. +""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import time + +from fs import session +from ui_review import command, panes, require, wait_for + + +def run(args): + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=True, mode=0o700) + root.chmod(0o700) + display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0')) + if not display.is_absolute(): + display = Path(os.environ['XDG_RUNTIME_DIR']) / display + require(display.exists(), 'native Wayland socket missing: ' + str(display)) + config = root / 'config/pardes' + config.mkdir(parents=True, exist_ok=True) + (config / 'init').write_text('Shell /bin/sh\nTheme forge\nWindowOpacity 100\n') + source_path = root / 'opacity.rs' + original = b'// Comments and syntax retain their original RGB values.\nconst opacity_sample: u32 = 70;\n' + b'\n' * 80 + source_path.write_bytes(original) + log = root / 'wayland.log' + name = 'opacity-' + str(os.getpid()) + handles = {} + launch = ['-c', 'exec "$@" 2>"$OPACITY_LOG"', name, + str(args.binary.resolve()), '--9p=' + name, str(source_path)] + inherited = {'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display), + 'WAYLAND_DEBUG': '1', 'OPACITY_LOG': str(log)} + report = {'backend': 'wayland', 'screenshots': []} + require(args.capture, '--capture is required for compositor foreground/background pixel evidence') + + with session('/bin/sh', root, name, socket_name=name, tty=True, + launch=launch, inherited=inherited, terminal=handles) as (client, _): + source = require(wait_for(lambda: next((serial for serial, tag in panes(client).items() + if str(source_path) in tag), None)), 'source missing') + before = set(panes(client)) + command(client, source, 'Tty') + terminal = require(wait_for(lambda: next((serial for serial in panes(client) + if serial not in before), None)), 'terminal missing') + client.write(f'/self/pane/{terminal}/pty/data', + b"PS1='$ '; printf '\\033[2J\\033[H\\033[36mTerminal opacity sample\\033[0m\\n'\r") + require(wait_for(lambda: b'Terminal opacity sample' in client.read(f'/self/pane/{terminal}/body')), + 'terminal output missing') + + def output(builtin): + before = set(panes(client)) + command(client, source, builtin) + pane = require(wait_for(lambda: next((serial for serial in panes(client) + if serial not in before), None)), builtin + ' output missing') + text = client.read(f'/self/pane/{pane}/body').decode() + client.write(f'/self/pane/{pane}/ctl', b'del\n') + return text + + help_text = output('Help') + require('WindowOpacity' in help_text and 'FgOpacity' not in help_text, + 'help did not replace FgOpacity with WindowOpacity') + settings = output('Config') + require('WindowOpacity: 100%\n' in settings and 'FgOpacity' not in settings, + 'initial Config opacity not applied: ' + settings) + require('Wayland opacity: background-only transparent surface' in log.read_text(errors='replace'), + 'background-only surface path not selected') + report['renderer_path'] = 'background-only transparent surface' + + def wait_applied(percent): + require(wait_for(lambda: f'WindowOpacity: {percent}%\n' in output('Config')), + 'opacity did not apply: ' + str(percent)) + + geometry = None + if args.capture: + def owned_window(): + windows = json.loads(subprocess.check_output(['niri', 'msg', '-j', 'windows'])) + return next((window for window in windows if window['pid'] == handles['pid']), None) + window = require(wait_for(owned_window), 'owned Niri window missing') + window_id = str(window['id']) + for action in (['move-window-to-floating'], ['set-window-width', '1100'], + ['set-window-height', '760'], ['move-floating-window', '-x', '100', '-y', '100']): + subprocess.run(['niri', 'msg', 'action', *action, '--id', window_id], check=True, + stdout=subprocess.DEVNULL) + workspaces = json.loads(subprocess.check_output(['niri', 'msg', '-j', 'workspaces'])) + workspace = next(workspace for workspace in workspaces if workspace['id'] == window['workspace_id']) + outputs = json.loads(subprocess.check_output(['niri', 'msg', '-j', 'outputs'])) + logical = outputs[workspace['output']]['logical'] + # Position and size above affect only the owned floating window. + geometry = f"{logical['x'] + 100},{logical['y'] + 100} 1100x760" + require(wait_for(lambda: owned_window()['layout']['window_size'] == [1100, 760]), + 'owned window did not reach the requested capture size') + report['window_id'] = window['id'] + + def samples(): + frame = client.screen() + result = {} + for marker in ('// Comments and syntax', 'const opacity_sample', 'Terminal opacity sample'): + for row in range(frame['rows']): + cells = frame['cells'][row * frame['cols']:(row + 1) * frame['cols']] + text = ''.join(cell[0] for cell in cells) + start = text.find(marker) + if start >= 0: + result[marker] = [(cell[0], frame['styles'][cell[1]]) + for cell in cells[start:start + len(marker)]] + break + require(marker in result, 'sample is not visible: ' + marker) + return result + + def capture(percent): + if not geometry: + return + # Allow the compositor to present its alpha commit before capture. + time.sleep(.25) + path = root / f'window-opacity-{percent}.png' + subprocess.run(['grim', '-g', geometry, str(path)], check=True, timeout=10) + report['screenshots'].append(str(path)) + + def pixels(label): + return subprocess.check_output(['magick', str(root / f'window-opacity-{label}.png'), + '-depth', '8', 'RGB:-']) + + command(client, source, 'WindowOpacity 100') + baseline = samples() + cursor_frame = client.screen() + cursor = require(cursor_frame['cursor'], 'block cursor missing from baseline') + cursor_cell = cursor_frame['cells'][cursor['y'] * cursor_frame['cols'] + cursor['x']] + cursor_ink = cursor_frame['styles'][cursor_cell[1]]['fg']['rgb'] + (root / 'screen-100.json').write_text(json.dumps(cursor_frame)) + capture(100) + command(client, source, 'WindowOpacity 70') + wait_applied(70) + require(samples() == baseline, 'opacity altered editor or terminal core RGB/text') + capture(70) + settings = output('Config') + require('WindowOpacity: 70%\n' in settings, '70% opacity not applied: ' + settings) + command(client, source, 'WindowOpacity 101') + require('WindowOpacity: 70%\n' in output('Config'), 'invalid opacity changed state') + command(client, source, 'WindowOpacity 0') + wait_applied(0) + require(samples() == baseline, 'zero opacity altered editor or terminal core RGB/text') + capture(0) + command(client, source, 'WindowOpacity 100') + wait_applied(100) + require(samples() == baseline, 'restored opacity altered editor or terminal core RGB/text') + capture('100-restored') + if args.capture: + opaque, translucent, clear, restored = pixels(100), pixels(70), pixels(0), pixels('100-restored') + require(len(opaque) == len(translucent) == len(clear) == len(restored), 'capture dimensions changed') + changed = sum(abs(a - b) > 1 for a, b in zip(opaque, translucent)) / len(opaque) + clear_changed = sum(abs(a - b) > 1 for a, b in zip(opaque, clear)) / len(opaque) + restored_changed = sum(abs(a - b) > 1 for a, b in zip(opaque, restored)) / len(opaque) + require(changed > .01, 'composited pixels did not visibly change at 70%') + require(clear_changed > .01, 'composited pixels did not visibly change at 0%') + require(restored_changed < .02, 'restored opaque pixels differ from original') + report['opaque_glyph_pixels'] = {} + colors = {marker: cells[0][1]['fg']['rgb'] for marker, cells in baseline.items()} + colors['plain text and block cursor'] = cursor_ink + for marker, color in colors.items(): + # Marker colors cover comments, syntax, and ANSI terminal ink; + # the cursor's ordinary foreground becomes its solid fill. + rgb = bytes(color) + positions = [at for at in range(0, len(opaque), 3) if opaque[at:at + 3] == rgb] + require(len(positions) >= 10, 'too few full-coverage glyph pixels for ' + marker) + counts = {str(percent): sum(frame[at:at + 3] == rgb for at in positions) + for percent, frame in ((70, translucent), (0, clear))} + for percent, count in counts.items(): + require(count >= 10 and count / len(positions) >= .95, + f'foreground faded at {percent}% for {marker}: {count}/{len(positions)} pixels') + report['opaque_glyph_pixels'][marker] = {'rgb': list(rgb), 'baseline': len(positions), **counts} + width, height = map(int, subprocess.check_output( + ['magick', 'identify', '-format', '%w %h', str(root / 'window-opacity-100.png')]).split()) + cell_width = width // cursor_frame['cols'] + cell_height = height // cursor_frame['rows'] + # These are the deliberate Forge fixture's chrome colors. Restrict + # each mask spatially to exclude matching glyph ink elsewhere. + masks = { + 'horizontal rules': ((71, 82, 99), lambda x, y: x >= cell_width * 2), + 'column spine': ((71, 82, 99), lambda x, y: x < 2 and y >= cell_height * 3), + 'scrollbar thumb': ((115, 128, 146), lambda x, y: x < cell_width and y >= cell_height * 3), + 'scrollbar track': ((26, 28, 33), lambda x, y: x < cell_width and y >= cell_height * 3), + 'gutter sliver': ((12, 12, 14), lambda x, y: cell_width // 2 < x < cell_width and y >= cell_height * 3), + } + report['translucent_chrome_pixels'] = {} + for name, (color, region) in masks.items(): + rgb = bytes(color) + positions = [at for at in range(0, len(opaque), 3) + if opaque[at:at + 3] == rgb and region(at // 3 % width, at // 3 // width)] + require(len(positions) >= 20, 'too few chrome pixels for ' + name) + counts = {str(percent): sum(any(abs(frame[at + ch] - rgb[ch]) > 1 for ch in range(3)) + for at in positions) + for percent, frame in ((70, translucent), (0, clear))} + for percent, count in counts.items(): + require(count / len(positions) >= .95, + f'chrome stayed opaque at {percent}% for {name}: only {count}/{len(positions)} changed') + report['translucent_chrome_pixels'][name] = {'baseline': len(positions), **counts} + report.update(composited_rgb_fraction_changed=changed, + clear_background_rgb_fraction_changed=clear_changed, + restored_rgb_fraction_changed=restored_changed) + multipliers = re.findall(r'wp_alpha_modifier_surface_v1[^\n]*\.set_multiplier\((\d+)\)', + log.read_text(errors='replace')) + require(all(int(value) == 0xffffffff for value in multipliers), + 'whole-window alpha modifier would fade foreground text') + require('WindowOpacity: 100%\n' in output('Config'), '100% opacity not restored') + require(source_path.read_bytes() == original == client.read(f'/self/pane/{source}/body'), + 'source contents changed') + client.write(f'/self/pane/{terminal}/pty/data', b'exit\r') + report.update(source_unchanged=True, core_rgb_and_text_unchanged=True, + invalid_value_ignored=True, help_updated=True, restored_opaque=True) + (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('binary', type=Path) + parser.add_argument('output', type=Path) + parser.add_argument('--capture', action='store_true', help='capture only the owned Niri window using grim') + run(parser.parse_args()) |
