summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/window_opacity.py225
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())