#!/usr/bin/env python3 """Review native palettes, compact tags, sized fonts and pets in an owned SDL session.""" import argparse import hashlib import json import os from pathlib import Path import subprocess import time from fs import session from ui_review import capture, command, panes, require, wait_for THEMES = ('forge', 'lagoon', 'solarium', 'spectrum', 'harvest', 'clay') NOTES = b'''Pardes / workspace review New belongs to the column. Tty opens a terminal. Mode switches its input mode. Font MartianMono-NrRg:18 TaglineSize 80 Native palettes forge familiar charcoal lagoon oceanic blue solarium quiet solar tones spectrum vivid syntax harvest warm earth clay soft rose and pine Pet cat / Pet frog / Pet off Companions stay in unused workspace-tag space. ''' PREFERENCE_NOTES = b'''Pardes / contrast studies Comments carry their weight. Line numbers stay in the background. Current line: bold quiet ink, no bright block. Forge: neutral, near-black charcoal Theme forge Theme forge_black Theme forge_soft Orchard: crisp woodland color Theme orchard Theme orchard_black Dusk: intentionally soft and warm Theme dusk ThemeSel: Pardes themes come first. ''' def run(args): root = args.output.resolve() root.mkdir(parents=True, mode=0o700, exist_ok=True) root.chmod(0o700) config = root / 'config/pardes' config.mkdir(parents=True, exist_ok=True) (config / 'init').write_text('Shell /bin/sh\nTheme forge\nFont MartianMono-NrRg:18\nTaglineSize 80\n') source_path = args.project.resolve() / 'gossip/src/crds_value.rs' original = source_path.read_bytes() name = 'appearance-gallery-' + str(os.getpid()) launch = ['-c', 'stty cols 150 rows 48; exec "$@"', name, str(args.binary.resolve()), '--9p=' + name, str(source_path)] inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CAPTURE_DIR': str(root), 'PARDES_TEST_COLS': '150', 'PARDES_TEST_ROWS': '48', 'SDL_VIDEODRIVER': 'x11'} if args.live_window: inherited = {'SDL_VIDEODRIVER': 'x11'} handles = {} report = {'themes': [], 'pets': [], 'os_windows': []} with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=inherited, terminal=handles) as (client, _): source = next(serial for serial, tag in panes(client).items() if str(source_path) in tag) before = set(panes(client)) command(client, source, 'Newcol') # empty, its tag with the keyboard right = int(client.read('/pane/new')) client.write(f'/pane/{right}/name', f'{root / "Review"}\n'.encode()) client.write(f'/pane/{right}/body', PREFERENCE_NOTES if args.theme_selector else NOTES, truncate=True) client.write(f'/pane/{right}/dirty', b'0\n') before = set(panes(client)) command(client, right, 'Tty') terminal = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'terminal missing') client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; printf '\\033[2J\\033[H\\033[1;36mPardes workspace\\033[0m\\n\\n'; printf 'Agave source: read-only\\nTerminal: ready\\n\\n'; printf '\\033[32mSmall touches. Same workflow.\\033[0m\\n'\r") require(wait_for(lambda: b'Small touches. Same workflow.' in client.read(f'/pane/{terminal}/body')), 'terminal output missing') def config_report(): before = set(panes(client)) command(client, right, 'Config') pane = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'Config output missing') text = client.read(f'/pane/{pane}/body').decode() client.write(f'/pane/{pane}/exec', b'Del\n') return text settings = config_report() require('Font effective size 18.00 pixels' in settings, 'configured font size not applied: ' + settings) require('TaglineSize 80' in settings, 'configured tag size missing') (root / 'effective-config.txt').write_text(settings) report['font_effective_pixels'] = 18 report['tagline_percent'] = 80 for serial, tag in panes(client).items(): require(' New ' not in tag, 'pane still contains New') require('Newtty' not in tag, 'old terminal name remains') require('Mode' in panes(client)[terminal], 'terminal mode action missing') def owned_window(): result = subprocess.run(['xdotool', 'search', '--onlyvisible', '--pid', str(handles['pid'])], text=True, capture_output=True) ids = result.stdout.split() if not ids: return None window = ids[0] actual_pid = subprocess.check_output(['xdotool', 'getwindowpid', window], text=True).strip() require(actual_pid == str(handles['pid']), 'OS capture window is not owned') return window window = require(wait_for(owned_window), 'owned SDL window missing') subprocess.run(['xdotool', 'windowsize', window, '1440', '960'], check=True) def take_frame(label): capture(client, root, label, not args.live_window) if not args.live_window: return window = require(owned_window(), 'owned SDL window disappeared') subprocess.run(['magick', 'import', '-window', window, str(root / (label + '.png'))], check=True) if window not in report['os_windows']: report['os_windows'].append(window) for theme in args.themes: command(client, right, 'Theme ' + theme) if args.theme_selector: # Keep both quiet active gutter ink and vivid comments in view. client.write(f'/pane/{source}/look', f'{source_path}:117\n'.encode()) take_frame('classic-' + theme) if args.theme_selector: frame = client.screen() row = frame['cursor']['y'] * frame['cols'] gutter = frame['styles'][frame['cells'][row + 2][1]] margin = frame['styles'][frame['cells'][row + 1][1]] require(gutter['bg'] == margin['bg'], 'active line number has a background block in ' + theme) require(gutter['bold'], 'active line number is not bold in ' + theme) previous = frame['styles'][frame['cells'][row - frame['cols'] + 2][1]] require(not previous['bold'], 'inactive line number is bold in ' + theme) active_tag = frame['styles'][frame['cells'][2 * frame['cols'] + 2][1]] inactive_tag = frame['styles'][frame['cells'][2 * frame['cols'] + frame['cols'] // 2 + 2][1]] def ink_polarity(style): return sum(style['fg']['rgb']) - sum(style['bg']['rgb']) require(ink_polarity(active_tag) * ink_polarity(inactive_tag) > 0, 'focused tag inverts the inactive tag in ' + theme) report['active_gutter_bold_without_block'] = True report['focus_tags_without_inversion'] = True report['themes'].append(theme) command(client, right, 'Theme forge') if args.theme_selector: command(client, terminal, 'Collapse') before = set(panes(client)) command(client, right, 'ThemeSel') selector = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'ThemeSel missing') body = client.read(f'/pane/{selector}/body').decode() native, legacy = body.split('# Legacy and imported themes', 1) for theme in ('forge_black', 'forge_soft', 'orchard_black'): require('Theme ' + theme + '\n' in native, 'native contrast variant below legacy heading') require('Theme helix\n' in legacy, 'legacy theme section missing') require('Theme orchard' in ''.join(c[0] for c in client.screen()['cells']), 'ThemeSel did not open at native first entry') take_frame('theme-selector') report['native_themes_first'] = True client.write(f'/pane/{selector}/exec', b'Del\n') command(client, terminal, 'Collapse') for pet in (() if args.skip_pets else ('cat', 'frog', 'off')): command(client, right, 'Pet ' + pet) settings = config_report() require('Pet: ' + pet + '\n' in settings, 'pet setting not applied') # Returning focus to the source closes all transient report state. client.write(f'/pane/{source}/look', f'{source_path}:1\n'.encode()) take_frame('pet-' + pet) if pet != 'off': frame = client.screen() original_tag = frame['cells'][:frame['cols']] time.sleep(1) take_frame('pet-' + pet + '-later') frame = client.screen() require(original_tag == frame['cells'][:frame['cols']], 'pet animation changed tag text or styles') def band_digest(label): pixels = subprocess.check_output(['magick', str(root / (label + '.png')), '-crop', '1500x20+0+0', '-depth', '8', 'RGB:-']) return hashlib.sha256(pixels).hexdigest() require(band_digest('pet-' + pet) != band_digest('pet-' + pet + '-later'), 'pet did not animate in workspace tag') report['pets'].append(pet) require(client.read(f'/pane/{source}/body') == original == source_path.read_bytes(), 'Agave source changed') report['source_unchanged'] = True client.write(f'/pane/{terminal}/pty/data', b'exit\r') (root / 'report.json').write_text(json.dumps(report, indent=2)) 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('--project', required=True, type=Path) parser.add_argument('--live-window', action='store_true', help='capture the owned OS window without offscreen test mode') parser.add_argument('--themes', nargs='+', default=THEMES, help='themes to compare in order') parser.add_argument('--skip-pets', action='store_true', help='review palettes without repeating pet animation tests') parser.add_argument('--theme-selector', action='store_true', help='capture native-first picker and focused source gutter') run(parser.parse_args())