summaryrefslogtreecommitdiff
path: root/test/window_opacity.py
blob: 0253e15c88bc07dde143f856bf45b9ebc79754a7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
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'/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'/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'/pane/{pane}/body').decode()
            client.write(f'/pane/{pane}/exec', 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('DumpConfig')
        require('WindowOpacity: 100%\n' in settings and 'FgOpacity' not in settings,
                'initial DumpConfig 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('DumpConfig')),
                    '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('DumpConfig')
        require('WindowOpacity: 70%\n' in settings, '70% opacity not applied: ' + settings)
        command(client, source, 'WindowOpacity 101')
        require('WindowOpacity: 70%\n' in output('DumpConfig'), '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('DumpConfig'), '100% opacity not restored')
        require(source_path.read_bytes() == original == client.read(f'/pane/{source}/body'),
                'source contents changed')
        client.write(f'/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())