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()