a73x

test/native_theme_config.py

Ref:   Size: 13.7 KiB   History

#!/usr/bin/env python3
"""Theme file/config/CLI acceptance through saved sessions and retained pixels."""
import argparse
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys

sys.dont_write_bytecode = True
from native_fonts import write_config
from native_lifecycle import LifecycleRig, start_workspace
from native_resize import by_id
from native_theme import check_samples, edge_sample
from native_tiling import eventually, require

DARK = {'background': '19232d', 'foreground': 'e8dcc8', 'cursor-color': 'efb456',
        'palette': {i: f'{40+i*10:02x}{60+i*7:02x}{80+i*5:02x}' for i in range(16)}}
LIGHT = {'background': 'faf1df', 'foreground': '263340', 'cursor-color': '874329',
         'palette': {i: f'{30+i*8:02x}{50+i*5:02x}{70+i*6:02x}' for i in range(16)}}
DARK['palette'].update({200: 'b8569a', 255: 'cbd5dd'})
LIGHT['palette'].update({200: '874780', 255: '475967'})


def theme_text(theme):
    return '# Ghostty color syntax\n' + ''.join(f'{k} = #{theme[k]}\n' for k in
        ('background', 'foreground', 'cursor-color')) + ''.join(
        f'palette = {i}={color}\n' for i, color in theme['palette'].items())


def specimen(rig, refs):
    for pane_id in refs:
        rig.focus(pane_id)
        text = '\\033[0m\\033[?25h\\033[2J\\033[HTHEME COLORS'
        for i in range(16):
            text += f'\\033[{3+i//8};{1+4*(i%8)}H\\033[48;5;{i}m   \\033[0m'
        for col, index in ((1, 17), (5, 200), (9, 255)):
            text += f'\\033[6;{col}H\\033[48;5;{index}m   \\033[0m'
        text += ('\\033[7;1H\\033[7m   \\033[0m'
                 '\\033[8;1H\\033[48;2;18;52;86m   \\033[0m'
                 '\\033[10;1HTHEME-CONFIG-READY\\033[12;2H')
        rig.shell("export PS1=''; printf '%b' " + shlex.quote(text))
        rig.wait_state(lambda s: 'THEME-CONFIG-READY' in by_id(s)[pane_id]['painted_text'])


def colors(rig, theme):
    state = rig.state()
    samples = []
    for pane in state['panes']:
        require(pane['cols'] >= 32 and pane['rows'] >= 12, 'theme specimen does not fit pane')
        rect = pane['content']
        cw, ch = state['cell_w'], state['cell_h']
        def cell(label, col, row, color):
            return label, rect['x']+(col+.5)*cw, rect['y']+(row+.5)*ch, color
        samples.append(edge_sample('terminal background', rect, theme['background']))
        for i in range(16):
            samples.append(cell(f'ANSI {i}', 4*(i%8), 2+i//8, theme['palette'][i]))
        for col, color in ((0, '00005f'), (4, theme['palette'][200]), (8, theme['palette'][255])):
            samples.append(cell('extended palette', col, 5, color))
        samples += [cell('inverse foreground', 0, 6, theme['foreground']),
                    cell('explicit RGB', 0, 7, '123456')]
        if pane['id'] == state['focus']:
            samples.append(cell('cursor', 1, 11, theme['cursor-color']))
    check_samples(rig, samples, 'loaded terminal colors including off-origin panes')
    rig.kernel_sizes()


def chrome(rig, expected):
    state = rig.state()
    samples = [edge_sample('derived header', p['header'], expected['focus'] if p['id'] == state['focus']
                           else expected['inactive']) for p in state['panes']]
    samples += [('divider', d['rect']['x'], d['rect']['y'], expected['divider']) for d in state['dividers']]
    check_samples(rig, samples, 'derived headers and dividers')
    for kind in ('picker', 'recovery'):
        rig.chord('enter' if kind == 'picker' else 'p')
        if kind == 'picker': rig.picker('hosts')
        modal = rig.wait_state(lambda s: s.get(kind))[kind]
        samples = [edge_sample('modal background', modal['rect'], expected['modal'])]
        for i, row in enumerate(modal['rows']):
            if row['rect']['h']:
                samples.append(edge_sample('modal row', row['rect'], expected['selected']
                                           if i == modal.get('selected', 0) else expected['modal']))
        check_samples(rig, samples, 'derived light/dark modal colors')
        rig.key('escape')
        if kind == 'picker': rig.key('escape')
    state = rig.state(); header = by_id(state)[state['focus']]['header']
    rig.shell("printf '\\007'")
    check_samples(rig, [edge_sample('bell', header, expected['bell'])], 'derived bell color')
    check_samples(rig, [edge_sample('focus', header, expected['focus'])], 'bell returns to focus')


def shell_ids(rig, refs):
    result = {}
    for pane_id in refs:
        rig.focus(pane_id)
        path = rig.root / f'pid-{pane_id}'
        path.unlink(missing_ok=True)
        rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
        eventually(lambda: path.exists() and path.stat().st_size, 'shell identity not written')
        result[pane_id] = path.read_text()
    return result


def rejected(rig, config, path, line, flags=()):
    write_config(rig, config)
    saved = rig.saved.read_bytes()
    env = rig.env.copy()
    env.pop('MUXG_TEST_FIFO', None)
    proc = subprocess.run([rig.muxg, *flags], env=env, capture_output=True, text=True, timeout=5)
    require(proc.returncode == 2, f'invalid theme launch accepted: {proc.stderr}')
    require(str(path) in proc.stderr and (line is None or f':{line}' in proc.stderr),
            f'wrong diagnostic source: {proc.stderr}')
    require(saved == rig.saved.read_bytes(), 'rejected config changed saved workspace')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('mux'); parser.add_argument('muxg'); parser.add_argument('--output')
    args = parser.parse_args()
    rig = LifecycleRig(args.mux, args.muxg)
    rig.env.pop('MUXG_TEST_THEME', None)
    original_scale = None
    def scale(value):
        response = subprocess.check_output(['swaymsg', '-r', 'output', args.output, 'scale', str(value)])
        require(all(x['success'] for x in json.loads(response)), 'scale request failed')
    try:
        if args.output:
            require(args.output.startswith('HEADLESS-') and rig.env['SDL_VIDEO_DRIVER'] == 'wayland',
                    'only an owned headless Wayland output is allowed')
            outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs']))
            original_scale = next(o['scale'] for o in outputs if o['name'] == args.output)
            scale(2)
        refs = start_workspace(rig)
        state = rig.state()
        check_samples(rig, [edge_sample('default background', p['content'], '101010')
                            for p in state['panes']], 'fresh no-config legacy appearance')
        ids = shell_ids(rig, refs)
        cfg = write_config(rig, '')
        themes = cfg.parent / 'themes'; themes.mkdir()
        dark = themes / 'Dark Test'; dark.write_text(theme_text(DARK))
        light = rig.root / 'Light Test'; light.write_text(theme_text(LIGHT))
        write_config(rig, 'theme = "Dark Test"\n')
        check_samples(rig, [edge_sample('no reload', p['content'], '101010')
                            for p in rig.state()['panes']], 'running client retains legacy background')
        rig.quit(); rig.launch_gui([], 'gui-dark')
        require(shell_ids(rig, refs) == ids, 'dark restart replaced shells')
        specimen(rig, refs); colors(rig, DARK)
        chrome(rig, {'focus': '64666e', 'inactive': '42484c', 'divider': '767672',
                     'modal': '31393f', 'selected': '373d47', 'bell': '46515f'})
        rig.ok('named theme loads all ANSI, extended overrides, cursor and inverse; explicit RGB stays exact')
        rig.quit()
        # CLI theme selection wins over a missing configured theme; explicit config still wins its colors.
        write_config(rig, 'background = #e9eadb\npalette = 200=778899\ntheme = Missing\n')
        flags = ['--theme', str(light), '--foreground', '142536', '--cursor-color', '#984321',
                 '--palette', '1=654321', '--palette', '255=123456']
        rig.launch_gui(flags, 'gui-light-overrides')
        merged = LIGHT | {'background': 'e9eadb', 'foreground': '142536', 'cursor-color': '984321',
                         'palette': LIGHT['palette'] | {1: '654321', 200: '778899', 255: '123456'}}
        require(shell_ids(rig, refs) == ids, 'light restart replaced shells')
        specimen(rig, refs); colors(rig, merged)
        rig.ok('absolute CLI theme, config before theme, multiple CLI palette entries and saved shells verified')
        if args.output:
            for value in (1, 1.5, 2):
                scale(value)
                rig.wait_state(lambda s: s['width'] == value*s['logical_width'])
                specimen(rig, refs); colors(rig, merged)
            rig.ok('configured colors and kernel PTYs survive NVIDIA scale round trip')
        rig.quit()
        # Config after theme overrides each color category; CLI wins over a valid selected theme.
        write_config(rig, 'theme = "' + str(light) + '"\nbackground = a1b2c3\nforeground = 213243\ncursor-color = 987654\npalette = 200=563412\n')
        rig.launch_gui([], 'gui-absolute-config')
        configured = LIGHT | {'background': 'a1b2c3', 'foreground': '213243', 'cursor-color': '987654',
                              'palette': LIGHT['palette'] | {200: '563412'}}
        specimen(rig, refs); colors(rig, configured); rig.quit()
        write_config(rig, 'theme = Dark Test\n')
        rig.launch_gui(['--theme', str(light), '--background', 'abcdef',
                        '--palette', '1=111111', '--palette', '1=654321'], 'gui-valid-theme-override')
        overridden = LIGHT | {'background': 'abcdef', 'palette': LIGHT['palette'] | {1: '654321'}}
        specimen(rig, refs); colors(rig, overridden); rig.quit()
        # HOME fallback finds named themes beside fallback config.
        home = rig.root / 'isolated-home'
        fallback = home / '.config/mux'; (fallback / 'themes').mkdir(parents=True)
        (fallback / 'config').write_text('theme = Fallback\n')
        (fallback / 'themes/Fallback').write_text(theme_text(LIGHT))
        old_home, old_xdg = rig.env.get('HOME'), rig.env['XDG_CONFIG_HOME']
        rig.env.update(HOME=str(home), XDG_CONFIG_HOME='')
        rig.launch_gui([], 'gui-fallback'); specimen(rig, refs); colors(rig, LIGHT)
        chrome(rig, {'focus': '4e506a', 'inactive': 'cfcbbf', 'divider': '9a9b97',
                     'modal': 'e0dacb', 'selected': 'b5b0b0', 'bell': '364158'})
        rig.quit()
        rig.env['XDG_CONFIG_HOME'] = old_xdg
        if old_home is not None: rig.env['HOME'] = old_home
        else: rig.env.pop('HOME', None)
        # Foreground-only theme inherits legacy background/palette and defaults cursor to final foreground.
        partial = themes / 'Partial'; partial.write_text('foreground = #abcdef\n')
        write_config(rig, 'theme = Partial\n')
        rig.launch_gui([], 'gui-partial'); specimen(rig, refs)
        state = rig.state(); focused = by_id(state)[state['focus']]; rect = focused['content']
        check_samples(rig, [edge_sample('inherited background', rect, '101010'),
                           ('default cursor', rect['x']+1.5*state['cell_w'], rect['y']+11.5*state['cell_h'], 'abcdef'),
                           ('inherited red', rect['x']+4.5*state['cell_w'], rect['y']+2.5*state['cell_h'], 'cc0000')],
                      'partial theme inherits defaults and foreground cursor')
        rig.quit()
        # Unsupported theme options must warn and have no side effects.
        unsupported = ['selection-background = #ff0000', 'font-size = 190', 'config-file = /missing',
                       'cursor-text = #000000', 'font-family = MissingFont', 'include = /missing']
        dark.write_text(theme_text(DARK) + '\n'.join(unsupported) + '\n')
        write_config(rig, 'theme = Dark Test\n')
        rig.launch_gui([], 'gui-warnings'); specimen(rig, refs); colors(rig, DARK)
        log = rig.gui_log.read_text()
        for i, entry in enumerate(unsupported):
            key = entry.split(' = ')[0]
            expected_line = len(theme_text(DARK).splitlines()) + i + 1
            matches = [line for line in log.splitlines() if key in line]
            require(any(f'{dark}:{expected_line}' in line and 'ignored' in line for line in matches),
                    f'missing file:line unsupported warning: {log}')
        rig.quit()
        for bad, line in (('background = nope\n', 1), ('# heading\npalette = 256=112233\n', 2),
                          ('background = 123456\nbackground = 234567\n', 2),
                          ('palette = 1=123456\npalette = 1=654321\n', 2)):
            dark.write_text(bad); rejected(rig, 'theme = Dark Test\n', dark, line)
        rejected(rig, 'theme = Missing\n', themes / 'Missing', None)
        for bad in ('theme = ../Dark Test\n', 'theme = nested/Name\n', 'background = #12345g\n', 'selection-background = #123456\n'):
            rejected(rig, bad, cfg, 1)
        write_config(rig, '')
        env = rig.env.copy(); env.pop('MUXG_TEST_FIFO', None)
        for flags in (['--background', 'wrong'], ['--foreground', '#12345g'],
                      ['--cursor-color', '12345'], ['--palette', '256=123456'], ['--palette', '1=nope']):
            result = subprocess.run([rig.muxg, *flags], env=env, capture_output=True, timeout=5)
            require(result.returncode == 2, f'invalid CLI color accepted: {flags}')
        write_config(rig, 'bad config\n')
        result = subprocess.run([rig.muxg, '--help'], env=rig.env, capture_output=True, timeout=5)
        require(result.returncode == 0, 'invalid config prevents help')
        rig.assert_cli_untouched()
        rig.ok('HOME lookup, unsupported warnings, fatal malformed values/paths, unchanged workspace and help verified')
        (rig.root / 'theme-config-result.json').write_text(json.dumps({'checks': rig.checkpoints, 'shell_ids': ids}, indent=2))
        print('PASS: native theme config', flush=True)
    finally:
        try:
            if original_scale is not None:
                scale(original_scale)
        finally:
            rig.close()


if __name__ == '__main__':
    main()