a73x

test/native_tmux_mouse.py

Ref:   Size: 8.0 KiB   History

#!/usr/bin/env python3
"""Wayland-only mouse selection shared by muxg and a real foot tmux client."""
import os
import shlex
import shutil
import subprocess
import sys
import time

sys.dont_write_bytecode = True
from native_resize import by_id
from native_selection import SelectionRig, cell_background
from native_tiling import eventually, require
from wayland_pointer import Pointer


def tmux(rig, socket, *args, check=True):
    return subprocess.run(['tmux', '-S', str(socket), *args], env=rig.env,
                          capture_output=True, text=True, timeout=3, check=check)


def find_pid(tree, pid):
    if tree.get('pid') == pid:
        return tree
    for node in tree.get('nodes', []) + tree.get('floating_nodes', []):
        found = find_pid(node, pid)
        if found:
            return found
    return None


def foot_point(pointer, pid, cols, rows, col, row):
    node = find_pid(pointer.query('get_tree'), pid)
    require(node is not None, 'foot window is not mapped')
    rect = node['rect']
    # The fixture removes Sway decorations and foot padding, so the terminal
    # grid starts at the client origin. Keep well away from either edge.
    return ((col + .5) * rect['width'] / cols,
            (row + .5) * rect['height'] / rows)


def pane_mode(rig, socket, target):
    return tmux(rig, socket, 'display-message', '-p', '-t', target,
                '#{pane_in_mode}').stdout.strip()


def buffer(rig, socket):
    return tmux(rig, socket, 'show-buffer', check=False).stdout


def leave_copy_mode(rig, socket, target):
    tmux(rig, socket, 'send-keys', '-t', target, '-X', 'cancel', check=False)
    eventually(lambda: pane_mode(rig, socket, target) == '0', 'tmux did not leave copy mode')


def write_tmux_config(root):
    config = root / 'tmux.conf'
    config.write_text(
        'set -g mouse on\n'
        'set -g status off\n'
        'set -g set-clipboard on\n'
        'set -g mode-keys vi\n'
        'bind -T copy-mode MouseDragEnd1Pane send -X copy-selection-no-clear\n'
        'bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-selection-no-clear\n')
    return config


def run(mux, muxg):
    if os.environ.get('MUXG_VIDEODRIVER') != 'wayland':
        print('Native tmux mouse skipped: requires MUXG_VIDEODRIVER=wayland', flush=True)
        return
    require(shutil.which('foot') and shutil.which('tmux'), 'native tmux mouse requires foot and tmux')
    pointer_binary = os.environ.get('MUXG_TEST_POINTER')
    require(pointer_binary, 'native tmux mouse requires MUXG_TEST_POINTER')

    rig = SelectionRig(mux, muxg)
    foot = None
    foot_pointer = None
    socket = rig.root / 'shared.tmux.sock'
    target = 'shared:0.0'
    try:
        sock, _ = rig.daemon('tmux')
        state = rig.launch_gui(['--sock', sock, '--session', 'tmux'], 'gui')
        pane = state['panes'][0]['id']
        config = write_tmux_config(rig.root)
        paint = ('printf "\\033[2J\\033[HROW-000 alpha alpha\\nROW-001 bravo bravo\\n'
                 'ROW-002 charlie charlie\\n"; exec sh')
        command = ('exec tmux -S ' + shlex.quote(str(socket)) + ' -f ' + shlex.quote(str(config)) +
                   ' new-session -A -s shared ' + shlex.quote(paint))
        rig.shell(command)
        state = rig.wait_state(lambda s: 'ROW-002 charlie charlie' in by_id(s)[pane]['painted_text'])
        eventually(lambda: 'ROW-001 bravo bravo' in tmux(rig, socket, 'capture-pane', '-p', '-e', '-t', target).stdout,
                   'tmux pane never painted the specimen')

        # This is a second real terminal client of the exact same tmux server.
        foot_config = rig.root / 'foot.ini'
        foot_config.write_text('[main]\npad=0x0\nfont=monospace:size=12\n')
        foot = rig.spawn(['foot', '-c', str(foot_config), '-a', 'muxg-tmux-mouse',
                          '-T', 'muxg-tmux-mouse', '-w', '960x600',
                          'tmux', '-S', str(socket), 'attach', '-t', 'shared'], 'foot')
        foot_pointer = Pointer(pointer_binary, rig.env, foot.pid)
        def foot_ready():
            node = find_pid(foot_pointer.query('get_tree'), foot.pid)
            clients = tmux(rig, socket, 'list-clients', '-F', '#{client_tty}').stdout.splitlines()
            return node is not None and len(clients) >= 2
        eventually(foot_ready, 'foot did not attach as a second tmux client', seconds=10)
        subprocess.run(['swaymsg', f'[pid={foot.pid}] floating enable, border none'], env=rig.env,
                       capture_output=True, check=True, timeout=3)

        # A normal muxg drag reaches tmux's mouse binding. Its copy-mode
        # selection remains visible in muxg and becomes the actual tmux buffer.
        subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] focus'], env=rig.env, capture_output=True, check=True)
        state = rig.state()
        before = cell_background(rig, state, pane, 10, 0)
        rig.select(pane, (8, 0), (12, 0))
        eventually(lambda: pane_mode(rig, socket, target) == '1', 'muxg drag did not enter tmux copy mode')
        eventually(lambda: 'alpha' in buffer(rig, socket), 'muxg drag did not populate tmux buffer')
        eventually(lambda: cell_background(rig, state, pane, 10, 0) != before,
                   'tmux selection was not visibly painted by muxg')
        rig.ok('ordinary muxg drag enters tmux copy mode, paints selection and copies its buffer')

        leave_copy_mode(rig, socket, target)
        state = rig.state()
        before = cell_background(rig, state, pane, 10, 1)
        subprocess.run(['swaymsg', f'[pid={foot.pid}] focus'], env=rig.env, capture_output=True, check=True)
        clients = tmux(rig, socket, 'list-clients', '-F', '#{client_termname} #{client_width} #{client_height}').stdout.splitlines()
        cols, rows = next(tuple(map(int, line.split()[1:])) for line in clients if line.startswith('foot '))
        a = foot_point(foot_pointer, foot.pid, cols, rows, 8, 1)
        b = foot_point(foot_pointer, foot.pid, cols, rows, 12, 1)
        foot_pointer.event('mousedown', *a)
        foot_pointer.event('mousemove', *b)
        foot_pointer.event('mouseup', *b)
        eventually(lambda: pane_mode(rig, socket, target) == '1', 'foot drag did not enter tmux copy mode')
        eventually(lambda: 'bravo' in buffer(rig, socket), 'foot drag did not populate shared tmux buffer')
        eventually(lambda: cell_background(rig, state, pane, 10, 1) != before,
                   'foot tmux selection did not repaint muxg')
        rig.ok('ordinary foot drag shares tmux selection state and muxg repaint')

        leave_copy_mode(rig, socket, target)
        prior = buffer(rig, socket)
        subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] focus'], env=rig.env, capture_output=True, check=True)
        state = rig.state()
        before = cell_background(rig, state, pane, 10, 0)
        a = rig.cell_point(state, pane, 8, 0)
        b = rig.cell_point(state, pane, 12, 0)
        rig.send('mouse:down,' + a + ',0,1', 'mouse:move,' + b + ',0,0',
                 'mouse:up,' + b + ',0,0')
        eventually(lambda: cell_background(rig, state, pane, 10, 0) != before,
                   'Shift muxg drag did not paint its local selection')
        rig.copied('alpha')
        time.sleep(.15)
        require(pane_mode(rig, socket, target) == '0' and buffer(rig, socket) == prior,
                'Shift muxg drag changed tmux selection or buffer')
        rig.ok('Shift muxg drag remains local and leaves shared tmux buffer unchanged')
        rig.quit()
        print('Native tmux mouse acceptance passed', flush=True)
    except BaseException:
        rig.failure_artifacts()
        raise
    finally:
        if foot_pointer is not None:
            foot_pointer.close()
        if foot is not None and foot.poll() is None:
            foot.terminate()
            try:
                foot.wait(timeout=3)
            except subprocess.TimeoutExpired:
                foot.kill()
                foot.wait(timeout=3)
        tmux(rig, socket, 'kill-server', check=False)
        rig.close()


def main():
    require(len(sys.argv) == 3, 'usage: native_tmux_mouse.py MUX MUXG')
    run(*sys.argv[1:])


if __name__ == '__main__':
    main()