#!/usr/bin/python3
# SPDX-License-Identifier: LicenseRef-Proprietary
"""Configure connected Gravitas displays without replacing personal settings."""

import argparse
import copy
import fcntl
import json
import math
import os
from pathlib import Path
import re
import select
import socket
import subprocess
import sys
import tempfile
import time
import tomllib


CONFIRM_SECONDS = 15
DISPLAY_SERVICE = 'org.gnome.Mutter.DisplayConfig'
STATE_SIGNATURE = 'ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv}'
SCALES = [100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 75, 50]
MISSING = object()


def config_path():
    return (Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') /
            'gravitas/compositor.toml').resolve()


def read_config(path):
    return path.read_text() if path.exists() else Path('/usr/share/gravitas/compositor.toml').read_text()


def output_entry(parsed, name):
    outputs = parsed.get('output', [])
    if not isinstance(outputs, list) or not all(isinstance(entry, dict) for entry in outputs):
        raise ValueError('Output settings must be [[output]] tables in compositor.toml.')
    entries = [entry for entry in outputs if entry.get('name') == name]
    if len(entries) > 1:
        raise ValueError(f'{name} has duplicate output settings. Resolve those entries first.')
    return entries[0] if entries else None


def value_text(value):
    # JSON strings and finite numbers are valid TOML values for these simple settings.
    return json.dumps(value, ensure_ascii=False, allow_nan=False)


def comment_suffix(value):
    quote = None
    escaped = False
    for index, char in enumerate(value):
        if escaped:
            escaped = False
        elif quote == '"' and char == '\\':
            escaped = True
        elif quote:
            if char == quote:
                quote = None
        elif char in ('"', "'"):
            quote = char
        elif char == '#':
            return ' ' + value[index:]
    return ''


def edit_setting(text, name, key, value=MISSING):
    """Accept only a text patch whose parsed meaning equals the one requested edit."""
    before = tomllib.loads(text)
    expected = copy.deepcopy(before)
    target = expected if name is None else output_entry(expected, name)
    if target is None:
        if value is MISSING:
            return text
        target = {'name': name}
        expected.setdefault('output', []).append(target)
    if value is MISSING:
        if key not in target:
            return text
        del target[key]
    else:
        if target.get(key, MISSING) == value:
            return text
        target[key] = value

    def accepts(candidate):
        try:
            return tomllib.loads(candidate) == expected
        except tomllib.TOMLDecodeError:
            return False

    pattern = rf'(?m)^([ \t]*(?:{key}|"{key}"|\'{key}\')[ \t]*=)([^\n]*)'
    for match in re.finditer(pattern, text):
        suffix = comment_suffix(match[2])
        replacement = suffix.lstrip() if value is MISSING else match[1] + ' ' + value_text(value) + suffix
        candidate = text[:match.start()] + replacement + text[match.end():]
        if accepts(candidate):
            return candidate
    if value is not MISSING:
        line = f'{key} = {value_text(value)}\n'
        # Semantic verification rejects headers or keys inside strings and the wrong output.
        positions = [0, len(text)] + [m.end() for m in re.finditer(r'(?m)^\s*\[.+\][^\n]*\n', text)]
        for pos in positions:
            prefix = text[:pos]
            candidate = prefix + ('\n' if prefix and not prefix.endswith('\n') else '') + line + text[pos:]
            if accepts(candidate):
                return candidate
        if name is not None and output_entry(before, name) is None:
            candidate = text + ('\n' if text and not text.endswith('\n') else '') + (
                f'\n[[output]]\nname = {value_text(name)}\n{line}')
            if accepts(candidate):
                return candidate
    raise ValueError(f'Cannot safely edit {name or "desktop"} {key}; use a separate [[output]] table with single-line values in compositor.toml.')


def remove_empty_output(text, name):
    before = tomllib.loads(text)
    if output_entry(before, name) != {'name': name}:
        return text
    expected = copy.deepcopy(before)
    expected['output'].remove({'name': name})
    if not expected['output']:
        del expected['output']
    headers = list(re.finditer(r'(?m)^[ \t]*\[.+\][^\n]*(?:\n|$)', text))
    for index, match in enumerate(headers):
        end = headers[index + 1].start() if index + 1 < len(headers) else len(text)
        block = text[match.end():end]
        for field in re.finditer(r"(?m)^[ \t]*(?:name|\"name\"|'name')[ \t]*=([^\n]*)", block):
            kept = block[:field.start()] + comment_suffix(field[1]).lstrip() + block[field.end():]
            header_comment = comment_suffix(match[0]).lstrip()
            candidate = text[:match.start()] + header_comment + kept + text[end:]
            try:
                if tomllib.loads(candidate) == expected:
                    return candidate
            except tomllib.TOMLDecodeError:
                pass
    raise ValueError(f'Cannot safely remove the temporary output entry for {name}.')


def scaled_config(text, percent):
    if percent not in range(50, 401, 25):
        raise ValueError('Choose a scale from 50 to 400 in steps of 25 percent.')
    return edit_setting(text, None, 'scale', percent / 100)


def atomic_write(path, text, expected, mode=None):
    current = path.read_text() if path.exists() else None
    if current != expected:
        raise RuntimeError('Settings changed while the menu was open. Open Display Settings again.')
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(mode='w', dir=path.parent, delete=False) as stream:
        temporary = Path(stream.name)
        try:
            stream.write(text)
            stream.flush()
            os.fsync(stream.fileno())
            os.chmod(temporary, mode if mode is not None else path.stat().st_mode & 0o777 if path.exists() else 0o600)
            # Check again after writing, so an editor saving during fsync is not overwritten.
            if (path.read_text() if path.exists() else None) != expected:
                raise RuntimeError('Settings changed while saving. Open Display Settings again.')
            os.replace(temporary, path)
        finally:
            temporary.unlink(missing_ok=True)


def rollback(path, original, changed, existed, edits):
    current = path.read_text() if path.exists() else None
    if current == changed:
        if existed:
            atomic_write(path, original, current)
        else:
            path.unlink()
        return
    if current is None:
        return
    restored = current
    conflicts = []
    before = tomllib.loads(original)
    for name, key, value in reversed(edits):
        parsed = tomllib.loads(restored)
        target = parsed if name is None else output_entry(parsed, name)
        old_target = before if name is None else output_entry(before, name)
        previous = old_target.get(key, MISSING) if old_target is not None else MISSING
        actual = target.get(key, MISSING) if target is not None else MISSING
        if actual == previous:
            continue
        if actual != value:
            conflicts.append(f'{name or "desktop"} {key}')
            continue
        restored = edit_setting(restored, name, key, previous)
        if name is not None and old_target is None:
            restored = remove_empty_output(restored, name)
    if restored != current:
        atomic_write(path, restored, current)
    if conflicts:
        raise RuntimeError('Newer edits were preserved instead of reverting: ' + ', '.join(conflicts))


def watch_transaction(fd, record_path):
    record = json.loads(Path(record_path).read_text())
    result = Path(record_path).with_suffix('.result')
    with socket.socket(fileno=fd) as channel:
        # Writing and fsync can take arbitrarily long before the atomic replacement.
        # Stay armed until the parent reports that replacement or closes its endpoint.
        channel.settimeout(None)
        channel.sendall(b'R')
        keep = False
        try:
            if channel.recv(1) == b'A':
                channel.settimeout(CONFIRM_SECONDS)
                keep = channel.recv(1) == b'K'
        except (TimeoutError, ConnectionError):
            pass
        try:
            if not keep:
                rollback(Path(record['path']), record['original'], record['changed'],
                         record['existed'], record['edits'])
            result.write_text('kept' if keep else 'reverted')
        except (OSError, ValueError, RuntimeError) as error:
            result.write_text(f'Could not revert automatically: {error}. Original settings: {record_path}')
            return 1
    return 0


def choose(prompt, choices, timeout=None, custom=False):
    try:
        result = subprocess.run(
            ['fuzzel', '--dmenu', '--log-level=error', '--prompt', prompt + ' > ', '--font', 'Noto Sans:size=12',
             '--background-color', '000000f7', '--text-color', 'e7f4f2ff', '--match-color', '8ae8dfff',
             '--selection-color', '167c80ff', '--selection-text-color', 'f2f5f5ff',
             '--selection-match-color', '8ae8dfff', '--border-color', '3fb5b9ff', '--width', '72'],
            input='\n'.join(choices), text=True, capture_output=True, timeout=timeout)
    except subprocess.TimeoutExpired:
        return None
    # Fuzzel 1.9 reports Escape as 1, also used for failures. Suppress informational
    # logs above, and accept that status only when it produced neither a choice nor an error.
    if result.returncode == 2 or (result.returncode == 1 and not result.stdout.strip() and not result.stderr.strip()):
        return None
    result.check_returncode()
    selected = result.stdout.strip()
    if not custom and selected not in choices:
        raise ValueError('Choose one of the listed options.')
    return selected


def apply_change(path, original, edits, confirm=True, lock_fd=None, verify=None, confirmation=None):
    changed = original
    for name, key, value in edits:
        changed = edit_setting(changed, name, key, value)
    if changed == original:
        return False
    existed = path.exists()
    expected = original if existed else None
    if not confirm:
        backup = path.with_name(path.name + '.pre-scale')
        if existed and not backup.exists():
            with backup.open('x') as stream:
                os.chmod(backup, path.stat().st_mode & 0o777)
                stream.write(original)
        atomic_write(path, changed, expected)
        return True
    # A separate session owns the rollback deadline even if this UI crashes or loses its display.
    # The shared advisory lock also prevents a second settings process starting before rollback.
    with tempfile.TemporaryDirectory(prefix='.display-trial-', dir=path.parent) as directory:
        record_path = Path(directory) / 'transaction.json'
        record_path.write_text(json.dumps({'path': str(path), 'original': original, 'changed': changed,
                                          'existed': existed, 'edits': edits}))
        os.chmod(record_path, 0o600)
        parent, child = socket.socketpair()
        with parent, child:
            process = subprocess.Popen([sys.executable, __file__, '--watch-transaction', str(child.fileno()),
                                        str(record_path)],
                                       pass_fds=(child.fileno(),) + ((lock_fd,) if lock_fd is not None else ()),
                                       start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            child.close()
            parent.settimeout(3)
            failure = None
            try:
                if parent.recv(1) != b'R':
                    raise RuntimeError('The display rollback guard did not start.')
                atomic_write(path, changed, expected)
                started = time.monotonic()
                parent.sendall(b'A')
                if verify is not None:
                    verify()
                remaining = max(0.01, CONFIRM_SECONDS - (time.monotonic() - started))
                selected = (confirmation(remaining) if confirmation else
                            choose(f'Keep these display settings? Revert in {math.ceil(remaining)}s',
                                   ['Revert changes', 'Keep these settings'], timeout=remaining))
                if selected == 'Keep these settings' and verify is not None:
                    verify()
                parent.sendall(b'K' if selected == 'Keep these settings' else b'R')
            except (BrokenPipeError, ConnectionResetError):
                pass  # The independent deadline already reverted the trial.
            except BaseException as error:
                failure = error
            finally:
                parent.close()
                try:
                    process.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    process.kill()
                    process.wait()
                    failure = RuntimeError('The display rollback guard stopped responding.')
            result_path = record_path.with_suffix('.result')
            result = result_path.read_text() if result_path.exists() else 'The display rollback guard stopped unexpectedly.'
            if result not in ('kept', 'reverted'):
                # Keep a private recovery copy rather than losing it with the temporary journal.
                recovery = path.with_name(path.name + '.display-recovery')
                atomic_write(recovery, original, recovery.read_text() if recovery.exists() else None, mode=0o600)
                try:
                    rollback(path, original, changed, existed, edits)
                except (OSError, ValueError, RuntimeError):
                    pass  # Both the guard's error and the recovery path are surfaced below.
                raise RuntimeError(f'{result} Recovery copy: {recovery}') from failure
            if failure is not None:
                raise failure
            return result == 'kept'


def variant(properties, name, signature, default=None):
    value = properties.get(name)
    return value['data'] if isinstance(value, dict) and value.get('type') == signature else default


def parse_state(reply):
    try:
        if reply['type'] != STATE_SIGNATURE or len(reply['data']) != 4:
            raise ValueError('Unexpected DisplayConfig reply signature.')
        serial, physical, logical, properties = reply['data']
        outputs = []
        for names, modes, props in physical:
            name = names[0]
            layout = next((row for row in logical if any(m[0] == name for m in row[5])), None)
            if layout is None:
                continue
            available = []
            for identifier, width, height, hz, _, _, mode_props in modes:
                if type(width) is not int or type(height) is not int or width <= 0 or height <= 0 or not math.isfinite(hz) or hz <= 0:
                    raise ValueError('Invalid display mode.')
                available.append({'id': f'{width}x{height}@{hz:.3f}', 'width': width, 'height': height,
                                  'hz': hz, 'current': variant(mode_props, 'is-current', 'b', False),
                                  'preferred': variant(mode_props, 'is-preferred', 'b', False)})
            current = next((mode for mode in available if mode['current']), None)
            if current is None or not math.isfinite(layout[2]) or layout[2] <= 0:
                raise ValueError('A connected monitor has no current mode or scale.')
            if not isinstance(name, str) or any(ord(c) < 32 for c in name):
                raise ValueError('Invalid connector name.')
            outputs.append({'name': name, 'identity': names, 'label': variant(props, 'display-name', 's', name),
                            'modes': available, 'current': current, 'x': layout[0], 'y': layout[1],
                            'scale': layout[2], 'transform': layout[3],
                            'vrr_supported': variant(props, 'gravitas-vrr-supported', 'b', False),
                            'vrr_policy': variant(props, 'gravitas-vrr-policy', 's')})
        if not outputs or len({o['name'] for o in outputs}) != len(outputs):
            raise ValueError('No usable connected displays were reported.')
        return {'serial': serial, 'outputs': outputs,
                'mode_supported': variant(properties, 'gravitas-mode-change-supported', 'b', False)}
    except (KeyError, TypeError, IndexError, StopIteration) as error:
        raise ValueError('Invalid display information from Gravitas.') from error


def current_state():
    result = subprocess.run(['busctl', '--user', '--auto-start=no', '--timeout=2', '--json=short', 'call',
                             DISPLAY_SERVICE, '/org/gnome/Mutter/DisplayConfig', DISPLAY_SERVICE,
                             'GetCurrentState'], capture_output=True, text=True, timeout=3)
    if result.returncode:
        raise RuntimeError('Cannot query connected displays. Run Display Settings inside the Gravitas session.')
    if len(result.stdout) > 1024 * 1024:
        raise ValueError('Display information is too large.')
    return parse_state(json.loads(result.stdout))


def monitor_summary(output):
    mode = output['current']
    label = ' '.join(str(output['label']).split())[:50]
    return (f'{output["name"]} — {label} · {mode["width"]}×{mode["height"]} '
            f'{mode["hz"]:g} Hz · {output["scale"] * 100:g}% · ({output["x"]}, {output["y"]})')


def logical_size(output):
    width, height = output['current']['width'], output['current']['height']
    if output['transform'] in (1, 3, 5, 7):
        width, height = height, width
    return math.floor(width / output['scale'] + 0.5), math.floor(height / output['scale'] + 0.5)


def relative_position(output, other, direction):
    width, height = logical_size(output)
    other_width, other_height = logical_size(other)
    return {'Left of': [other['x'] - width, other['y']],
            'Right of': [other['x'] + other_width, other['y']],
            'Above': [other['x'], other['y'] - height],
            'Below': [other['x'], other['y'] + other_height]}[direction]


def canonical_mode(text):
    match = re.fullmatch(r'(\d+)x(\d+)@([0-9]+(?:\.[0-9]+)?)', text)
    if match is None:
        raise ValueError('Mode must be WIDTHxHEIGHT@HZ from the advertised monitor modes.')
    width, height = int(match[1]), int(match[2])
    hz = float(match[3])
    if not 0 < width <= 65535 or not 0 < height <= 65535 or not math.isfinite(hz) or hz <= 0:
        raise ValueError('Invalid display mode.')
    return f'{width}x{height}@{hz:.3f}'


def parse_position(text):
    if not re.fullmatch(r'-?\d+,-?\d+', text):
        raise ValueError('Position must be X,Y in logical pixels.')
    position = [int(n) for n in text.split(',')]
    if any(abs(n) > 100000 for n in position):
        raise ValueError('Position must be between -100000 and 100000 logical pixels.')
    return position


def layout_edits(state, name, position):
    # Automatic placement depends on connector order. Pin the other monitors where they are,
    # otherwise moving the first monitor also moves the one used as the reference.
    return [(o['name'], 'position', position if o['name'] == name else [o['x'], o['y']])
            for o in state['outputs']]


def wait_for_settings(edits, before, previous_state):
    deadline = time.monotonic() + 3
    while True:
        state = current_state()
        outputs = {o['name']: o for o in state['outputs']}
        previous = {o['name']: o for o in previous_state['outputs']}
        if outputs.keys() != previous.keys() or any(outputs[name]['identity'] != previous[name]['identity'] for name in outputs):
            raise RuntimeError('Connected displays changed during the trial; reverting.')
        matches = True
        root_scale = next((v for n, k, v in edits if n is None and k == 'scale'), before.get('scale', 1))
        for name, key, value in edits:
            if name is None:
                for output in outputs.values():
                    old = output_entry(before, output['name']) or {}
                    matches &= math.isclose(output['scale'], root_scale * old.get('scale', 1), abs_tol=0.01)
                continue
            output = outputs.get(name)
            if output is None:
                raise RuntimeError(f'{name} disconnected during the trial; reverting.')
            if key == 'mode':
                matches &= output['current']['id'] == value
            elif key == 'scale':
                matches &= math.isclose(output['scale'], root_scale * value, abs_tol=0.01)
            elif key == 'position':
                matches &= [output['x'], output['y']] == value
            elif key == 'vrr':
                matches &= output['vrr_policy'] == value
        if matches:
            return
        if time.monotonic() >= deadline:
            raise RuntimeError('Gravitas did not apply the requested display settings; reverting.')
        time.sleep(0.1)


def monitor_menu(state, output, parsed):
    name = output['name']
    entry = output_entry(parsed, name) or {}
    options = ['Size of text and controls', 'Arrange this display']
    if state['mode_supported']:
        options.insert(0, 'Resolution and refresh rate')
    if output['vrr_supported']:
        options.append(f'Variable refresh rate ({output["vrr_policy"] or entry.get("vrr", "off")})')
    selected = choose(name, options + ['Back'])
    if selected == 'Resolution and refresh rate':
        choices = {}
        for mode in output['modes']:
            suffix = ' · current' if mode['current'] else ' · preferred' if mode['preferred'] else ''
            choices[f'{mode["width"]} × {mode["height"]} · {mode["hz"]:g} Hz{suffix}'] = mode['id']
        picked = choose(f'{name} resolution and refresh', list(choices))
        return [(name, 'mode', choices[picked])] if picked else []
    if selected == 'Size of text and controls':
        picked = choose(f'{name} scale ({output["scale"] * 100:g}%)', [f'{n}%' for n in SCALES])
        return [(name, 'scale', int(picked[:-1]) / 100 / parsed.get('scale', 1))] if picked else []
    if selected == 'Arrange this display':
        others = [other for other in state['outputs'] if other['name'] != name]
        choices = {f'{direction} {other["name"]}': relative_position(output, other, direction)
                   for other in others for direction in ('Left of', 'Right of', 'Above', 'Below')}
        choices['At origin (0, 0)'] = [0, 0]
        picked = choose(f'{name} position in logical pixels', list(choices) + ['Exact coordinates…'])
        if picked == 'Exact coordinates…':
            entered = choose('Position X,Y (logical pixels; negatives allowed)',
                             [f'{output["x"]},{output["y"]}'], custom=True)
            return layout_edits(state, name, parse_position(entered)) if entered else []
        return layout_edits(state, name, choices[picked]) if picked else []
    if selected and selected.startswith('Variable refresh rate'):
        policies = {'Off': 'off', 'Fullscreen only': 'fullscreen-only', 'Always on': 'on'}
        picked = choose(f'{name} variable refresh rate', list(policies))
        return [(name, 'vrr', policies[picked])] if picked else []
    return []


def json_confirmation(remaining):
    print(json.dumps({'confirm_seconds': remaining}), flush=True)
    deadline = time.monotonic() + remaining
    descriptor = sys.stdin.fileno()
    blocking = os.get_blocking(descriptor)
    answer = bytearray()
    try:
        os.set_blocking(descriptor, False)
        while len(answer) < 16:
            timeout = deadline - time.monotonic()
            if timeout <= 0 or not select.select([descriptor], [], [], timeout)[0]:
                break
            try:
                chunk = os.read(descriptor, 16 - len(answer))
            except BlockingIOError:
                continue
            if not chunk:
                break
            answer.extend(chunk)
            if b'\n' in answer:
                return ('Keep these settings' if answer.partition(b'\n')[0] == b'keep'
                        else 'Revert changes')
    finally:
        os.set_blocking(descriptor, blocking)
    return 'Revert changes'


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--scale', type=int, metavar='PERCENT', help='desktop scale, or effective scale with --output')
    parser.add_argument('--output', metavar='CONNECTOR')
    parser.add_argument('--mode', metavar='WIDTHxHEIGHT@HZ', help='one of the connected monitor modes')
    parser.add_argument('--position', metavar='X,Y', help='logical coordinates; negative positions are supported')
    parser.add_argument('--vrr', choices=['off', 'on', 'fullscreen-only'])
    parser.add_argument('--json-trial', action='store_true', help='JSON confirmation over stdin/stdout for Settings')
    parser.add_argument('--status', action='store_true', help='print desktop-wide scale')
    parser.add_argument('--list', action='store_true', help='print connected display information as JSON')
    parser.add_argument('--watch-transaction', nargs=2, help=argparse.SUPPRESS)
    args = parser.parse_args()
    if args.json_trial and (not args.output or not any(v is not None for v in (args.mode, args.position, args.vrr, args.scale))):
        parser.error('--json-trial requires --output and a display change')
    if args.watch_transaction:
        raise SystemExit(watch_transaction(int(args.watch_transaction[0]), args.watch_transaction[1]))
    if any(value is not None for value in (args.mode, args.position, args.vrr)) and not args.output:
        parser.error('--mode, --position and --vrr require --output')
    if args.list:
        print(json.dumps(current_state(), ensure_ascii=False))
        return
    path = config_path()
    original = read_config(path)
    if args.status:
        print(f'{tomllib.loads(original).get("scale", 1) * 100:g}%')
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.with_name(path.name + '.display-lock').open('a') as lock:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError as error:
            raise RuntimeError('Display Settings is already open.') from error
        if args.scale is not None and args.output is None:
            scaled_config(original, args.scale)
            apply_change(path, original, [(None, 'scale', args.scale / 100)], confirm=False)
            print(f'Display scale set to {args.scale}%. Gravitas applies it immediately.')
            return
        while True:
            original = read_config(path)
            parsed = tomllib.loads(original)
            scale = parsed.get('scale', 1)
            if type(scale) not in (int, float) or not math.isfinite(scale) or not 0.5 <= scale <= 4:
                raise ValueError('The desktop scale in compositor.toml must be between 0.5 and 4.')
            state = current_state()
            if args.output:
                output = next((o for o in state['outputs'] if o['name'] == args.output), None)
                if output is None:
                    raise ValueError(f'{args.output} is not connected.')
                edits = []
                if args.mode:
                    mode = canonical_mode(args.mode)
                    if not state['mode_supported'] or mode not in [m['id'] for m in output['modes']]:
                        raise ValueError('Choose an advertised mode from --list; this session must support mode changes.')
                    edits.append((args.output, 'mode', mode))
                if args.scale is not None:
                    scaled_config(original, args.scale)
                    edits.append((args.output, 'scale', args.scale / 100 / parsed.get('scale', 1)))
                if args.position:
                    edits.extend(layout_edits(state, args.output, parse_position(args.position)))
                if args.vrr:
                    if not output['vrr_supported']:
                        raise ValueError('This monitor does not report variable refresh support.')
                    edits.append((args.output, 'vrr', args.vrr))
                if not edits:
                    edits = monitor_menu(state, output, parsed)
            else:
                choices = {monitor_summary(output): output for output in state['outputs']}
                picked = choose('Display Settings', list(choices) + ['Desktop-wide scale', 'Done'])
                if not picked or picked == 'Done':
                    return
                if picked == 'Desktop-wide scale':
                    scale = choose('Desktop-wide scale (multiplies each display)', [f'{n}%' for n in SCALES])
                    edits = [(None, 'scale', int(scale[:-1]) / 100)] if scale else []
                else:
                    edits = monitor_menu(state, choices[picked], parsed)
            if edits:
                # Re-query before changing anything, so a cable change invalidates a stale menu.
                if current_state() != state:
                    raise RuntimeError('Displays changed while the menu was open. Open Display Settings again.')
                kept = apply_change(path, original, edits, lock_fd=lock.fileno(),
                                    verify=lambda: wait_for_settings(edits, parsed, state),
                                    confirmation=json_confirmation if args.json_trial else None)
                if args.json_trial:
                    print(json.dumps({'result': 'kept' if kept else 'reverted or unchanged'}), flush=True)
                else:
                    print('Display settings kept.' if kept else 'Display settings reverted or unchanged.')
            if args.output:
                return


if __name__ == '__main__':
    try:
        main()
    except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
        print(f'Display settings: {error}', file=sys.stderr)
        if len(sys.argv) == 1:
            try:
                choose(str(error), ['Close'])
            except (OSError, ValueError, subprocess.SubprocessError):
                pass
        raise SystemExit(1)
