6849 lines
271 KiB
Python
6849 lines
271 KiB
Python
#!/usr/bin/env python3
|
|
# retoor <retoor@molodetz.nl>
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import codecs
|
|
import collections
|
|
import contextlib
|
|
import difflib
|
|
import getpass
|
|
import glob
|
|
import hashlib
|
|
import importlib.util
|
|
import hmac
|
|
import html
|
|
import io
|
|
import itertools
|
|
import json
|
|
import operator
|
|
import os
|
|
import queue
|
|
import random
|
|
import re
|
|
import shutil
|
|
import shlex
|
|
import signal
|
|
import socket
|
|
import sqlite3
|
|
import ssl
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
import weakref
|
|
import zipfile
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
try:
|
|
import readline
|
|
except ImportError:
|
|
readline = None
|
|
|
|
try:
|
|
import fcntl
|
|
except ImportError:
|
|
fcntl = None
|
|
|
|
try:
|
|
import pty
|
|
import select
|
|
import termios
|
|
import tty
|
|
except ImportError:
|
|
pty = select = termios = tty = None
|
|
|
|
PLEXER_DIR_NAME = "plexer"
|
|
PLEXER_PANE_ENV = "TAI_PLEXER_PANE"
|
|
PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET"
|
|
PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE"
|
|
PLEXER_RECV_MAX = 1 << 20
|
|
|
|
|
|
def plexer_dir(home):
|
|
path = os.path.join(home, PLEXER_DIR_NAME)
|
|
os.makedirs(path, exist_ok=True)
|
|
try:
|
|
os.chmod(path, 0o700)
|
|
except OSError:
|
|
pass
|
|
return path
|
|
|
|
|
|
class PlexerBackend:
|
|
def run(self):
|
|
raise NotImplementedError
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Mplex: tai's own terminal multiplexer. One server process per TAI_HOME owns
|
|
# every pty, window layout and scrollback; clients attach over a Unix socket
|
|
# and receive finished frames. Key bindings, command names and option names
|
|
# follow tmux 3.6 (the default tables below were dumped from `tmux list-keys`),
|
|
# and ~/.tai/mplex.toml rebinds or retunes any of them. Spec: mplex.md.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
MPLEX_SOCKET_NAME = "server.sock"
|
|
MPLEX_LOCK_NAME = "server.lock"
|
|
MPLEX_LOG_NAME = "server.log"
|
|
MPLEX_CONFIG_NAME = "mplex.toml"
|
|
MPLEX_MAGIC = b"\x01"
|
|
MPLEX_FRAME_MAX = 1 << 22
|
|
MPLEX_CLIENT_OUT_MAX = 1 << 22
|
|
MPLEX_PANE_IN_MAX = 1 << 22
|
|
MPLEX_MAX_SESSIONS = 64
|
|
MPLEX_MAX_WINDOWS = 256
|
|
MPLEX_MAX_PANES = 256
|
|
MPLEX_MAX_CLIENTS = 32
|
|
MPLEX_MESSAGES_MAX = 200
|
|
MPLEX_HISTORY_CAP = 200000
|
|
MPLEX_BUFFERS_CAP = 100
|
|
MPLEX_PROMPT_HISTORY_CAP = 100
|
|
MPLEX_COMMAND_DEPTH_MAX = 16
|
|
MPLEX_SHELL_TIMEOUT = 5.0
|
|
MPLEX_SHELL_OUTPUT_MAX = 65536
|
|
MPLEX_PASTE_MAX = 1 << 22
|
|
MPLEX_RENDER_INTERVAL = 0.012
|
|
MPLEX_REAP_KILL_AFTER = 2.0
|
|
MPLEX_IDLE_EXIT = 15.0
|
|
MPLEX_LOOP_ERRORS_MAX = 50
|
|
MPLEX_LOG_MAX = 1 << 24
|
|
MPLEX_CONFIG_MAX = 1 << 20
|
|
MPLEX_UNSUPPORTED = ("display-menu", "display-popup", "customize-mode", "lock-server", "lock-session", "lock-client", "pipe-pane", "join-pane", "move-pane", "link-window", "unlink-window", "respawn-pane", "respawn-window", "resize-window", "set-hook", "show-hooks", "wait-for", "server-access", "choose-tree-mouse")
|
|
|
|
# (table, key, command, repeat, note): tmux 3.6 defaults minus mouse bindings.
|
|
MPLEX_DEFAULT_KEYS = (
|
|
('copy-mode', 'Escape', 'send-keys -X cancel', False, ''),
|
|
('copy-mode', 'Space', 'send-keys -X page-down', False, ''),
|
|
('copy-mode', ',', 'send-keys -X jump-reverse', False, ''),
|
|
('copy-mode', ';', 'send-keys -X jump-again', False, ''),
|
|
('copy-mode', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''),
|
|
('copy-mode', 'N', 'send-keys -X search-reverse', False, ''),
|
|
('copy-mode', 'P', 'send-keys -X toggle-position', False, ''),
|
|
('copy-mode', 'R', 'send-keys -X rectangle-toggle', False, ''),
|
|
('copy-mode', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''),
|
|
('copy-mode', 'X', 'send-keys -X set-mark', False, ''),
|
|
('copy-mode', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''),
|
|
('copy-mode', 'g', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''),
|
|
('copy-mode', 'n', 'send-keys -X search-again', False, ''),
|
|
('copy-mode', 'q', 'send-keys -X cancel', False, ''),
|
|
('copy-mode', 'r', 'send-keys -X refresh-from-pane', False, ''),
|
|
('copy-mode', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''),
|
|
('copy-mode', 'Home', 'send-keys -X start-of-line', False, ''),
|
|
('copy-mode', 'End', 'send-keys -X end-of-line', False, ''),
|
|
('copy-mode', 'NPage', 'send-keys -X page-down', False, ''),
|
|
('copy-mode', 'PPage', 'send-keys -X page-up', False, ''),
|
|
('copy-mode', 'Up', 'send-keys -X cursor-up', False, ''),
|
|
('copy-mode', 'Down', 'send-keys -X cursor-down', False, ''),
|
|
('copy-mode', 'Left', 'send-keys -X cursor-left', False, ''),
|
|
('copy-mode', 'Right', 'send-keys -X cursor-right', False, ''),
|
|
('copy-mode', 'M-1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode', 'M-<', 'send-keys -X history-top', False, ''),
|
|
('copy-mode', 'M->', 'send-keys -X history-bottom', False, ''),
|
|
('copy-mode', 'M-R', 'send-keys -X top-line', False, ''),
|
|
('copy-mode', 'M-b', 'send-keys -X previous-word', False, ''),
|
|
('copy-mode', 'M-f', 'send-keys -X next-word-end', False, ''),
|
|
('copy-mode', 'M-l', 'send-keys -X cursor-centre-horizontal', False, ''),
|
|
('copy-mode', 'M-m', 'send-keys -X back-to-indentation', False, ''),
|
|
('copy-mode', 'M-r', 'send-keys -X middle-line', False, ''),
|
|
('copy-mode', 'M-v', 'send-keys -X page-up', False, ''),
|
|
('copy-mode', 'M-w', 'send-keys -X copy-pipe-and-cancel', False, ''),
|
|
('copy-mode', 'M-x', 'send-keys -X jump-to-mark', False, ''),
|
|
('copy-mode', 'M-{', 'send-keys -X previous-paragraph', False, ''),
|
|
('copy-mode', 'M-}', 'send-keys -X next-paragraph', False, ''),
|
|
('copy-mode', 'M-Up', 'send-keys -X halfpage-up', False, ''),
|
|
('copy-mode', 'M-Down', 'send-keys -X halfpage-down', False, ''),
|
|
('copy-mode', 'C-Space', 'send-keys -X begin-selection', False, ''),
|
|
('copy-mode', 'C-a', 'send-keys -X start-of-line', False, ''),
|
|
('copy-mode', 'C-b', 'send-keys -X cursor-left', False, ''),
|
|
('copy-mode', 'C-c', 'send-keys -X cancel', False, ''),
|
|
('copy-mode', 'C-e', 'send-keys -X end-of-line', False, ''),
|
|
('copy-mode', 'C-f', 'send-keys -X cursor-right', False, ''),
|
|
('copy-mode', 'C-g', 'send-keys -X clear-selection', False, ''),
|
|
('copy-mode', 'C-k', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''),
|
|
('copy-mode', 'C-l', 'send-keys -X cursor-centre-vertical', False, ''),
|
|
('copy-mode', 'C-n', 'send-keys -X cursor-down', False, ''),
|
|
('copy-mode', 'C-p', 'send-keys -X cursor-up', False, ''),
|
|
('copy-mode', 'C-r', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }', False, ''),
|
|
('copy-mode', 'C-s', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }', False, ''),
|
|
('copy-mode', 'C-v', 'send-keys -X page-down', False, ''),
|
|
('copy-mode', 'C-w', 'send-keys -X copy-pipe-and-cancel', False, ''),
|
|
('copy-mode', 'C-Up', 'send-keys -X scroll-up', False, ''),
|
|
('copy-mode', 'C-Down', 'send-keys -X scroll-down', False, ''),
|
|
('copy-mode', 'C-M-b', 'send-keys -X previous-matching-bracket', False, ''),
|
|
('copy-mode', 'C-M-f', 'send-keys -X next-matching-bracket', False, ''),
|
|
('copy-mode-vi', 'Enter', 'send-keys -X copy-pipe-and-cancel', False, ''),
|
|
('copy-mode-vi', 'Escape', 'send-keys -X clear-selection', False, ''),
|
|
('copy-mode-vi', 'Space', 'send-keys -X begin-selection', False, ''),
|
|
('copy-mode-vi', '#', 'send-keys -FX search-backward -- "#{copy_cursor_word}"', False, ''),
|
|
('copy-mode-vi', '$', 'send-keys -X end-of-line', False, ''),
|
|
('copy-mode-vi', '%', 'send-keys -X next-matching-bracket', False, ''),
|
|
('copy-mode-vi', '*', 'send-keys -FX search-forward -- "#{copy_cursor_word}"', False, ''),
|
|
('copy-mode-vi', ',', 'send-keys -X jump-reverse', False, ''),
|
|
('copy-mode-vi', '/', 'command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }', False, ''),
|
|
('copy-mode-vi', '0', 'send-keys -X start-of-line', False, ''),
|
|
('copy-mode-vi', '1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', '9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''),
|
|
('copy-mode-vi', ':', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''),
|
|
('copy-mode-vi', ';', 'send-keys -X jump-again', False, ''),
|
|
('copy-mode-vi', '?', 'command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }', False, ''),
|
|
('copy-mode-vi', 'A', 'send-keys -X append-selection-and-cancel', False, ''),
|
|
('copy-mode-vi', 'B', 'send-keys -X previous-space', False, ''),
|
|
('copy-mode-vi', 'D', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''),
|
|
('copy-mode-vi', 'E', 'send-keys -X next-space-end', False, ''),
|
|
('copy-mode-vi', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''),
|
|
('copy-mode-vi', 'G', 'send-keys -X history-bottom', False, ''),
|
|
('copy-mode-vi', 'H', 'send-keys -X top-line', False, ''),
|
|
('copy-mode-vi', 'J', 'send-keys -X scroll-down', False, ''),
|
|
('copy-mode-vi', 'K', 'send-keys -X scroll-up', False, ''),
|
|
('copy-mode-vi', 'L', 'send-keys -X bottom-line', False, ''),
|
|
('copy-mode-vi', 'M', 'send-keys -X middle-line', False, ''),
|
|
('copy-mode-vi', 'N', 'send-keys -X search-reverse', False, ''),
|
|
('copy-mode-vi', 'P', 'send-keys -X toggle-position', False, ''),
|
|
('copy-mode-vi', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''),
|
|
('copy-mode-vi', 'V', 'send-keys -X select-line', False, ''),
|
|
('copy-mode-vi', 'W', 'send-keys -X next-space', False, ''),
|
|
('copy-mode-vi', 'X', 'send-keys -X set-mark', False, ''),
|
|
('copy-mode-vi', '^', 'send-keys -X back-to-indentation', False, ''),
|
|
('copy-mode-vi', 'b', 'send-keys -X previous-word', False, ''),
|
|
('copy-mode-vi', 'e', 'send-keys -X next-word-end', False, ''),
|
|
('copy-mode-vi', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''),
|
|
('copy-mode-vi', 'g', 'send-keys -X history-top', False, ''),
|
|
('copy-mode-vi', 'h', 'send-keys -X cursor-left', False, ''),
|
|
('copy-mode-vi', 'j', 'send-keys -X cursor-down', False, ''),
|
|
('copy-mode-vi', 'k', 'send-keys -X cursor-up', False, ''),
|
|
('copy-mode-vi', 'l', 'send-keys -X cursor-right', False, ''),
|
|
('copy-mode-vi', 'n', 'send-keys -X search-again', False, ''),
|
|
('copy-mode-vi', 'o', 'send-keys -X other-end', False, ''),
|
|
('copy-mode-vi', 'q', 'send-keys -X cancel', False, ''),
|
|
('copy-mode-vi', 'r', 'send-keys -X refresh-from-pane', False, ''),
|
|
('copy-mode-vi', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''),
|
|
('copy-mode-vi', 'v', 'send-keys -X rectangle-toggle', False, ''),
|
|
('copy-mode-vi', 'w', 'send-keys -X next-word', False, ''),
|
|
('copy-mode-vi', 'z', 'send-keys -X scroll-middle', False, ''),
|
|
('copy-mode-vi', '{', 'send-keys -X previous-paragraph', False, ''),
|
|
('copy-mode-vi', '}', 'send-keys -X next-paragraph', False, ''),
|
|
('copy-mode-vi', 'BSpace', 'send-keys -X cursor-left', False, ''),
|
|
('copy-mode-vi', 'Home', 'send-keys -X start-of-line', False, ''),
|
|
('copy-mode-vi', 'End', 'send-keys -X end-of-line', False, ''),
|
|
('copy-mode-vi', 'NPage', 'send-keys -X page-down', False, ''),
|
|
('copy-mode-vi', 'PPage', 'send-keys -X page-up', False, ''),
|
|
('copy-mode-vi', 'Up', 'send-keys -X cursor-up', False, ''),
|
|
('copy-mode-vi', 'Down', 'send-keys -X cursor-down', False, ''),
|
|
('copy-mode-vi', 'Left', 'send-keys -X cursor-left', False, ''),
|
|
('copy-mode-vi', 'Right', 'send-keys -X cursor-right', False, ''),
|
|
('copy-mode-vi', 'M-x', 'send-keys -X jump-to-mark', False, ''),
|
|
('copy-mode-vi', 'C-b', 'send-keys -X page-up', False, ''),
|
|
('copy-mode-vi', 'C-c', 'send-keys -X cancel', False, ''),
|
|
('copy-mode-vi', 'C-d', 'send-keys -X halfpage-down', False, ''),
|
|
('copy-mode-vi', 'C-e', 'send-keys -X scroll-down', False, ''),
|
|
('copy-mode-vi', 'C-f', 'send-keys -X page-down', False, ''),
|
|
('copy-mode-vi', 'C-h', 'send-keys -X cursor-left', False, ''),
|
|
('copy-mode-vi', 'C-j', 'send-keys -X copy-pipe-and-cancel', False, ''),
|
|
('copy-mode-vi', 'C-u', 'send-keys -X halfpage-up', False, ''),
|
|
('copy-mode-vi', 'C-v', 'send-keys -X rectangle-toggle', False, ''),
|
|
('copy-mode-vi', 'C-y', 'send-keys -X scroll-up', False, ''),
|
|
('copy-mode-vi', 'C-Up', 'send-keys -X scroll-up', False, ''),
|
|
('copy-mode-vi', 'C-Down', 'send-keys -X scroll-down', False, ''),
|
|
('prefix', 'Space', 'next-layout', False, 'Select next layout'),
|
|
('prefix', '!', 'break-pane', False, 'Break pane to a new window'),
|
|
('prefix', '"', 'split-window', False, 'Split window vertically'),
|
|
('prefix', '#', 'list-buffers', False, 'List all paste buffers'),
|
|
('prefix', '$', 'command-prompt -I "#S" { rename-session "%%" }', False, 'Rename current session'),
|
|
('prefix', '%', 'split-window -h', False, 'Split window horizontally'),
|
|
('prefix', '&', 'confirm-before -p "kill-window #W? (y/n)" kill-window', False, 'Kill current window'),
|
|
('prefix', "'", 'command-prompt -T window-target -p index { select-window -t ":%%" }', False, 'Prompt for window index to select'),
|
|
('prefix', '(', 'switch-client -p', False, 'Switch to previous client'),
|
|
('prefix', ')', 'switch-client -n', False, 'Switch to next client'),
|
|
('prefix', ',', 'command-prompt -I "#W" { rename-window "%%" }', False, 'Rename current window'),
|
|
('prefix', '-', 'delete-buffer', False, 'Delete the most recent paste buffer'),
|
|
('prefix', '.', 'command-prompt -T target { move-window -t "%%" }', False, 'Move the current window'),
|
|
('prefix', '/', 'command-prompt -k -p key { list-keys -1N "%%" }', False, 'Describe key binding'),
|
|
('prefix', '0', 'select-window -t :=0', False, 'Select window 0'),
|
|
('prefix', '1', 'select-window -t :=1', False, 'Select window 1'),
|
|
('prefix', '2', 'select-window -t :=2', False, 'Select window 2'),
|
|
('prefix', '3', 'select-window -t :=3', False, 'Select window 3'),
|
|
('prefix', '4', 'select-window -t :=4', False, 'Select window 4'),
|
|
('prefix', '5', 'select-window -t :=5', False, 'Select window 5'),
|
|
('prefix', '6', 'select-window -t :=6', False, 'Select window 6'),
|
|
('prefix', '7', 'select-window -t :=7', False, 'Select window 7'),
|
|
('prefix', '8', 'select-window -t :=8', False, 'Select window 8'),
|
|
('prefix', '9', 'select-window -t :=9', False, 'Select window 9'),
|
|
('prefix', ':', 'command-prompt', False, 'Prompt for a command'),
|
|
('prefix', ';', 'last-pane', False, 'Move to the previously active pane'),
|
|
('prefix', '<', 'display-menu', False, 'Display window menu'),
|
|
('prefix', '=', 'choose-buffer -Z', False, 'Choose a paste buffer from a list'),
|
|
('prefix', '>', 'display-menu', False, 'Display pane menu'),
|
|
('prefix', '?', 'list-keys -N', False, 'List key bindings'),
|
|
('prefix', 'C', 'customize-mode -Z', False, 'Customize options'),
|
|
('prefix', 'D', 'choose-client -Z', False, 'Choose and detach a client from a list'),
|
|
('prefix', 'E', 'select-layout -E', False, 'Spread panes out evenly'),
|
|
('prefix', 'L', 'switch-client -l', False, 'Switch to the last client'),
|
|
('prefix', 'M', 'select-pane -M', False, 'Clear the marked pane'),
|
|
('prefix', '[', 'copy-mode', False, 'Enter copy mode'),
|
|
('prefix', ']', 'paste-buffer -p', False, 'Paste the most recent paste buffer'),
|
|
('prefix', 'c', 'new-window', False, 'Create a new window'),
|
|
('prefix', 'd', 'detach-client', False, 'Detach the current client'),
|
|
('prefix', 'f', 'command-prompt { find-window -Z "%%" }', False, 'Search for a pane'),
|
|
('prefix', 'i', 'display-message', False, 'Display window information'),
|
|
('prefix', 'l', 'last-window', False, 'Select the previously current window'),
|
|
('prefix', 'm', 'select-pane -m', False, 'Toggle the marked pane'),
|
|
('prefix', 'n', 'next-window', False, 'Select the next window'),
|
|
('prefix', 'o', 'select-pane -t :.+', False, 'Select the next pane'),
|
|
('prefix', 'p', 'previous-window', False, 'Select the previous window'),
|
|
('prefix', 'q', 'display-panes', False, 'Display pane numbers'),
|
|
('prefix', 'r', 'refresh-client', False, 'Redraw the current client'),
|
|
('prefix', 's', 'choose-tree -Zs', False, 'Choose a session from a list'),
|
|
('prefix', 't', 'clock-mode', False, 'Show a clock'),
|
|
('prefix', 'w', 'choose-tree -Zw', False, 'Choose a window from a list'),
|
|
('prefix', 'x', 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', False, 'Kill the active pane'),
|
|
('prefix', 'z', 'resize-pane -Z', False, 'Zoom the active pane'),
|
|
('prefix', '{', 'swap-pane -U', False, 'Swap the active pane with the pane above'),
|
|
('prefix', '}', 'swap-pane -D', False, 'Swap the active pane with the pane below'),
|
|
('prefix', '~', 'show-messages', False, 'Show messages'),
|
|
('prefix', 'DC', 'refresh-client -c', True, 'Reset so the visible part of the window follows the cursor'),
|
|
('prefix', 'PPage', 'copy-mode -u', False, 'Enter copy mode and scroll up'),
|
|
('prefix', 'Up', 'select-pane -U', True, 'Select the pane above the active pane'),
|
|
('prefix', 'Down', 'select-pane -D', True, 'Select the pane below the active pane'),
|
|
('prefix', 'Left', 'select-pane -L', True, 'Select the pane to the left of the active pane'),
|
|
('prefix', 'Right', 'select-pane -R', True, 'Select the pane to the right of the active pane'),
|
|
('prefix', 'M-1', 'select-layout even-horizontal', False, 'Set the even-horizontal layout'),
|
|
('prefix', 'M-2', 'select-layout even-vertical', False, 'Set the even-vertical layout'),
|
|
('prefix', 'M-3', 'select-layout main-horizontal', False, 'Set the main-horizontal layout'),
|
|
('prefix', 'M-4', 'select-layout main-vertical', False, 'Set the main-vertical layout'),
|
|
('prefix', 'M-5', 'select-layout tiled', False, 'Select the tiled layout'),
|
|
('prefix', 'M-6', 'select-layout main-horizontal-mirrored', False, 'Set the main-horizontal-mirrored layout'),
|
|
('prefix', 'M-7', 'select-layout main-vertical-mirrored', False, 'Set the main-vertical-mirrored layout'),
|
|
('prefix', 'M-n', 'next-window -a', False, 'Select the next window with an alert'),
|
|
('prefix', 'M-o', 'rotate-window -D', False, 'Rotate through the panes in reverse'),
|
|
('prefix', 'M-p', 'previous-window -a', False, 'Select the previous window with an alert'),
|
|
('prefix', 'M-Up', 'resize-pane -U 5', True, 'Resize the pane up by 5'),
|
|
('prefix', 'M-Down', 'resize-pane -D 5', True, 'Resize the pane down by 5'),
|
|
('prefix', 'M-Left', 'resize-pane -L 5', True, 'Resize the pane left by 5'),
|
|
('prefix', 'M-Right', 'resize-pane -R 5', True, 'Resize the pane right by 5'),
|
|
('prefix', 'C-b', 'send-prefix', False, 'Send the prefix key'),
|
|
('prefix', 'C-o', 'rotate-window', False, 'Rotate through the panes'),
|
|
('prefix', 'C-z', 'suspend-client', False, 'Suspend the current client'),
|
|
('prefix', 'C-Up', 'resize-pane -U', True, 'Resize the pane up'),
|
|
('prefix', 'C-Down', 'resize-pane -D', True, 'Resize the pane down'),
|
|
('prefix', 'C-Left', 'resize-pane -L', True, 'Resize the pane left'),
|
|
('prefix', 'C-Right', 'resize-pane -R', True, 'Resize the pane right'),
|
|
('prefix', 'S-Up', 'refresh-client -U 10', True, 'Move the visible part of the window up'),
|
|
('prefix', 'S-Down', 'refresh-client -D 10', True, 'Move the visible part of the window down'),
|
|
('prefix', 'S-Left', 'refresh-client -L 10', True, 'Move the visible part of the window left'),
|
|
('prefix', 'S-Right', 'refresh-client -R 10', True, 'Move the visible part of the window right'),
|
|
)
|
|
|
|
MPLEX_LAYOUT_NAMES = ("even-horizontal", "even-vertical", "main-horizontal", "main-horizontal-mirrored", "main-vertical", "main-vertical-mirrored", "tiled")
|
|
|
|
|
|
class MplexError(Exception):
|
|
"""A user-facing failure: the text is shown verbatim as the command's error."""
|
|
|
|
|
|
class MplexKeys:
|
|
"""Key names in tmux notation (C-b, M-x, S-Up, PPage, F5 ...), byte decoding of what
|
|
a terminal sends, and byte encoding for send-keys."""
|
|
|
|
ALIASES = {"PageUp": "PPage", "PgUp": "PPage", "PageDown": "NPage", "PgDn": "NPage", "Insert": "IC", "Delete": "DC",
|
|
"Return": "Enter", "Backspace": "BSpace", "Esc": "Escape", "Backtab": "BTab"}
|
|
NAMED = ("Up", "Down", "Left", "Right", "Home", "End", "PPage", "NPage", "IC", "DC", "Enter", "Tab", "BTab", "BSpace",
|
|
"Escape", "Space") + tuple("F%d" % n for n in range(1, 13))
|
|
FROM_TUI = {"up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End",
|
|
"pageup": "PPage", "pagedown": "NPage", "insert": "IC", "delete": "DC", "backtab": "BTab",
|
|
"enter": "Enter", "tab": "Tab", "backspace": "BSpace", "escape": "Escape", "space": "Space"}
|
|
CSI_FINAL = {"Up": "A", "Down": "B", "Right": "C", "Left": "D", "Home": "H", "End": "F"}
|
|
CSI_TILDE = {"IC": 2, "DC": 3, "PPage": 5, "NPage": 6, "F5": 15, "F6": 17, "F7": 18, "F8": 19, "F9": 20, "F10": 21, "F11": 23, "F12": 24}
|
|
SS3_FUNC = {"F1": "P", "F2": "Q", "F3": "R", "F4": "S"}
|
|
CTRL_PUNCT = {"\\": 0x1C, "]": 0x1D, "^": 0x1E, "_": 0x1F, "@": 0x00, "[": 0x1B, "?": 0x7F}
|
|
|
|
@classmethod
|
|
def canonical(cls, spec):
|
|
if spec == " ":
|
|
return "Space"
|
|
text = str(spec).strip()
|
|
if not text:
|
|
raise MplexError("empty key name")
|
|
mods = set()
|
|
while len(text) >= 3 and text[1] == "-" and text[0] in "CMS":
|
|
mods.add(text[0])
|
|
text = text[2:]
|
|
if len(text) == 2 and text[0] == "^":
|
|
mods.add("C")
|
|
text = text[1]
|
|
text = cls.ALIASES.get(text, text)
|
|
if len(text) == 1:
|
|
if text == " ":
|
|
text = "Space"
|
|
elif "C" in mods and text.isalpha():
|
|
text = text.lower()
|
|
elif "S" in mods and text.isalpha():
|
|
text = text.upper()
|
|
mods.discard("S")
|
|
elif text not in cls.NAMED:
|
|
raise MplexError("unknown key: %s" % spec)
|
|
if "C" in mods:
|
|
if text == "i":
|
|
mods.discard("C")
|
|
text = "Tab"
|
|
elif text == "m":
|
|
mods.discard("C")
|
|
text = "Enter"
|
|
elif text == "[":
|
|
mods.discard("C")
|
|
text = "Escape"
|
|
return ("C-" if "C" in mods else "") + ("M-" if "M" in mods else "") + ("S-" if "S" in mods else "") + text
|
|
|
|
@classmethod
|
|
def name_of_char(cls, raw):
|
|
"""Canonical name for one decoded character (no escape prefix)."""
|
|
if len(raw) == 1:
|
|
byte = raw[0]
|
|
if byte == 0x00:
|
|
return "C-Space"
|
|
if byte == 0x09:
|
|
return "Tab"
|
|
if byte == 0x0D:
|
|
return "Enter"
|
|
if byte == 0x1B:
|
|
return "Escape"
|
|
if byte == 0x20:
|
|
return "Space"
|
|
if byte == 0x7F:
|
|
return "BSpace"
|
|
if 0x01 <= byte <= 0x1A:
|
|
return "C-" + chr(byte + 0x60)
|
|
if 0x1C <= byte <= 0x1F:
|
|
return "C-" + "\\]^_"[byte - 0x1C]
|
|
return chr(byte)
|
|
return raw.decode("utf-8", "replace")
|
|
|
|
@classmethod
|
|
def split(cls, data, final=False):
|
|
"""bytes -> ([(name, raw)], rest). name is "" for sequences that are not keys
|
|
(they are still forwarded). A lone ESC waits until final=True."""
|
|
tokens = []
|
|
buf = bytes(data)
|
|
while buf:
|
|
if buf[0] != 0x1B:
|
|
length = 1
|
|
lead = buf[0]
|
|
if lead >= 0xF0:
|
|
length = 4
|
|
elif lead >= 0xE0:
|
|
length = 3
|
|
elif lead >= 0xC0:
|
|
length = 2
|
|
if length > len(buf):
|
|
if not final:
|
|
break
|
|
length = len(buf)
|
|
raw = buf[:length]
|
|
tokens.append((cls.name_of_char(raw), raw))
|
|
buf = buf[length:]
|
|
continue
|
|
if len(buf) == 1:
|
|
if not final:
|
|
break
|
|
tokens.append(("Escape", b"\x1b"))
|
|
buf = b""
|
|
break
|
|
event, rest = tui_parse_one(buf, final)
|
|
if event == "need_more":
|
|
if not final:
|
|
break
|
|
tokens.append(("Escape", b"\x1b"))
|
|
buf = buf[1:]
|
|
continue
|
|
if event == "escape":
|
|
tokens.append(("Escape", b"\x1b"))
|
|
buf = buf[1:]
|
|
continue
|
|
consumed = len(buf) - len(rest)
|
|
if consumed <= 0:
|
|
consumed = 1
|
|
raw = buf[:consumed]
|
|
buf = buf[consumed:]
|
|
tokens.append((cls._name_of_event(event, raw), raw))
|
|
return tokens, buf
|
|
|
|
@classmethod
|
|
def _name_of_event(cls, event, raw):
|
|
if not isinstance(event, TuiKey):
|
|
return ""
|
|
if len(raw) >= 2 and raw[1:2] not in (b"[", b"O"):
|
|
inner = cls.name_of_char(raw[1:])
|
|
try:
|
|
return cls.canonical("M-" + inner)
|
|
except MplexError:
|
|
return ""
|
|
key = event.key
|
|
base = key.split("+")[-1] if not key.endswith("+") else "+"
|
|
if base in cls.FROM_TUI:
|
|
base = cls.FROM_TUI[base]
|
|
elif len(base) >= 2 and base[0] == "f" and base[1:].isdigit():
|
|
base = "F" + base[1:]
|
|
else:
|
|
return ""
|
|
prefix = ""
|
|
mods = set(getattr(event, "modifiers", None) or [])
|
|
if "ctrl" in mods:
|
|
prefix += "C-"
|
|
if "alt" in mods or "meta" in mods:
|
|
prefix += "M-"
|
|
if "shift" in mods:
|
|
prefix += "S-"
|
|
try:
|
|
return cls.canonical(prefix + base)
|
|
except MplexError:
|
|
return ""
|
|
|
|
@classmethod
|
|
def to_bytes(cls, name, app_cursor=False):
|
|
"""Bytes a terminal would send for a canonical key name."""
|
|
mods = ""
|
|
text = name
|
|
while len(text) >= 3 and text[1] == "-" and text[0] in "CMS":
|
|
mods += text[0]
|
|
text = text[2:]
|
|
if len(text) == 1 or text == "Space":
|
|
char = " " if text == "Space" else text
|
|
data = char.encode("utf-8")
|
|
if "C" in mods:
|
|
if char == " ":
|
|
data = b"\x00"
|
|
elif char.isalpha() and char.isascii():
|
|
data = bytes([ord(char.upper()) & 0x1F])
|
|
elif char in cls.CTRL_PUNCT:
|
|
data = bytes([cls.CTRL_PUNCT[char]])
|
|
return (b"\x1b" if "M" in mods else b"") + data
|
|
simple = {"Enter": b"\r", "Tab": b"\t", "BSpace": b"\x7f", "Escape": b"\x1b", "BTab": b"\x1b[Z"}
|
|
if text in simple and not ("S" in mods and text not in ("BTab",)):
|
|
return (b"\x1b" if "M" in mods else b"") + simple[text]
|
|
mod_code = 1 + (1 if "S" in mods else 0) + (2 if "M" in mods else 0) + (4 if "C" in mods else 0)
|
|
if text in cls.CSI_FINAL:
|
|
if mod_code > 1:
|
|
return ("\x1b[1;%d%s" % (mod_code, cls.CSI_FINAL[text])).encode("ascii")
|
|
return (b"\x1bO" if app_cursor else b"\x1b[") + cls.CSI_FINAL[text].encode("ascii")
|
|
if text in cls.CSI_TILDE:
|
|
if mod_code > 1:
|
|
return ("\x1b[%d;%d~" % (cls.CSI_TILDE[text], mod_code)).encode("ascii")
|
|
return ("\x1b[%d~" % cls.CSI_TILDE[text]).encode("ascii")
|
|
if text in cls.SS3_FUNC:
|
|
if mod_code > 1:
|
|
return ("\x1b[1;%d%s" % (mod_code, cls.SS3_FUNC[text])).encode("ascii")
|
|
return ("\x1bO" + cls.SS3_FUNC[text]).encode("ascii")
|
|
raise MplexError("unknown key: %s" % name)
|
|
|
|
@classmethod
|
|
def app_cursor_form(cls, raw):
|
|
"""Plain CSI cursor keys become SS3 when the pane asked for application cursor mode."""
|
|
if len(raw) == 3 and raw[:2] == b"\x1b[" and raw[2:3] in (b"A", b"B", b"C", b"D", b"H", b"F"):
|
|
return b"\x1bO" + raw[2:3]
|
|
return raw
|
|
|
|
|
|
class MplexArgs:
|
|
"""getopt-style tmux argument parsing. spec "bdt:" => -b and -d are flags, -t takes a value."""
|
|
|
|
def __init__(self, flags, positional):
|
|
self.flags = flags
|
|
self.positional = positional
|
|
|
|
def has(self, flag):
|
|
return flag in self.flags
|
|
|
|
def get(self, flag, default=None):
|
|
values = self.flags.get(flag)
|
|
if not values:
|
|
return default
|
|
return values[-1]
|
|
|
|
def all(self, flag):
|
|
return list(self.flags.get(flag) or [])
|
|
|
|
def int(self, flag, default=None):
|
|
value = self.get(flag)
|
|
if value is None or value is True:
|
|
return default
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
raise MplexError("-%s expects a number: %s" % (flag, value))
|
|
|
|
@classmethod
|
|
def parse(cls, name, spec, words, min_args=0, max_args=None):
|
|
takes = set()
|
|
known = set()
|
|
index = 0
|
|
while index < len(spec):
|
|
known.add(spec[index])
|
|
if index + 1 < len(spec) and spec[index + 1] == ":":
|
|
takes.add(spec[index])
|
|
index += 1
|
|
index += 1
|
|
flags = {}
|
|
positional = []
|
|
pos = 0
|
|
while pos < len(words):
|
|
word = words[pos]
|
|
if word == "--":
|
|
positional.extend(words[pos + 1:])
|
|
break
|
|
if len(word) < 2 or word[0] != "-":
|
|
positional.extend(words[pos:])
|
|
break
|
|
inner = 1
|
|
while inner < len(word):
|
|
letter = word[inner]
|
|
if letter not in known:
|
|
raise MplexError("command %s: unknown flag -%s" % (name, letter))
|
|
if letter in takes:
|
|
if inner + 1 < len(word):
|
|
value = word[inner + 1:]
|
|
else:
|
|
pos += 1
|
|
if pos >= len(words):
|
|
raise MplexError("command %s: -%s expects an argument" % (name, letter))
|
|
value = words[pos]
|
|
flags.setdefault(letter, []).append(value)
|
|
break
|
|
flags.setdefault(letter, []).append(True)
|
|
inner += 1
|
|
pos += 1
|
|
if len(positional) < min_args or (max_args is not None and len(positional) > max_args):
|
|
raise MplexError("command %s: wrong number of arguments" % name)
|
|
return cls(flags, positional)
|
|
|
|
|
|
class MplexCommandParser:
|
|
"""Splits tmux-syntax command text into commands of words: quotes, backslash,
|
|
{braces} as one argument, ';' and newlines between commands."""
|
|
|
|
@staticmethod
|
|
def parse(text):
|
|
commands = []
|
|
words = []
|
|
cur = []
|
|
has = False
|
|
i = 0
|
|
n = len(text)
|
|
|
|
def end_word():
|
|
nonlocal cur, has
|
|
if has:
|
|
words.append("".join(cur))
|
|
cur = []
|
|
has = False
|
|
|
|
def end_command():
|
|
nonlocal words
|
|
end_word()
|
|
if words:
|
|
commands.append(words)
|
|
words = []
|
|
|
|
while i < n:
|
|
c = text[i]
|
|
if c in " \t\r":
|
|
end_word()
|
|
i += 1
|
|
elif c == "\n" or c == ";":
|
|
end_command()
|
|
i += 1
|
|
elif c == "\\":
|
|
if i + 1 < n:
|
|
if text[i + 1] == "\n":
|
|
i += 2
|
|
continue
|
|
cur.append(text[i + 1])
|
|
has = True
|
|
i += 2
|
|
elif c == "'":
|
|
j = text.find("'", i + 1)
|
|
if j < 0:
|
|
raise MplexError("unterminated single quote")
|
|
cur.append(text[i + 1:j])
|
|
has = True
|
|
i = j + 1
|
|
elif c == '"':
|
|
j = i + 1
|
|
piece = []
|
|
while j < n and text[j] != '"':
|
|
if text[j] == "\\" and j + 1 < n:
|
|
nxt = text[j + 1]
|
|
piece.append({"n": "\n", "t": "\t", "e": "\x1b", "r": "\r"}.get(nxt, nxt))
|
|
j += 2
|
|
else:
|
|
piece.append(text[j])
|
|
j += 1
|
|
if j >= n:
|
|
raise MplexError("unterminated double quote")
|
|
cur.append("".join(piece))
|
|
has = True
|
|
i = j + 1
|
|
elif c == "{" and not has:
|
|
depth = 0
|
|
j = i
|
|
quote = ""
|
|
while j < n:
|
|
ch = text[j]
|
|
if quote:
|
|
if ch == "\\" and quote == '"':
|
|
j += 1
|
|
elif ch == quote:
|
|
quote = ""
|
|
elif ch in "'\"":
|
|
quote = ch
|
|
elif ch == "\\":
|
|
j += 1
|
|
elif ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
j += 1
|
|
if depth != 0 or j >= n:
|
|
raise MplexError("unterminated { block")
|
|
cur.append(text[i + 1:j].strip())
|
|
has = True
|
|
i = j + 1
|
|
else:
|
|
cur.append(c)
|
|
has = True
|
|
i += 1
|
|
end_command()
|
|
return commands
|
|
|
|
@staticmethod
|
|
def quote(word):
|
|
word = str(word)
|
|
if word and all(ch.isalnum() or ch in "-_./:=%@+," for ch in word):
|
|
return word
|
|
return '"' + word.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"'
|
|
|
|
|
|
class MplexStyle:
|
|
"""tmux style strings ("bg=green,fg=black,bold") to (fg, bg, attr) in the cell
|
|
format TuiVtScreen uses: SGR fragments for colors, a bitmask for attributes."""
|
|
|
|
NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")
|
|
ATTRS = {"bold": 1, "bright": 1, "dim": 2, "italics": 32, "underscore": 4, "underline": 4, "blink": 64,
|
|
"reverse": 8, "hidden": 128, "strikethrough": 16}
|
|
|
|
@classmethod
|
|
def color(cls, text, background):
|
|
text = text.strip().lower()
|
|
if text in ("default", "terminal", ""):
|
|
return None
|
|
base = 40 if background else 30
|
|
if text in cls.NAMES:
|
|
return str(base + cls.NAMES.index(text))
|
|
if text.startswith("bright") and text[6:] in cls.NAMES:
|
|
return str(base + 60 + cls.NAMES.index(text[6:]))
|
|
if text.startswith("colour") or text.startswith("color"):
|
|
digits = text[6:] if text.startswith("colour") else text[5:]
|
|
if digits.isdigit() and int(digits) < 256:
|
|
return "%d;5;%d" % (48 if background else 38, int(digits))
|
|
if text.startswith("#") and len(text) == 7:
|
|
try:
|
|
red, green, blue = int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16)
|
|
except ValueError:
|
|
raise MplexError("bad colour: %s" % text)
|
|
return "%d;2;%d;%d;%d" % (48 if background else 38, red, green, blue)
|
|
raise MplexError("bad colour: %s" % text)
|
|
|
|
@classmethod
|
|
def parse(cls, text, base=(None, None, 0), reset=None):
|
|
"""Apply a style string on top of base; "default" returns to reset (base when omitted)."""
|
|
fg, bg, attr = base
|
|
reset = base if reset is None else reset
|
|
for item in re.split(r"[,\s]+", str(text).strip()):
|
|
if not item:
|
|
continue
|
|
low = item.lower()
|
|
if low == "default":
|
|
fg, bg, attr = reset
|
|
elif low in ("none", "noattr"):
|
|
attr = 0
|
|
elif low.startswith("fg="):
|
|
fg = cls.color(item[3:], False)
|
|
elif low.startswith("bg="):
|
|
bg = cls.color(item[3:], True)
|
|
elif low in cls.ATTRS:
|
|
attr |= cls.ATTRS[low]
|
|
elif low.startswith("no") and low[2:] in cls.ATTRS:
|
|
attr &= ~cls.ATTRS[low[2:]]
|
|
elif low.startswith(("align=", "list=", "range=", "push-default", "pop-default", "fill=", "norange", "nolist")):
|
|
continue
|
|
else:
|
|
raise MplexError("bad style: %s" % item)
|
|
return (fg, bg, attr)
|
|
|
|
@staticmethod
|
|
def sgr(fg, bg, attr):
|
|
codes = ["0"]
|
|
for bit, code in ((1, "1"), (2, "2"), (32, "3"), (4, "4"), (64, "5"), (8, "7"), (128, "8"), (16, "9")):
|
|
if attr & bit:
|
|
codes.append(code)
|
|
if fg:
|
|
codes.append(fg)
|
|
if bg:
|
|
codes.append(bg)
|
|
return "\x1b[" + ";".join(codes) + "m"
|
|
|
|
|
|
class MplexOptions:
|
|
"""Typed option store with a parent chain: window, session, then global."""
|
|
|
|
WORD_SEPARATORS = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~"
|
|
DEFS = {
|
|
"prefix": ("key", "C-b"), "prefix2": ("key", "None"),
|
|
"base-index": ("int:0:9999", 0), "pane-base-index": ("int:0:9999", 0),
|
|
"escape-time": ("int:0:10000", 10), "repeat-time": ("int:0:10000", 500),
|
|
"history-limit": ("int:0:%d" % MPLEX_HISTORY_CAP, 2000),
|
|
"mode-keys": ("choice:vi|emacs", ""), "status-keys": ("choice:vi|emacs", ""),
|
|
"status": ("bool", True), "status-position": ("choice:top|bottom", "bottom"),
|
|
"status-interval": ("int:0:3600", 15), "status-justify": ("choice:left|centre|center|right", "left"),
|
|
"status-left": ("str", "[#{session_name}] "),
|
|
"status-right": ("str", "\"#{=21:pane_title}\" %H:%M %d-%b-%y"),
|
|
"status-left-length": ("int:0:1000", 10), "status-right-length": ("int:0:1000", 40),
|
|
"status-style": ("style", "bg=green,fg=black"), "status-left-style": ("style", "default"), "status-right-style": ("style", "default"),
|
|
"message-style": ("style", "bg=yellow,fg=black"), "message-command-style": ("style", "bg=black,fg=yellow"),
|
|
"mode-style": ("style", "noattr,bg=yellow,fg=black"),
|
|
"copy-mode-match-style": ("style", "bg=cyan,fg=black"), "copy-mode-current-match-style": ("style", "bg=magenta,fg=black"),
|
|
"copy-mode-mark-style": ("style", "bg=red,fg=black"),
|
|
"display-time": ("int:0:600000", 750), "display-panes-time": ("int:1:600000", 1000),
|
|
"display-panes-colour": ("colour", "blue"), "display-panes-active-colour": ("colour", "red"),
|
|
"window-status-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"),
|
|
"window-status-current-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"),
|
|
"window-status-separator": ("str", " "),
|
|
"window-status-style": ("style", "default"), "window-status-current-style": ("style", "default"), "window-status-last-style": ("style", "default"),
|
|
"pane-border-style": ("style", "default"),
|
|
"pane-active-border-style": ("str", "#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}"),
|
|
"pane-border-lines": ("choice:single|double|heavy|simple", "single"),
|
|
"clock-mode-colour": ("colour", "blue"), "clock-mode-style": ("choice:12|24", "24"),
|
|
"main-pane-width": ("int:1:10000", 80), "main-pane-height": ("int:1:10000", 24),
|
|
"renumber-windows": ("bool", False), "automatic-rename": ("bool", True),
|
|
"automatic-rename-format": ("str", "#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}"),
|
|
"allow-set-title": ("bool", True),
|
|
"default-shell": ("str", ""), "default-command": ("str", ""), "default-terminal": ("str", "tmux-256color"),
|
|
"detach-on-destroy": ("bool", True), "wrap-search": ("bool", True),
|
|
"word-separators": ("str", WORD_SEPARATORS), "remain-on-exit": ("bool", False),
|
|
"synchronize-panes": ("bool", False), "copy-command": ("str", ""),
|
|
"buffer-limit": ("int:1:%d" % MPLEX_BUFFERS_CAP, 50),
|
|
"set-clipboard": ("choice:on|external|off", "off"),
|
|
}
|
|
|
|
def __init__(self, parent=None):
|
|
self.parent = parent
|
|
self.values = {}
|
|
|
|
@classmethod
|
|
def check(cls, name, raw):
|
|
if name not in cls.DEFS:
|
|
raise MplexError("unknown or unsupported option: %s" % name)
|
|
kind = cls.DEFS[name][0]
|
|
text = str(raw)
|
|
if kind == "str":
|
|
return text
|
|
if kind == "bool":
|
|
low = text.strip().lower()
|
|
if low in ("on", "yes", "true", "1"):
|
|
return True
|
|
if low in ("off", "no", "false", "0"):
|
|
return False
|
|
raise MplexError("value is invalid: %s" % text)
|
|
if kind == "key":
|
|
return "None" if text.strip() in ("None", "none", "") else MplexKeys.canonical(text)
|
|
if kind == "colour":
|
|
MplexStyle.color(text, False)
|
|
return text
|
|
if kind == "style":
|
|
if "#{" not in text:
|
|
MplexStyle.parse(text)
|
|
return text
|
|
if kind.startswith("int:"):
|
|
_tag, low, high = kind.split(":")
|
|
try:
|
|
value = int(text)
|
|
except ValueError:
|
|
raise MplexError("value is invalid: %s" % text)
|
|
if value < int(low) or value > int(high):
|
|
raise MplexError("value out of range %s..%s: %s" % (low, high, text))
|
|
return value
|
|
if kind.startswith("choice:"):
|
|
choices = kind.split(":", 1)[1].split("|")
|
|
if text not in choices:
|
|
raise MplexError("value is invalid (use %s): %s" % ("|".join(choices), text))
|
|
return text
|
|
raise MplexError("bad option kind for %s" % name)
|
|
|
|
def get(self, name):
|
|
node = self
|
|
while node is not None:
|
|
if name in node.values:
|
|
return node.values[name]
|
|
node = node.parent
|
|
default = self.DEFS[name][1]
|
|
if name in ("mode-keys", "status-keys"):
|
|
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
|
|
return "vi" if "vi" in editor else "emacs"
|
|
return default
|
|
|
|
def is_set(self, name):
|
|
return name in self.values
|
|
|
|
def set(self, name, raw, append=False):
|
|
value = self.check(name, raw)
|
|
if append and isinstance(value, str) and name in self.values:
|
|
value = self.values[name] + value
|
|
self.values[name] = value
|
|
return value
|
|
|
|
def toggle(self, name):
|
|
if self.DEFS.get(name, ("",))[0] != "bool":
|
|
raise MplexError("only boolean options can be toggled: %s" % name)
|
|
self.values[name] = not self.get(name)
|
|
|
|
def unset(self, name):
|
|
if name not in self.DEFS:
|
|
raise MplexError("unknown or unsupported option: %s" % name)
|
|
self.values.pop(name, None)
|
|
|
|
@staticmethod
|
|
def show(value):
|
|
if value is True:
|
|
return "on"
|
|
if value is False:
|
|
return "off"
|
|
return str(value)
|
|
|
|
|
|
class MplexBinding:
|
|
__slots__ = ("command", "repeat", "note")
|
|
|
|
def __init__(self, command, repeat=False, note=""):
|
|
self.command = command
|
|
self.repeat = repeat
|
|
self.note = note
|
|
|
|
|
|
class MplexConfig:
|
|
"""Defaults (tmux 3.6) plus ~/.tai/mplex.toml. Problems never abort the load:
|
|
each bad entry is skipped, counted in errors, and shown to the first client."""
|
|
|
|
ERRORS_MAX = 50
|
|
TOP_KEYS = ("options", "keys")
|
|
|
|
def __init__(self):
|
|
self.options = MplexOptions()
|
|
self.tables = {}
|
|
self.errors = []
|
|
self.path = ""
|
|
self.skipped = 0
|
|
for table, key, command, repeat, note in MPLEX_DEFAULT_KEYS:
|
|
self.bind(table, MplexKeys.canonical(key), command, repeat, note)
|
|
|
|
def error(self, text):
|
|
self.skipped += 1
|
|
if len(self.errors) < self.ERRORS_MAX:
|
|
self.errors.append(text)
|
|
|
|
def bind(self, table, key, command, repeat=False, note=""):
|
|
self.tables.setdefault(table, {})[key] = MplexBinding(command, repeat, note)
|
|
|
|
def unbind(self, table, key):
|
|
return self.tables.get(table, {}).pop(key, None) is not None
|
|
|
|
def lookup(self, table, key):
|
|
return self.tables.get(table, {}).get(key)
|
|
|
|
@staticmethod
|
|
def default_path(home):
|
|
return os.path.join(home, MPLEX_CONFIG_NAME)
|
|
|
|
@classmethod
|
|
def load(cls, path):
|
|
config = cls()
|
|
config.path = path
|
|
config.apply_file(path)
|
|
return config
|
|
|
|
def apply_file(self, path):
|
|
try:
|
|
if os.path.getsize(path) > MPLEX_CONFIG_MAX:
|
|
self.error("%s: larger than %d bytes, ignored" % (path, MPLEX_CONFIG_MAX))
|
|
return
|
|
with open(path, "rb") as handle:
|
|
raw = handle.read()
|
|
except FileNotFoundError:
|
|
return
|
|
except OSError as exc:
|
|
self.error("%s: cannot read: %s" % (path, short_error(exc)))
|
|
return
|
|
try:
|
|
import tomllib
|
|
except ImportError:
|
|
self.error("%s: needs Python 3.11 or newer (tomllib), file ignored" % path)
|
|
return
|
|
try:
|
|
data = tomllib.loads(raw.decode("utf-8"))
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
self.error("%s: not valid TOML: %s" % (path, exc))
|
|
return
|
|
self.apply_data(data)
|
|
|
|
def apply_data(self, data):
|
|
for key in data:
|
|
if key not in self.TOP_KEYS:
|
|
self.error("unknown top-level key [%s] (use options, keys)" % key)
|
|
options = data.get("options", {})
|
|
if not isinstance(options, dict):
|
|
self.error("[options] must be a table")
|
|
options = {}
|
|
for name, value in options.items():
|
|
try:
|
|
if isinstance(value, bool):
|
|
value = "on" if value else "off"
|
|
elif not isinstance(value, (str, int)):
|
|
raise MplexError("value must be a string, number or boolean")
|
|
self.options.set(name, value)
|
|
except MplexError as exc:
|
|
self.error("options.%s: %s" % (name, exc))
|
|
keys = data.get("keys", {})
|
|
if not isinstance(keys, dict):
|
|
self.error("[keys] must be a table")
|
|
return
|
|
clear = keys.get("clear", [])
|
|
if not isinstance(clear, list):
|
|
self.error("keys.clear must be a list of table names")
|
|
clear = []
|
|
for table in clear:
|
|
self.tables[str(table)] = {}
|
|
for table, entries in keys.items():
|
|
if table == "clear":
|
|
continue
|
|
if not isinstance(entries, dict):
|
|
self.error("keys.%s must be a table of key = command" % table)
|
|
continue
|
|
for key, spec in entries.items():
|
|
self._apply_binding(table, key, spec)
|
|
|
|
def _apply_binding(self, table, key, spec):
|
|
where = "keys.%s.%s" % (table, key)
|
|
try:
|
|
name = MplexKeys.canonical(key)
|
|
if spec is False or spec == "":
|
|
self.unbind(table, name)
|
|
return
|
|
note = ""
|
|
repeat = False
|
|
if isinstance(spec, dict):
|
|
unknown = [item for item in spec if item not in ("command", "repeat", "note")]
|
|
if unknown:
|
|
raise MplexError("unknown field %s (use command, repeat, note)" % unknown[0])
|
|
command = spec.get("command")
|
|
repeat = bool(spec.get("repeat", False))
|
|
note = str(spec.get("note", ""))
|
|
else:
|
|
command = spec
|
|
if not isinstance(command, str) or not command.strip():
|
|
raise MplexError("command must be a non-empty string, or false to unbind")
|
|
for words in MplexCommandParser.parse(command):
|
|
MplexCommands.resolve(words[0])
|
|
self.bind(table, name, command, repeat, note)
|
|
except MplexError as exc:
|
|
self.error("%s: %s" % (where, exc))
|
|
|
|
|
|
class MplexFormat:
|
|
"""tmux format strings: #S style shorthands, #{var}, #{?cond,a,b}, modifiers such as
|
|
=N:, E:, q:, ==:, m:, s/a/b/: and #(shell). Style markers #[...] pass through."""
|
|
|
|
SHORT = {"D": "pane_id", "F": "window_flags", "H": "host", "I": "window_index", "P": "pane_index",
|
|
"S": "session_name", "T": "pane_title", "W": "window_name", "h": "host_short"}
|
|
DEPTH_MAX = 12
|
|
|
|
@staticmethod
|
|
def match_close(text, start, opener, closer):
|
|
"""Index of the closer matching the opener at text[start] (nested #{ / #( count), or -1."""
|
|
depth = 1
|
|
pos = start + 1
|
|
while pos < len(text):
|
|
ch = text[pos]
|
|
if ch == "#" and pos + 1 < len(text):
|
|
if text[pos + 1] == opener:
|
|
depth += 1
|
|
pos += 2
|
|
continue
|
|
if ch == closer:
|
|
depth -= 1
|
|
if depth == 0:
|
|
return pos
|
|
pos += 1
|
|
return -1
|
|
|
|
@classmethod
|
|
def split_commas(cls, text):
|
|
parts = []
|
|
cur = []
|
|
depth = 0
|
|
pos = 0
|
|
while pos < len(text):
|
|
ch = text[pos]
|
|
if ch == "#" and pos + 1 < len(text):
|
|
nxt = text[pos + 1]
|
|
if nxt == "," and depth == 0:
|
|
cur.append("#,")
|
|
pos += 2
|
|
continue
|
|
if nxt in "{(":
|
|
depth += 1
|
|
cur.append(text[pos:pos + 2])
|
|
pos += 2
|
|
continue
|
|
cur.append(text[pos:pos + 2])
|
|
pos += 2
|
|
continue
|
|
if ch == "}" or ch == ")":
|
|
depth = max(0, depth - 1)
|
|
if ch == "," and depth == 0:
|
|
parts.append("".join(cur))
|
|
cur = []
|
|
else:
|
|
cur.append(ch)
|
|
pos += 1
|
|
parts.append("".join(cur))
|
|
return parts
|
|
|
|
@classmethod
|
|
def expand(cls, text, resolve, depth=0):
|
|
if depth > cls.DEPTH_MAX or not text:
|
|
return ""
|
|
out = []
|
|
pos = 0
|
|
n = len(text)
|
|
while pos < n:
|
|
ch = text[pos]
|
|
if ch != "#" or pos + 1 >= n:
|
|
out.append(ch)
|
|
pos += 1
|
|
continue
|
|
nxt = text[pos + 1]
|
|
if nxt == "#":
|
|
out.append("#")
|
|
pos += 2
|
|
elif nxt == "{":
|
|
end = cls.match_close(text, pos + 1, "{", "}")
|
|
if end < 0:
|
|
out.append(ch)
|
|
pos += 1
|
|
else:
|
|
out.append(cls.evaluate(text[pos + 2:end], resolve, depth))
|
|
pos = end + 1
|
|
elif nxt == "(":
|
|
end = cls.match_close(text, pos + 1, "(", ")")
|
|
if end < 0:
|
|
out.append(ch)
|
|
pos += 1
|
|
else:
|
|
out.append(resolve("(" + text[pos + 2:end]) or "")
|
|
pos = end + 1
|
|
elif nxt == "[":
|
|
end = text.find("]", pos + 2)
|
|
if end < 0:
|
|
out.append(ch)
|
|
pos += 1
|
|
else:
|
|
out.append(text[pos:end + 1])
|
|
pos = end + 1
|
|
elif nxt in cls.SHORT:
|
|
out.append(resolve(cls.SHORT[nxt]) or "")
|
|
pos += 2
|
|
elif nxt in ",}":
|
|
out.append(nxt)
|
|
pos += 2
|
|
else:
|
|
out.append(ch)
|
|
pos += 1
|
|
return "".join(out)
|
|
|
|
@staticmethod
|
|
def truthy(value):
|
|
return value not in ("", "0", None)
|
|
|
|
@classmethod
|
|
def value_of(cls, text, resolve, depth):
|
|
"""A variable name or a nested format: its text value."""
|
|
if "#" in text:
|
|
return cls.expand(text, resolve, depth + 1)
|
|
value = resolve(text)
|
|
return "" if value is None else str(value)
|
|
|
|
@classmethod
|
|
def evaluate(cls, inner, resolve, depth):
|
|
if inner.startswith("?"):
|
|
parts = cls.split_commas(inner[1:])
|
|
index = 0
|
|
while index + 1 < len(parts):
|
|
cond = cls.value_of(parts[index], resolve, depth)
|
|
if cls.truthy(cond):
|
|
return cls.expand(parts[index + 1], resolve, depth + 1)
|
|
index += 2
|
|
if index < len(parts):
|
|
return cls.expand(parts[index], resolve, depth + 1)
|
|
return ""
|
|
colon = cls.modifier_colon(inner)
|
|
if colon < 0:
|
|
value = resolve(inner)
|
|
return "" if value is None else str(value)
|
|
mods = inner[:colon]
|
|
rest = inner[colon + 1:]
|
|
return cls.apply_modifiers(mods, rest, resolve, depth)
|
|
|
|
@staticmethod
|
|
def modifier_colon(inner):
|
|
"""Index of the ':' that ends a modifier list, or -1 when inner is a plain name."""
|
|
colon = inner.find(":")
|
|
if colon <= 0:
|
|
return -1
|
|
head = inner[:colon]
|
|
if "#" in head or "{" in head or " " in head:
|
|
return -1
|
|
return colon
|
|
|
|
@classmethod
|
|
def apply_modifiers(cls, mods, rest, resolve, depth):
|
|
specs = [item for item in mods.split(";") if item]
|
|
text = None
|
|
for spec in specs:
|
|
if spec in ("==", "!=", "<", ">", "<=", ">=", "&&", "||") or spec.startswith("e|"):
|
|
parts = cls.split_commas(rest)
|
|
left = cls.expand(parts[0], resolve, depth + 1) if parts else ""
|
|
right = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else ""
|
|
return cls.compare(spec, left, right)
|
|
if spec in ("m", "m/r", "m/ri", "m/i"):
|
|
parts = cls.split_commas(rest)
|
|
pattern = cls.expand(parts[0], resolve, depth + 1) if parts else ""
|
|
subject = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else ""
|
|
flags = re.IGNORECASE if spec.endswith("i") else 0
|
|
if spec.startswith("m/r"):
|
|
try:
|
|
return "1" if re.search(pattern, subject, flags) else "0"
|
|
except re.error:
|
|
return "0"
|
|
import fnmatch
|
|
return "1" if fnmatch.fnmatchcase(subject.lower() if flags else subject, pattern.lower() if flags else pattern) else "0"
|
|
if spec == "l":
|
|
return rest
|
|
if text is None:
|
|
text = cls.value_of(rest, resolve, depth)
|
|
text = cls.apply_one(spec, text, resolve, depth)
|
|
return text or ""
|
|
|
|
@classmethod
|
|
def apply_one(cls, spec, text, resolve, depth):
|
|
if spec == "E" or spec == "T":
|
|
return cls.expand(text, resolve, depth + 1)
|
|
if spec == "q":
|
|
return re.sub(r"([\\\"'$#;{}\[\]()*?<>&|~ ])", r"\\\1", text)
|
|
if spec == "t":
|
|
try:
|
|
return time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(float(text)))
|
|
except (ValueError, OverflowError, OSError):
|
|
return text
|
|
if spec == "n":
|
|
return str(len(text))
|
|
if spec == "b":
|
|
return os.path.basename(text)
|
|
if spec == "d":
|
|
return os.path.dirname(text)
|
|
if spec == "!":
|
|
return "0" if cls.truthy(text) else "1"
|
|
if spec == "!!":
|
|
return "1" if cls.truthy(text) else "0"
|
|
if spec.startswith("="):
|
|
body = spec[1:]
|
|
marker = ""
|
|
if body.startswith("/"):
|
|
_blank, count, marker = (body.split("/", 2) + ["", ""])[:3]
|
|
body = count
|
|
try:
|
|
limit = int(body)
|
|
except ValueError:
|
|
return text
|
|
if limit >= 0 and len(text) > limit:
|
|
return text[:limit] + marker
|
|
if limit < 0 and len(text) > -limit:
|
|
return marker + text[limit:]
|
|
return text
|
|
if spec.startswith("s/"):
|
|
fields = spec.split("/")
|
|
if len(fields) >= 3:
|
|
try:
|
|
return re.sub(fields[1], fields[2], text)
|
|
except re.error:
|
|
return text
|
|
return text
|
|
|
|
@staticmethod
|
|
def compare(op, left, right):
|
|
if op.startswith("e|"):
|
|
kind = op.split("|")[1] if "|" in op else ""
|
|
try:
|
|
a, b = float(left), float(right)
|
|
except ValueError:
|
|
return "0"
|
|
table = {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else 0, "%": a % b if b else 0,
|
|
"==": a == b, "!=": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}
|
|
if kind not in table:
|
|
return "0"
|
|
value = table[kind]
|
|
if isinstance(value, bool):
|
|
return "1" if value else "0"
|
|
return str(int(value)) if float(value).is_integer() else str(value)
|
|
if op == "==":
|
|
return "1" if left == right else "0"
|
|
if op == "!=":
|
|
return "1" if left != right else "0"
|
|
if op == "&&":
|
|
return "1" if MplexFormat.truthy(left) and MplexFormat.truthy(right) else "0"
|
|
if op == "||":
|
|
return "1" if MplexFormat.truthy(left) or MplexFormat.truthy(right) else "0"
|
|
try:
|
|
a, b = float(left), float(right)
|
|
except ValueError:
|
|
a, b = left, right
|
|
return "1" if {"<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}[op] else "0"
|
|
|
|
@staticmethod
|
|
def with_time(text, stamp=None):
|
|
if "%" not in text:
|
|
return text
|
|
try:
|
|
return time.strftime(text, time.localtime(stamp))
|
|
except (ValueError, OverflowError):
|
|
return text
|
|
|
|
@staticmethod
|
|
def strip_styles(text):
|
|
return re.sub(r"#\[[^\]]*\]", "", text)
|
|
|
|
|
|
class MplexLayoutNode:
|
|
__slots__ = ("kind", "children", "parent", "pane", "x", "y", "w", "h", "reverse")
|
|
|
|
def __init__(self, kind, pane=None, w=1, h=1):
|
|
self.reverse = False
|
|
self.kind = kind
|
|
self.children = []
|
|
self.parent = None
|
|
self.pane = pane
|
|
self.x = 0
|
|
self.y = 0
|
|
self.w = w
|
|
self.h = h
|
|
|
|
def leaves(self):
|
|
if self.kind == "pane":
|
|
return [self]
|
|
found = []
|
|
for child in self.children:
|
|
found.extend(child.leaves())
|
|
return found
|
|
|
|
def adopt(self, child):
|
|
child.parent = self
|
|
self.children.append(child)
|
|
return child
|
|
|
|
|
|
class MplexLayout:
|
|
"""Split tree: a node is a pane or a container laid out side by side ("lr") or
|
|
stacked ("tb"); a one-cell border separates siblings. Sizes are absolute and are
|
|
rescaled proportionally when the window changes size."""
|
|
|
|
@staticmethod
|
|
def size_of(node, kind):
|
|
return node.w if kind == "lr" else node.h
|
|
|
|
@staticmethod
|
|
def set_size(node, kind, value):
|
|
if kind == "lr":
|
|
node.w = value
|
|
else:
|
|
node.h = value
|
|
|
|
@classmethod
|
|
def assign(cls, node, x, y, w, h):
|
|
node.x, node.y, node.w, node.h = x, y, w, h
|
|
if node.kind == "pane":
|
|
return
|
|
kids = node.children
|
|
count = len(kids)
|
|
kind = node.kind
|
|
total = (w if kind == "lr" else h) - (count - 1)
|
|
if total < count:
|
|
sizes = [1] * count
|
|
else:
|
|
sizes = [max(1, cls.size_of(kid, kind)) for kid in kids]
|
|
weight = sum(sizes) or count
|
|
if weight != total:
|
|
sizes = [max(1, int(round(size * total / weight))) for size in sizes]
|
|
diff = total - sum(sizes)
|
|
while diff != 0:
|
|
moved = False
|
|
for index in (range(count) if diff > 0 else range(count - 1, -1, -1)):
|
|
if diff > 0:
|
|
sizes[index] += 1
|
|
diff -= 1
|
|
moved = True
|
|
elif sizes[index] > 1:
|
|
sizes[index] -= 1
|
|
diff += 1
|
|
moved = True
|
|
if diff == 0:
|
|
break
|
|
if not moved:
|
|
break
|
|
order = list(zip(kids, sizes))
|
|
if node.reverse:
|
|
order.reverse()
|
|
pos = x if kind == "lr" else y
|
|
for kid, size in order:
|
|
if kind == "lr":
|
|
cls.assign(kid, pos, y, size, h)
|
|
else:
|
|
cls.assign(kid, x, pos, w, size)
|
|
pos += size + 1
|
|
|
|
@classmethod
|
|
def split(cls, root, leaf, kind, before, new_pane, size=None):
|
|
"""Insert new_pane beside leaf. Returns (root, new_leaf)."""
|
|
total = cls.size_of(leaf, kind)
|
|
if total < 3:
|
|
raise MplexError("no space for new pane")
|
|
avail = total - 1
|
|
if size is None:
|
|
new_size = avail - avail // 2 if before else avail // 2
|
|
else:
|
|
new_size = max(1, min(avail - 1, size))
|
|
old_size = avail - new_size
|
|
fresh = MplexLayoutNode("pane", pane=new_pane, w=leaf.w, h=leaf.h)
|
|
parent = leaf.parent
|
|
if parent is not None and parent.kind == kind:
|
|
index = parent.children.index(leaf)
|
|
parent.children.insert(index if before else index + 1, fresh)
|
|
fresh.parent = parent
|
|
else:
|
|
box = MplexLayoutNode(kind, w=leaf.w, h=leaf.h)
|
|
box.x, box.y = leaf.x, leaf.y
|
|
box.parent = parent
|
|
if parent is None:
|
|
root = box
|
|
else:
|
|
parent.children[parent.children.index(leaf)] = box
|
|
for item in ((fresh, leaf) if before else (leaf, fresh)):
|
|
box.adopt(item)
|
|
cls.set_size(leaf, kind, old_size)
|
|
cls.set_size(fresh, kind, new_size)
|
|
return root, fresh
|
|
|
|
@classmethod
|
|
def remove(cls, root, leaf):
|
|
"""Drop leaf, hand its space to a neighbour. Returns the new root (None when empty)."""
|
|
parent = leaf.parent
|
|
if parent is None:
|
|
return None
|
|
index = parent.children.index(leaf)
|
|
parent.children.remove(leaf)
|
|
neighbour = parent.children[index - 1] if index > 0 else parent.children[0]
|
|
kind = parent.kind
|
|
cls.set_size(neighbour, kind, cls.size_of(neighbour, kind) + cls.size_of(leaf, kind) + 1)
|
|
if len(parent.children) == 1:
|
|
only = parent.children[0]
|
|
grand = parent.parent
|
|
only.w, only.h = parent.w, parent.h
|
|
only.parent = grand
|
|
if grand is None:
|
|
return only
|
|
slot = grand.children.index(parent)
|
|
if only.kind == grand.kind:
|
|
grand.children[slot:slot + 1] = only.children
|
|
for child in only.children:
|
|
child.parent = grand
|
|
else:
|
|
grand.children[slot] = only
|
|
return root
|
|
|
|
@classmethod
|
|
def resize_by(cls, leaf, kind, delta, prefer_after):
|
|
"""Move the border of leaf's nearest enclosing "kind" container by delta cells."""
|
|
node = leaf
|
|
while node.parent is not None and node.parent.kind != kind:
|
|
node = node.parent
|
|
parent = node.parent
|
|
if parent is None:
|
|
raise MplexError("no border to resize")
|
|
kids = parent.children
|
|
index = kids.index(node)
|
|
use_after = (index < len(kids) - 1) if prefer_after else (index == 0)
|
|
first = index if use_after else index - 1
|
|
a, b = kids[first], kids[first + 1]
|
|
size_a, size_b = cls.size_of(a, kind), cls.size_of(b, kind)
|
|
new_a = max(1, min(size_a + size_b - 1, size_a + delta))
|
|
cls.set_size(a, kind, new_a)
|
|
cls.set_size(b, kind, size_a + size_b - new_a)
|
|
|
|
@staticmethod
|
|
def shares(total, count, spare_first):
|
|
"""Split total cells among count panes with count-1 one-cell borders; the remainder goes first or last."""
|
|
room = total - (count - 1)
|
|
base, extra = divmod(room, count)
|
|
sizes = [base] * count
|
|
for index in (range(extra) if spare_first else range(count - extra, count)):
|
|
sizes[index] += 1
|
|
return sizes
|
|
|
|
@classmethod
|
|
def resize_to(cls, leaf, kind, size):
|
|
"""Make leaf exactly size cells along kind by moving the border it can move (the one after it, else the one before)."""
|
|
change = size - cls.size_of(leaf, kind)
|
|
node = leaf
|
|
while node.parent is not None and node.parent.kind != kind:
|
|
node = node.parent
|
|
if node.parent is None:
|
|
raise MplexError("no border to resize")
|
|
has_next = node.parent.children.index(node) < len(node.parent.children) - 1
|
|
cls.resize_by(leaf, kind, change if has_next else -change, True)
|
|
|
|
@classmethod
|
|
def preset(cls, name, panes, width, height, main_width, main_height):
|
|
count = len(panes)
|
|
leaves = [MplexLayoutNode("pane", pane=pane, w=1, h=1) for pane in panes]
|
|
mirrored = name.endswith("-mirrored")
|
|
base = name[:-len("-mirrored")] if mirrored else name
|
|
if count == 1:
|
|
leaves[0].w, leaves[0].h = width, height
|
|
return leaves[0]
|
|
|
|
def box(kind, items, w, h, spare_first=True):
|
|
node = MplexLayoutNode(kind, w=w, h=h)
|
|
for item in items:
|
|
node.adopt(item)
|
|
for item, size in zip(items, cls.shares(w if kind == "lr" else h, len(items), spare_first)):
|
|
if kind == "lr":
|
|
item.w, item.h = size, h
|
|
else:
|
|
item.w, item.h = w, size
|
|
return node
|
|
|
|
if base == "even-horizontal":
|
|
return box("lr", leaves, width, height)
|
|
if base == "even-vertical":
|
|
return box("tb", leaves, width, height)
|
|
if base in ("main-horizontal", "main-vertical"):
|
|
horizontal = base == "main-horizontal"
|
|
span = height if horizontal else width
|
|
main_size = max(1, min(span - 2, main_height if horizontal else main_width))
|
|
main = leaves[0]
|
|
rest = leaves[1:]
|
|
other_size = span - main_size - 1
|
|
if horizontal:
|
|
others = rest[0] if len(rest) == 1 else box("lr", rest, width, other_size)
|
|
main.w, main.h, others.w, others.h = width, main_size, width, other_size
|
|
root = box("tb", [main, others], width, height)
|
|
else:
|
|
others = rest[0] if len(rest) == 1 else box("tb", rest, other_size, height)
|
|
main.w, main.h, others.w, others.h = main_size, height, other_size, height
|
|
root = box("lr", [main, others], width, height)
|
|
if horizontal:
|
|
main.h, others.h = main_size, other_size
|
|
else:
|
|
main.w, others.w = main_size, other_size
|
|
root.reverse = mirrored
|
|
return root
|
|
rows = columns = 1
|
|
while rows * columns < count:
|
|
rows += 1
|
|
if rows * columns < count:
|
|
columns += 1
|
|
lines = []
|
|
for start in range(0, count, columns):
|
|
chunk = leaves[start:start + columns]
|
|
lines.append(chunk[0] if len(chunk) == 1 else box("lr", chunk, width, 1, spare_first=False))
|
|
if len(lines) == 1:
|
|
return lines[0]
|
|
root = box("tb", lines, width, height, spare_first=False)
|
|
for line in lines:
|
|
line.w = width
|
|
return root
|
|
|
|
@staticmethod
|
|
def neighbour(panes, current, direction, width, height):
|
|
left, top = current.x, current.y
|
|
right, bottom = current.x + current.w, current.y + current.h
|
|
|
|
def overlaps(other):
|
|
if direction in ("L", "R"):
|
|
return other.y < bottom and other.y + other.h > top
|
|
return other.x < right and other.x + other.w > left
|
|
|
|
wanted = []
|
|
for other in panes:
|
|
if other is current or not overlaps(other):
|
|
continue
|
|
if direction == "L" and other.x + other.w + 1 == left:
|
|
wanted.append(other)
|
|
elif direction == "R" and other.x == right + 1:
|
|
wanted.append(other)
|
|
elif direction == "U" and other.y + other.h + 1 == top:
|
|
wanted.append(other)
|
|
elif direction == "D" and other.y == bottom + 1:
|
|
wanted.append(other)
|
|
if not wanted:
|
|
for other in panes:
|
|
if other is current or not overlaps(other):
|
|
continue
|
|
if direction == "L" and other.x + other.w >= width:
|
|
wanted.append(other)
|
|
elif direction == "R" and other.x == 0:
|
|
wanted.append(other)
|
|
elif direction == "U" and other.y + other.h >= height:
|
|
wanted.append(other)
|
|
elif direction == "D" and other.y == 0:
|
|
wanted.append(other)
|
|
if not wanted:
|
|
return None
|
|
return max(wanted, key=lambda item: item.last_active)
|
|
|
|
|
|
class MplexPane:
|
|
"""One pty-backed shell: its screen, scrollback, output queue and mode stack."""
|
|
|
|
def __init__(self, ident, cols, rows, history_limit, home_dir, stamp="0"):
|
|
self.id = ident
|
|
self.window = None
|
|
self.x = 0
|
|
self.y = 0
|
|
self.w = max(1, cols)
|
|
self.h = max(1, rows)
|
|
self.screen = TuiVtScreen(self.h, self.w)
|
|
self.screen.history = collections.deque(maxlen=max(0, history_limit))
|
|
self.master_fd = None
|
|
self.pid = None
|
|
self.dead = False
|
|
self.exit_status = None
|
|
self.pending_in = bytearray()
|
|
self.dropped = 0
|
|
self.modes = []
|
|
self.prefix_count = 1
|
|
self.created = time.time()
|
|
self.last_active = 0.0
|
|
self.shell_name = ""
|
|
self.last_search = None
|
|
self.log_path = os.path.join(home_dir, "%s-pane%d.log" % (stamp, ident))
|
|
self._log = None
|
|
self._log_size = 0
|
|
self.input_bytes = 0
|
|
|
|
@property
|
|
def tag(self):
|
|
return "%%%d" % self.id
|
|
|
|
@property
|
|
def title(self):
|
|
return self.screen.title or socket.gethostname()
|
|
|
|
def spawn(self, file, argv, cwd, env):
|
|
master, slave = os.openpty()
|
|
try:
|
|
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0))
|
|
attrs = termios.tcgetattr(slave)
|
|
attrs[0] |= getattr(termios, "IUTF8", 0)
|
|
termios.tcsetattr(slave, termios.TCSANOW, attrs)
|
|
except (OSError, termios.error):
|
|
pass
|
|
log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
self._log = os.fdopen(log_fd, "ab", buffering=0)
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
try:
|
|
os.setsid()
|
|
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
|
|
for fd in (0, 1, 2):
|
|
os.dup2(slave, fd)
|
|
if slave > 2:
|
|
os.close(slave)
|
|
os.close(master)
|
|
for sig in (signal.SIGPIPE, signal.SIGINT, signal.SIGHUP, signal.SIGTERM):
|
|
signal.signal(sig, signal.SIG_DFL)
|
|
try:
|
|
os.chdir(cwd)
|
|
except OSError:
|
|
pass
|
|
os.execvpe(file, argv, env)
|
|
except BaseException:
|
|
pass
|
|
finally:
|
|
os._exit(127)
|
|
os.close(slave)
|
|
os.set_blocking(master, False)
|
|
self.master_fd = master
|
|
self.pid = pid
|
|
self.shell_name = os.path.basename(file)
|
|
|
|
def place(self, x, y, w, h):
|
|
self.x, self.y = x, y
|
|
w, h = max(1, w), max(1, h)
|
|
if (w, h) == (self.w, self.h):
|
|
return
|
|
self.w, self.h = w, h
|
|
self.screen.resize(h, w)
|
|
self.apply_winsize()
|
|
for mode in self.modes:
|
|
mode.on_resize()
|
|
|
|
def apply_winsize(self):
|
|
if self.master_fd is None:
|
|
return
|
|
try:
|
|
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0))
|
|
except OSError:
|
|
pass
|
|
|
|
def feed(self, data):
|
|
history = self.screen.history
|
|
before = self.screen.history_pushed
|
|
before_len = len(history) if history is not None else 0
|
|
self.screen.feed(data)
|
|
pushed = self.screen.history_pushed - before
|
|
evicted = max(0, before_len + pushed - len(history)) if history is not None and pushed else 0
|
|
if self._log is not None:
|
|
self.log(data)
|
|
replies = self.screen.take_replies()
|
|
if replies:
|
|
self.write(replies)
|
|
for mode in self.modes:
|
|
mode.on_output(pushed, evicted)
|
|
|
|
def log(self, data):
|
|
"""Append to the session log; at MPLEX_LOG_MAX it rolls over to <name>.1 (one generation kept)."""
|
|
try:
|
|
if self._log_size + len(data) > MPLEX_LOG_MAX:
|
|
self._log.close()
|
|
os.replace(self.log_path, self.log_path + ".1")
|
|
fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
self._log = os.fdopen(fd, "ab", buffering=0)
|
|
self._log_size = 0
|
|
self._log.write(data)
|
|
self._log_size += len(data)
|
|
except OSError:
|
|
pass
|
|
|
|
def write(self, data):
|
|
if not data:
|
|
return
|
|
if len(self.pending_in) + len(data) > MPLEX_PANE_IN_MAX:
|
|
self.dropped += len(data)
|
|
return
|
|
self.pending_in += data
|
|
|
|
def drain(self):
|
|
if not self.pending_in or self.master_fd is None:
|
|
return
|
|
try:
|
|
written = os.write(self.master_fd, bytes(self.pending_in[:4096]))
|
|
del self.pending_in[:written]
|
|
except BlockingIOError:
|
|
pass
|
|
except OSError:
|
|
self.pending_in.clear()
|
|
|
|
def top_mode(self):
|
|
return self.modes[-1] if self.modes else None
|
|
|
|
def push_mode(self, mode):
|
|
self.modes.append(mode)
|
|
return mode
|
|
|
|
def pop_mode(self, mode=None):
|
|
if mode is None:
|
|
if self.modes:
|
|
self.modes.pop()
|
|
elif mode in self.modes:
|
|
self.modes.remove(mode)
|
|
|
|
def find_mode(self, cls):
|
|
for mode in self.modes:
|
|
if isinstance(mode, cls):
|
|
return mode
|
|
return None
|
|
|
|
def foreground_command(self):
|
|
try:
|
|
group = os.tcgetpgrp(self.master_fd)
|
|
with open("/proc/%d/comm" % group, "r") as handle:
|
|
return handle.read().strip() or self.shell_name
|
|
except (OSError, TypeError, ValueError):
|
|
return self.shell_name
|
|
|
|
def current_path(self):
|
|
try:
|
|
return os.readlink("/proc/%d/cwd" % self.pid)
|
|
except (OSError, TypeError):
|
|
return ""
|
|
|
|
def history_size(self):
|
|
history = self.screen.history
|
|
return len(history) if history is not None else 0
|
|
|
|
def clear_history(self):
|
|
if self.screen.history is not None:
|
|
self.screen.history.clear()
|
|
|
|
def close(self, hard=False):
|
|
"""Hang up the child: SIGHUP to its group, close the master; hard SIGKILLs instead."""
|
|
if self.pid is not None:
|
|
sig = signal.SIGKILL if hard else signal.SIGHUP
|
|
try:
|
|
os.killpg(self.pid, sig)
|
|
except OSError:
|
|
try:
|
|
os.kill(self.pid, sig)
|
|
except OSError:
|
|
pass
|
|
if self.master_fd is not None:
|
|
try:
|
|
os.close(self.master_fd)
|
|
except OSError:
|
|
pass
|
|
self.master_fd = None
|
|
if self._log is not None:
|
|
try:
|
|
self._log.close()
|
|
except OSError:
|
|
pass
|
|
self._log = None
|
|
|
|
def tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24):
|
|
"""Last lines of the raw session log, reading only the tail in growing chunks."""
|
|
try:
|
|
size = os.path.getsize(self.log_path)
|
|
except OSError:
|
|
return ""
|
|
with open(self.log_path, "rb") as handle:
|
|
read_from = size
|
|
data = b""
|
|
while True:
|
|
read_from = max(0, read_from - chunk_size)
|
|
handle.seek(read_from)
|
|
data = handle.read(size - read_from)
|
|
if data.count(b"\n") >= lines or read_from == 0 or len(data) >= max_bytes:
|
|
break
|
|
return "\n".join(data.decode("utf-8", "replace").splitlines()[-lines:])
|
|
|
|
|
|
class MplexWindow:
|
|
def __init__(self, ident, name, width, height, parent_options):
|
|
self.id = ident
|
|
self.name = name
|
|
self.root = None
|
|
self.active = None
|
|
self.last_pane = None
|
|
self.zoomed = None
|
|
self.width = width
|
|
self.height = height
|
|
self.layout_index = -1
|
|
self.options = MplexOptions(parent_options)
|
|
self.auto_rename = True
|
|
self.created = time.time()
|
|
self.session = None
|
|
|
|
@property
|
|
def tag(self):
|
|
return "@%d" % self.id
|
|
|
|
def panes(self):
|
|
return [leaf.pane for leaf in self.root.leaves()] if self.root is not None else []
|
|
|
|
def relayout(self):
|
|
if self.root is None:
|
|
return
|
|
MplexLayout.assign(self.root, 0, 0, self.width, self.height)
|
|
for leaf in self.root.leaves():
|
|
leaf.pane.place(leaf.x, leaf.y, leaf.w, leaf.h)
|
|
if self.zoomed is not None and self.zoomed in self.panes():
|
|
self.zoomed.place(0, 0, self.width, self.height)
|
|
else:
|
|
self.zoomed = None
|
|
|
|
def visible_panes(self):
|
|
return [self.zoomed] if self.zoomed is not None else self.panes()
|
|
|
|
def leaf_of(self, pane):
|
|
for leaf in self.root.leaves():
|
|
if leaf.pane is pane:
|
|
return leaf
|
|
return None
|
|
|
|
def set_active(self, pane):
|
|
if pane is not self.active:
|
|
self.last_pane = self.active
|
|
self.active = pane
|
|
pane.last_active = time.monotonic()
|
|
|
|
|
|
class MplexSession:
|
|
def __init__(self, ident, name, parent_options, cwd, env):
|
|
self.id = ident
|
|
self.name = name
|
|
self.windows = {}
|
|
self.current = None
|
|
self.history = []
|
|
self.created = time.time()
|
|
self.options = MplexOptions(parent_options)
|
|
self.cwd = cwd
|
|
self.env = env
|
|
self.last_attached = time.time()
|
|
|
|
@property
|
|
def tag(self):
|
|
return "$%d" % self.id
|
|
|
|
def window(self):
|
|
return self.windows.get(self.current)
|
|
|
|
def indexes(self):
|
|
return sorted(self.windows)
|
|
|
|
def free_index(self, base):
|
|
index = base
|
|
while index in self.windows:
|
|
index += 1
|
|
return index
|
|
|
|
@property
|
|
def last(self):
|
|
"""Index of the window that was current before this one, or None."""
|
|
for window in self.history:
|
|
index = self.index_of(window)
|
|
if index is not None:
|
|
return index
|
|
return None
|
|
|
|
def select(self, index):
|
|
if index not in self.windows:
|
|
return False
|
|
if index != self.current:
|
|
previous = self.window()
|
|
target = self.windows[index]
|
|
self.history = [item for item in self.history if item is not previous and item is not target]
|
|
if previous is not None:
|
|
self.history.insert(0, previous)
|
|
self.current = index
|
|
return True
|
|
|
|
def index_of(self, window):
|
|
for index, item in self.windows.items():
|
|
if item is window:
|
|
return index
|
|
return None
|
|
|
|
|
|
class MplexCanvas:
|
|
"""A client-sized grid of (char, fg, bg, attr) cells plus a cursor, and the diff that
|
|
turns two successive grids into the escape sequences that repaint only what changed."""
|
|
|
|
BLANK = (" ", None, None, 0)
|
|
GLYPHS = {
|
|
"single": "─│┌┐└┘├┤┬┴┼",
|
|
"double": "═║╔╗╚╝╠╣╦╩╬",
|
|
"heavy": "━┃┏┓┗┛┣┫┳┻╋",
|
|
"simple": "-|+++++++++",
|
|
}
|
|
|
|
def __init__(self, rows, cols):
|
|
self.rows = rows
|
|
self.cols = cols
|
|
self.cells = [[self.BLANK] * cols for _row in range(rows)]
|
|
self.cursor = None
|
|
|
|
def put(self, x, y, text, style=(None, None, 0), limit=None):
|
|
if y < 0 or y >= self.rows:
|
|
return x
|
|
fg, bg, attr = style
|
|
row = self.cells[y]
|
|
for char in text:
|
|
if limit is not None and limit <= 0:
|
|
break
|
|
if 0 <= x < self.cols:
|
|
row[x] = (char, fg, bg, attr)
|
|
x += 1
|
|
if limit is not None:
|
|
limit -= 1
|
|
return x
|
|
|
|
def put_cells(self, x, y, cells):
|
|
if y < 0 or y >= self.rows:
|
|
return
|
|
row = self.cells[y]
|
|
for cell in cells:
|
|
if 0 <= x < self.cols:
|
|
row[x] = cell
|
|
x += 1
|
|
|
|
def blit(self, x, y, block, width):
|
|
for index, source in enumerate(block):
|
|
target = y + index
|
|
if target < 0 or target >= self.rows:
|
|
continue
|
|
row = self.cells[target]
|
|
take = min(width, len(source), self.cols - x)
|
|
if take > 0:
|
|
row[x:x + take] = source[:take]
|
|
|
|
def fill(self, x, y, w, h, style=(None, None, 0), char=" "):
|
|
cell = (char, style[0], style[1], style[2])
|
|
for target in range(max(0, y), min(self.rows, y + h)):
|
|
row = self.cells[target]
|
|
for col in range(max(0, x), min(self.cols, x + w)):
|
|
row[col] = cell
|
|
|
|
@classmethod
|
|
def border_char(cls, up, down, left, right, lines):
|
|
glyphs = cls.GLYPHS.get(lines, cls.GLYPHS["single"])
|
|
horiz, vert, tl, tr, bl, br, tee_r, tee_l, tee_d, tee_u, cross = glyphs
|
|
if (up or down) and not (left or right):
|
|
return vert
|
|
if (left or right) and not (up or down):
|
|
return horiz
|
|
if up and down and left and right:
|
|
return cross
|
|
if up and down and right:
|
|
return tee_r
|
|
if up and down and left:
|
|
return tee_l
|
|
if left and right and down:
|
|
return tee_d
|
|
if left and right and up:
|
|
return tee_u
|
|
if down and right:
|
|
return tl
|
|
if down and left:
|
|
return tr
|
|
if up and right:
|
|
return bl
|
|
if up and left:
|
|
return br
|
|
return cross
|
|
|
|
def encode(self, previous):
|
|
"""Escape bytes repainting this grid over previous (a list of row tuples, or None)."""
|
|
snapshot = [tuple(row) for row in self.cells]
|
|
out = ["\x1b[?25l"]
|
|
if previous is None or len(previous) != len(snapshot):
|
|
out.append("\x1b[0m\x1b[2J")
|
|
previous = None
|
|
for index, row in enumerate(snapshot):
|
|
if previous is not None and previous[index] == row:
|
|
continue
|
|
out.append("\x1b[%d;1H" % (index + 1))
|
|
current = None
|
|
for char, fg, bg, attr in row:
|
|
style = (fg, bg, attr)
|
|
if style != current:
|
|
out.append(MplexStyle.sgr(fg, bg, attr))
|
|
current = style
|
|
out.append(char)
|
|
out.append("\x1b[0m")
|
|
if self.cursor is not None:
|
|
out.append("\x1b[%d;%dH\x1b[?25h" % (self.cursor[1] + 1, self.cursor[0] + 1))
|
|
return "".join(out).encode("utf-8", "replace"), snapshot
|
|
|
|
|
|
class MplexStatusText:
|
|
"""Format-expanded text with #[...] style markers turned into styled cells."""
|
|
|
|
@staticmethod
|
|
def cells(text, base, reset=None):
|
|
reset = base if reset is None else reset
|
|
current = base
|
|
out = []
|
|
pos = 0
|
|
while pos < len(text):
|
|
if text.startswith("#[", pos):
|
|
end = text.find("]", pos + 2)
|
|
if end > 0:
|
|
try:
|
|
current = MplexStyle.parse(text[pos + 2:end], current, reset)
|
|
except MplexError:
|
|
pass
|
|
pos = end + 1
|
|
continue
|
|
out.append((text[pos], current[0], current[1], current[2]))
|
|
pos += 1
|
|
return out
|
|
|
|
|
|
class MplexMode:
|
|
"""Pane mode base: owns the pane's keyboard and its drawing while it is on top."""
|
|
|
|
name = "mode"
|
|
|
|
def __init__(self, server, pane):
|
|
self.server = server
|
|
self.pane = pane
|
|
|
|
def render(self, client):
|
|
raise NotImplementedError
|
|
|
|
def key(self, name, raw, client):
|
|
return None
|
|
|
|
def on_output(self, pushed, evicted):
|
|
return None
|
|
|
|
def on_resize(self):
|
|
return None
|
|
|
|
def close(self):
|
|
self.pane.pop_mode(self)
|
|
self.server.mark_dirty(self.pane.window)
|
|
|
|
|
|
class MplexCopyMode(MplexMode):
|
|
"""Scrollback browser: movement, search, selection and copy, driven by the
|
|
copy-mode / copy-mode-vi key tables through "send-keys -X <command>"."""
|
|
|
|
name = "copy-mode"
|
|
|
|
def __init__(self, server, pane, lines=None, view=False, exit_at_bottom=False, show_position=True):
|
|
super().__init__(server, pane)
|
|
self.static_rows = None
|
|
if lines is not None:
|
|
self.name = "view-mode"
|
|
self.static_rows = [[(char, None, None, 0) for char in line] for line in lines]
|
|
while len(self.static_rows) < pane.h:
|
|
self.static_rows.append([])
|
|
self.oy = 0
|
|
self.cx = 0 if lines is not None else pane.screen.cx
|
|
self.cy = 0 if lines is not None else pane.screen.cy
|
|
self.anchor = None
|
|
self.rect = False
|
|
self.line_select = False
|
|
self.mark = None
|
|
self.jump = None
|
|
self.inc_origin = None
|
|
self.show_position = show_position
|
|
self.exit_at_bottom = exit_at_bottom
|
|
self.count = 1
|
|
self.lastcx = 0
|
|
self.lastsx = 0
|
|
# a search string outlives copy mode; re-entering it starts searching upward, as tmux does
|
|
self.search = (pane.last_search[0], False, pane.last_search[2]) if lines is None and pane.last_search else None
|
|
self._line_cache = (-1, "")
|
|
if lines is not None:
|
|
self.oy = self.hist()
|
|
|
|
@property
|
|
def rows(self):
|
|
return self.pane.h
|
|
|
|
@property
|
|
def cols(self):
|
|
return self.pane.w
|
|
|
|
def hist(self):
|
|
if self.static_rows is not None:
|
|
return max(0, len(self.static_rows) - self.rows)
|
|
return self.pane.history_size()
|
|
|
|
def total(self):
|
|
return self.hist() + self.rows
|
|
|
|
def top(self):
|
|
return self.hist() - self.oy
|
|
|
|
def abs_y(self):
|
|
return self.top() + self.cy
|
|
|
|
def row(self, ay):
|
|
if self.static_rows is not None:
|
|
return self.static_rows[ay] if 0 <= ay < len(self.static_rows) else []
|
|
hist = self.hist()
|
|
if ay < hist:
|
|
return self.pane.screen.history[ay]
|
|
grid = self.pane.screen.grid
|
|
index = ay - hist
|
|
return grid[index] if 0 <= index < len(grid) else []
|
|
|
|
def text(self, ay):
|
|
return "".join(cell[0] if cell else " " for cell in self.row(ay)).rstrip()
|
|
|
|
def clamp(self):
|
|
self.oy = max(0, min(self.hist(), self.oy))
|
|
self.cy = max(0, min(self.rows - 1, self.cy))
|
|
self.cx = max(0, min(self.cols, self.cx))
|
|
|
|
def goto(self, x, ay):
|
|
ay = max(0, min(self.total() - 1, ay))
|
|
top = self.top()
|
|
if ay < top:
|
|
top = ay
|
|
elif ay >= top + self.rows:
|
|
top = ay - self.rows + 1
|
|
self.oy = max(0, min(self.hist(), self.hist() - top))
|
|
self.cy = ay - self.top()
|
|
self.cx = max(0, min(self.cols, x))
|
|
|
|
def on_output(self, pushed, evicted):
|
|
if self.static_rows is not None:
|
|
return
|
|
if self.oy > 0 and pushed:
|
|
self.oy += pushed
|
|
if evicted:
|
|
if self.anchor:
|
|
self.anchor = (self.anchor[0], self.anchor[1] - evicted)
|
|
if self.mark:
|
|
self.mark = (self.mark[0], self.mark[1] - evicted)
|
|
self.clamp()
|
|
|
|
def on_resize(self):
|
|
self.clamp()
|
|
|
|
# ---- rendering -------------------------------------------------------
|
|
|
|
def vi_keys(self):
|
|
return self.server.pane_options(self.pane).get("mode-keys") == "vi"
|
|
|
|
def selected(self, x, ay):
|
|
if self.anchor is None:
|
|
return False
|
|
ax, ay0 = self.anchor
|
|
cx, cy = self.cx, self.abs_y()
|
|
extra = 1 if self.vi_keys() else 0
|
|
if self.rect:
|
|
return min(ay0, cy) <= ay <= max(ay0, cy) and min(ax, cx) <= x < max(ax, cx) + extra
|
|
if self.line_select:
|
|
return min(ay0, cy) <= ay <= max(ay0, cy)
|
|
first, last = sorted(((ay0, ax), (cy, cx)))
|
|
return first <= (ay, x) < (last[0], last[1] + extra)
|
|
|
|
def pattern(self):
|
|
if not self.search:
|
|
return None
|
|
return self.compile(self.search[0], len(self.search) > 2 and self.search[2])
|
|
|
|
def render(self, client):
|
|
opts = self.server.pane_options(self.pane)
|
|
mode_style = MplexStyle.parse(opts.get("mode-style"))
|
|
match_style = MplexStyle.parse(opts.get("copy-mode-match-style"))
|
|
pattern = self.pattern()
|
|
top = self.top()
|
|
out = []
|
|
for view_row in range(self.rows):
|
|
ay = top + view_row
|
|
source = self.row(ay)
|
|
cells = []
|
|
for col in range(self.cols):
|
|
cell = source[col] if col < len(source) and source[col] is not None else MplexCanvas.BLANK
|
|
cells.append(cell)
|
|
if pattern is not None:
|
|
line = "".join(cell[0] for cell in cells)
|
|
for match in pattern.finditer(line):
|
|
if match.end() == match.start():
|
|
continue
|
|
for col in range(match.start(), match.end()):
|
|
cells[col] = self.tint(cells[col], match_style)
|
|
if self.anchor is not None:
|
|
for col in range(self.cols):
|
|
if self.selected(col, ay):
|
|
cells[col] = self.tint(cells[col], mode_style)
|
|
out.append(cells)
|
|
if self.show_position and out:
|
|
label = "[%d/%d]" % (self.oy, self.hist())
|
|
start = max(0, self.cols - len(label))
|
|
for index, char in enumerate(label[:self.cols]):
|
|
out[0][start + index] = (char, mode_style[0], mode_style[1], mode_style[2])
|
|
return out, (self.cx, self.cy)
|
|
|
|
@staticmethod
|
|
def tint(cell, style):
|
|
char, fg, bg, attr = cell
|
|
return (char, style[0] or fg, style[1] or bg, attr | style[2])
|
|
|
|
# ---- keys ------------------------------------------------------------
|
|
|
|
def key(self, name, raw, client):
|
|
table = "copy-mode-vi" if self.server.pane_options(self.pane).get("mode-keys") == "vi" else "copy-mode"
|
|
binding = self.server.config.lookup(table, name)
|
|
if binding is not None:
|
|
self.server.run_binding(client, binding, self.pane)
|
|
return True
|
|
|
|
def command(self, name, words, client):
|
|
"""Run one copy-mode command (send-keys -X name args) count times."""
|
|
method = getattr(self, "c_" + name.replace("-", "_"), None)
|
|
if method is None:
|
|
raise MplexError("unknown copy mode command: %s" % name)
|
|
count = max(1, min(self.pane.prefix_count, 10000))
|
|
self.pane.prefix_count = 1
|
|
self.count = count
|
|
self._line_cache = (-1, "")
|
|
result = method(words, client)
|
|
self.clamp()
|
|
self.server.mark_dirty(self.pane.window)
|
|
if result == "cancel":
|
|
self.close()
|
|
|
|
# ---- cursor and scrolling --------------------------------------------
|
|
# The cursor sits between characters like tmux's: it may stand one past the
|
|
# last character of a line (x == line length), and vertical moves remember the
|
|
# column they started from (lastcx / lastsx), snapping to the line end the way
|
|
# tmux does. These rules were checked against tmux 3.6 with a differential fuzz.
|
|
|
|
def llen(self, ay):
|
|
return len(self.text(ay))
|
|
|
|
def snap(self):
|
|
px = self.llen(self.abs_y())
|
|
if (self.cx >= self.lastsx and self.cx != px) or self.cx > px:
|
|
self.cx = px
|
|
|
|
def c_cancel(self, words, client):
|
|
return "cancel"
|
|
|
|
def step_up(self, scroll_only):
|
|
ox = self.llen(self.abs_y())
|
|
if self.cx != ox:
|
|
self.lastcx, self.lastsx = self.cx, ox
|
|
self.cx = self.lastcx
|
|
if scroll_only or self.cy == 0:
|
|
if self.oy < self.hist():
|
|
self.oy += 1
|
|
else:
|
|
self.cy -= 1
|
|
self.snap()
|
|
|
|
def step_down(self, scroll_only):
|
|
ox = self.llen(self.abs_y())
|
|
if self.cx != ox:
|
|
self.lastcx, self.lastsx = self.cx, ox
|
|
self.cx = self.lastcx
|
|
if scroll_only or self.cy == self.rows - 1:
|
|
if self.oy > 0:
|
|
self.oy -= 1
|
|
else:
|
|
self.cy += 1
|
|
self.snap()
|
|
|
|
def c_cursor_up(self, words, client):
|
|
for _i in range(self.count):
|
|
self.step_up(False)
|
|
|
|
def c_cursor_down(self, words, client):
|
|
for _i in range(self.count):
|
|
self.step_down(False)
|
|
return self.bottom_exit()
|
|
|
|
def c_cursor_down_and_cancel(self, words, client):
|
|
for _i in range(self.count):
|
|
self.step_down(False)
|
|
if self.oy == 0 and self.cy == self.rows - 1:
|
|
return "cancel"
|
|
|
|
def bottom_exit(self):
|
|
return "cancel" if self.exit_at_bottom and self.oy == 0 else None
|
|
|
|
def c_cursor_left(self, words, client):
|
|
for _i in range(self.count):
|
|
ay = self.abs_y()
|
|
if self.cx == 0 and ay > 0:
|
|
self.goto(self.llen(ay - 1), ay - 1)
|
|
elif self.cx > 0:
|
|
self.cx -= 1
|
|
|
|
def c_cursor_right(self, words, client):
|
|
for _i in range(self.count):
|
|
ay = self.abs_y()
|
|
if self.cx >= self.llen(ay):
|
|
if ay < self.total() - 1:
|
|
self.goto(0, ay + 1)
|
|
else:
|
|
self.cx += 1
|
|
|
|
def c_start_of_line(self, words, client):
|
|
self.cx = 0
|
|
|
|
def c_end_of_line(self, words, client):
|
|
self.cx = self.llen(self.abs_y())
|
|
|
|
def c_back_to_indentation(self, words, client):
|
|
line = self.text(self.abs_y())
|
|
if line:
|
|
self.cx = len(line) - len(line.lstrip(" "))
|
|
|
|
def c_top_line(self, words, client):
|
|
self.cx, self.cy = 0, 0
|
|
|
|
def c_middle_line(self, words, client):
|
|
self.cx, self.cy = 0, (self.rows - 1) // 2
|
|
|
|
def c_bottom_line(self, words, client):
|
|
self.cx, self.cy = 0, self.rows - 1
|
|
|
|
def c_history_top(self, words, client):
|
|
self.oy, self.cx, self.cy = self.hist(), 0, 0
|
|
|
|
def c_history_bottom(self, words, client):
|
|
self.oy, self.cy = 0, self.rows - 1
|
|
self.cx = self.llen(self.abs_y())
|
|
return self.bottom_exit()
|
|
|
|
def page_size(self, half):
|
|
return max(1, self.rows // 2 if half else self.rows - 2) if self.rows > 2 else 1
|
|
|
|
def remember_column(self):
|
|
ox = self.llen(self.abs_y())
|
|
if self.cx != ox:
|
|
self.lastcx, self.lastsx = self.cx, ox
|
|
self.cx = self.lastcx
|
|
|
|
def page_up(self, half):
|
|
size = self.page_size(half)
|
|
self.remember_column()
|
|
if self.oy + size > self.hist():
|
|
self.oy = self.hist()
|
|
self.cy = self.cy - size if self.cy > size else 0
|
|
else:
|
|
self.oy += size
|
|
self.snap()
|
|
|
|
def page_down(self, half):
|
|
size = self.page_size(half)
|
|
self.remember_column()
|
|
if self.oy < size:
|
|
self.cy = min(self.rows - 1, self.cy + size)
|
|
self.oy = 0
|
|
else:
|
|
self.oy -= size
|
|
self.snap()
|
|
|
|
def c_page_up(self, words, client):
|
|
for _i in range(self.count):
|
|
self.page_up(False)
|
|
|
|
def c_page_down(self, words, client):
|
|
for _i in range(self.count):
|
|
self.page_down(False)
|
|
return self.bottom_exit()
|
|
|
|
def c_page_down_and_cancel(self, words, client):
|
|
for _i in range(self.count):
|
|
self.page_down(False)
|
|
if self.oy == 0:
|
|
return "cancel"
|
|
|
|
def c_halfpage_up(self, words, client):
|
|
for _i in range(self.count):
|
|
self.page_up(True)
|
|
|
|
def c_halfpage_down(self, words, client):
|
|
for _i in range(self.count):
|
|
self.page_down(True)
|
|
return self.bottom_exit()
|
|
|
|
def c_halfpage_down_and_cancel(self, words, client):
|
|
for _i in range(self.count):
|
|
self.page_down(True)
|
|
if self.oy == 0:
|
|
return "cancel"
|
|
|
|
def c_scroll_up(self, words, client):
|
|
for _i in range(self.count):
|
|
self.step_up(True)
|
|
|
|
def c_scroll_down(self, words, client):
|
|
for _i in range(self.count):
|
|
self.step_down(True)
|
|
return self.bottom_exit()
|
|
|
|
def c_scroll_down_and_cancel(self, words, client):
|
|
for _i in range(self.count):
|
|
self.step_down(True)
|
|
if self.oy == 0:
|
|
return "cancel"
|
|
|
|
def c_scroll_middle(self, words, client):
|
|
mid = (self.rows - 1) // 2
|
|
delta = self.cy - mid
|
|
if delta > 0 and self.oy >= delta:
|
|
self.oy -= delta
|
|
self.cy = mid
|
|
elif delta < 0 and self.hist() - self.oy >= -delta:
|
|
self.oy += -delta
|
|
self.cy = mid
|
|
|
|
def c_cursor_centre_vertical(self, words, client):
|
|
self.cy = self.rows // 2
|
|
|
|
def c_cursor_centre_horizontal(self, words, client):
|
|
return None
|
|
|
|
def c_scroll_top(self, words, client):
|
|
if self.oy >= self.cy:
|
|
self.oy -= self.cy
|
|
self.cy = 0
|
|
|
|
def c_scroll_bottom(self, words, client):
|
|
need = self.rows - 1 - self.cy
|
|
if self.hist() - self.oy >= need:
|
|
self.oy += need
|
|
self.cy = self.rows - 1
|
|
|
|
def c_goto_line(self, words, client):
|
|
try:
|
|
line = int(words[0])
|
|
except (IndexError, ValueError):
|
|
raise MplexError("goto-line needs a line number")
|
|
self.oy = max(0, min(self.hist(), line))
|
|
|
|
def c_refresh_from_pane(self, words, client):
|
|
return None
|
|
|
|
def c_toggle_position(self, words, client):
|
|
self.show_position = not self.show_position
|
|
|
|
# ---- words, paragraphs, brackets -------------------------------------
|
|
|
|
def in_ws(self, x, ay):
|
|
return self.char_at(x, ay) == " "
|
|
|
|
def in_sep(self, x, ay, separators):
|
|
return self.char_at(x, ay) in separators
|
|
|
|
def separators(self, big):
|
|
return "" if big else self.server.pane_options(self.pane).get("word-separators")
|
|
|
|
def char_at(self, x, ay):
|
|
if self._line_cache[0] != ay:
|
|
self._line_cache = (ay, self.text(ay))
|
|
line = self._line_cache[1]
|
|
return line[x] if x < len(line) else " "
|
|
|
|
def advance_blanks(self, x, ay, xx):
|
|
"""Skip whitespace, running off the line end onto the next line. Returns (x, ay, xx, hit_bottom)."""
|
|
last = self.total() - 1
|
|
while x > xx or self.in_ws(x, ay):
|
|
if x > xx:
|
|
if ay == last:
|
|
return x, ay, xx, True
|
|
ay += 1
|
|
x = 0
|
|
xx = self.llen(ay)
|
|
else:
|
|
x += 1
|
|
return x, ay, xx, False
|
|
|
|
def run_end(self, x, ay, xx, separators):
|
|
"""x just past the run of separators (or of word characters) that starts at x."""
|
|
if self.in_sep(x, ay, separators) and not self.in_ws(x, ay):
|
|
x += 1
|
|
while x <= xx and self.in_sep(x, ay, separators) and not self.in_ws(x, ay):
|
|
x += 1
|
|
else:
|
|
x += 1
|
|
while x <= xx and not (self.in_sep(x, ay, separators) or self.in_ws(x, ay)):
|
|
x += 1
|
|
return x
|
|
|
|
def c_next_word(self, words, client, big=False):
|
|
separators = self.separators(big)
|
|
x, ay = self.cx, self.abs_y()
|
|
for _i in range(self.count):
|
|
xx = self.llen(ay)
|
|
if x > xx:
|
|
# past the line end (reachable after centre-vertical or goto-line): tmux carries on skipping
|
|
# the first word of the next line before it looks for a word start
|
|
if ay == self.total() - 1:
|
|
break
|
|
ay += 1
|
|
x = 0
|
|
xx = self.llen(ay)
|
|
if not self.in_ws(x, ay):
|
|
x = self.run_end(x, ay, xx, separators)
|
|
x, ay, xx, _bottom = self.advance_blanks(x, ay, xx)
|
|
self.goto(x, ay)
|
|
|
|
def c_next_space(self, words, client):
|
|
self.c_next_word(words, client, True)
|
|
|
|
def c_next_word_end(self, words, client, big=False):
|
|
separators = self.separators(big)
|
|
vi = self.server.pane_options(self.pane).get("mode-keys") == "vi"
|
|
x, ay = self.cx, self.abs_y()
|
|
for _i in range(self.count):
|
|
xx = self.llen(ay)
|
|
if vi:
|
|
if x < xx:
|
|
x += 1
|
|
elif ay < self.total() - 1:
|
|
self.goto(0, ay + 1)
|
|
x, ay = 0, ay + 1
|
|
xx = self.llen(ay)
|
|
x, ay, xx, bottom = self.advance_blanks(x, ay, xx)
|
|
if not bottom:
|
|
x = self.run_end(x, ay, xx, separators)
|
|
if vi and x > 0:
|
|
x -= 1
|
|
if bottom:
|
|
break
|
|
self.goto(x, ay)
|
|
|
|
def c_next_space_end(self, words, client):
|
|
self.c_next_word_end(words, client, True)
|
|
|
|
def c_previous_word(self, words, client, big=False):
|
|
separators = self.separators(big)
|
|
x, ay = self.cx, self.abs_y()
|
|
for _i in range(self.count):
|
|
# one step back, then over whitespace (and empty lines) to a word character
|
|
while True:
|
|
if x == 0:
|
|
if ay == 0:
|
|
break
|
|
ay -= 1
|
|
x = self.llen(ay)
|
|
else:
|
|
x -= 1
|
|
if not self.in_ws(x, ay):
|
|
break
|
|
if self.in_ws(x, ay):
|
|
break
|
|
letters = not self.in_sep(x, ay, separators)
|
|
while x > 0 and not self.in_ws(x - 1, ay) and letters == (not self.in_sep(x - 1, ay, separators)):
|
|
x -= 1
|
|
self.goto(x, ay)
|
|
|
|
def c_previous_space(self, words, client):
|
|
self.c_previous_word(words, client, True)
|
|
|
|
def c_next_paragraph(self, words, client):
|
|
ay = self.abs_y()
|
|
last = self.total() - 1
|
|
for _i in range(self.count):
|
|
while ay < last and not self.llen(ay):
|
|
ay += 1
|
|
while ay < last and self.llen(ay):
|
|
ay += 1
|
|
self.scroll_to(self.llen(ay), ay)
|
|
|
|
def c_previous_paragraph(self, words, client):
|
|
ay = self.abs_y()
|
|
for _i in range(self.count):
|
|
while ay > 0 and not self.llen(ay):
|
|
ay -= 1
|
|
while ay > 0 and self.llen(ay):
|
|
ay -= 1
|
|
self.scroll_to(0, ay)
|
|
|
|
def match_bracket(self, x, ay, forward):
|
|
"""Position of the bracket matching the one at (x, ay), or None."""
|
|
pairs = {"(": ")", "[": "]", "{": "}"}
|
|
reverse = {value: key for key, value in pairs.items()}
|
|
opener = self.char_at(x, ay)
|
|
closer = pairs.get(opener) if forward else reverse.get(opener)
|
|
if closer is None:
|
|
return None
|
|
depth = 0
|
|
pos = (x, ay)
|
|
for _i in range(20000):
|
|
char = self.char_at(*pos)
|
|
if char == opener:
|
|
depth += 1
|
|
elif char == closer:
|
|
depth -= 1
|
|
if depth == 0:
|
|
return pos
|
|
pos = self.step(pos[0], pos[1], forward)
|
|
if pos is None:
|
|
return None
|
|
return None
|
|
|
|
def step(self, x, ay, forward):
|
|
"""Next position, line ends counting as one blank cell. None at the buffer edge."""
|
|
if forward:
|
|
if x < self.llen(ay):
|
|
return x + 1, ay
|
|
return (0, ay + 1) if ay + 1 < self.total() else None
|
|
if x > 0:
|
|
return x - 1, ay
|
|
return (self.llen(ay - 1), ay - 1) if ay > 0 else None
|
|
|
|
def c_next_matching_bracket(self, words, client):
|
|
ay = self.abs_y()
|
|
if not self.llen(ay):
|
|
return
|
|
here = self.char_at(self.cx, ay)
|
|
found = None
|
|
if self.vi_keys():
|
|
if here in "([{":
|
|
found = self.match_bracket(self.cx, ay, True)
|
|
elif here in ")]}":
|
|
found = self.match_bracket(self.cx, ay, False)
|
|
else:
|
|
for x in (self.cx, self.cx + 1):
|
|
if self.char_at(x, ay) in "([{" and found is None and (x == self.cx or here == " "):
|
|
found = self.match_bracket(x, ay, True)
|
|
if found is not None:
|
|
self.goto(*found)
|
|
else:
|
|
self.c_next_word_end([], None, True)
|
|
|
|
def c_previous_matching_bracket(self, words, client):
|
|
ay = self.abs_y()
|
|
if not self.llen(ay):
|
|
return
|
|
here = self.char_at(self.cx, ay)
|
|
found = None
|
|
if here in ")]}":
|
|
found = self.match_bracket(self.cx, ay, False)
|
|
elif self.vi_keys():
|
|
if here in "([{" or self.char_at(self.cx + 1, ay) in "([{":
|
|
return
|
|
elif self.cx > 0 and self.char_at(self.cx - 1, ay) in ")]}":
|
|
found = self.match_bracket(self.cx - 1, ay, False)
|
|
if found is not None:
|
|
self.goto(*found)
|
|
else:
|
|
self.c_previous_word([], None, True)
|
|
|
|
# ---- jump ------------------------------------------------------------
|
|
|
|
def do_jump(self, kind, char):
|
|
line = self.text(self.abs_y())
|
|
x = self.cx
|
|
for _i in range(self.count):
|
|
if kind == "f":
|
|
found = line.find(char, x + 1)
|
|
elif kind == "t":
|
|
found = line.find(char, x + 2)
|
|
found = found - 1 if found >= 0 else -1
|
|
elif kind == "F":
|
|
found = line.rfind(char, 0, x)
|
|
else:
|
|
found = line.rfind(char, 0, max(0, x - 1))
|
|
found = found + 1 if found >= 0 else -1
|
|
if found < 0:
|
|
return
|
|
x = found
|
|
self.cx = x
|
|
|
|
def set_jump(self, kind, words):
|
|
if not words or not words[0]:
|
|
raise MplexError("jump needs a character")
|
|
self.jump = (kind, words[0][0])
|
|
self.do_jump(*self.jump)
|
|
|
|
def c_jump_forward(self, words, client):
|
|
self.set_jump("f", words)
|
|
|
|
def c_jump_backward(self, words, client):
|
|
self.set_jump("F", words)
|
|
|
|
def c_jump_to_forward(self, words, client):
|
|
self.set_jump("t", words)
|
|
|
|
def c_jump_to_backward(self, words, client):
|
|
self.set_jump("T", words)
|
|
|
|
def c_jump_again(self, words, client):
|
|
if self.jump:
|
|
self.do_jump(*self.jump)
|
|
|
|
def c_jump_reverse(self, words, client):
|
|
if self.jump:
|
|
opposite = {"f": "F", "F": "f", "t": "T", "T": "t"}[self.jump[0]]
|
|
self.do_jump(opposite, self.jump[1])
|
|
|
|
# ---- selection and copy ----------------------------------------------
|
|
|
|
def c_begin_selection(self, words, client):
|
|
self.anchor = (self.cx, self.abs_y())
|
|
self.line_select = False
|
|
|
|
def c_stop_selection(self, words, client):
|
|
return None
|
|
|
|
def c_clear_selection(self, words, client):
|
|
self.anchor = None
|
|
self.line_select = False
|
|
|
|
def c_select_line(self, words, client):
|
|
self.anchor = (0, self.abs_y())
|
|
self.line_select = True
|
|
self.rect = False
|
|
for _i in range(self.count - 1):
|
|
self.step_down(False)
|
|
self.cx = self.llen(self.abs_y())
|
|
|
|
def c_select_word(self, words, client):
|
|
ay = self.abs_y()
|
|
separators = self.separators(False)
|
|
line = self.text(ay)
|
|
if not line:
|
|
return
|
|
start = min(self.cx, len(line) - 1)
|
|
while start > 0 and self.in_ws(start, ay):
|
|
start -= 1
|
|
if self.in_ws(start, ay):
|
|
return
|
|
letters = not self.in_sep(start, ay, separators)
|
|
end = start
|
|
while start > 0 and not self.in_ws(start - 1, ay) and letters == (not self.in_sep(start - 1, ay, separators)):
|
|
start -= 1
|
|
while end + 1 < len(line) and not self.in_ws(end + 1, ay) and letters == (not self.in_sep(end + 1, ay, separators)):
|
|
end += 1
|
|
self.anchor = (start, ay)
|
|
self.line_select = False
|
|
self.cx = end if self.vi_keys() else end + 1
|
|
|
|
def c_rectangle_toggle(self, words, client):
|
|
self.rect = not self.rect
|
|
|
|
def c_rectangle_on(self, words, client):
|
|
self.rect = True
|
|
|
|
def c_rectangle_off(self, words, client):
|
|
self.rect = False
|
|
|
|
def c_other_end(self, words, client):
|
|
if self.anchor is None:
|
|
return
|
|
old = (self.cx, self.abs_y())
|
|
self.goto(*self.anchor)
|
|
self.anchor = old
|
|
|
|
def c_set_mark(self, words, client):
|
|
self.mark = (self.cx, self.abs_y())
|
|
|
|
def c_jump_to_mark(self, words, client):
|
|
if self.mark is None:
|
|
return
|
|
old = (self.cx, self.abs_y())
|
|
self.goto(*self.mark)
|
|
self.mark = old
|
|
|
|
def selection_text(self):
|
|
"""The selected text. Emacs keys: the cursor stands between characters, so the end is
|
|
exclusive; vi keys: the character under the cursor is included, and so is the line end
|
|
(a newline) when the cursor stands on it. Both checked against tmux 3.6."""
|
|
if self.anchor is None:
|
|
return ""
|
|
vi = self.vi_keys()
|
|
ax, ay0 = self.anchor
|
|
cx, cy = self.cx, self.abs_y()
|
|
extra = 1 if vi else 0
|
|
if self.line_select:
|
|
lines = [self.text(ay) for ay in range(min(ay0, cy), max(ay0, cy) + 1)]
|
|
return "\n".join(lines) + ("\n" if vi else "")
|
|
if self.rect:
|
|
left, right = min(ax, cx), max(ax, cx) + extra
|
|
return "\n".join(self.text(ay)[left:right] for ay in range(min(ay0, cy), max(ay0, cy) + 1))
|
|
(sy, sx), (ey, ex) = sorted(((ay0, ax), (cy, cx)))
|
|
parts = []
|
|
for ay in range(sy, ey + 1):
|
|
line = self.text(ay)
|
|
first = sx if ay == sy else 0
|
|
last = ex + extra if ay == ey else len(line)
|
|
piece = line[first:last]
|
|
if ay == ey and vi and last > len(line):
|
|
piece += "\n"
|
|
parts.append(piece)
|
|
return "\n".join(parts)
|
|
|
|
def deliver(self, text, words, append=False):
|
|
if not text:
|
|
return
|
|
command = words[0] if words else ""
|
|
self.server.store_copy(text, command, append)
|
|
|
|
def c_copy_selection(self, words, client):
|
|
self.deliver(self.selection_text(), [])
|
|
self.c_clear_selection(words, client)
|
|
|
|
def c_copy_selection_and_cancel(self, words, client):
|
|
self.deliver(self.selection_text(), [])
|
|
return "cancel"
|
|
|
|
def c_copy_pipe(self, words, client):
|
|
self.deliver(self.selection_text(), words)
|
|
self.c_clear_selection(words, client)
|
|
|
|
def c_copy_pipe_and_cancel(self, words, client):
|
|
self.deliver(self.selection_text(), words)
|
|
return "cancel"
|
|
|
|
def c_append_selection(self, words, client):
|
|
self.deliver(self.selection_text(), [], True)
|
|
self.c_clear_selection(words, client)
|
|
|
|
def c_append_selection_and_cancel(self, words, client):
|
|
self.deliver(self.selection_text(), [], True)
|
|
return "cancel"
|
|
|
|
def copy_span(self, from_start, words, cancel):
|
|
"""copy-end-of-line / copy-line: select from the cursor (or the line start) to the line end, count lines."""
|
|
ay = self.abs_y()
|
|
self.anchor = (0 if from_start else self.cx, ay)
|
|
self.line_select = False
|
|
self.rect = False
|
|
for _i in range(self.count - 1):
|
|
self.step_down(False)
|
|
self.cx = self.llen(self.abs_y())
|
|
self.deliver(self.selection_text(), words)
|
|
self.c_clear_selection(words, None)
|
|
return "cancel" if cancel else None
|
|
|
|
def c_copy_end_of_line(self, words, client):
|
|
return self.copy_span(False, [], False)
|
|
|
|
def c_copy_end_of_line_and_cancel(self, words, client):
|
|
return self.copy_span(False, [], True)
|
|
|
|
def c_copy_pipe_end_of_line(self, words, client):
|
|
return self.copy_span(False, words, False)
|
|
|
|
def c_copy_pipe_end_of_line_and_cancel(self, words, client):
|
|
return self.copy_span(False, words, True)
|
|
|
|
def c_copy_line(self, words, client):
|
|
return self.copy_span(True, [], False)
|
|
|
|
def c_copy_line_and_cancel(self, words, client):
|
|
return self.copy_span(True, [], True)
|
|
|
|
def c_copy_pipe_line(self, words, client):
|
|
return self.copy_span(True, words, False)
|
|
|
|
def c_copy_pipe_line_and_cancel(self, words, client):
|
|
return self.copy_span(True, words, True)
|
|
|
|
# ---- search ----------------------------------------------------------
|
|
|
|
def compile(self, text, literal=False):
|
|
flags = re.IGNORECASE if text == text.lower() else 0
|
|
if literal:
|
|
return re.compile(re.escape(text), flags)
|
|
try:
|
|
return re.compile(text, flags)
|
|
except re.error:
|
|
return None
|
|
|
|
def find(self, pattern, forward, x, ay, wrap):
|
|
"""First match at or after (x, ay) going forward, or before it going backward; returns (start, end, line)."""
|
|
total = self.total()
|
|
|
|
def matches_of(index):
|
|
return [m for m in pattern.finditer(self.text(index)) if m.end() > m.start()]
|
|
|
|
order = range(ay, total) if forward else range(ay, -1, -1)
|
|
for index in order:
|
|
found = matches_of(index)
|
|
if index == ay:
|
|
found = [m for m in found if (m.start() >= x if forward else m.start() < x)]
|
|
if found:
|
|
match = found[0] if forward else found[-1]
|
|
return match.start(), match.end(), index
|
|
if wrap:
|
|
for index in (range(0, ay + 1) if forward else range(total - 1, ay - 1, -1)):
|
|
found = matches_of(index)
|
|
if found:
|
|
match = found[0] if forward else found[-1]
|
|
return match.start(), match.end(), index
|
|
return None
|
|
|
|
def scroll_to(self, x, ay):
|
|
"""Place the cursor on (x, ay), scrolling the way tmux does when the line is out of view."""
|
|
hist, rows = self.hist(), self.rows
|
|
self.cx = max(0, min(self.cols, x))
|
|
top = hist - self.oy
|
|
if top <= ay < top + rows:
|
|
self.cy = ay - top
|
|
return
|
|
gap = rows // 4
|
|
if ay < rows:
|
|
offset, self.cy = 0, ay
|
|
elif ay > hist + rows - gap:
|
|
offset, self.cy = hist, ay - hist
|
|
else:
|
|
offset, self.cy = ay + gap - rows, rows - gap
|
|
self.oy = max(0, min(hist, hist - offset))
|
|
|
|
def run_search(self, text, forward, literal=False, origin=None):
|
|
if not text:
|
|
return False
|
|
self.search = (text, forward, literal)
|
|
if self.static_rows is None:
|
|
self.pane.last_search = self.search
|
|
pattern = self.compile(text, literal)
|
|
if pattern is None:
|
|
return False
|
|
wrap = bool(self.server.pane_options(self.pane).get("wrap-search"))
|
|
vi = self.server.pane_options(self.pane).get("mode-keys") == "vi"
|
|
x, ay = origin if origin is not None else (self.cx, self.abs_y())
|
|
if forward and vi and origin is None:
|
|
x += 1
|
|
found = self.find(pattern, forward, x, ay, wrap)
|
|
if found is None:
|
|
return False
|
|
start, end, line = found
|
|
self.scroll_to(end if forward and not vi else start, line)
|
|
return True
|
|
|
|
def search_failed(self, client):
|
|
"""tmux reports a failed search as a status message, not as a command error."""
|
|
self.server.message(client, "Search failed")
|
|
|
|
def search_command(self, words, forward, literal=False, client=None):
|
|
text = words[0] if words else ""
|
|
if not text and self.search:
|
|
text = self.search[0]
|
|
if not self.run_search(text, forward, literal):
|
|
self.search_failed(client)
|
|
|
|
def c_search_forward(self, words, client):
|
|
self.search_command(words, True, False, client)
|
|
|
|
def c_search_backward(self, words, client):
|
|
self.search_command(words, False, False, client)
|
|
|
|
def c_search_forward_text(self, words, client):
|
|
self.search_command(words, True, True, client)
|
|
|
|
def c_search_backward_text(self, words, client):
|
|
self.search_command(words, False, True, client)
|
|
|
|
def c_search_again(self, words, client):
|
|
if not self.search:
|
|
return
|
|
text, forward, literal = self.search
|
|
if not self.run_search(text, forward, literal):
|
|
self.search_failed(client)
|
|
|
|
def c_search_reverse(self, words, client):
|
|
if not self.search:
|
|
return
|
|
text, forward, literal = self.search
|
|
if not self.run_search(text, not forward, literal):
|
|
self.search_failed(client)
|
|
self.search = (text, forward, literal)
|
|
|
|
def incremental(self, words, forward):
|
|
arg = words[0] if words else ""
|
|
if self.inc_origin is None:
|
|
self.inc_origin = (self.cx, self.abs_y())
|
|
mark = arg[:1]
|
|
if mark == "=":
|
|
origin = (self.inc_origin[0] - 1 if forward else self.inc_origin[0] + 1, self.inc_origin[1])
|
|
self.run_search(arg[1:], forward, origin=origin)
|
|
elif mark in ("+", "-"):
|
|
self.run_search(arg[1:], mark == "+")
|
|
else:
|
|
self.inc_origin = None
|
|
if arg:
|
|
self.search = (arg, forward, False)
|
|
|
|
def c_search_forward_incremental(self, words, client):
|
|
self.incremental(words, True)
|
|
|
|
def c_search_backward_incremental(self, words, client):
|
|
self.incremental(words, False)
|
|
|
|
def search_cancel(self):
|
|
if self.inc_origin is not None:
|
|
self.goto(*self.inc_origin)
|
|
self.inc_origin = None
|
|
self.server.mark_dirty(self.pane.window)
|
|
|
|
|
|
class MplexPrompt:
|
|
"""The command line in the status row: emacs-style editing, history, and the
|
|
single-key (-1), numeric (-N), key-name (-k) and incremental (-i) variants."""
|
|
|
|
def __init__(self, label, text, on_done, history=None, single=False, numeric=False, key_name=False,
|
|
on_change=None, on_cancel=None, on_step=None, confirm=False):
|
|
self.label = label
|
|
self.text = text
|
|
self.pos = len(text)
|
|
self.on_done = on_done
|
|
self.history = history if history is not None else []
|
|
self.hist_index = None
|
|
self.single = single
|
|
self.numeric = numeric
|
|
self.key_name = key_name
|
|
self.on_change = on_change
|
|
self.on_cancel = on_cancel
|
|
self.on_step = on_step
|
|
self.confirm = confirm
|
|
|
|
def view(self):
|
|
return self.label + self.text, len(self.label) + self.pos
|
|
|
|
def finish(self, server, client, value):
|
|
client.prompt = None
|
|
if value and not (self.single or self.numeric or self.key_name or self.confirm):
|
|
self.history.append(value)
|
|
del self.history[:-MPLEX_PROMPT_HISTORY_CAP]
|
|
self.on_done(value)
|
|
|
|
def cancel(self, client):
|
|
client.prompt = None
|
|
if self.on_cancel:
|
|
self.on_cancel()
|
|
|
|
def changed(self):
|
|
if self.on_change:
|
|
self.on_change(self.text)
|
|
|
|
@staticmethod
|
|
def printable(name, raw):
|
|
if name == "Space":
|
|
return " "
|
|
if len(raw) >= 1 and (len(name) == 1 or raw.decode("utf-8", "ignore") == name) and name.isprintable():
|
|
return name
|
|
return ""
|
|
|
|
def key(self, server, client, name, raw):
|
|
char = self.printable(name, raw)
|
|
if self.confirm:
|
|
if char == "y":
|
|
self.finish(server, client, "y")
|
|
else:
|
|
self.cancel(client)
|
|
return
|
|
if self.key_name:
|
|
self.finish(server, client, name)
|
|
return
|
|
if self.single:
|
|
if char:
|
|
self.finish(server, client, char)
|
|
elif name in ("Escape", "C-c", "C-g"):
|
|
self.cancel(client)
|
|
return
|
|
if self.numeric:
|
|
if char.isdigit():
|
|
self.text += char
|
|
self.pos = len(self.text)
|
|
return
|
|
self.finish(server, client, self.text)
|
|
client.redispatch(name, raw)
|
|
return
|
|
text, pos = self.text, self.pos
|
|
if name in ("Enter", "C-j"):
|
|
self.finish(server, client, text)
|
|
return
|
|
if name in ("Escape", "C-c", "C-g"):
|
|
self.cancel(client)
|
|
return
|
|
if name in ("C-a", "Home"):
|
|
pos = 0
|
|
elif name in ("C-e", "End"):
|
|
pos = len(text)
|
|
elif name in ("C-b", "Left"):
|
|
pos = max(0, pos - 1)
|
|
elif name in ("C-f", "Right"):
|
|
pos = min(len(text), pos + 1)
|
|
elif name == "M-b":
|
|
while pos > 0 and text[pos - 1] == " ":
|
|
pos -= 1
|
|
while pos > 0 and text[pos - 1] != " ":
|
|
pos -= 1
|
|
elif name == "M-f":
|
|
while pos < len(text) and text[pos] == " ":
|
|
pos += 1
|
|
while pos < len(text) and text[pos] != " ":
|
|
pos += 1
|
|
elif name in ("BSpace", "C-h"):
|
|
if pos > 0:
|
|
text = text[:pos - 1] + text[pos:]
|
|
pos -= 1
|
|
elif name in ("DC", "C-d"):
|
|
text = text[:pos] + text[pos + 1:]
|
|
elif name == "C-k":
|
|
text = text[:pos]
|
|
elif name == "C-u":
|
|
text = text[pos:]
|
|
pos = 0
|
|
elif name == "C-w":
|
|
start = pos
|
|
while start > 0 and text[start - 1] == " ":
|
|
start -= 1
|
|
while start > 0 and text[start - 1] != " ":
|
|
start -= 1
|
|
text = text[:start] + text[pos:]
|
|
pos = start
|
|
elif name == "C-y":
|
|
piece = server.top_buffer_text()
|
|
text = text[:pos] + piece + text[pos:]
|
|
pos += len(piece)
|
|
elif name == "C-t":
|
|
if 0 < pos < len(text):
|
|
text = text[:pos - 1] + text[pos] + text[pos - 1] + text[pos + 1:]
|
|
pos += 1
|
|
elif name in ("Up", "C-p", "Down", "C-n"):
|
|
if self.history:
|
|
if name in ("Up", "C-p"):
|
|
self.hist_index = len(self.history) - 1 if self.hist_index is None else max(0, self.hist_index - 1)
|
|
elif self.hist_index is not None:
|
|
self.hist_index += 1
|
|
if self.hist_index is not None and self.hist_index >= len(self.history):
|
|
self.hist_index = None
|
|
text = ""
|
|
elif self.hist_index is not None:
|
|
text = self.history[self.hist_index]
|
|
pos = len(text)
|
|
elif name in ("C-s", "C-r") and self.on_step:
|
|
self.on_step(name, text)
|
|
return
|
|
elif char:
|
|
text = text[:pos] + char + text[pos:]
|
|
pos += len(char)
|
|
else:
|
|
return
|
|
edited = text != self.text
|
|
self.text, self.pos = text, pos
|
|
if edited:
|
|
self.changed()
|
|
|
|
|
|
class MplexFont:
|
|
GLYPHS = {
|
|
"0": ("#####", "#...#", "#...#", "#...#", "#####"), "1": ("..#..", "..#..", "..#..", "..#..", "..#.."),
|
|
"2": ("#####", "....#", "#####", "#....", "#####"), "3": ("#####", "....#", "#####", "....#", "#####"),
|
|
"4": ("#...#", "#...#", "#####", "....#", "....#"), "5": ("#####", "#....", "#####", "....#", "#####"),
|
|
"6": ("#####", "#....", "#####", "#...#", "#####"), "7": ("#####", "....#", "....#", "....#", "....#"),
|
|
"8": ("#####", "#...#", "#####", "#...#", "#####"), "9": ("#####", "#...#", "#####", "....#", "#####"),
|
|
":": (".....", "..#..", ".....", "..#..", "....."), "A": ("#####", "#...#", "#####", "#...#", "#...#"),
|
|
"P": ("#####", "#...#", "#####", "#....", "#...."), "M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"),
|
|
}
|
|
HEIGHT = 5
|
|
|
|
@classmethod
|
|
def width(cls, text):
|
|
return len(text) * 6 - 1 if text else 0
|
|
|
|
@classmethod
|
|
def draw(cls, text, bg):
|
|
"""Five rows of cells for text in big glyphs, lit pixels painted with background bg."""
|
|
rows = []
|
|
lit = (" ", None, bg, 0)
|
|
dark = MplexCanvas.BLANK
|
|
for line in range(cls.HEIGHT):
|
|
row = []
|
|
for index, char in enumerate(text):
|
|
glyph = cls.GLYPHS.get(char, cls.GLYPHS["0"])
|
|
row.extend(lit if pixel == "#" else dark for pixel in glyph[line])
|
|
if index != len(text) - 1:
|
|
row.append(dark)
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
class MplexClockMode(MplexMode):
|
|
name = "clock-mode"
|
|
|
|
def render(self, client):
|
|
opts = self.server.pane_options(self.pane)
|
|
bg = MplexStyle.color(opts.get("clock-mode-colour"), True)
|
|
now = time.localtime()
|
|
text = time.strftime("%H:%M", now) if opts.get("clock-mode-style") == "24" else time.strftime("%I:%M%p", now)
|
|
grid = [[MplexCanvas.BLANK] * self.pane.w for _row in range(self.pane.h)]
|
|
if self.pane.h >= MplexFont.HEIGHT and self.pane.w >= MplexFont.width(text):
|
|
big = MplexFont.draw(text, bg)
|
|
top = (self.pane.h - MplexFont.HEIGHT) // 2
|
|
left = (self.pane.w - MplexFont.width(text)) // 2
|
|
for index, row in enumerate(big):
|
|
grid[top + index][left:left + len(row)] = row
|
|
else:
|
|
top = self.pane.h // 2
|
|
left = max(0, (self.pane.w - len(text)) // 2)
|
|
for index, char in enumerate(text[:self.pane.w]):
|
|
grid[top][left + index] = (char, None, bg, 0)
|
|
return grid, None
|
|
|
|
def key(self, name, raw, client):
|
|
self.close()
|
|
return True
|
|
|
|
|
|
class MplexPaneNumbers:
|
|
"""display-panes overlay: a big index on every pane until a key or the timer ends it."""
|
|
|
|
def __init__(self, server, window, until):
|
|
self.server = server
|
|
self.window = window
|
|
self.until = until
|
|
|
|
def key(self, name, raw, client):
|
|
client.overlay = None
|
|
if len(name) == 1 and name.isdigit():
|
|
base = self.server.pane_options(self.window.active).get("pane-base-index")
|
|
panes = self.window.panes()
|
|
index = int(name) - base
|
|
if 0 <= index < len(panes):
|
|
self.server.select_pane(self.window, panes[index])
|
|
self.server.mark_dirty(self.window)
|
|
|
|
def draw(self, canvas, area_y):
|
|
opts = self.window.options
|
|
base = opts.get("pane-base-index")
|
|
normal = MplexStyle.color(opts.get("display-panes-colour"), True)
|
|
active = MplexStyle.color(opts.get("display-panes-active-colour"), True)
|
|
for index, pane in enumerate(self.window.visible_panes() if self.window.zoomed else self.window.panes()):
|
|
bg = active if pane is self.window.active else normal
|
|
text = str(index + base)
|
|
if pane.h >= MplexFont.HEIGHT and pane.w >= MplexFont.width(text):
|
|
big = MplexFont.draw(text, bg)
|
|
top = pane.y + (pane.h - MplexFont.HEIGHT) // 2
|
|
left = pane.x + (pane.w - MplexFont.width(text)) // 2
|
|
for offset, row in enumerate(big):
|
|
canvas.put_cells(left, area_y + top + offset, row)
|
|
else:
|
|
canvas.put(pane.x + max(0, (pane.w - len(text)) // 2), area_y + pane.y + pane.h // 2, text, (None, bg, 0))
|
|
size = "%dx%d" % (pane.w, pane.h)
|
|
if pane.w > len(size) + 1:
|
|
canvas.put(pane.x + pane.w - len(size), area_y + pane.y, size, (None, bg, 0))
|
|
|
|
|
|
class MplexChoice:
|
|
__slots__ = ("label", "payload", "children", "expanded", "depth", "tag")
|
|
|
|
def __init__(self, label, payload=None, children=None, expanded=False):
|
|
self.label = label
|
|
self.payload = payload
|
|
self.children = children or []
|
|
self.expanded = expanded
|
|
self.depth = 0
|
|
self.tag = False
|
|
|
|
|
|
class MplexChooser(MplexMode):
|
|
"""choose-tree / choose-buffer / choose-client: a list you move through, with an
|
|
optional live preview of the selected item below it."""
|
|
|
|
name = "tree-mode"
|
|
HOT_KEYS = "0123456789"
|
|
|
|
def __init__(self, server, pane, kind, builder, on_choose, on_delete=None, preview=None, selected=None, on_close=None):
|
|
super().__init__(server, pane)
|
|
self.name = kind
|
|
self.builder = builder
|
|
self.items = builder()
|
|
self.on_close = on_close
|
|
self.on_choose = on_choose
|
|
self.on_delete = on_delete
|
|
self.preview = preview
|
|
self.show_preview = preview is not None
|
|
self.cursor = 0
|
|
self.visible = []
|
|
self.reflatten()
|
|
if selected is not None:
|
|
for index, item in enumerate(self.visible):
|
|
if item.payload == selected:
|
|
self.cursor = index
|
|
|
|
def reflatten(self):
|
|
self.visible = []
|
|
|
|
def walk(nodes, depth):
|
|
for node in nodes:
|
|
node.depth = depth
|
|
self.visible.append(node)
|
|
if node.children and node.expanded:
|
|
walk(node.children, depth + 1)
|
|
|
|
walk(self.items, 0)
|
|
self.cursor = max(0, min(len(self.visible) - 1, self.cursor))
|
|
|
|
def selected(self):
|
|
return self.visible[self.cursor] if self.visible else None
|
|
|
|
def rebuild(self):
|
|
opened = set()
|
|
|
|
def collect(nodes):
|
|
for node in nodes:
|
|
if node.expanded:
|
|
opened.add(repr(node.payload))
|
|
collect(node.children)
|
|
|
|
def restore(nodes):
|
|
for node in nodes:
|
|
node.expanded = node.expanded or repr(node.payload) in opened
|
|
restore(node.children)
|
|
|
|
collect(self.items)
|
|
self.items = self.builder()
|
|
restore(self.items)
|
|
self.reflatten()
|
|
if not self.visible:
|
|
self.close()
|
|
|
|
def close(self):
|
|
super().close()
|
|
if self.on_close is not None:
|
|
callback, self.on_close = self.on_close, None
|
|
callback()
|
|
|
|
def list_height(self):
|
|
if self.show_preview and self.pane.h >= 8:
|
|
return max(3, self.pane.h // 2)
|
|
return self.pane.h
|
|
|
|
def render(self, client):
|
|
w, h = self.pane.w, self.pane.h
|
|
opts = self.server.pane_options(self.pane)
|
|
mode_style = MplexStyle.parse(opts.get("mode-style"))
|
|
grid = [[MplexCanvas.BLANK] * w for _row in range(h)]
|
|
list_h = self.list_height()
|
|
top = max(0, min(self.cursor - list_h + 1, len(self.visible) - list_h)) if self.cursor >= list_h else 0
|
|
for row in range(list_h):
|
|
index = top + row
|
|
if index >= len(self.visible):
|
|
break
|
|
node = self.visible[index]
|
|
hot = self.HOT_KEYS[index] if index < len(self.HOT_KEYS) else " "
|
|
marker = ("- " if node.expanded else "+ ") if node.children else " "
|
|
line = ("(%s) %s%s%s%s" % (hot, " " * node.depth, marker, "* " if node.tag else "", node.label))[:w].ljust(w)
|
|
style = mode_style if index == self.cursor else (None, None, 0)
|
|
grid[row] = [(char, style[0], style[1], style[2]) for char in line]
|
|
if self.show_preview and list_h < h:
|
|
border = MplexCanvas.GLYPHS["single"][0]
|
|
grid[list_h] = [(border, None, None, 0)] * w
|
|
chosen = self.selected()
|
|
if chosen is not None:
|
|
block = self.preview(chosen, w, h - list_h - 1) or []
|
|
for offset, cells in enumerate(block[:h - list_h - 1]):
|
|
grid[list_h + 1 + offset] = (list(cells) + [MplexCanvas.BLANK] * w)[:w]
|
|
return grid, None
|
|
|
|
def move(self, delta):
|
|
self.cursor = max(0, min(len(self.visible) - 1, self.cursor + delta))
|
|
|
|
def key(self, name, raw, client):
|
|
node = self.selected()
|
|
page = max(1, self.list_height() - 1)
|
|
if name in ("q", "Escape", "C-c"):
|
|
self.close()
|
|
elif name in ("Up", "k", "C-p"):
|
|
self.move(-1)
|
|
elif name in ("Down", "j", "C-n"):
|
|
self.move(1)
|
|
elif name in ("PPage", "C-b"):
|
|
self.move(-page)
|
|
elif name in ("NPage", "C-f"):
|
|
self.move(page)
|
|
elif name in ("Home", "g"):
|
|
self.cursor = 0
|
|
elif name in ("End", "G"):
|
|
self.cursor = max(0, len(self.visible) - 1)
|
|
elif name in ("Right", "l", "+") and node is not None and node.children:
|
|
node.expanded = True
|
|
self.reflatten()
|
|
elif name in ("Left", "h", "-") and node is not None:
|
|
if node.children and node.expanded:
|
|
node.expanded = False
|
|
else:
|
|
for index in range(self.cursor - 1, -1, -1):
|
|
if self.visible[index].depth < node.depth:
|
|
self.cursor = index
|
|
break
|
|
self.reflatten()
|
|
elif name == "t" and node is not None:
|
|
node.tag = not node.tag
|
|
elif name == "v" and self.preview is not None:
|
|
self.show_preview = not self.show_preview
|
|
elif name == "x" and node is not None and self.on_delete is not None:
|
|
self.server.ask_confirm(client, "Kill %s? (y/n)" % node.label.split(":")[0], lambda: self.delete(node, client))
|
|
elif name == "Enter" and node is not None:
|
|
self.close()
|
|
self.on_choose(node, client)
|
|
elif len(name) == 1 and name in self.HOT_KEYS:
|
|
index = self.HOT_KEYS.index(name)
|
|
if index < len(self.visible):
|
|
self.cursor = index
|
|
self.close()
|
|
self.on_choose(self.visible[index], client)
|
|
self.server.mark_dirty(self.pane.window)
|
|
return True
|
|
|
|
def delete(self, node, client):
|
|
self.on_delete(node, client)
|
|
self.server.refresh_choosers()
|
|
|
|
|
|
class MplexRenderer:
|
|
"""Composes one client's whole screen: panes, borders, status row, prompt, overlays."""
|
|
|
|
def __init__(self, server):
|
|
self.server = server
|
|
|
|
def block_of(self, pane, client):
|
|
mode = pane.top_mode()
|
|
if mode is not None:
|
|
return mode.render(client)
|
|
grid = pane.screen.grid
|
|
blank = MplexCanvas.BLANK
|
|
block = [[cell if cell is not None else blank for cell in row] for row in grid]
|
|
cursor = (pane.screen.cx, pane.screen.cy) if pane.screen.cursor_visible else None
|
|
return block, cursor
|
|
|
|
def frame(self, client):
|
|
server = self.server
|
|
canvas = MplexCanvas(client.rows, client.cols)
|
|
session = client.session
|
|
window = session.window()
|
|
opts = window.options
|
|
status_on = bool(opts.get("status"))
|
|
on_top = opts.get("status-position") == "top"
|
|
area_y = 1 if (status_on and on_top) else 0
|
|
cursor = None
|
|
active = window.active
|
|
for pane in window.visible_panes():
|
|
block, pane_cursor = self.block_of(pane, client)
|
|
canvas.blit(pane.x, area_y + pane.y, block, pane.w)
|
|
if pane is active and pane_cursor is not None:
|
|
cursor = (pane.x + pane_cursor[0], area_y + pane.y + pane_cursor[1])
|
|
if window.zoomed is None and len(window.panes()) > 1:
|
|
self.borders(canvas, window, area_y)
|
|
if isinstance(client.overlay, MplexPaneNumbers):
|
|
client.overlay.draw(canvas, area_y)
|
|
cursor = None
|
|
status_row = 0 if on_top else client.rows - 1
|
|
if status_on:
|
|
self.status(canvas, client, session, window, status_row)
|
|
line_row = status_row if status_on else client.rows - 1
|
|
if client.prompt is not None:
|
|
text, pos = client.prompt.view()
|
|
style = MplexStyle.parse(opts.get("message-style"))
|
|
canvas.fill(0, line_row, client.cols, 1, style)
|
|
canvas.put(0, line_row, text, style, client.cols)
|
|
cursor = (min(client.cols - 1, pos), line_row)
|
|
elif client.message is not None and time.monotonic() < client.message[1]:
|
|
style = MplexStyle.parse(opts.get("message-style"))
|
|
canvas.fill(0, line_row, client.cols, 1, style)
|
|
canvas.put(0, line_row, client.message[0].replace("\n", " "), style, client.cols)
|
|
canvas.cursor = cursor
|
|
return canvas
|
|
|
|
def borders(self, canvas, window, area_y):
|
|
W, H = window.width, window.height
|
|
panes = window.panes()
|
|
cells = set()
|
|
for pane in panes:
|
|
if pane.x + pane.w < W:
|
|
for row in range(pane.y, min(H, pane.y + pane.h + 1)):
|
|
cells.add((pane.x + pane.w, row))
|
|
if pane.y + pane.h < H:
|
|
for col in range(pane.x, min(W, pane.x + pane.w + 1)):
|
|
cells.add((col, pane.y + pane.h))
|
|
active = window.active
|
|
hot = set()
|
|
if active is not None:
|
|
for col in range(active.x - 1, active.x + active.w + 1):
|
|
hot.add((col, active.y - 1))
|
|
hot.add((col, active.y + active.h))
|
|
for row in range(active.y - 1, active.y + active.h + 1):
|
|
hot.add((active.x - 1, row))
|
|
hot.add((active.x + active.w, row))
|
|
hot &= cells
|
|
opts = window.options
|
|
lines = opts.get("pane-border-lines")
|
|
plain = MplexStyle.parse(opts.get("pane-border-style"))
|
|
hot_text = MplexFormat.expand(opts.get("pane-active-border-style"), self.server.resolver_for(window.session, window, active))
|
|
hot_style = MplexStyle.parse(hot_text, plain)
|
|
for (x, y) in cells:
|
|
style = hot_style if (x, y) in hot else plain
|
|
glyph = MplexCanvas.border_char((x, y - 1) in cells, (x, y + 1) in cells, (x - 1, y) in cells, (x + 1, y) in cells, lines)
|
|
canvas.put(x, area_y + y, glyph, style)
|
|
|
|
def status(self, canvas, client, session, window, row):
|
|
server = self.server
|
|
opts = window.options
|
|
base = MplexStyle.parse(opts.get("status-style"))
|
|
canvas.fill(0, row, client.cols, 1, base)
|
|
resolver = server.resolver_for(session, window, window.active, client)
|
|
left = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-left"), resolver)),
|
|
MplexStyle.parse(opts.get("status-left-style"), base), base)
|
|
right = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-right"), resolver)),
|
|
MplexStyle.parse(opts.get("status-right-style"), base), base)
|
|
left = left[:opts.get("status-left-length")]
|
|
right = right[:opts.get("status-right-length")]
|
|
if len(left) > client.cols:
|
|
left = left[:client.cols]
|
|
room_right = max(0, client.cols - len(left))
|
|
right = right[-room_right:] if len(right) > room_right and room_right else right[:room_right]
|
|
avail = client.cols - len(left) - len(right)
|
|
entries = []
|
|
current_at = 0
|
|
total = 0
|
|
last_index = session.last
|
|
separator = opts.get("window-status-separator")
|
|
for index in session.indexes():
|
|
item = session.windows[index]
|
|
wres = server.resolver_for(session, item, item.active, client)
|
|
is_current = index == session.current
|
|
fmt = item.options.get("window-status-current-format" if is_current else "window-status-format")
|
|
style_name = "window-status-current-style" if is_current else ("window-status-last-style" if index == last_index else "window-status-style")
|
|
style = MplexStyle.parse(item.options.get(style_name), base)
|
|
cells = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(fmt, wres)), style, style)
|
|
if entries:
|
|
cells = [(char, base[0], base[1], base[2]) for char in separator] + cells
|
|
if is_current:
|
|
current_at = total + (len(separator) if entries else 0)
|
|
entries.append(cells)
|
|
total += len(cells)
|
|
listing = [cell for cells in entries for cell in cells]
|
|
if len(listing) > avail:
|
|
listing = self.fit_list(listing, avail, current_at, base)
|
|
justify = opts.get("status-justify")
|
|
offset = 0
|
|
if justify in ("centre", "center"):
|
|
offset = max(0, (avail - len(listing)) // 2)
|
|
elif justify == "right":
|
|
offset = max(0, avail - len(listing))
|
|
canvas.put_cells(0, row, left)
|
|
canvas.put_cells(len(left) + offset, row, listing)
|
|
canvas.put_cells(client.cols - len(right), row, right)
|
|
|
|
@staticmethod
|
|
def fit_list(cells, avail, current_at, base):
|
|
"""The slice of the window list around the current window, with < and > markers."""
|
|
if avail <= 0:
|
|
return []
|
|
start = max(0, min(current_at - avail // 3, len(cells) - avail))
|
|
piece = cells[start:start + avail]
|
|
marker = lambda char: (char, base[0], base[1], base[2] | 1)
|
|
if start > 0 and piece:
|
|
piece[0] = marker("<")
|
|
if start + avail < len(cells) and piece:
|
|
piece[-1] = marker(">")
|
|
return piece
|
|
|
|
|
|
class MplexCtx:
|
|
"""What a command runs against: the issuing client and its current session, window, pane."""
|
|
|
|
def __init__(self, server, client=None, pane=None, interactive=False):
|
|
self.server = server
|
|
self.client = client
|
|
self.interactive = interactive
|
|
self.depth = 0
|
|
self.pane = pane
|
|
self.window = pane.window if pane is not None else None
|
|
self.session = self.window.session if self.window is not None else None
|
|
if client is not None and client.session is not None and pane is None:
|
|
self.session = client.session
|
|
self.window = self.session.window()
|
|
self.pane = self.window.active if self.window is not None else None
|
|
elif self.session is None and client is not None and client.env_pane is not None:
|
|
found = server.panes.get(client.env_pane)
|
|
if found is not None and found.window is not None:
|
|
self.pane = found
|
|
self.window = found.window
|
|
self.session = found.window.session
|
|
if self.session is None:
|
|
self.session = server.latest_session()
|
|
if self.session is not None:
|
|
self.window = self.session.window()
|
|
self.pane = self.window.active if self.window is not None else None
|
|
|
|
|
|
class MplexTargets:
|
|
"""tmux target syntax: session, session:window, session:window.pane, %pane, @window, $session."""
|
|
|
|
@staticmethod
|
|
def session(ctx, spec):
|
|
server = ctx.server
|
|
if not spec:
|
|
if ctx.session is None:
|
|
raise MplexError("no current session")
|
|
return ctx.session
|
|
found = None
|
|
if spec.startswith("$") and spec[1:].isdigit():
|
|
found = next((s for s in server.sessions.values() if s.id == int(spec[1:])), None)
|
|
else:
|
|
spec = spec.lstrip("=")
|
|
found = next((s for s in server.sessions.values() if s.name == spec), None)
|
|
if found is None:
|
|
matches = [s for s in server.sessions.values() if s.name.startswith(spec)]
|
|
if len(matches) == 1:
|
|
found = matches[0]
|
|
if found is None:
|
|
import fnmatch
|
|
matches = [s for s in server.sessions.values() if fnmatch.fnmatchcase(s.name, spec)]
|
|
if len(matches) == 1:
|
|
found = matches[0]
|
|
if found is None:
|
|
raise MplexError("can't find session: %s" % spec)
|
|
return found
|
|
|
|
@classmethod
|
|
def window(cls, ctx, spec):
|
|
"""Returns (session, index, window)."""
|
|
server = ctx.server
|
|
spec = spec or ""
|
|
if spec.startswith("@") and spec[1:].isdigit():
|
|
window = server.windows.get(int(spec[1:]))
|
|
if window is None:
|
|
raise MplexError("can't find window: %s" % spec)
|
|
return window.session, window.session.index_of(window), window
|
|
if ":" in spec:
|
|
sess_part, _sep, win_part = spec.partition(":")
|
|
session = cls.session(ctx, sess_part)
|
|
else:
|
|
sess_part, win_part, session = "", spec, ctx.session
|
|
if session is None:
|
|
raise MplexError("no current session")
|
|
index = cls.window_index(session, win_part)
|
|
if index is None and ":" not in spec and win_part:
|
|
try:
|
|
session = cls.session(ctx, win_part)
|
|
index = session.current
|
|
except MplexError:
|
|
index = None
|
|
if index is None or index not in session.windows:
|
|
raise MplexError("can't find window: %s" % (win_part or spec))
|
|
return session, index, session.windows[index]
|
|
|
|
@staticmethod
|
|
def window_index(session, text):
|
|
indexes = session.indexes()
|
|
if not indexes:
|
|
return None
|
|
text = text.lstrip("=") if not text.startswith("=") or text[1:].isdigit() else text
|
|
if text == "":
|
|
return session.current
|
|
if text in ("!", "{last}"):
|
|
return session.last
|
|
if text in ("^", "{start}"):
|
|
return indexes[0]
|
|
if text in ("$", "{end}"):
|
|
return indexes[-1]
|
|
if text == "{next}":
|
|
text = "+"
|
|
if text == "{previous}":
|
|
text = "-"
|
|
if text[0] in "+-":
|
|
step = int(text[1:]) if text[1:].isdigit() else 1
|
|
here = indexes.index(session.current) if session.current in indexes else 0
|
|
return indexes[(here + (step if text[0] == "+" else -step)) % len(indexes)]
|
|
if text.isdigit():
|
|
return int(text)
|
|
for index in indexes:
|
|
if session.windows[index].name == text:
|
|
return index
|
|
matches = [index for index in indexes if session.windows[index].name.startswith(text)]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
import fnmatch
|
|
matches = [index for index in indexes if fnmatch.fnmatchcase(session.windows[index].name, text)]
|
|
return matches[0] if len(matches) == 1 else None
|
|
|
|
@classmethod
|
|
def pane(cls, ctx, spec):
|
|
"""Returns (session, window, pane)."""
|
|
server = ctx.server
|
|
spec = spec or ""
|
|
if spec.startswith("%") and spec[1:].isdigit():
|
|
pane = server.panes.get(int(spec[1:]))
|
|
if pane is None or pane.window is None:
|
|
raise MplexError("can't find pane: %s" % spec)
|
|
return pane.window.session, pane.window, pane
|
|
if ":" in spec:
|
|
sess_part, _sep, rest = spec.partition(":")
|
|
if "." in rest:
|
|
win_part, _dot, pane_part = rest.rpartition(".")
|
|
else:
|
|
win_part, pane_part = rest, ""
|
|
ctx_for_window = sess_part + ":" + win_part
|
|
session, _index, window = cls.window(ctx, ctx_for_window)
|
|
elif "." in spec:
|
|
win_part, _dot, pane_part = spec.rpartition(".")
|
|
session, _index, window = cls.window(ctx, win_part) if win_part else (ctx.session, None, ctx.window)
|
|
else:
|
|
pane_part = spec
|
|
session, window = ctx.session, ctx.window
|
|
if pane_part and not (pane_part.isdigit() or pane_part[0] in "+-!{"):
|
|
try:
|
|
session, _index, window = cls.window(ctx, pane_part)
|
|
pane_part = ""
|
|
except MplexError:
|
|
raise MplexError("can't find pane: %s" % spec)
|
|
if window is None:
|
|
raise MplexError("no current window")
|
|
panes = window.panes()
|
|
if not pane_part:
|
|
return session, window, window.active
|
|
base = window.options.get("pane-base-index")
|
|
if pane_part.isdigit():
|
|
index = int(pane_part) - base
|
|
if 0 <= index < len(panes):
|
|
return session, window, panes[index]
|
|
raise MplexError("can't find pane: %s" % pane_part)
|
|
here = panes.index(window.active) if window.active in panes else 0
|
|
if pane_part in ("!", "{last}"):
|
|
if window.last_pane in panes:
|
|
return session, window, window.last_pane
|
|
raise MplexError("can't find pane: %s" % pane_part)
|
|
if pane_part[0] in "+-" or pane_part in ("{next}", "{previous}"):
|
|
sign = "-" if pane_part in ("-", "{previous}") or pane_part.startswith("-") else "+"
|
|
amount = int(pane_part[1:]) if pane_part[1:].isdigit() else 1
|
|
return session, window, panes[(here + (amount if sign == "+" else -amount)) % len(panes)]
|
|
edges = {"{top}": lambda p: p.y == 0, "{bottom}": lambda p: p.y + p.h >= window.height,
|
|
"{left}": lambda p: p.x == 0, "{right}": lambda p: p.x + p.w >= window.width,
|
|
"{top-left}": lambda p: p.y == 0 and p.x == 0, "{top-right}": lambda p: p.y == 0 and p.x + p.w >= window.width,
|
|
"{bottom-left}": lambda p: p.y + p.h >= window.height and p.x == 0,
|
|
"{bottom-right}": lambda p: p.y + p.h >= window.height and p.x + p.w >= window.width}
|
|
if pane_part in edges:
|
|
for pane in panes:
|
|
if edges[pane_part](pane):
|
|
return session, window, pane
|
|
raise MplexError("can't find pane: %s" % pane_part)
|
|
|
|
|
|
class MplexClientConn:
|
|
"""Server-side state of one connection: framing, key table position, prompt, overlay, last frame."""
|
|
|
|
def __init__(self, server, sock, ident):
|
|
self.server = server
|
|
self.sock = sock
|
|
self.id = ident
|
|
self.inbuf = bytearray()
|
|
self.out = bytearray()
|
|
self.rows = 24
|
|
self.cols = 80
|
|
self.session = None
|
|
self.attached = False
|
|
self.table = "root"
|
|
self.repeating = False
|
|
self.repeat_until = 0.0
|
|
self.keybuf = b""
|
|
self.esc_deadline = None
|
|
self.prompt = None
|
|
self.message = None
|
|
self.overlay = None
|
|
self.prev = None
|
|
self.cursor = None
|
|
self.dirty = True
|
|
self.last_render = 0.0
|
|
self.pid = 0
|
|
self.tty = ""
|
|
self.term = ""
|
|
self.cwd = os.getcwd()
|
|
self.env = {}
|
|
self.env_pane = None
|
|
self.closing = False
|
|
self.created = time.time()
|
|
|
|
def send(self, kind, payload=b""):
|
|
if len(self.out) > MPLEX_CLIENT_OUT_MAX:
|
|
self.prev = None
|
|
return False
|
|
self.out += kind + struct.pack(">I", len(payload)) + payload
|
|
return True
|
|
|
|
def frames(self, data):
|
|
"""Complete (kind, payload) frames in what arrived; None when the stream is corrupt."""
|
|
self.inbuf += data
|
|
found = []
|
|
while len(self.inbuf) >= 5:
|
|
length = struct.unpack(">I", bytes(self.inbuf[1:5]))[0]
|
|
if length > MPLEX_FRAME_MAX:
|
|
return None
|
|
if len(self.inbuf) < 5 + length:
|
|
break
|
|
found.append((bytes(self.inbuf[0:1]), bytes(self.inbuf[5:5 + length])))
|
|
del self.inbuf[:5 + length]
|
|
return found
|
|
|
|
def redispatch(self, name, raw):
|
|
self.server.handle_key(self, name, raw)
|
|
|
|
def flush(self):
|
|
if not self.out:
|
|
return
|
|
try:
|
|
sent = self.sock.send(bytes(self.out[:65536]))
|
|
del self.out[:sent]
|
|
except (BlockingIOError, InterruptedError):
|
|
pass
|
|
except OSError:
|
|
self.out.clear()
|
|
self.closing = True
|
|
|
|
def close(self):
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class MplexServer:
|
|
"""Owns every session, window, pane and client. One select loop, no threads."""
|
|
|
|
def __init__(self, home):
|
|
self.home = home
|
|
self.dir = plexer_dir(home)
|
|
self.sock_path = os.path.join(self.dir, MPLEX_SOCKET_NAME)
|
|
self.config = MplexConfig.load(MplexConfig.default_path(home))
|
|
self.options = self.config.options
|
|
self.sessions = {}
|
|
self.windows = {}
|
|
self.panes = {}
|
|
self.clients = []
|
|
self.conns = []
|
|
self.buffers = []
|
|
self.messages = collections.deque(maxlen=MPLEX_MESSAGES_MAX)
|
|
self.prompt_history = {}
|
|
self.timers = []
|
|
self.reaping = []
|
|
self.marked = None
|
|
self.ids = {"session": 0, "window": 0, "pane": 0, "client": 0, "buffer": 0}
|
|
self.had_session = False
|
|
self.running = False
|
|
self.listener = None
|
|
self.renderer = MplexRenderer(self)
|
|
self.commands = MplexCommands(self)
|
|
self.shell_cache = {}
|
|
self.started = time.monotonic()
|
|
self.stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
self.next_tick = 0.0
|
|
self.notice = None
|
|
for problem in self.config.errors:
|
|
self.log_message("mplex.toml: " + problem)
|
|
if self.config.skipped:
|
|
self.notice = "mplex.toml: %d problem(s) skipped, see show-messages" % self.config.skipped
|
|
|
|
# ---- bookkeeping -----------------------------------------------------
|
|
|
|
def next_id(self, kind):
|
|
value = self.ids[kind]
|
|
self.ids[kind] += 1
|
|
return value
|
|
|
|
def log_message(self, text):
|
|
self.messages.append((time.time(), text))
|
|
|
|
def latest_session(self):
|
|
if not self.sessions:
|
|
return None
|
|
return max(self.sessions.values(), key=lambda item: (item.last_attached, item.id))
|
|
|
|
def attached_clients(self, session=None):
|
|
return [c for c in self.clients if c.attached and (session is None or c.session is session)]
|
|
|
|
def pane_options(self, pane):
|
|
return pane.window.options if pane.window is not None else self.options
|
|
|
|
def message(self, client, text, milliseconds=None):
|
|
self.log_message(text)
|
|
if client is None:
|
|
return
|
|
if milliseconds is None:
|
|
milliseconds = self.options.get("display-time") if client.session is None else client.session.options.get("display-time")
|
|
client.message = (text, time.monotonic() + milliseconds / 1000.0)
|
|
client.dirty = True
|
|
self.add_timer(client.message[1] + 0.01, lambda: setattr(client, "dirty", True))
|
|
|
|
def mark_dirty(self, window=None):
|
|
for client in self.clients:
|
|
if client.attached and (window is None or client.session.window() is window):
|
|
client.dirty = True
|
|
|
|
def mark_all_dirty(self):
|
|
for client in self.clients:
|
|
client.dirty = True
|
|
|
|
def add_timer(self, deadline, callback):
|
|
if len(self.timers) < 1024:
|
|
self.timers.append((deadline, callback))
|
|
|
|
# ---- buffers and copy ------------------------------------------------
|
|
|
|
def add_buffer(self, text, name=None):
|
|
if name is None:
|
|
name = "buffer%d" % self.next_id("buffer")
|
|
self.buffers = [item for item in self.buffers if item["name"] != name]
|
|
self.buffers.insert(0, {"name": name, "data": text[:MPLEX_PASTE_MAX]})
|
|
del self.buffers[self.options.get("buffer-limit"):]
|
|
return name
|
|
|
|
def top_buffer_text(self):
|
|
return self.buffers[0]["data"] if self.buffers else ""
|
|
|
|
def find_buffer(self, name):
|
|
if not name:
|
|
if not self.buffers:
|
|
raise MplexError("no buffers")
|
|
return self.buffers[0]
|
|
for item in self.buffers:
|
|
if item["name"] == name:
|
|
return item
|
|
raise MplexError("unknown buffer: %s" % name)
|
|
|
|
def run_shell_input(self, command, text):
|
|
try:
|
|
done = subprocess.run(command, shell=True, input=text.encode("utf-8", "replace"), stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE, timeout=MPLEX_SHELL_TIMEOUT)
|
|
if done.returncode != 0:
|
|
raise MplexError("copy command failed: " + done.stderr.decode("utf-8", "replace").strip()[:200])
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise MplexError("copy command failed: " + short_error(exc))
|
|
|
|
def store_copy(self, text, command="", append=False):
|
|
if append and self.buffers:
|
|
self.buffers[0]["data"] = (self.buffers[0]["data"] + text)[:MPLEX_PASTE_MAX]
|
|
text = self.buffers[0]["data"]
|
|
else:
|
|
self.add_buffer(text)
|
|
pipe = command or self.options.get("copy-command")
|
|
if pipe:
|
|
self.run_shell_input(pipe, text)
|
|
if self.options.get("set-clipboard") != "off":
|
|
sequence = "\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07"
|
|
for client in self.attached_clients():
|
|
client.send(b"O", sequence.encode("ascii"))
|
|
|
|
# ---- sizing ----------------------------------------------------------
|
|
|
|
def resize_session(self, session):
|
|
sizes = [(c.cols, c.rows) for c in self.attached_clients(session)]
|
|
if sizes:
|
|
session.size = (min(item[0] for item in sizes), min(item[1] for item in sizes))
|
|
elif not hasattr(session, "size"):
|
|
session.size = (80, 24)
|
|
cols, rows = session.size
|
|
for window in session.windows.values():
|
|
window.width = max(1, cols)
|
|
window.height = max(1, rows - (1 if window.options.get("status") else 0))
|
|
window.relayout()
|
|
self.mark_dirty()
|
|
|
|
def resize_all(self):
|
|
for session in self.sessions.values():
|
|
self.resize_session(session)
|
|
|
|
# ---- model: panes, windows, sessions ---------------------------------
|
|
|
|
def default_shell(self, options):
|
|
return options.get("default-shell") or os.environ.get("SHELL") or "/bin/sh"
|
|
|
|
def resolve_term(self, name):
|
|
wanted = [name, "screen-256color", "xterm-256color", "xterm", "vt100"]
|
|
roots = [os.environ.get("TERMINFO", ""), os.path.expanduser("~/.terminfo"), "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo"]
|
|
for term in wanted:
|
|
for root in roots:
|
|
if root and (os.path.exists(os.path.join(root, term[0], term)) or os.path.exists(os.path.join(root, "%02x" % ord(term[0]), term))):
|
|
return term
|
|
return "xterm"
|
|
|
|
def spawn_pane(self, window, width, height, words, cwd, env_extra=None):
|
|
if len(self.panes) >= MPLEX_MAX_PANES:
|
|
raise MplexError("too many panes (limit %d)" % MPLEX_MAX_PANES)
|
|
session = window.session
|
|
options = window.options
|
|
pane = MplexPane(self.next_id("pane"), width, height, options.get("history-limit"), self.dir, self.stamp)
|
|
shell = self.default_shell(options)
|
|
if words:
|
|
file = shell if len(words) == 1 else words[0]
|
|
argv = [shell, "-c", words[0]] if len(words) == 1 else list(words)
|
|
elif options.get("default-command"):
|
|
file, argv = shell, [shell, "-c", options.get("default-command")]
|
|
else:
|
|
file, argv = shell, ["-" + os.path.basename(shell)]
|
|
env = dict(session.env)
|
|
env.pop("LINES", None)
|
|
env.pop("COLUMNS", None)
|
|
env["TERM"] = self.resolve_term(options.get("default-terminal"))
|
|
env[PLEXER_ACTIVE_ENV] = "1"
|
|
env[PLEXER_SOCKET_ENV] = self.sock_path
|
|
env[PLEXER_PANE_ENV] = str(pane.id)
|
|
env.update(env_extra or {})
|
|
pane.window = window
|
|
pane.spawn(file, argv, cwd or session.cwd, env)
|
|
self.panes[pane.id] = pane
|
|
return pane
|
|
|
|
def register_window(self, session, window, index, select):
|
|
window.session = session
|
|
window.options.parent = session.options
|
|
session.windows[index] = window
|
|
self.windows[window.id] = window
|
|
if select or session.current is None:
|
|
session.select(index)
|
|
|
|
def create_window(self, session, name, cwd, words, env_extra, index, select):
|
|
if len(self.windows) >= MPLEX_MAX_WINDOWS:
|
|
raise MplexError("too many windows (limit %d)" % MPLEX_MAX_WINDOWS)
|
|
cols, rows = getattr(session, "size", (80, 24))
|
|
window = MplexWindow(self.next_id("window"), name or "", max(1, cols), max(1, rows - (1 if self.options.get("status") else 0)), session.options)
|
|
window.session = session
|
|
pane = self.spawn_pane(window, window.width, window.height, words, cwd, env_extra)
|
|
window.root = MplexLayoutNode("pane", pane=pane, w=window.width, h=window.height)
|
|
window.active = pane
|
|
pane.last_active = time.monotonic()
|
|
window.auto_rename = not name
|
|
if not name:
|
|
window.name = pane.shell_name if not words else os.path.basename(words[0].split()[0] if len(words) == 1 else words[0])
|
|
self.register_window(session, window, index, select)
|
|
window.relayout()
|
|
self.mark_dirty()
|
|
return window
|
|
|
|
def create_session(self, name, cwd, env, words, window_name, size, index_hint=None):
|
|
if len(self.sessions) >= MPLEX_MAX_SESSIONS:
|
|
raise MplexError("too many sessions (limit %d)" % MPLEX_MAX_SESSIONS)
|
|
session = MplexSession(self.next_id("session"), name, self.options, cwd, env)
|
|
session.size = size
|
|
self.sessions[session.id] = session
|
|
self.had_session = True
|
|
try:
|
|
self.create_window(session, window_name, cwd, words, None, self.options.get("base-index"), True)
|
|
except Exception:
|
|
self.sessions.pop(session.id, None)
|
|
raise
|
|
return session
|
|
|
|
def auto_session_name(self):
|
|
taken = {s.name for s in self.sessions.values()}
|
|
number = 0
|
|
while str(number) in taken:
|
|
number += 1
|
|
return str(number)
|
|
|
|
def split_pane(self, window, target, kind, before, words, cwd, env_extra, size, select):
|
|
leaf = window.leaf_of(target)
|
|
if leaf is None:
|
|
raise MplexError("pane is not in this window")
|
|
if window.zoomed is not None:
|
|
window.zoomed = None
|
|
window.relayout()
|
|
leaf = window.leaf_of(target)
|
|
span = leaf.w if kind == "lr" else leaf.h
|
|
if span < 3:
|
|
raise MplexError("no space for new pane")
|
|
pane = self.spawn_pane(window, 1, 1, words, cwd, env_extra)
|
|
try:
|
|
window.root, fresh = MplexLayout.split(window.root, leaf, kind, before, pane, size)
|
|
except MplexError:
|
|
self.discard_pane(pane)
|
|
raise
|
|
window.layout_index = -1
|
|
window.relayout()
|
|
if select:
|
|
window.set_active(pane)
|
|
self.mark_dirty(window)
|
|
return pane
|
|
|
|
def discard_pane(self, pane):
|
|
pane.close()
|
|
self.panes.pop(pane.id, None)
|
|
if pane.pid is not None:
|
|
self.reaping.append((pane.pid, time.monotonic()))
|
|
pane.pid = None
|
|
|
|
def select_pane(self, window, pane):
|
|
if window.zoomed is not None and pane is not window.zoomed:
|
|
window.zoomed = None
|
|
window.relayout()
|
|
window.set_active(pane)
|
|
self.mark_dirty(window)
|
|
|
|
def kill_pane(self, pane):
|
|
window = pane.window
|
|
if window is None:
|
|
return
|
|
if len(window.panes()) == 1:
|
|
self.kill_window(window)
|
|
return
|
|
was_active = window.active is pane
|
|
window.root = MplexLayout.remove(window.root, window.leaf_of(pane))
|
|
if window.zoomed is pane:
|
|
window.zoomed = None
|
|
if self.marked is pane:
|
|
self.marked = None
|
|
self.discard_pane(pane)
|
|
rest = window.panes()
|
|
if window.last_pane is pane:
|
|
window.last_pane = None
|
|
if was_active:
|
|
window.active = window.last_pane if window.last_pane in rest else max(rest, key=lambda item: item.last_active)
|
|
window.last_pane = None
|
|
window.layout_index = -1
|
|
window.relayout()
|
|
self.mark_dirty(window)
|
|
|
|
def kill_window(self, window):
|
|
session = window.session
|
|
index = session.index_of(window)
|
|
for pane in window.panes():
|
|
if self.marked is pane:
|
|
self.marked = None
|
|
self.discard_pane(pane)
|
|
self.windows.pop(window.id, None)
|
|
if index is not None:
|
|
session.windows.pop(index, None)
|
|
if not session.windows:
|
|
self.kill_session(session)
|
|
return
|
|
session.history = [item for item in session.history if item is not window]
|
|
if session.current == index:
|
|
nxt = session.last
|
|
if nxt is None:
|
|
lower = [i for i in session.indexes() if i < index]
|
|
nxt = lower[-1] if lower else session.indexes()[-1]
|
|
session.current = nxt
|
|
session.history = [item for item in session.history if item is not session.windows[nxt]]
|
|
if session.options.get("renumber-windows"):
|
|
self.renumber(session)
|
|
self.mark_dirty()
|
|
|
|
def renumber(self, session):
|
|
base = self.options.get("base-index")
|
|
current = session.window()
|
|
ordered = [session.windows[i] for i in session.indexes()]
|
|
session.windows = {base + n: window for n, window in enumerate(ordered)}
|
|
session.current = session.index_of(current) if current else None
|
|
|
|
def kill_session(self, session):
|
|
for window in list(session.windows.values()):
|
|
for pane in window.panes():
|
|
if self.marked is pane:
|
|
self.marked = None
|
|
self.discard_pane(pane)
|
|
self.windows.pop(window.id, None)
|
|
session.windows.clear()
|
|
self.sessions.pop(session.id, None)
|
|
for client in list(self.attached_clients(session)):
|
|
others = [s for s in self.sessions.values()]
|
|
if others and not self.options.get("detach-on-destroy"):
|
|
self.attach_client(client, self.latest_session())
|
|
else:
|
|
self.detach_client(client, "[exited]", b"X")
|
|
self.mark_dirty()
|
|
|
|
def attach_client(self, client, session):
|
|
old = client.session
|
|
client.session = session
|
|
session.last_attached = time.time()
|
|
client.table = "root"
|
|
client.prompt = None
|
|
client.overlay = None
|
|
client.prev = None
|
|
if not client.attached:
|
|
client.attached = True
|
|
client.send(b"A")
|
|
if old is not None and old is not session:
|
|
self.resize_session(old)
|
|
self.resize_session(session)
|
|
client.dirty = True
|
|
if self.notice:
|
|
self.message(client, self.notice, 4000)
|
|
self.notice = None
|
|
|
|
def detach_client(self, client, text="", kind=b"D"):
|
|
old = client.session
|
|
client.send(kind, text.encode("utf-8"))
|
|
client.attached = False
|
|
client.session = None
|
|
client.closing = True
|
|
if old is not None and old.id in self.sessions:
|
|
self.resize_session(old)
|
|
|
|
# ---- formats ---------------------------------------------------------
|
|
|
|
def window_flags(self, session, window):
|
|
flags = ""
|
|
index = session.index_of(window)
|
|
if index == session.current:
|
|
flags += "*"
|
|
elif index == session.last:
|
|
flags += "-"
|
|
if window.zoomed is not None:
|
|
flags += "Z"
|
|
if self.marked is not None and self.marked.window is window:
|
|
flags += "M"
|
|
return flags
|
|
|
|
def resolver_for(self, session, window, pane, client=None):
|
|
server = self
|
|
|
|
def get(name):
|
|
if name.startswith("("):
|
|
return server.shell_output(name[1:])
|
|
return server.variable(name, session, window, pane, client)
|
|
|
|
return get
|
|
|
|
def shell_output(self, command):
|
|
now = time.monotonic()
|
|
cached = self.shell_cache.get(command)
|
|
interval = max(1, self.options.get("status-interval"))
|
|
if cached and now - cached[0] < interval:
|
|
return cached[1]
|
|
try:
|
|
done = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=MPLEX_SHELL_TIMEOUT)
|
|
text = done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace").strip().split("\n")[0]
|
|
except (OSError, subprocess.SubprocessError):
|
|
text = ""
|
|
if len(self.shell_cache) > 64:
|
|
self.shell_cache.clear()
|
|
self.shell_cache[command] = (now, text)
|
|
return text
|
|
|
|
def variable(self, name, session, window, pane, client):
|
|
if name in MplexOptions.DEFS:
|
|
scope = window.options if window is not None else (session.options if session is not None else self.options)
|
|
value = scope.get(name)
|
|
if isinstance(value, bool):
|
|
return "1" if value else "0"
|
|
return str(value)
|
|
if name == "host":
|
|
return socket.gethostname()
|
|
if name == "host_short":
|
|
return socket.gethostname().split(".")[0]
|
|
if name == "version":
|
|
return VERSION
|
|
if session is not None:
|
|
if name == "session_name":
|
|
return session.name
|
|
if name == "session_id":
|
|
return session.tag
|
|
if name == "session_windows":
|
|
return str(len(session.windows))
|
|
if name == "session_created":
|
|
return str(int(session.created))
|
|
if name == "session_attached":
|
|
return str(len(self.attached_clients(session)))
|
|
if window is not None and session is not None:
|
|
index = session.index_of(window)
|
|
if name == "window_index":
|
|
return str(index)
|
|
if name == "window_name":
|
|
return window.name
|
|
if name == "window_id":
|
|
return window.tag
|
|
if name == "window_flags":
|
|
return self.window_flags(session, window)
|
|
if name == "window_active":
|
|
return "1" if index == session.current else "0"
|
|
if name == "window_last_flag":
|
|
return "1" if index == session.last else "0"
|
|
if name == "window_panes":
|
|
return str(len(window.panes()))
|
|
if name == "window_zoomed_flag":
|
|
return "1" if window.zoomed is not None else "0"
|
|
if name == "window_width":
|
|
return str(window.width)
|
|
if name == "window_height":
|
|
return str(window.height)
|
|
if name in ("window_activity_flag", "window_bell_flag", "window_silence_flag", "window_bigger"):
|
|
return "0"
|
|
if name == "window_marked_flag":
|
|
return "1" if self.marked is not None and self.marked.window is window else "0"
|
|
if pane is not None:
|
|
mode = pane.top_mode()
|
|
screen = pane.screen
|
|
if name == "pane_id":
|
|
return pane.tag
|
|
if name == "pane_index":
|
|
return str(pane.window.panes().index(pane) + pane.window.options.get("pane-base-index")) if pane.window else "0"
|
|
if name == "pane_title":
|
|
return pane.title
|
|
if name == "pane_current_command":
|
|
return pane.foreground_command()
|
|
if name == "pane_current_path":
|
|
return pane.current_path()
|
|
if name == "pane_pid":
|
|
return str(pane.pid or "")
|
|
if name == "pane_width":
|
|
return str(pane.w)
|
|
if name == "pane_height":
|
|
return str(pane.h)
|
|
if name == "pane_active":
|
|
return "1" if pane.window is not None and pane.window.active is pane else "0"
|
|
if name == "pane_last":
|
|
return "1" if pane.window is not None and pane.window.last_pane is pane else "0"
|
|
if name == "pane_in_mode":
|
|
return "1" if mode is not None else "0"
|
|
if name == "pane_mode":
|
|
return mode.name if mode is not None else ""
|
|
if name == "pane_dead":
|
|
return "1" if pane.dead else "0"
|
|
if name == "pane_dead_status":
|
|
return "" if pane.exit_status is None else str(pane.exit_status)
|
|
if name == "pane_left":
|
|
return str(pane.x)
|
|
if name == "pane_top":
|
|
return str(pane.y)
|
|
if name == "pane_right":
|
|
return str(pane.x + pane.w - 1)
|
|
if name == "pane_bottom":
|
|
return str(pane.y + pane.h - 1)
|
|
if name == "pane_synchronized":
|
|
return "1" if pane.window is not None and pane.window.options.get("synchronize-panes") else "0"
|
|
if name == "pane_marked":
|
|
return "1" if self.marked is pane else "0"
|
|
if name == "pane_marked_set":
|
|
return "1" if self.marked is not None else "0"
|
|
if name == "alternate_on":
|
|
return "1" if screen.alt_active else "0"
|
|
if name == "history_size":
|
|
return str(pane.history_size())
|
|
if name == "history_limit":
|
|
return str(screen.history.maxlen if screen.history is not None else 0)
|
|
if name == "cursor_x":
|
|
return str(screen.cx)
|
|
if name == "cursor_y":
|
|
return str(screen.cy)
|
|
if name == "pane_search_string":
|
|
return mode.search[0] if isinstance(mode, MplexCopyMode) and mode.search else ""
|
|
if isinstance(mode, MplexCopyMode):
|
|
if name == "scroll_position":
|
|
return str(mode.oy)
|
|
if name == "copy_cursor_x":
|
|
return str(mode.cx)
|
|
if name == "copy_cursor_y":
|
|
return str(mode.cy)
|
|
if name == "selection_present":
|
|
return "1" if mode.anchor is not None else "0"
|
|
if name == "copy_cursor_line":
|
|
return mode.text(mode.abs_y())
|
|
if name == "copy_cursor_word":
|
|
line = mode.text(mode.abs_y())
|
|
if not line:
|
|
return ""
|
|
x = min(mode.cx, len(line) - 1)
|
|
seps = pane.window.options.get("word-separators") + " "
|
|
start = end = x
|
|
while start > 0 and line[start - 1] not in seps:
|
|
start -= 1
|
|
while end + 1 < len(line) and line[end + 1] not in seps:
|
|
end += 1
|
|
return line[start:end + 1]
|
|
if name == "scroll_position":
|
|
return ""
|
|
if client is not None:
|
|
if name == "client_width":
|
|
return str(client.cols)
|
|
if name == "client_height":
|
|
return str(client.rows)
|
|
if name == "client_session":
|
|
return client.session.name if client.session else ""
|
|
if name == "client_tty":
|
|
return client.tty
|
|
if name == "client_termname":
|
|
return client.term
|
|
if name == "client_pid":
|
|
return str(client.pid)
|
|
if name == "client_key_table":
|
|
return client.table
|
|
if name == "client_prefix":
|
|
return "1" if client.table == "prefix" else "0"
|
|
if name == "client_created":
|
|
return str(int(client.created))
|
|
return None
|
|
|
|
def expand(self, text, ctx, extra=None):
|
|
"""Format-expand text for a command context (strftime applied)."""
|
|
base = self.resolver_for(ctx.session, ctx.window, ctx.pane, ctx.client)
|
|
if extra:
|
|
def resolve(name):
|
|
return extra[name] if name in extra else base(name)
|
|
else:
|
|
resolve = base
|
|
return MplexFormat.with_time(MplexFormat.expand(text, resolve))
|
|
|
|
# ---- key handling ----------------------------------------------------
|
|
|
|
def is_prefix(self, session, name):
|
|
opts = session.options
|
|
return name in (opts.get("prefix"), opts.get("prefix2")) and name != "None"
|
|
|
|
def input_bytes(self, client, data):
|
|
client.keybuf = (client.keybuf + data)[-65536:]
|
|
self.process_keys(client, False)
|
|
|
|
def process_keys(self, client, final):
|
|
tokens, rest = MplexKeys.split(client.keybuf, final)
|
|
client.keybuf = rest
|
|
for name, raw in tokens:
|
|
if not client.attached:
|
|
break
|
|
self.handle_key(client, name, raw)
|
|
if client.keybuf and client.attached:
|
|
client.esc_deadline = time.monotonic() + client.session.options.get("escape-time") / 1000.0
|
|
else:
|
|
client.esc_deadline = None
|
|
|
|
def handle_key(self, client, name, raw):
|
|
if not client.attached:
|
|
return
|
|
client.dirty = True
|
|
if client.prompt is not None:
|
|
client.prompt.key(self, client, name, raw)
|
|
return
|
|
if client.overlay is not None:
|
|
client.overlay.key(name, raw, client)
|
|
return
|
|
client.message = None
|
|
session = client.session
|
|
window = session.window()
|
|
pane = window.active
|
|
if not name:
|
|
self.forward(pane, window, raw)
|
|
return
|
|
table = client.table
|
|
if table == "root":
|
|
binding = self.config.lookup("root", name)
|
|
if binding is not None:
|
|
self.run_binding(client, binding, pane)
|
|
return
|
|
if self.is_prefix(session, name):
|
|
client.table = "prefix"
|
|
client.repeating = False
|
|
return
|
|
mode = pane.top_mode()
|
|
if mode is not None:
|
|
mode.key(name, raw, client)
|
|
return
|
|
self.forward(pane, window, raw)
|
|
return
|
|
binding = self.config.lookup(table, name)
|
|
if client.repeating and not (binding is not None and binding.repeat):
|
|
client.table = "root"
|
|
client.repeating = False
|
|
self.handle_key(client, name, raw)
|
|
return
|
|
client.table = "root"
|
|
client.repeating = False
|
|
if binding is not None:
|
|
self.run_binding(client, binding, pane)
|
|
if binding.repeat and client.attached and client.table == "root":
|
|
client.table = table
|
|
client.repeating = True
|
|
client.repeat_until = time.monotonic() + client.session.options.get("repeat-time") / 1000.0
|
|
|
|
def forward(self, pane, window, raw):
|
|
data = MplexKeys.app_cursor_form(raw) if pane.screen.app_cursor else raw
|
|
targets = window.panes() if window.options.get("synchronize-panes") else [pane]
|
|
for target in targets:
|
|
if not target.dead:
|
|
target.write(data)
|
|
target.input_bytes += len(data)
|
|
|
|
def run_binding(self, client, binding, pane=None):
|
|
self.run_checked(MplexCtx(self, client=client, pane=pane, interactive=True), binding.command)
|
|
|
|
def run_checked(self, ctx, text):
|
|
try:
|
|
output = self.run_line(ctx, text)
|
|
if output:
|
|
self.show_output(ctx, output)
|
|
except MplexError as exc:
|
|
self.message(ctx.client, str(exc))
|
|
except Exception as exc: # noqa: BLE001 -- a bad command must never take the server down
|
|
import traceback
|
|
sys.stderr.write(traceback.format_exc())
|
|
self.message(ctx.client, "internal error: " + short_error(exc))
|
|
|
|
def show_output(self, ctx, text):
|
|
pane = ctx.pane
|
|
lines = text.rstrip("\n").split("\n")
|
|
if pane is None or (len(lines) == 1 and len(lines[0]) < 200):
|
|
self.message(ctx.client, lines[0] if lines else "")
|
|
return
|
|
old = pane.find_mode(MplexCopyMode)
|
|
if old is not None and old.static_rows is not None:
|
|
old.close()
|
|
pane.push_mode(MplexCopyMode(self, pane, lines=lines))
|
|
self.mark_dirty(pane.window)
|
|
|
|
def ask_confirm(self, client, text, callback):
|
|
def done(value):
|
|
callback()
|
|
client.prompt = MplexPrompt(text + " ", "", done, confirm=True)
|
|
client.dirty = True
|
|
|
|
def refresh_choosers(self):
|
|
for pane in list(self.panes.values()):
|
|
for mode in list(pane.modes):
|
|
if isinstance(mode, MplexChooser):
|
|
mode.rebuild()
|
|
self.mark_dirty()
|
|
|
|
# ---- command execution -----------------------------------------------
|
|
|
|
def run_line(self, ctx, text, depth=0):
|
|
return self.run_commands(ctx, MplexCommandParser.parse(text), depth)
|
|
|
|
def run_commands(self, ctx, commands, depth=0):
|
|
if depth > MPLEX_COMMAND_DEPTH_MAX:
|
|
raise MplexError("commands nested too deeply")
|
|
flat = []
|
|
for words in commands:
|
|
piece = []
|
|
for word in words:
|
|
if word == ";":
|
|
if piece:
|
|
flat.append(piece)
|
|
piece = []
|
|
else:
|
|
piece.append(word)
|
|
if piece:
|
|
flat.append(piece)
|
|
output = []
|
|
for words in flat:
|
|
text = self.run_one(ctx, words, depth)
|
|
if text:
|
|
output.append(text)
|
|
return "\n".join(output)
|
|
|
|
def run_words(self, ctx, words):
|
|
return self.run_commands(ctx, [words], 0)
|
|
|
|
def run_one(self, ctx, words, depth):
|
|
name = MplexCommands.resolve(words[0])
|
|
aliases, spec, low, high = MPLEX_COMMAND_SPECS[name]
|
|
args = MplexArgs.parse(name, spec, words[1:], low, high)
|
|
ctx.depth = depth
|
|
handler = getattr(self.commands, "cmd_" + name.replace("-", "_"))
|
|
return handler(ctx, args) or ""
|
|
|
|
# ---- sockets ---------------------------------------------------------
|
|
|
|
def open_listener(self):
|
|
lock_fd = os.open(os.path.join(self.dir, MPLEX_LOCK_NAME), os.O_RDWR | os.O_CREAT, 0o600)
|
|
try:
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
os.close(lock_fd)
|
|
return None
|
|
self.lock_fd = lock_fd
|
|
try:
|
|
os.unlink(self.sock_path)
|
|
except OSError:
|
|
pass
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
listener.bind(self.sock_path)
|
|
os.chmod(self.sock_path, 0o600)
|
|
listener.listen(16)
|
|
listener.setblocking(False)
|
|
return listener
|
|
|
|
def accept(self):
|
|
try:
|
|
sock, _addr = self.listener.accept()
|
|
except OSError:
|
|
return
|
|
sock.setblocking(False)
|
|
if len(self.conns) + len(self.clients) >= MPLEX_MAX_CLIENTS * 2:
|
|
sock.close()
|
|
return
|
|
self.conns.append(MplexPending(sock))
|
|
|
|
def pending_read(self, pending):
|
|
try:
|
|
data = pending.sock.recv(65536)
|
|
except (BlockingIOError, InterruptedError):
|
|
return
|
|
except OSError:
|
|
data = b""
|
|
if not data:
|
|
self.drop_pending(pending)
|
|
return
|
|
pending.buf += data
|
|
if pending.buf[:1] == MPLEX_MAGIC:
|
|
if len(self.clients) >= MPLEX_MAX_CLIENTS:
|
|
self.drop_pending(pending)
|
|
return
|
|
self.conns.remove(pending)
|
|
client = MplexClientConn(self, pending.sock, self.next_id("client"))
|
|
self.clients.append(client)
|
|
self.client_data(client, bytes(pending.buf[1:]))
|
|
return
|
|
if b"\n" in pending.buf or len(pending.buf) > PLEXER_RECV_MAX:
|
|
self.control(pending)
|
|
|
|
def drop_pending(self, pending):
|
|
if pending in self.conns:
|
|
self.conns.remove(pending)
|
|
try:
|
|
pending.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def control(self, pending):
|
|
"""Legacy JSON line protocol: send / read / status against a pane (see NativePlexerController)."""
|
|
try:
|
|
request = json.loads(bytes(pending.buf).decode("utf-8", "replace"))
|
|
if not isinstance(request, dict):
|
|
raise ValueError("request must be a JSON object")
|
|
reply = self.dispatch_control(request)
|
|
except Exception as exc: # noqa: BLE001 -- one bad request must not end the session
|
|
reply = {"ok": False, "error": short_error(exc)}
|
|
try:
|
|
pending.sock.setblocking(True)
|
|
pending.sock.settimeout(2)
|
|
pending.sock.sendall((json.dumps(reply) + "\n").encode("utf-8"))
|
|
except OSError:
|
|
pass
|
|
self.drop_pending(pending)
|
|
|
|
def control_pane(self, request):
|
|
ident = request.get("pane")
|
|
if ident not in (None, ""):
|
|
pane = self.panes.get(int(str(ident).lstrip("%")))
|
|
if pane is None:
|
|
raise ValueError("no such pane: %s" % ident)
|
|
return pane
|
|
session = self.latest_session()
|
|
if session is None or session.window() is None:
|
|
raise ValueError("no session")
|
|
return session.window().active
|
|
|
|
def dispatch_control(self, request):
|
|
cmd = request.get("cmd")
|
|
if cmd == "send":
|
|
pane = self.control_pane(request)
|
|
text = str(request.get("text") or "")
|
|
pane.write(text.encode("utf-8", "replace"))
|
|
if request.get("enter"):
|
|
pane.write(b"\r")
|
|
return {"ok": True, "data": True}
|
|
if cmd == "read":
|
|
pane = self.control_pane(request)
|
|
return {"ok": True, "data": pane.tail_log(max(1, min(int(request.get("lines") or 200), 5000)))}
|
|
if cmd == "status":
|
|
pane = self.control_pane(request)
|
|
window = pane.window
|
|
return {"ok": True, "data": {"backend": "native", "pid": pane.pid, "log": pane.log_path, "socket": self.sock_path,
|
|
"pane": pane.tag, "session": window.session.name, "window": window.session.index_of(window)}}
|
|
return {"ok": False, "error": "unknown command %r" % cmd}
|
|
|
|
def client_read(self, client):
|
|
try:
|
|
data = client.sock.recv(65536)
|
|
except (BlockingIOError, InterruptedError):
|
|
return
|
|
except OSError:
|
|
data = b""
|
|
if not data:
|
|
self.drop_client(client)
|
|
return
|
|
self.client_data(client, data)
|
|
|
|
def client_data(self, client, data):
|
|
frames = client.frames(data)
|
|
if frames is None:
|
|
self.drop_client(client)
|
|
return
|
|
for kind, payload in frames:
|
|
if kind == b"H":
|
|
self.on_hello(client, payload)
|
|
elif kind == b"I" and client.attached:
|
|
self.input_bytes(client, payload)
|
|
elif kind == b"R":
|
|
try:
|
|
size = json.loads(payload.decode("utf-8", "replace"))
|
|
client.rows = max(2, min(1000, int(size["rows"])))
|
|
client.cols = max(2, min(2000, int(size["cols"])))
|
|
except (ValueError, KeyError, TypeError):
|
|
continue
|
|
client.prev = None
|
|
if client.attached:
|
|
self.resize_session(client.session)
|
|
client.dirty = True
|
|
|
|
def on_hello(self, client, payload):
|
|
try:
|
|
info = json.loads(payload.decode("utf-8", "replace"))
|
|
if not isinstance(info, dict):
|
|
raise ValueError("hello must be an object")
|
|
client.rows = max(2, min(1000, int(info.get("rows") or 24)))
|
|
client.cols = max(2, min(2000, int(info.get("cols") or 80)))
|
|
client.term = str(info.get("term") or "")[:64]
|
|
client.tty = str(info.get("tty") or "")[:128]
|
|
client.cwd = str(info.get("cwd") or "/")[:4096]
|
|
client.pid = int(info.get("pid") or 0)
|
|
env = info.get("env") or {}
|
|
client.env = {str(k)[:256]: str(v)[:8192] for k, v in list(env.items())[:512]} if isinstance(env, dict) else {}
|
|
pane = info.get("pane")
|
|
client.env_pane = int(pane) if pane not in (None, "") else None
|
|
commands = [[str(word) for word in item][:256] for item in (info.get("commands") or [["new-session"]])[:64] if item]
|
|
except (ValueError, TypeError) as exc:
|
|
client.send(b"E", ("bad hello: " + short_error(exc)).encode("utf-8"))
|
|
client.closing = True
|
|
return
|
|
ctx = MplexCtx(self, client=client)
|
|
try:
|
|
output = "\n".join(text for text in (self.run_one(ctx, words, 0) for words in commands) if text)
|
|
except MplexError as exc:
|
|
client.send(b"E", str(exc).encode("utf-8"))
|
|
client.closing = True
|
|
return
|
|
except Exception as exc: # noqa: BLE001
|
|
import traceback
|
|
sys.stderr.write(traceback.format_exc())
|
|
client.send(b"E", ("internal error: " + short_error(exc)).encode("utf-8"))
|
|
client.closing = True
|
|
return
|
|
if not client.attached:
|
|
if output:
|
|
client.send(b"M", output.encode("utf-8"))
|
|
client.send(b"X", b"")
|
|
client.closing = True
|
|
|
|
def drop_client(self, client):
|
|
if client in self.clients:
|
|
self.clients.remove(client)
|
|
session = client.session
|
|
client.attached = False
|
|
client.session = None
|
|
client.close()
|
|
if session is not None and session.id in self.sessions:
|
|
self.resize_session(session)
|
|
|
|
# ---- main loop -------------------------------------------------------
|
|
|
|
def serve(self):
|
|
self.listener = self.open_listener()
|
|
if self.listener is None:
|
|
return 1
|
|
signal.signal(signal.SIGTERM, lambda _s, _f: setattr(self, "running", False))
|
|
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
self.running = True
|
|
try:
|
|
self.loop()
|
|
finally:
|
|
self.shutdown()
|
|
return 0
|
|
|
|
def stop(self):
|
|
self.running = False
|
|
|
|
def loop(self):
|
|
"""Run until stopped. One failing iteration is logged and survived, because it would otherwise
|
|
end every pane; MPLEX_LOOP_ERRORS_MAX failures in a row end the server."""
|
|
failures = 0
|
|
while self.running:
|
|
try:
|
|
self.iterate()
|
|
failures = 0
|
|
except Exception: # noqa: BLE001 -- see docstring
|
|
import traceback
|
|
sys.stderr.write(traceback.format_exc())
|
|
sys.stderr.flush()
|
|
failures += 1
|
|
if failures >= MPLEX_LOOP_ERRORS_MAX:
|
|
sys.stderr.write("mplex server: %d failed iterations in a row, exiting\n" % failures)
|
|
self.running = False
|
|
time.sleep(0.05)
|
|
|
|
def iterate(self):
|
|
now = time.monotonic()
|
|
self.run_deadlines(now)
|
|
fd_map = {self.listener.fileno(): ("listen", None)}
|
|
for pending in self.conns:
|
|
fd_map[pending.sock.fileno()] = ("pending", pending)
|
|
for client in self.clients:
|
|
fd_map[client.sock.fileno()] = ("client", client)
|
|
for pane in self.panes.values():
|
|
if pane.master_fd is not None:
|
|
fd_map[pane.master_fd] = ("pane", pane)
|
|
writers = {}
|
|
for client in self.clients:
|
|
if client.out:
|
|
writers[client.sock.fileno()] = client
|
|
for pane in self.panes.values():
|
|
if pane.pending_in and pane.master_fd is not None:
|
|
writers[pane.master_fd] = pane
|
|
try:
|
|
ready, can_write, _ = select.select(list(fd_map), list(writers), [], self.wakeup(now))
|
|
except InterruptedError:
|
|
return
|
|
except (OSError, ValueError):
|
|
self.sweep_bad_fds()
|
|
return
|
|
for fd in ready:
|
|
kind, obj = fd_map[fd]
|
|
if kind == "listen":
|
|
self.accept()
|
|
elif kind == "pending":
|
|
self.pending_read(obj)
|
|
elif kind == "client":
|
|
self.client_read(obj)
|
|
elif kind == "pane":
|
|
self.pane_read(obj)
|
|
for fd in can_write:
|
|
target = writers[fd]
|
|
if isinstance(target, MplexPane):
|
|
target.drain()
|
|
else:
|
|
target.flush()
|
|
now = time.monotonic()
|
|
self.reap_children(now)
|
|
self.render_clients(now)
|
|
self.close_finished()
|
|
if not self.sessions and (self.had_session or now - self.started > MPLEX_IDLE_EXIT) and not self.clients_busy():
|
|
self.running = False
|
|
|
|
def clients_busy(self):
|
|
return any(client.out for client in self.clients)
|
|
|
|
def sweep_bad_fds(self):
|
|
for pending in list(self.conns):
|
|
if pending.sock.fileno() < 0:
|
|
self.conns.remove(pending)
|
|
for client in list(self.clients):
|
|
if client.sock.fileno() < 0:
|
|
self.drop_client(client)
|
|
for pane in list(self.panes.values()):
|
|
if pane.master_fd is not None:
|
|
try:
|
|
os.fstat(pane.master_fd)
|
|
except OSError:
|
|
self.pane_exited(pane)
|
|
|
|
def wakeup(self, now):
|
|
due = [now + 1.0]
|
|
due.extend(deadline for deadline, _cb in self.timers)
|
|
for client in self.clients:
|
|
if client.esc_deadline:
|
|
due.append(client.esc_deadline)
|
|
if client.repeating:
|
|
due.append(client.repeat_until)
|
|
if client.dirty and client.attached and len(client.out) <= MPLEX_CLIENT_OUT_MAX // 4:
|
|
due.append(client.last_render + MPLEX_RENDER_INTERVAL)
|
|
if client.overlay is not None and hasattr(client.overlay, "until"):
|
|
due.append(client.overlay.until)
|
|
return max(0.0, min(due) - now)
|
|
|
|
def run_deadlines(self, now):
|
|
if self.timers:
|
|
ripe = [item for item in self.timers if item[0] <= now]
|
|
self.timers = [item for item in self.timers if item[0] > now]
|
|
for _deadline, callback in ripe:
|
|
callback()
|
|
for client in list(self.clients):
|
|
if client.esc_deadline and now >= client.esc_deadline and client.attached:
|
|
client.esc_deadline = None
|
|
self.process_keys(client, True)
|
|
if client.repeating and now >= client.repeat_until:
|
|
client.repeating = False
|
|
client.table = "root"
|
|
client.dirty = True
|
|
overlay = client.overlay
|
|
if overlay is not None and hasattr(overlay, "until") and now >= overlay.until:
|
|
client.overlay = None
|
|
client.dirty = True
|
|
if now >= self.next_tick:
|
|
self.next_tick = now + 1.0
|
|
self.tick(now)
|
|
for pending in list(self.conns):
|
|
if now - pending.created > 5.0:
|
|
self.drop_pending(pending)
|
|
|
|
def tick(self, now):
|
|
for window in list(self.windows.values()):
|
|
if window.auto_rename and window.active is not None and window.session is not None:
|
|
template = window.options.get("automatic-rename-format")
|
|
if window.options.get("automatic-rename"):
|
|
resolver = self.resolver_for(window.session, window, window.active)
|
|
name = MplexFormat.expand(template, resolver)
|
|
if name and name != window.name:
|
|
window.name = name[:64]
|
|
self.mark_all_dirty()
|
|
for pane in window.panes():
|
|
if isinstance(pane.top_mode(), MplexClockMode):
|
|
self.mark_dirty(window)
|
|
interval = self.options.get("status-interval")
|
|
if interval and int(now) % interval == 0:
|
|
self.mark_all_dirty()
|
|
|
|
def pane_read(self, pane):
|
|
try:
|
|
data = os.read(pane.master_fd, 65536)
|
|
except (BlockingIOError, InterruptedError):
|
|
return
|
|
except OSError:
|
|
data = b""
|
|
if not data:
|
|
self.pane_exited(pane)
|
|
return
|
|
pane.feed(data)
|
|
self.mark_dirty(pane.window)
|
|
|
|
def pane_exited(self, pane):
|
|
status = None
|
|
if pane.pid is not None:
|
|
try:
|
|
done, raw = os.waitpid(pane.pid, os.WNOHANG)
|
|
if done:
|
|
status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else raw
|
|
pane.pid = None
|
|
except OSError:
|
|
pane.pid = None
|
|
if pane.window is not None and pane.window.options.get("remain-on-exit") and not pane.dead:
|
|
pane.dead = True
|
|
pane.exit_status = status
|
|
pane.screen.feed(("\r\nPane is dead (status %s)\r\n" % status).encode("utf-8"))
|
|
if pane.master_fd is not None:
|
|
try:
|
|
os.close(pane.master_fd)
|
|
except OSError:
|
|
pass
|
|
pane.master_fd = None
|
|
self.mark_dirty(pane.window)
|
|
return
|
|
if pane.window is not None and pane.id in self.panes:
|
|
self.kill_pane(pane)
|
|
|
|
def reap_children(self, now):
|
|
keep = []
|
|
for pid, since in self.reaping:
|
|
try:
|
|
done, _status = os.waitpid(pid, os.WNOHANG)
|
|
except OSError:
|
|
continue
|
|
if done:
|
|
continue
|
|
if now - since > MPLEX_REAP_KILL_AFTER:
|
|
try:
|
|
os.killpg(pid, signal.SIGKILL)
|
|
except OSError:
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
keep.append((pid, since))
|
|
self.reaping = keep
|
|
|
|
def render_clients(self, now):
|
|
for client in self.clients:
|
|
if not client.attached or not client.dirty:
|
|
continue
|
|
if now - client.last_render < MPLEX_RENDER_INTERVAL:
|
|
continue
|
|
if len(client.out) > MPLEX_CLIENT_OUT_MAX // 4:
|
|
continue
|
|
canvas = self.renderer.frame(client)
|
|
data, snapshot = canvas.encode(client.prev)
|
|
client.last_render = now
|
|
client.dirty = False
|
|
if client.prev is not None and snapshot == client.prev and canvas.cursor == client.cursor:
|
|
continue
|
|
client.prev = snapshot
|
|
client.cursor = canvas.cursor
|
|
client.send(b"O", data)
|
|
|
|
def close_finished(self):
|
|
for client in list(self.clients):
|
|
if client.closing and not client.out:
|
|
self.drop_client(client)
|
|
|
|
def shutdown(self):
|
|
for client in list(self.clients):
|
|
if client.attached:
|
|
client.send(b"X", b"[server exited]")
|
|
for _try in range(20):
|
|
if not client.out:
|
|
break
|
|
client.flush()
|
|
if client.out:
|
|
time.sleep(0.005)
|
|
client.close()
|
|
self.clients = []
|
|
for pane in list(self.panes.values()):
|
|
pane.close(hard=True)
|
|
if pane.pid is not None:
|
|
try:
|
|
os.waitpid(pane.pid, os.WNOHANG)
|
|
except OSError:
|
|
pass
|
|
for item in (self.listener,):
|
|
try:
|
|
item.close()
|
|
except (OSError, AttributeError):
|
|
pass
|
|
try:
|
|
os.unlink(self.sock_path)
|
|
except OSError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def probe(path):
|
|
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
probe.settimeout(1)
|
|
try:
|
|
probe.connect(path)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
probe.close()
|
|
|
|
@classmethod
|
|
def ensure_running(cls, home, wait=5.0):
|
|
"""Start the server as a detached daemon unless one already answers on its socket."""
|
|
path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME)
|
|
if cls.probe(path):
|
|
return True
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
code = 1
|
|
try:
|
|
os.setsid()
|
|
if os.fork() > 0:
|
|
os._exit(0)
|
|
devnull = os.open(os.devnull, os.O_RDWR)
|
|
log = os.open(os.path.join(plexer_dir(home), MPLEX_LOG_NAME), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
os.dup2(devnull, 0)
|
|
os.dup2(log, 1)
|
|
os.dup2(log, 2)
|
|
os.chdir("/")
|
|
code = cls(home).serve()
|
|
except BaseException: # noqa: BLE001
|
|
import traceback
|
|
traceback.print_exc()
|
|
os._exit(code)
|
|
os.waitpid(pid, 0)
|
|
deadline = time.monotonic() + wait
|
|
while time.monotonic() < deadline:
|
|
if cls.probe(path):
|
|
return True
|
|
time.sleep(0.05)
|
|
return False
|
|
|
|
|
|
class MplexPending:
|
|
"""A connection that has not yet said whether it speaks frames or one JSON line."""
|
|
|
|
def __init__(self, sock):
|
|
self.sock = sock
|
|
self.buf = bytearray()
|
|
self.created = time.monotonic()
|
|
|
|
|
|
# name: (aliases, getopt spec, min positional, max positional)
|
|
MPLEX_COMMAND_SPECS = {
|
|
"attach-session": (("attach", "a"), "dErxc:f:t:", 0, 0),
|
|
"bind-key": (("bind",), "nrT:N:", 1, None),
|
|
"break-pane": (("breakp",), "abdPF:n:s:t:", 0, 0),
|
|
"capture-pane": (("capturep",), "aCeJMNpPqTb:E:S:t:", 0, 0),
|
|
"choose-buffer": ((), "NrZF:f:K:O:t:", 0, 1),
|
|
"choose-client": ((), "NrZF:f:K:O:t:", 0, 1),
|
|
"choose-tree": ((), "GNrswZF:f:K:O:t:", 0, 1),
|
|
"clear-history": (("clearhist",), "Ht:", 0, 0),
|
|
"clock-mode": ((), "t:", 0, 0),
|
|
"command-prompt": ((), "1bFkliNI:p:t:T:", 0, 1),
|
|
"confirm-before": (("confirm",), "byc:p:t:", 1, 1),
|
|
"copy-mode": ((), "deHMqSus:t:", 0, 0),
|
|
"delete-buffer": (("deleteb",), "b:", 0, 0),
|
|
"detach-client": (("detach",), "aPE:s:t:", 0, 0),
|
|
"display-message": (("display",), "aCIlNpvc:d:F:t:", 0, 1),
|
|
"display-panes": (("displayp",), "bNd:t:", 0, 1),
|
|
"find-window": (("findw",), "CiNrTZt:", 1, 1),
|
|
"has-session": (("has",), "t:", 0, 0),
|
|
"if-shell": (("if",), "bFt:", 2, 3),
|
|
"kill-pane": (("killp",), "at:", 0, 0),
|
|
"kill-server": ((), "", 0, 0),
|
|
"kill-session": ((), "aCt:", 0, 0),
|
|
"kill-window": (("killw",), "at:", 0, 0),
|
|
"last-pane": (("lastp",), "deZt:", 0, 0),
|
|
"last-window": (("last",), "t:", 0, 0),
|
|
"list-buffers": (("lsb",), "F:f:", 0, 0),
|
|
"list-clients": (("lsc",), "F:f:t:", 0, 0),
|
|
"list-commands": (("lscm",), "F:", 0, 1),
|
|
"list-keys": (("lsk",), "1aNP:T:", 0, 1),
|
|
"list-panes": (("lsp",), "asF:f:t:", 0, 0),
|
|
"list-sessions": (("ls",), "F:f:", 0, 0),
|
|
"list-windows": (("lsw",), "aF:f:t:", 0, 0),
|
|
"load-buffer": (("loadb",), "b:t:", 1, 1),
|
|
"move-window": (("movew",), "abdkrs:t:", 0, 0),
|
|
"new-session": (("new",), "AdDEPXc:e:F:f:n:s:t:x:y:", 0, None),
|
|
"new-window": (("neww",), "abdkPSc:e:F:n:t:", 0, None),
|
|
"next-layout": (("nextl",), "t:", 0, 0),
|
|
"next-window": (("next",), "at:", 0, 0),
|
|
"paste-buffer": (("pasteb",), "dprs:b:t:", 0, 0),
|
|
"previous-layout": (("prevl",), "t:", 0, 0),
|
|
"previous-window": (("prev",), "at:", 0, 0),
|
|
"refresh-client": (("refresh",), "cDlLRSUA:B:C:f:r:t:", 0, 1),
|
|
"rename-session": (("rename",), "t:", 1, 1),
|
|
"rename-window": (("renamew",), "t:", 1, 1),
|
|
"resize-pane": (("resizep",), "DLMRTUZx:y:t:", 0, 1),
|
|
"rotate-window": (("rotatew",), "DUZt:", 0, 0),
|
|
"run-shell": (("run",), "bCEc:d:t:", 0, 1),
|
|
"save-buffer": (("saveb",), "ab:", 1, 1),
|
|
"select-layout": (("selectl",), "Enopt:", 0, 1),
|
|
"select-pane": (("selectp",), "DdeLlMmRUZT:t:", 0, 0),
|
|
"select-window": (("selectw",), "lnpTt:", 0, 0),
|
|
"send-keys": (("send",), "FHKlMRXc:N:t:", 0, None),
|
|
"send-prefix": ((), "2t:", 0, 0),
|
|
"set-buffer": (("setb",), "awb:n:t:", 0, 1),
|
|
"set-option": (("set",), "aFgopqsuUwt:", 1, 2),
|
|
"set-window-option": (("setw",), "aFgoqut:", 1, 2),
|
|
"show-buffer": (("showb",), "b:", 0, 0),
|
|
"show-messages": (("showmsgs",), "JTt:", 0, 0),
|
|
"show-options": (("show",), "AgHpqsvwt:", 0, 1),
|
|
"show-window-options": (("showw",), "gvt:", 0, 1),
|
|
"source-file": (("source",), "Fnqvt:", 1, None),
|
|
"split-window": (("splitw",), "bdefhIPvZc:e:F:l:t:", 0, None),
|
|
"start-server": (("start",), "", 0, 0),
|
|
"suspend-client": (("suspendc",), "t:", 0, 0),
|
|
"swap-pane": (("swapp",), "dDUZs:t:", 0, 0),
|
|
"swap-window": (("swapw",), "ds:t:", 0, 0),
|
|
"switch-client": (("switchc",), "ElnprZc:t:T:", 0, 0),
|
|
"unbind-key": (("unbind",), "anqT:", 0, 1),
|
|
}
|
|
|
|
MPLEX_DEFAULT_FORMATS = {
|
|
"list-sessions": "#{session_name}: #{session_windows} windows (created #{t:session_created})#{?session_attached, (attached),}",
|
|
"list-windows": "#{window_index}: #{window_name}#{window_flags} (#{window_panes} panes) [#{window_width}x#{window_height}] #{window_id}#{?window_active, (active),}",
|
|
"list-panes": "#{pane_index}: [#{pane_width}x#{pane_height}] [history #{history_size}/#{history_limit}] #{pane_id}#{?pane_active, (active),}",
|
|
"list-clients": "#{client_tty}: #{session_name} [#{client_width}x#{client_height} #{client_termname}]",
|
|
"list-buffers": "#{buffer_name}: #{buffer_size} bytes: \"#{buffer_sample}\"",
|
|
"display-message": "[#{session_name}] #{window_index}:#{window_name}, current pane #{pane_index} - (%H:%M %d-%b-%y)",
|
|
}
|
|
|
|
|
|
class MplexCommands:
|
|
"""tmux's command set as methods named cmd_<name>(ctx, args)."""
|
|
|
|
def __init__(self, server):
|
|
self.s = server
|
|
|
|
@staticmethod
|
|
def resolve(word):
|
|
if word in MPLEX_COMMAND_SPECS:
|
|
return word
|
|
for name, spec in MPLEX_COMMAND_SPECS.items():
|
|
if word in spec[0]:
|
|
return name
|
|
if word in MPLEX_UNSUPPORTED:
|
|
raise MplexError("command not supported by mplex: %s" % word)
|
|
matches = [name for name in MPLEX_COMMAND_SPECS if name.startswith(word)]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if len(matches) > 1:
|
|
raise MplexError("ambiguous command: %s (%s)" % (word, ", ".join(sorted(matches)[:5])))
|
|
raise MplexError("unknown command: %s" % word)
|
|
|
|
# ---- helpers ---------------------------------------------------------
|
|
|
|
def need_client(self, ctx):
|
|
if ctx.client is None or not ctx.client.attached:
|
|
raise MplexError("no attached client")
|
|
return ctx.client
|
|
|
|
def pane_target(self, ctx, args, flag="t"):
|
|
return MplexTargets.pane(ctx, args.get(flag))
|
|
|
|
def window_target(self, ctx, args, flag="t"):
|
|
return MplexTargets.window(ctx, args.get(flag))
|
|
|
|
def session_target(self, ctx, args, flag="t"):
|
|
return MplexTargets.session(ctx, args.get(flag))
|
|
|
|
def fmt(self, text, session=None, window=None, pane=None, client=None, extra=None):
|
|
base = self.s.resolver_for(session, window, pane, client)
|
|
if extra:
|
|
def resolve(name):
|
|
return extra[name] if name in extra else base(name)
|
|
else:
|
|
resolve = base
|
|
return MplexFormat.with_time(MplexFormat.expand(text, resolve))
|
|
|
|
def listing(self, command, args, rows):
|
|
template = args.get("F") or MPLEX_DEFAULT_FORMATS[command]
|
|
flt = args.get("f")
|
|
lines = []
|
|
for session, window, pane, client, extra in rows:
|
|
if flt and not MplexFormat.truthy(self.fmt(flt, session, window, pane, client, extra)):
|
|
continue
|
|
lines.append(self.fmt(template, session, window, pane, client, extra))
|
|
return "\n".join(lines)
|
|
|
|
def substitute(self, template, answers):
|
|
out = []
|
|
in_quote = False
|
|
pos = 0
|
|
while pos < len(template):
|
|
char = template[pos]
|
|
if char == "\\" and pos + 1 < len(template):
|
|
out.append(template[pos:pos + 2])
|
|
pos += 2
|
|
continue
|
|
if char == '"':
|
|
in_quote = not in_quote
|
|
if char == "%" and pos + 1 < len(template):
|
|
nxt = template[pos + 1]
|
|
index = 0 if nxt == "%" else (int(nxt) - 1 if nxt.isdigit() and nxt != "0" else None)
|
|
if index is not None:
|
|
value = answers[index] if index < len(answers) else ""
|
|
if in_quote:
|
|
value = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
|
|
out.append(value)
|
|
pos += 2
|
|
continue
|
|
out.append(char)
|
|
pos += 1
|
|
return "".join(out)
|
|
|
|
def run_later(self, ctx, template, answers):
|
|
"""Run template after a prompt finished, against the pane the prompt was opened for."""
|
|
pane = ctx.pane if ctx.pane is not None and ctx.pane.id in self.s.panes else None
|
|
fresh = MplexCtx(self.s, client=ctx.client, pane=pane, interactive=True)
|
|
fresh.depth = ctx.depth + 1
|
|
self.s.run_checked(fresh, self.substitute(template, answers))
|
|
|
|
# ---- sessions and clients --------------------------------------------
|
|
|
|
def cmd_new_session(self, ctx, args):
|
|
s = self.s
|
|
name = args.get("s") or s.auto_session_name()
|
|
if ":" in name or "." in name:
|
|
raise MplexError("bad session name: %s" % name)
|
|
existing = next((item for item in s.sessions.values() if item.name == name), None)
|
|
if existing is not None and args.has("A"):
|
|
return self.attach(ctx, existing, args.has("D"))
|
|
if existing is not None:
|
|
raise MplexError("duplicate session: %s" % name)
|
|
client = ctx.client
|
|
cwd = os.path.expanduser(args.get("c") or (client.cwd if client else os.getcwd()))
|
|
env = dict(client.env) if client is not None and client.env else dict(os.environ)
|
|
for item in args.all("e"):
|
|
key, _eq, value = item.partition("=")
|
|
env[key] = value
|
|
size = (80, 24 + (1 if s.options.get("status") else 0))
|
|
if args.get("x") or args.get("y"):
|
|
size = (args.int("x", 80), args.int("y", 24) + (1 if s.options.get("status") else 0))
|
|
elif client is not None and not args.has("d"):
|
|
size = (client.cols, client.rows)
|
|
session = s.create_session(name, cwd, env, args.positional, args.get("n"), size)
|
|
if not args.has("d") and client is not None:
|
|
s.attach_client(client, session)
|
|
if args.has("P"):
|
|
return self.fmt(args.get("F") or "#{session_name}:", session, session.window(), session.window().active)
|
|
return ""
|
|
|
|
def attach(self, ctx, session, detach_others):
|
|
client = self.need_client_for_attach(ctx)
|
|
if detach_others:
|
|
for other in list(self.s.attached_clients(session)):
|
|
if other is not client:
|
|
self.s.detach_client(other, "[detached (from session %s)]" % session.name)
|
|
self.s.attach_client(client, session)
|
|
return ""
|
|
|
|
def need_client_for_attach(self, ctx):
|
|
if ctx.client is None:
|
|
raise MplexError("no client to attach")
|
|
return ctx.client
|
|
|
|
def cmd_attach_session(self, ctx, args):
|
|
if args.get("t"):
|
|
session = MplexTargets.session(ctx, args.get("t"))
|
|
else:
|
|
session = self.s.latest_session()
|
|
if session is None:
|
|
raise MplexError("no sessions")
|
|
return self.attach(ctx, session, args.has("d"))
|
|
|
|
def cmd_detach_client(self, ctx, args):
|
|
s = self.s
|
|
if args.get("s"):
|
|
session = MplexTargets.session(ctx, args.get("s"))
|
|
targets = list(s.attached_clients(session))
|
|
else:
|
|
current = self.find_client(ctx, args.get("t"))
|
|
targets = [c for c in s.attached_clients() if c is not current] if args.has("a") else [current]
|
|
for client in targets:
|
|
s.detach_client(client, "[detached (from session %s)]" % client.session.name)
|
|
return ""
|
|
|
|
def find_client(self, ctx, spec):
|
|
if not spec:
|
|
return self.need_client(ctx)
|
|
for client in self.s.attached_clients():
|
|
if spec in (client.tty, str(client.id), str(client.pid)):
|
|
return client
|
|
raise MplexError("can't find client: %s" % spec)
|
|
|
|
def cmd_has_session(self, ctx, args):
|
|
MplexTargets.session(ctx, args.get("t") or (ctx.session.name if ctx.session else None))
|
|
return ""
|
|
|
|
def cmd_kill_session(self, ctx, args):
|
|
target = self.session_target(ctx, args)
|
|
if args.has("a"):
|
|
for session in [item for item in self.s.sessions.values() if item is not target]:
|
|
self.s.kill_session(session)
|
|
else:
|
|
self.s.kill_session(target)
|
|
return ""
|
|
|
|
def cmd_kill_server(self, ctx, args):
|
|
self.s.stop()
|
|
return ""
|
|
|
|
def cmd_start_server(self, ctx, args):
|
|
return ""
|
|
|
|
def cmd_list_sessions(self, ctx, args):
|
|
rows = [(item, item.window(), item.window().active, None, None) for item in sorted(self.s.sessions.values(), key=lambda x: x.id)]
|
|
return self.listing("list-sessions", args, rows)
|
|
|
|
def cmd_list_windows(self, ctx, args):
|
|
sessions = sorted(self.s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)]
|
|
rows = [(sess, sess.windows[i], sess.windows[i].active, None, None) for sess in sessions for i in sess.indexes()]
|
|
return self.listing("list-windows", args, rows)
|
|
|
|
def cmd_list_panes(self, ctx, args):
|
|
s = self.s
|
|
if args.has("a") or args.has("s"):
|
|
sessions = sorted(s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)]
|
|
windows = [(sess, sess.windows[i]) for sess in sessions for i in sess.indexes()]
|
|
else:
|
|
sess, _index, window = self.window_target(ctx, args)
|
|
windows = [(sess, window)]
|
|
rows = [(sess, window, pane, None, None) for sess, window in windows for pane in window.panes()]
|
|
return self.listing("list-panes", args, rows)
|
|
|
|
def cmd_list_clients(self, ctx, args):
|
|
rows = [(c.session, c.session.window(), c.session.window().active, c, None) for c in self.s.attached_clients()]
|
|
return self.listing("list-clients", args, rows)
|
|
|
|
def cmd_rename_session(self, ctx, args):
|
|
session = self.session_target(ctx, args)
|
|
name = args.positional[0]
|
|
if not name or ":" in name or "." in name:
|
|
raise MplexError("bad session name: %s" % name)
|
|
if any(item.name == name and item is not session for item in self.s.sessions.values()):
|
|
raise MplexError("duplicate session: %s" % name)
|
|
session.name = name
|
|
self.s.mark_all_dirty()
|
|
return ""
|
|
|
|
def cmd_switch_client(self, ctx, args):
|
|
client = self.need_client(ctx)
|
|
s = self.s
|
|
if args.has("T"):
|
|
client.table = args.get("T")
|
|
client.repeating = False
|
|
return ""
|
|
ordered = sorted(s.sessions.values(), key=lambda x: x.id)
|
|
here = ordered.index(client.session)
|
|
window = None
|
|
if args.has("n"):
|
|
target = ordered[(here + 1) % len(ordered)]
|
|
elif args.has("p"):
|
|
target = ordered[(here - 1) % len(ordered)]
|
|
elif args.has("l"):
|
|
target = getattr(client, "last_session", None)
|
|
if target is None or target.id not in s.sessions:
|
|
raise MplexError("no last session")
|
|
elif args.get("t"):
|
|
spec = args.get("t")
|
|
if spec.startswith("%"):
|
|
target, window = MplexTargets.pane(ctx, spec)[:2]
|
|
elif ":" in spec or spec.startswith("@"):
|
|
target, _index, window = MplexTargets.window(ctx, spec)
|
|
else:
|
|
target = MplexTargets.session(ctx, spec)
|
|
else:
|
|
raise MplexError("switch-client needs -n, -p, -l, -t or -T")
|
|
previous = client.session
|
|
s.attach_client(client, target)
|
|
if target is not previous:
|
|
client.last_session = previous
|
|
if window is not None:
|
|
target.select(target.index_of(window))
|
|
return ""
|
|
|
|
def cmd_suspend_client(self, ctx, args):
|
|
self.need_client(ctx).send(b"Z")
|
|
return ""
|
|
|
|
def cmd_refresh_client(self, ctx, args):
|
|
client = self.need_client(ctx)
|
|
client.prev = None
|
|
client.dirty = True
|
|
return ""
|
|
|
|
def cmd_list_commands(self, ctx, args):
|
|
lines = []
|
|
for name in sorted(MPLEX_COMMAND_SPECS):
|
|
aliases, spec, low, high = MPLEX_COMMAND_SPECS[name]
|
|
lines.append("%s%s %s" % (name, " (%s)" % aliases[0] if aliases else "", "[" + spec.replace(":", " arg").strip() + "]" if spec else ""))
|
|
return "\n".join(lines)
|
|
|
|
# ---- windows ---------------------------------------------------------
|
|
|
|
def cmd_new_window(self, ctx, args):
|
|
s = self.s
|
|
spec = args.get("t")
|
|
session = ctx.session
|
|
index = None
|
|
anchor = None
|
|
if spec and not spec.endswith(":") and ":" not in spec and not spec.lstrip("=").isdigit() and not spec.startswith(("@", "%", "+", "-", "!", "^", "$", "{")):
|
|
has_window = ctx.session is not None and MplexTargets.window_index(ctx.session, spec) is not None
|
|
if not has_window:
|
|
spec = spec + ":"
|
|
if spec and spec.endswith(":"):
|
|
session = MplexTargets.session(ctx, spec[:-1])
|
|
elif spec and self.is_new_index(ctx, spec):
|
|
sess_part, _sep, idx_part = spec.rpartition(":")
|
|
session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
|
|
index = int(idx_part.lstrip("="))
|
|
elif spec:
|
|
session, index, anchor = MplexTargets.window(ctx, spec)
|
|
if session is None:
|
|
raise MplexError("no current session")
|
|
if index is None:
|
|
index = session.free_index(s.options.get("base-index"))
|
|
current = session.current
|
|
if (args.has("a") or args.has("b")) and current is not None:
|
|
index = current + 1 if args.has("a") else current
|
|
self.make_room(session, index)
|
|
elif anchor is not None:
|
|
if args.has("a"):
|
|
index += 1
|
|
self.make_room(session, index)
|
|
elif args.has("b"):
|
|
self.make_room(session, index)
|
|
elif args.has("k"):
|
|
s.kill_window(anchor)
|
|
else:
|
|
raise MplexError("index in use: %d" % index)
|
|
cwd = os.path.expanduser(args.get("c") or session.cwd)
|
|
env_extra = dict(item.partition("=")[::2] for item in args.all("e"))
|
|
if args.has("S"):
|
|
for idx in session.indexes():
|
|
if session.windows[idx].name == args.get("n"):
|
|
session.select(idx)
|
|
return ""
|
|
created = s.create_window(session, args.get("n"), cwd, args.positional, env_extra, index, not args.has("d"))
|
|
if args.has("P"):
|
|
return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, created, created.active)
|
|
return ""
|
|
|
|
def is_new_index(self, ctx, spec):
|
|
"""-t names an index that does not exist yet (the usual new-window -t :N case)."""
|
|
if ":" not in spec:
|
|
return False
|
|
sess_part, _sep, idx_part = spec.rpartition(":")
|
|
idx_part = idx_part.lstrip("=")
|
|
if not idx_part.isdigit():
|
|
return False
|
|
try:
|
|
session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
|
|
except MplexError:
|
|
return False
|
|
return int(idx_part) not in session.windows
|
|
|
|
def make_room(self, session, index):
|
|
if index not in session.windows:
|
|
return
|
|
current = session.window()
|
|
moved = {}
|
|
for idx in session.indexes():
|
|
moved[idx + 1 if idx >= index else idx] = session.windows[idx]
|
|
session.windows = moved
|
|
session.current = session.index_of(current) if current else None
|
|
|
|
def cmd_kill_window(self, ctx, args):
|
|
session, index, window = self.window_target(ctx, args)
|
|
if args.has("a"):
|
|
for other in [session.windows[i] for i in session.indexes() if i != index]:
|
|
self.s.kill_window(other)
|
|
else:
|
|
self.s.kill_window(window)
|
|
return ""
|
|
|
|
def cmd_select_window(self, ctx, args):
|
|
if args.has("n") or args.has("p"):
|
|
return self.step_window(ctx, args, 1 if args.has("n") else -1)
|
|
if args.has("l"):
|
|
return self.cmd_last_window(ctx, args)
|
|
session, index, window = self.window_target(ctx, args)
|
|
if args.has("T") and index == session.current:
|
|
if session.last is None:
|
|
raise MplexError("no last window")
|
|
session.select(session.last)
|
|
self.s.mark_dirty()
|
|
return ""
|
|
session.select(index)
|
|
self.s.mark_dirty()
|
|
return ""
|
|
|
|
def step_window(self, ctx, args, step):
|
|
session = MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0])
|
|
if args.has("a"):
|
|
raise MplexError("no %s window" % ("next" if step > 0 else "previous"))
|
|
indexes = session.indexes()
|
|
here = indexes.index(session.current)
|
|
session.select(indexes[(here + step) % len(indexes)])
|
|
self.s.mark_dirty()
|
|
return ""
|
|
|
|
def cmd_next_window(self, ctx, args):
|
|
return self.step_window(ctx, args, 1)
|
|
|
|
def cmd_previous_window(self, ctx, args):
|
|
return self.step_window(ctx, args, -1)
|
|
|
|
def cmd_last_window(self, ctx, args):
|
|
session = MplexTargets.session(ctx, args.get("t"))
|
|
if session.last not in session.windows:
|
|
raise MplexError("no last window")
|
|
session.select(session.last)
|
|
self.s.mark_dirty()
|
|
return ""
|
|
|
|
def cmd_rename_window(self, ctx, args):
|
|
session, index, window = self.window_target(ctx, args)
|
|
if not args.positional[0]:
|
|
raise MplexError("empty window name")
|
|
window.name = args.positional[0][:64]
|
|
window.auto_rename = False
|
|
self.s.mark_all_dirty()
|
|
return ""
|
|
|
|
def parse_window_destination(self, ctx, spec):
|
|
if ":" in spec:
|
|
sess_part, _sep, idx_part = spec.rpartition(":")
|
|
session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
|
|
else:
|
|
session, idx_part = ctx.session, spec
|
|
idx_part = idx_part.lstrip("=")
|
|
if not idx_part.lstrip("-").isdigit():
|
|
raise MplexError("bad window index: %s" % idx_part)
|
|
return session, int(idx_part)
|
|
|
|
def cmd_move_window(self, ctx, args):
|
|
s = self.s
|
|
if args.has("r"):
|
|
s.renumber(MplexTargets.session(ctx, (args.get("t") or "").partition(":")[0]))
|
|
s.mark_all_dirty()
|
|
return ""
|
|
src_session, src_index, window = MplexTargets.window(ctx, args.get("s"))
|
|
if not args.get("t"):
|
|
raise MplexError("move-window needs -t")
|
|
dst_session, idx = self.parse_window_destination(ctx, args.get("t"))
|
|
if args.has("a"):
|
|
idx += 1
|
|
self.make_room(dst_session, idx)
|
|
elif args.has("b"):
|
|
self.make_room(dst_session, idx)
|
|
elif idx in dst_session.windows and dst_session.windows[idx] is not window:
|
|
if args.has("k"):
|
|
s.kill_window(dst_session.windows[idx])
|
|
else:
|
|
raise MplexError("index in use: %d" % idx)
|
|
if dst_session is src_session and idx == src_index:
|
|
return ""
|
|
was_current = src_session.current == src_index
|
|
src_session.windows.pop(src_index)
|
|
src_session.history = [item for item in src_session.history if item is not window]
|
|
window.session = dst_session
|
|
window.options.parent = dst_session.options
|
|
dst_session.windows[idx] = window
|
|
if was_current and src_session.windows and src_session is not dst_session:
|
|
src_session.current = src_session.indexes()[0]
|
|
elif was_current and src_session is dst_session:
|
|
src_session.current = idx
|
|
if not args.has("d"):
|
|
dst_session.select(idx)
|
|
if not src_session.windows:
|
|
s.kill_session(src_session)
|
|
else:
|
|
s.resize_session(src_session)
|
|
s.resize_session(dst_session)
|
|
return ""
|
|
|
|
def cmd_swap_window(self, ctx, args):
|
|
src_session, src_index, src = MplexTargets.window(ctx, args.get("s"))
|
|
dst_session, dst_index, dst = MplexTargets.window(ctx, args.get("t"))
|
|
was = (src_session.window(), dst_session.window())
|
|
src_session.windows[src_index], dst_session.windows[dst_index] = dst, src
|
|
src.session, dst.session = dst_session, src_session
|
|
src.options.parent, dst.options.parent = dst_session.options, src_session.options
|
|
for session, current in ((src_session, was[0]), (dst_session, was[1])):
|
|
if current is not None:
|
|
session.current = session.index_of(current)
|
|
self.s.resize_session(src_session)
|
|
self.s.resize_session(dst_session)
|
|
return ""
|
|
|
|
def cmd_find_window(self, ctx, args):
|
|
s = self.s
|
|
text = args.positional[0]
|
|
fields = ("C" if args.has("C") else "") + ("N" if args.has("N") else "") + ("T" if args.has("T") else "")
|
|
fields = fields or "CNT"
|
|
if args.has("r"):
|
|
flags = re.IGNORECASE if args.has("i") else 0
|
|
try:
|
|
matcher = re.compile(text, flags).search
|
|
except re.error as exc:
|
|
raise MplexError("bad regular expression: %s" % exc)
|
|
else:
|
|
import fnmatch
|
|
pattern = "*%s*" % text
|
|
low = pattern.lower() if args.has("i") else pattern
|
|
|
|
def matcher(value):
|
|
return fnmatch.fnmatchcase(value.lower() if args.has("i") else value, low)
|
|
|
|
found = []
|
|
for session in sorted(s.sessions.values(), key=lambda x: x.id):
|
|
for index in session.indexes():
|
|
window = session.windows[index]
|
|
hit = False
|
|
if "N" in fields and matcher(window.name):
|
|
hit = True
|
|
for pane in window.panes():
|
|
if "T" in fields and matcher(pane.title):
|
|
hit = True
|
|
if "C" in fields and matcher(pane.screen.text()):
|
|
hit = True
|
|
if hit:
|
|
found.append((session, index, window))
|
|
if not found:
|
|
raise MplexError("no windows matching: %s" % text)
|
|
if len(found) == 1:
|
|
session, index, _window = found[0]
|
|
client = ctx.client
|
|
if client is not None and client.attached and client.session is not session:
|
|
s.attach_client(client, session)
|
|
session.select(index)
|
|
s.mark_dirty()
|
|
return ""
|
|
items = [MplexChoice("%s:%d: %s%s" % (sess.name, idx, win.name, s.window_flags(sess, win)), ("window", win.id)) for sess, idx, win in found]
|
|
return self.open_chooser(ctx, "tree-mode", lambda: [MplexChoice(i.label, i.payload) for i in items], args)
|
|
|
|
# ---- panes -----------------------------------------------------------
|
|
|
|
def size_arg(self, text, total):
|
|
text = text.strip()
|
|
if text.endswith("%"):
|
|
return max(1, total * int(text[:-1]) // 100)
|
|
return int(text)
|
|
|
|
def cmd_split_window(self, ctx, args):
|
|
s = self.s
|
|
session, window, target = self.pane_target(ctx, args)
|
|
kind = "lr" if args.has("h") else "tb"
|
|
leaf = window.leaf_of(target)
|
|
size = None
|
|
if args.get("l"):
|
|
try:
|
|
size = self.size_arg(args.get("l"), leaf.w if kind == "lr" else leaf.h)
|
|
except ValueError:
|
|
raise MplexError("bad size: %s" % args.get("l"))
|
|
cwd = os.path.expanduser(args.get("c") or session.cwd)
|
|
env_extra = dict(item.partition("=")[::2] for item in args.all("e"))
|
|
pane = s.split_pane(window, target, kind, args.has("b"), args.positional, cwd, env_extra, size, not args.has("d"))
|
|
if args.has("Z") and not args.has("d"):
|
|
window.zoomed = pane
|
|
window.relayout()
|
|
if args.has("P"):
|
|
return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, window, pane)
|
|
return ""
|
|
|
|
def cmd_kill_pane(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
if args.has("a"):
|
|
for other in [item for item in window.panes() if item is not pane]:
|
|
self.s.kill_pane(other)
|
|
else:
|
|
self.s.kill_pane(pane)
|
|
return ""
|
|
|
|
def cmd_select_pane(self, ctx, args):
|
|
s = self.s
|
|
if args.has("e") or args.has("d"):
|
|
raise MplexError("select-pane -e/-d (pane input switch) is not supported by mplex")
|
|
if args.has("M"):
|
|
s.marked = None
|
|
s.mark_all_dirty()
|
|
return ""
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
if args.has("m"):
|
|
s.marked = None if s.marked is pane else pane
|
|
s.mark_all_dirty()
|
|
return ""
|
|
if args.get("T") is not None:
|
|
pane.screen.title = args.get("T")[:256]
|
|
s.mark_all_dirty()
|
|
return ""
|
|
new = pane
|
|
for flag in "UDLR":
|
|
if args.has(flag):
|
|
found = MplexLayout.neighbour(window.visible_panes() if window.zoomed else window.panes(), pane, flag, window.width, window.height)
|
|
if found is None:
|
|
raise MplexError("no pane in that direction")
|
|
new = found
|
|
if args.has("l"):
|
|
if window.last_pane not in window.panes():
|
|
raise MplexError("no last pane")
|
|
new = window.last_pane
|
|
keep_zoom = args.has("Z") and window.zoomed is not None
|
|
zoomed = window.zoomed
|
|
s.select_pane(window, new)
|
|
if keep_zoom and zoomed is not None:
|
|
window.zoomed = new
|
|
window.relayout()
|
|
if session.window() is not window:
|
|
session.select(session.index_of(window))
|
|
return ""
|
|
|
|
def cmd_last_pane(self, ctx, args):
|
|
_session, _index, window = MplexTargets.window(ctx, args.get("t"))
|
|
if window.last_pane not in window.panes():
|
|
raise MplexError("no last pane")
|
|
self.s.select_pane(window, window.last_pane)
|
|
return ""
|
|
|
|
def cmd_resize_pane(self, ctx, args):
|
|
if args.has("M") or args.has("T"):
|
|
raise MplexError("resize-pane -M/-T is not supported by mplex")
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
if args.has("Z"):
|
|
if window.zoomed is not None:
|
|
window.zoomed = None
|
|
elif len(window.panes()) > 1:
|
|
window.zoomed = pane
|
|
window.relayout()
|
|
self.s.mark_dirty(window)
|
|
return ""
|
|
leaf = window.leaf_of(pane)
|
|
amount = 1
|
|
if args.positional:
|
|
try:
|
|
amount = int(args.positional[0])
|
|
except ValueError:
|
|
raise MplexError("bad adjustment: %s" % args.positional[0])
|
|
try:
|
|
if args.get("x"):
|
|
MplexLayout.resize_to(leaf, "lr", self.size_arg(args.get("x"), window.width))
|
|
if args.get("y"):
|
|
MplexLayout.resize_to(leaf, "tb", self.size_arg(args.get("y"), window.height))
|
|
if args.has("R"):
|
|
MplexLayout.resize_by(leaf, "lr", amount, True)
|
|
elif args.has("L"):
|
|
MplexLayout.resize_by(leaf, "lr", -amount, False)
|
|
elif args.has("D"):
|
|
MplexLayout.resize_by(leaf, "tb", amount, True)
|
|
elif args.has("U"):
|
|
MplexLayout.resize_by(leaf, "tb", -amount, False)
|
|
except MplexError:
|
|
pass
|
|
window.layout_index = -1
|
|
window.relayout()
|
|
self.s.mark_dirty(window)
|
|
return ""
|
|
|
|
def cmd_swap_pane(self, ctx, args):
|
|
s = self.s
|
|
if args.has("U") or args.has("D"):
|
|
_s, window, dst = self.pane_target(ctx, args)
|
|
panes = window.panes()
|
|
here = panes.index(dst)
|
|
src = panes[(here - 1) % len(panes)] if args.has("U") else panes[(here + 1) % len(panes)]
|
|
else:
|
|
_s, _w, dst = self.pane_target(ctx, args)
|
|
if args.get("s"):
|
|
_s2, _w2, src = MplexTargets.pane(ctx, args.get("s"))
|
|
elif s.marked is not None:
|
|
src = s.marked
|
|
else:
|
|
raise MplexError("no marked pane")
|
|
if src is dst:
|
|
return ""
|
|
wa, wb = src.window, dst.window
|
|
leaf_a, leaf_b = wa.leaf_of(src), wb.leaf_of(dst)
|
|
leaf_a.pane, leaf_b.pane = dst, src
|
|
src.window, dst.window = wb, wa
|
|
if wa is not wb:
|
|
if wa.active is src:
|
|
wa.active = dst
|
|
if wb.active is dst:
|
|
wb.active = src
|
|
for window in (wa,) if wa is wb else (wa, wb):
|
|
window.relayout()
|
|
s.marked = None
|
|
s.mark_all_dirty()
|
|
return ""
|
|
|
|
def cmd_rotate_window(self, ctx, args):
|
|
session, index, window = self.window_target(ctx, args)
|
|
panes = window.panes()
|
|
if len(panes) < 2:
|
|
return ""
|
|
order = panes[-1:] + panes[:-1] if args.has("D") else panes[1:] + panes[:1]
|
|
position = panes.index(window.active)
|
|
for leaf, pane in zip(window.root.leaves(), order):
|
|
leaf.pane = pane
|
|
window.active = order[position]
|
|
window.relayout()
|
|
s = self.s
|
|
s.mark_dirty(window)
|
|
return ""
|
|
|
|
def cmd_break_pane(self, ctx, args):
|
|
s = self.s
|
|
session, window, pane = MplexTargets.pane(ctx, args.get("s"))
|
|
if len(window.panes()) < 2:
|
|
raise MplexError("can't break with only one pane")
|
|
dst_session = session
|
|
index = dst_session.free_index(s.options.get("base-index"))
|
|
if args.get("t"):
|
|
dst_session, index = self.parse_window_destination(ctx, args.get("t"))
|
|
if index in dst_session.windows:
|
|
raise MplexError("index in use: %d" % index)
|
|
leaf = window.leaf_of(pane)
|
|
was_active = window.active is pane
|
|
window.root = MplexLayout.remove(window.root, leaf)
|
|
if window.zoomed is pane:
|
|
window.zoomed = None
|
|
rest = window.panes()
|
|
if was_active or window.active is pane:
|
|
window.active = window.last_pane if window.last_pane in rest else rest[0]
|
|
if window.last_pane is pane:
|
|
window.last_pane = None
|
|
window.relayout()
|
|
cols, rows = getattr(dst_session, "size", (80, 24))
|
|
fresh = MplexWindow(s.next_id("window"), args.get("n") or pane.foreground_command() or pane.shell_name, cols, max(1, rows - (1 if s.options.get("status") else 0)), dst_session.options)
|
|
fresh.auto_rename = not args.get("n")
|
|
fresh.root = MplexLayoutNode("pane", pane=pane, w=fresh.width, h=fresh.height)
|
|
fresh.active = pane
|
|
pane.window = fresh
|
|
s.register_window(dst_session, fresh, index, not args.has("d"))
|
|
fresh.relayout()
|
|
s.mark_all_dirty()
|
|
if args.has("P"):
|
|
return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane)
|
|
return ""
|
|
|
|
def apply_layout(self, window, name, index):
|
|
panes = window.panes()
|
|
opts = window.options
|
|
window.zoomed = None
|
|
window.root = MplexLayout.preset(name, panes, window.width, window.height, opts.get("main-pane-width"), opts.get("main-pane-height"))
|
|
window.layout_index = index
|
|
window.relayout()
|
|
self.s.mark_dirty(window)
|
|
|
|
def cmd_select_layout(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
names = MPLEX_LAYOUT_NAMES
|
|
if args.has("E"):
|
|
leaf = window.leaf_of(pane)
|
|
parent = leaf.parent
|
|
if parent is not None:
|
|
for child in parent.children:
|
|
MplexLayout.set_size(child, parent.kind, 1)
|
|
window.relayout()
|
|
self.s.mark_dirty(window)
|
|
return ""
|
|
if args.positional:
|
|
wanted = args.positional[0]
|
|
matches = [item for item in names if item == wanted] or [item for item in names if item.startswith(wanted)]
|
|
if len(matches) != 1:
|
|
raise MplexError("unknown or ambiguous layout: %s" % wanted)
|
|
self.apply_layout(window, matches[0], names.index(matches[0]))
|
|
return ""
|
|
if args.has("p"):
|
|
index = (window.layout_index - 1) % len(names)
|
|
elif args.has("o"):
|
|
index = window.layout_index if window.layout_index >= 0 else 0
|
|
else:
|
|
index = (window.layout_index + 1) % len(names)
|
|
self.apply_layout(window, names[index], index)
|
|
return ""
|
|
|
|
def cmd_next_layout(self, ctx, args):
|
|
session, index, window = self.window_target(ctx, args)
|
|
self.apply_layout(window, MPLEX_LAYOUT_NAMES[(window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)], (window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES))
|
|
return ""
|
|
|
|
def cmd_previous_layout(self, ctx, args):
|
|
session, index, window = self.window_target(ctx, args)
|
|
step = (window.layout_index - 1) % len(MPLEX_LAYOUT_NAMES)
|
|
self.apply_layout(window, MPLEX_LAYOUT_NAMES[step], step)
|
|
return ""
|
|
|
|
def cmd_clear_history(self, ctx, args):
|
|
_s, _w, pane = self.pane_target(ctx, args)
|
|
pane.clear_history()
|
|
return ""
|
|
|
|
def cmd_capture_pane(self, ctx, args):
|
|
_s, _w, pane = self.pane_target(ctx, args)
|
|
screen = pane.screen
|
|
if args.has("a") and not screen.alt_active:
|
|
if args.has("q"):
|
|
return ""
|
|
raise MplexError("no alternate screen")
|
|
history = list(screen.history or [])
|
|
rows = history + [list(row) for row in screen.grid]
|
|
base = len(history)
|
|
|
|
def line_of(row):
|
|
text = "".join(cell[0] if cell else " " for cell in row)
|
|
return text if args.has("N") else text.rstrip()
|
|
|
|
def bound(value, default):
|
|
if value is None:
|
|
return default
|
|
if value == "-":
|
|
return None
|
|
try:
|
|
return base + int(value)
|
|
except ValueError:
|
|
raise MplexError("bad line number: %s" % value)
|
|
|
|
start = bound(args.get("S"), base)
|
|
end = bound(args.get("E"), base + pane.h - 1)
|
|
start = 0 if start is None else max(0, start)
|
|
end = len(rows) - 1 if end is None else min(len(rows) - 1, end)
|
|
text = "\n".join(line_of(rows[i]) for i in range(start, end + 1))
|
|
if args.has("p"):
|
|
return text
|
|
self.s.add_buffer(text + "\n", args.get("b"))
|
|
return ""
|
|
|
|
# ---- keys, prompts, input --------------------------------------------
|
|
|
|
def cmd_send_prefix(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
name = session.options.get("prefix2" if args.has("2") else "prefix")
|
|
if name == "None":
|
|
raise MplexError("no prefix key set")
|
|
self.s.forward(pane, window, MplexKeys.to_bytes(name, pane.screen.app_cursor))
|
|
return ""
|
|
|
|
def cmd_send_keys(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
words = list(args.positional)
|
|
count = args.int("N", None)
|
|
if args.has("M"):
|
|
raise MplexError("send-keys -M (mouse) is not supported by mplex")
|
|
if args.has("X"):
|
|
mode = pane.top_mode()
|
|
if not isinstance(mode, MplexCopyMode):
|
|
raise MplexError("pane is not in a mode")
|
|
if not words:
|
|
raise MplexError("send-keys -X needs a command")
|
|
if len(words) > 1 and words[1] == "--":
|
|
del words[1]
|
|
if args.has("F"):
|
|
words = [self.fmt(item, session, window, pane, ctx.client) for item in words]
|
|
if count:
|
|
pane.prefix_count = count
|
|
mode.command(words[0], words[1:], ctx.client)
|
|
return ""
|
|
if args.has("R"):
|
|
pane.screen.reset()
|
|
if not words:
|
|
if count:
|
|
pane.prefix_count = count
|
|
return ""
|
|
if args.has("F"):
|
|
words = [self.fmt(item, session, window, pane, ctx.client) for item in words]
|
|
data = b""
|
|
if args.has("l"):
|
|
data = "".join(words).encode("utf-8")
|
|
elif args.has("H"):
|
|
try:
|
|
data = bytes(int(item, 16) for item in words)
|
|
except ValueError:
|
|
raise MplexError("bad hex byte in send-keys -H")
|
|
else:
|
|
for word in words:
|
|
try:
|
|
data += MplexKeys.to_bytes(MplexKeys.canonical(word), pane.screen.app_cursor)
|
|
except MplexError:
|
|
data += word.encode("utf-8")
|
|
mode = pane.top_mode()
|
|
for _i in range(max(1, min(count or 1, 1000))):
|
|
if mode is not None and not args.has("l") and not args.has("H"):
|
|
tokens, _rest = MplexKeys.split(data, True)
|
|
for name, raw in tokens:
|
|
mode.key(name, raw, ctx.client)
|
|
elif not pane.dead:
|
|
pane.write(data)
|
|
self.s.mark_dirty(window)
|
|
return ""
|
|
|
|
def cmd_bind_key(self, ctx, args):
|
|
table = "root" if args.has("n") else (args.get("T") or "prefix")
|
|
key = MplexKeys.canonical(args.positional[0])
|
|
words = args.positional[1:]
|
|
if not words:
|
|
raise MplexError("bind-key needs a command")
|
|
command = " ".join(MplexCommandParser.quote(word) for word in words)
|
|
for parsed in MplexCommandParser.parse(command):
|
|
self.resolve(parsed[0])
|
|
self.s.config.bind(table, key, command, args.has("r"), args.get("N") or "")
|
|
return ""
|
|
|
|
def cmd_unbind_key(self, ctx, args):
|
|
table = "root" if args.has("n") else (args.get("T") or "prefix")
|
|
config = self.s.config
|
|
if args.has("a"):
|
|
config.tables[table] = {}
|
|
return ""
|
|
if not args.positional:
|
|
raise MplexError("unbind-key needs a key (or -a)")
|
|
key = MplexKeys.canonical(args.positional[0])
|
|
if not config.unbind(table, key) and not args.has("q"):
|
|
raise MplexError("unknown key: %s" % args.positional[0])
|
|
return ""
|
|
|
|
def cmd_list_keys(self, ctx, args):
|
|
config = self.s.config
|
|
opts = ctx.session.options if ctx.session else self.s.options
|
|
only = MplexKeys.canonical(args.positional[0]) if args.positional else None
|
|
tables = [args.get("T")] if args.get("T") else list(config.tables)
|
|
lines = []
|
|
if args.has("N"):
|
|
prefix = opts.get("prefix")
|
|
rows = []
|
|
for table in ([args.get("T")] if args.get("T") else ["root", "prefix"]):
|
|
for key, binding in config.tables.get(table, {}).items():
|
|
if only is not None and key != only:
|
|
continue
|
|
note = binding.note or (binding.command if args.has("a") else "")
|
|
if not note:
|
|
continue
|
|
label = key if table == "root" else "%s %s" % (prefix, key)
|
|
if args.get("P") is not None:
|
|
label = args.get("P") + key
|
|
rows.append((label, note))
|
|
if only is not None and not rows:
|
|
raise MplexError("unknown key: %s" % args.positional[0])
|
|
if args.has("1"):
|
|
rows = rows[:1]
|
|
width = max([len(label) for label, _note in rows] or [0])
|
|
return "\n".join("%s %s" % (label.ljust(width), note) for label, note in rows)
|
|
for table in tables:
|
|
for key, binding in config.tables.get(table, {}).items():
|
|
if only is not None and key != only:
|
|
continue
|
|
lines.append("bind-key %s -T %-12s %-12s %s" % ("-r" if binding.repeat else " ", table, key, binding.command))
|
|
if args.has("1") and lines:
|
|
lines = lines[:1]
|
|
return "\n".join(lines)
|
|
|
|
def cmd_command_prompt(self, ctx, args):
|
|
s = self.s
|
|
client = self.need_client(ctx)
|
|
template = args.positional[0] if args.positional else None
|
|
prompts = args.get("p").split(",") if args.get("p") else []
|
|
inputs = args.get("I").split(",") if args.get("I") else []
|
|
prompts = [MplexFormat.strip_styles(self.fmt(item, ctx.session, ctx.window, ctx.pane, client)) for item in prompts]
|
|
inputs = [self.fmt(item, ctx.session, ctx.window, ctx.pane, client) for item in inputs]
|
|
if template is not None and args.has("F"):
|
|
template = self.fmt(template, ctx.session, ctx.window, ctx.pane, client)
|
|
kind = args.get("T") or "command"
|
|
history = s.prompt_history.setdefault(kind, [])
|
|
answers = []
|
|
count = max(1, len(prompts))
|
|
pane = ctx.pane
|
|
copy_mode = pane.find_mode(MplexCopyMode) if pane is not None else None
|
|
|
|
def ask(position):
|
|
label = (prompts[position] + " ") if prompts else ":"
|
|
initial = inputs[position] if position < len(inputs) else ""
|
|
|
|
def done(value):
|
|
answers.append(value)
|
|
if position + 1 < count:
|
|
ask(position + 1)
|
|
return
|
|
if template is None:
|
|
self.run_later(ctx, "%%", answers)
|
|
else:
|
|
self.run_later(ctx, template, answers)
|
|
|
|
kwargs = {}
|
|
if args.has("i") and template is not None:
|
|
kwargs["on_change"] = lambda text: self.run_later(ctx, template, ["=" + text])
|
|
kwargs["on_step"] = lambda name, text: self.run_later(ctx, template, [("+" if name == "C-s" else "-") + text])
|
|
if copy_mode is not None:
|
|
kwargs["on_cancel"] = copy_mode.search_cancel
|
|
client.prompt = MplexPrompt(label, initial, done, history, single=args.has("1"), numeric=args.has("N"), key_name=args.has("k"), **kwargs)
|
|
client.dirty = True
|
|
|
|
ask(0)
|
|
return ""
|
|
|
|
def cmd_confirm_before(self, ctx, args):
|
|
client = self.need_client(ctx)
|
|
command = args.positional[0]
|
|
text = args.get("p") or "Confirm '%s'? (y/n)" % command
|
|
text = self.fmt(text, ctx.session, ctx.window, ctx.pane, client)
|
|
pane = ctx.pane
|
|
|
|
def run():
|
|
fresh = MplexCtx(self.s, client=client, pane=pane if pane is not None and pane.id in self.s.panes else None, interactive=True)
|
|
fresh.depth = ctx.depth + 1
|
|
self.s.run_checked(fresh, command)
|
|
|
|
self.s.ask_confirm(client, text, run)
|
|
return ""
|
|
|
|
def cmd_display_message(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
template = args.positional[0] if args.positional else MPLEX_DEFAULT_FORMATS["display-message"]
|
|
text = self.fmt(template, session, window, pane, ctx.client)
|
|
text = MplexFormat.strip_styles(text)
|
|
if args.has("p"):
|
|
return text
|
|
client = ctx.client
|
|
if client is not None and client.attached:
|
|
delay = args.int("d", None)
|
|
self.s.message(client, text, delay)
|
|
return ""
|
|
|
|
def cmd_display_panes(self, ctx, args):
|
|
client = self.need_client(ctx)
|
|
window = client.session.window()
|
|
duration = args.int("d", None) or window.options.get("display-panes-time")
|
|
client.overlay = MplexPaneNumbers(self.s, window, time.monotonic() + duration / 1000.0)
|
|
client.dirty = True
|
|
return ""
|
|
|
|
def cmd_clock_mode(self, ctx, args):
|
|
_s, window, pane = self.pane_target(ctx, args)
|
|
if pane.find_mode(MplexClockMode) is None:
|
|
pane.push_mode(MplexClockMode(self.s, pane))
|
|
self.s.mark_dirty(window)
|
|
return ""
|
|
|
|
def cmd_copy_mode(self, ctx, args):
|
|
if args.has("M"):
|
|
raise MplexError("copy-mode -M (mouse) is not supported by mplex")
|
|
_s, window, pane = self.pane_target(ctx, args)
|
|
existing = pane.find_mode(MplexCopyMode)
|
|
if args.has("q"):
|
|
if existing is not None:
|
|
existing.close()
|
|
return ""
|
|
if existing is None:
|
|
existing = MplexCopyMode(self.s, pane, exit_at_bottom=args.has("e"), show_position=not args.has("H"))
|
|
pane.push_mode(existing)
|
|
if args.has("u"):
|
|
existing.command("page-up", [], ctx.client)
|
|
self.s.mark_dirty(window)
|
|
return ""
|
|
|
|
def cmd_show_messages(self, ctx, args):
|
|
return "\n".join("%s %s" % (time.strftime("%H:%M:%S", time.localtime(stamp)), text) for stamp, text in self.s.messages)
|
|
|
|
# ---- buffers ---------------------------------------------------------
|
|
|
|
def buffer_rows(self):
|
|
rows = []
|
|
for item in self.s.buffers:
|
|
sample = item["data"][:50].replace("\n", " ")
|
|
rows.append((None, None, None, None, {"buffer_name": item["name"], "buffer_size": str(len(item["data"].encode("utf-8"))), "buffer_sample": sample}))
|
|
return rows
|
|
|
|
def cmd_list_buffers(self, ctx, args):
|
|
return self.listing("list-buffers", args, self.buffer_rows())
|
|
|
|
def cmd_set_buffer(self, ctx, args):
|
|
name = args.get("b")
|
|
if args.get("n"):
|
|
item = self.s.find_buffer(name)
|
|
item["name"] = args.get("n")
|
|
return ""
|
|
if not args.positional:
|
|
raise MplexError("set-buffer needs data")
|
|
data = args.positional[0]
|
|
if args.has("a"):
|
|
item = self.s.find_buffer(name)
|
|
item["data"] = (item["data"] + data)[:MPLEX_PASTE_MAX]
|
|
else:
|
|
self.s.add_buffer(data, name)
|
|
return ""
|
|
|
|
def cmd_show_buffer(self, ctx, args):
|
|
return self.s.find_buffer(args.get("b"))["data"]
|
|
|
|
def cmd_delete_buffer(self, ctx, args):
|
|
item = self.s.find_buffer(args.get("b"))
|
|
self.s.buffers.remove(item)
|
|
self.s.refresh_choosers()
|
|
return ""
|
|
|
|
def cmd_save_buffer(self, ctx, args):
|
|
item = self.s.find_buffer(args.get("b"))
|
|
path = os.path.expanduser(args.positional[0])
|
|
try:
|
|
with open(path, "ab" if args.has("a") else "wb") as handle:
|
|
handle.write(item["data"].encode("utf-8"))
|
|
except OSError as exc:
|
|
raise MplexError("cannot write %s: %s" % (path, short_error(exc)))
|
|
return ""
|
|
|
|
def cmd_load_buffer(self, ctx, args):
|
|
path = os.path.expanduser(args.positional[0])
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
data = handle.read(MPLEX_PASTE_MAX)
|
|
except OSError as exc:
|
|
raise MplexError("cannot read %s: %s" % (path, short_error(exc)))
|
|
self.s.add_buffer(data.decode("utf-8", "replace"), args.get("b"))
|
|
return ""
|
|
|
|
def cmd_paste_buffer(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
item = self.s.find_buffer(args.get("b"))
|
|
separator = args.get("s") if args.get("s") is not None else "\r"
|
|
text = item["data"] if args.has("r") else item["data"].replace("\n", separator)
|
|
data = text.encode("utf-8")
|
|
if args.has("p") and pane.screen.bracketed_paste:
|
|
data = b"\x1b[200~" + data + b"\x1b[201~"
|
|
if not pane.dead:
|
|
pane.write(data)
|
|
if args.has("d"):
|
|
self.s.buffers.remove(item)
|
|
return ""
|
|
|
|
# ---- choosers --------------------------------------------------------
|
|
|
|
def open_chooser(self, ctx, kind, builder, args, on_choose=None, on_delete=None, preview=None, selected=None):
|
|
s = self.s
|
|
pane = ctx.pane
|
|
if pane is None:
|
|
raise MplexError("no pane")
|
|
window = pane.window
|
|
zoom_on_open = args.has("Z") and window.zoomed is None and len(window.panes()) > 1
|
|
if zoom_on_open:
|
|
window.zoomed = pane
|
|
window.relayout()
|
|
|
|
def restore():
|
|
if zoom_on_open and window.zoomed is pane:
|
|
window.zoomed = None
|
|
window.relayout()
|
|
|
|
def choose(node, client):
|
|
kind_tag, ident = node.payload
|
|
if kind_tag == "window":
|
|
target = s.windows.get(ident)
|
|
if target is None:
|
|
return
|
|
if client is not None and client.session is not target.session:
|
|
s.attach_client(client, target.session)
|
|
target.session.select(target.session.index_of(target))
|
|
elif kind_tag == "pane":
|
|
target = s.panes.get(ident)
|
|
if target is None or target.window is None:
|
|
return
|
|
if client is not None and client.session is not target.window.session:
|
|
s.attach_client(client, target.window.session)
|
|
target.window.session.select(target.window.session.index_of(target.window))
|
|
s.select_pane(target.window, target)
|
|
elif kind_tag == "session":
|
|
target = s.sessions.get(ident)
|
|
if target is not None and client is not None:
|
|
s.attach_client(client, target)
|
|
s.mark_all_dirty()
|
|
|
|
mode = MplexChooser(s, pane, kind, builder, on_choose or choose, on_delete, preview, selected, on_close=restore)
|
|
pane.push_mode(mode)
|
|
s.mark_dirty(window)
|
|
return ""
|
|
|
|
def cmd_choose_tree(self, ctx, args):
|
|
s = self.s
|
|
sessions_only = args.has("s")
|
|
windows_collapsed = args.has("w")
|
|
|
|
def builder():
|
|
items = []
|
|
for session in sorted(s.sessions.values(), key=lambda x: x.id):
|
|
kids = []
|
|
for index in session.indexes():
|
|
window = session.windows[index]
|
|
panes = []
|
|
for number, pane in enumerate(window.panes()):
|
|
panes.append(MplexChoice("%d: %s \"%s\"%s" % (number + window.options.get("pane-base-index"), pane.foreground_command(), pane.title, "*" if pane is window.active else ""), ("pane", pane.id)))
|
|
kids.append(MplexChoice("%d: %s%s (%d panes) [%dx%d]" % (index, window.name, s.window_flags(session, window), len(panes), window.width, window.height),
|
|
("window", window.id), panes, expanded=not (windows_collapsed or sessions_only) and len(panes) > 1))
|
|
attached = " (attached)" if s.attached_clients(session) else ""
|
|
items.append(MplexChoice("%s: %d windows%s" % (session.name, len(session.windows), attached), ("session", session.id), kids, expanded=not sessions_only))
|
|
return items
|
|
|
|
def preview(node, width, height):
|
|
kind_tag, ident = node.payload
|
|
pane = None
|
|
if kind_tag == "pane":
|
|
pane = s.panes.get(ident)
|
|
elif kind_tag == "window":
|
|
window = s.windows.get(ident)
|
|
pane = window.active if window else None
|
|
else:
|
|
session = s.sessions.get(ident)
|
|
pane = session.window().active if session else None
|
|
if pane is None:
|
|
return []
|
|
blank = MplexCanvas.BLANK
|
|
return [[cell if cell is not None else blank for cell in row[:width]] for row in pane.screen.grid[:height]]
|
|
|
|
def delete(node, client):
|
|
kind_tag, ident = node.payload
|
|
if kind_tag == "session" and ident in s.sessions:
|
|
s.kill_session(s.sessions[ident])
|
|
elif kind_tag == "window" and ident in s.windows:
|
|
s.kill_window(s.windows[ident])
|
|
elif kind_tag == "pane" and ident in s.panes:
|
|
s.kill_pane(s.panes[ident])
|
|
|
|
selected = ("window", ctx.window.id) if ctx.window is not None else None
|
|
return self.open_chooser(ctx, "tree-mode", builder, args, on_delete=delete, preview=preview, selected=selected)
|
|
|
|
def cmd_choose_buffer(self, ctx, args):
|
|
s = self.s
|
|
pane = ctx.pane
|
|
|
|
def builder():
|
|
return [MplexChoice("%s: %d bytes: \"%s\"" % (item["name"], len(item["data"]), item["data"][:50].replace("\n", " ")), ("buffer", item["name"])) for item in s.buffers]
|
|
|
|
def choose(node, client):
|
|
if pane is not None and pane.id in s.panes and not pane.dead:
|
|
item = s.find_buffer(node.payload[1])
|
|
pane.write(item["data"].replace("\n", "\r").encode("utf-8"))
|
|
|
|
def delete(node, client):
|
|
s.buffers[:] = [item for item in s.buffers if item["name"] != node.payload[1]]
|
|
|
|
def preview(node, width, height):
|
|
data = next((item["data"] for item in s.buffers if item["name"] == node.payload[1]), "")
|
|
return [[(char, None, None, 0) for char in line[:width]] for line in data.split("\n")[:height]]
|
|
|
|
if not s.buffers:
|
|
raise MplexError("no buffers")
|
|
return self.open_chooser(ctx, "buffer-mode", builder, args, on_choose=choose, on_delete=delete, preview=preview)
|
|
|
|
def cmd_choose_client(self, ctx, args):
|
|
s = self.s
|
|
|
|
def builder():
|
|
return [MplexChoice("%s: %s [%dx%d %s]" % (c.tty or c.id, c.session.name, c.cols, c.rows, c.term), ("client", c.id)) for c in s.attached_clients()]
|
|
|
|
def choose(node, client):
|
|
for target in s.attached_clients():
|
|
if target.id == node.payload[1]:
|
|
s.detach_client(target, "[detached (from session %s)]" % target.session.name)
|
|
|
|
return self.open_chooser(ctx, "client-mode", builder, args, on_choose=choose, on_delete=choose)
|
|
|
|
# ---- options, shell, files -------------------------------------------
|
|
|
|
def option_name(self, name):
|
|
if name in MplexOptions.DEFS:
|
|
return name
|
|
matches = [item for item in MplexOptions.DEFS if item.startswith(name)]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
raise MplexError("unknown or unsupported option: %s" % name)
|
|
|
|
def option_scope(self, ctx, args, window_scope):
|
|
if args.has("g") or args.has("s"):
|
|
return self.s.options
|
|
if window_scope or args.has("w"):
|
|
return MplexTargets.window(ctx, args.get("t"))[2].options
|
|
return MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]).options
|
|
|
|
def set_option(self, ctx, args, window_scope):
|
|
try:
|
|
name = self.option_name(args.positional[0])
|
|
except MplexError:
|
|
if args.has("q"):
|
|
return ""
|
|
raise
|
|
scope = self.option_scope(ctx, args, window_scope)
|
|
if args.has("u"):
|
|
scope.unset(name)
|
|
else:
|
|
if len(args.positional) < 2:
|
|
scope.toggle(name)
|
|
else:
|
|
value = args.positional[1]
|
|
if args.has("F"):
|
|
value = self.fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client)
|
|
if args.has("o") and scope.is_set(name):
|
|
raise MplexError("already set: %s" % name)
|
|
scope.set(name, value, args.has("a"))
|
|
self.s.resize_all()
|
|
self.s.mark_all_dirty()
|
|
return ""
|
|
|
|
def cmd_set_option(self, ctx, args):
|
|
return self.set_option(ctx, args, False)
|
|
|
|
def cmd_set_window_option(self, ctx, args):
|
|
return self.set_option(ctx, args, True)
|
|
|
|
def show_options(self, ctx, args, window_scope):
|
|
scope = self.option_scope(ctx, args, window_scope)
|
|
names = [self.option_name(args.positional[0])] if args.positional else sorted(MplexOptions.DEFS)
|
|
lines = []
|
|
for name in names:
|
|
if args.positional or args.has("A") or args.has("g") or args.has("s") or scope.is_set(name):
|
|
value = scope.get(name)
|
|
text = MplexOptions.show(value)
|
|
if args.has("v"):
|
|
lines.append(text)
|
|
else:
|
|
lines.append("%s %s" % (name, MplexCommandParser.quote(text) if isinstance(value, str) else text))
|
|
return "\n".join(lines)
|
|
|
|
def cmd_show_options(self, ctx, args):
|
|
return self.show_options(ctx, args, False)
|
|
|
|
def cmd_show_window_options(self, ctx, args):
|
|
return self.show_options(ctx, args, True)
|
|
|
|
def shell_in(self, ctx, command, pane):
|
|
cwd = pane.current_path() if pane is not None else None
|
|
env = dict(ctx.session.env) if ctx.session is not None else None
|
|
try:
|
|
done = subprocess.run(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
timeout=MPLEX_SHELL_TIMEOUT, cwd=cwd or None, env=env)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise MplexError("shell command failed: " + short_error(exc))
|
|
return done.returncode, done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace")
|
|
|
|
def cmd_if_shell(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
condition = args.positional[0]
|
|
if args.has("F"):
|
|
ok = MplexFormat.truthy(self.fmt(condition, session, window, pane, ctx.client))
|
|
else:
|
|
ok = self.shell_in(ctx, self.fmt(condition, session, window, pane, ctx.client), pane)[0] == 0
|
|
chosen = args.positional[1] if ok else (args.positional[2] if len(args.positional) > 2 else "")
|
|
if not chosen:
|
|
return ""
|
|
return self.s.run_line(ctx, chosen, ctx.depth + 1)
|
|
|
|
def cmd_run_shell(self, ctx, args):
|
|
session, window, pane = self.pane_target(ctx, args)
|
|
if args.get("d"):
|
|
raise MplexError("run-shell -d (delay) is not supported by mplex")
|
|
if not args.positional:
|
|
return ""
|
|
command = self.fmt(args.positional[0], session, window, pane, ctx.client)
|
|
if args.has("C"):
|
|
return self.s.run_line(ctx, command, ctx.depth + 1)
|
|
if args.has("b"):
|
|
try:
|
|
subprocess.Popen(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
cwd=pane.current_path() or None, start_new_session=True)
|
|
except OSError as exc:
|
|
raise MplexError("shell command failed: " + short_error(exc))
|
|
return ""
|
|
return self.shell_in(ctx, command, pane)[1].rstrip("\n")
|
|
|
|
def cmd_source_file(self, ctx, args):
|
|
s = self.s
|
|
out = []
|
|
for raw in args.positional:
|
|
path = os.path.expanduser(raw)
|
|
if not os.path.exists(path):
|
|
if args.has("q"):
|
|
continue
|
|
raise MplexError("no such file: %s" % raw)
|
|
if path.endswith(".toml"):
|
|
before = s.config.skipped
|
|
s.config.apply_file(path)
|
|
for problem in s.config.errors[before:]:
|
|
s.log_message("mplex.toml: " + problem)
|
|
skipped = s.config.skipped - before
|
|
if skipped:
|
|
out.append("%s: %d problem(s) skipped, see show-messages" % (path, skipped))
|
|
s.resize_all()
|
|
s.mark_all_dirty()
|
|
continue
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
lines = handle.read(MPLEX_CONFIG_MAX).splitlines()
|
|
except OSError as exc:
|
|
raise MplexError("cannot read %s: %s" % (path, short_error(exc)))
|
|
for number, line in enumerate(lines, 1):
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
try:
|
|
text = s.run_line(ctx, line, ctx.depth + 1)
|
|
except MplexError as exc:
|
|
raise MplexError("%s:%d: %s" % (raw, number, exc))
|
|
if text:
|
|
out.append(text)
|
|
return "\n".join(out)
|
|
|
|
|
|
class MplexClient:
|
|
"""The attached terminal: forwards keys and resizes to the server, paints what it sends."""
|
|
|
|
def __init__(self, home, commands):
|
|
self.home = home
|
|
self.commands = commands
|
|
self.sock_path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME)
|
|
self.stdin_fd = None
|
|
self.saved = None
|
|
self.attached = False
|
|
self.wake_r = None
|
|
self.wake_w = None
|
|
|
|
@staticmethod
|
|
def frame(kind, payload=b""):
|
|
return kind + struct.pack(">I", len(payload)) + payload
|
|
|
|
def size(self):
|
|
try:
|
|
packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8)
|
|
rows, cols = struct.unpack("HH", packed[:4])
|
|
if rows and cols:
|
|
return rows, cols
|
|
except (OSError, ValueError, AttributeError):
|
|
pass
|
|
return 24, 80
|
|
|
|
def hello(self):
|
|
rows, cols = self.size()
|
|
pane = os.environ.get(PLEXER_PANE_ENV, "")
|
|
info = {"rows": rows, "cols": cols, "term": os.environ.get("TERM", ""), "cwd": os.getcwd(), "pid": os.getpid(),
|
|
"tty": os.ttyname(sys.stdin.fileno()) if sys.stdin.isatty() else "", "commands": self.commands,
|
|
"env": dict(list(os.environ.items())[:512]), "pane": pane if pane.isdigit() else ""}
|
|
return self.frame(b"H", json.dumps(info).encode("utf-8"))
|
|
|
|
def enter(self):
|
|
self.stdin_fd = sys.stdin.fileno()
|
|
try:
|
|
self.saved = termios.tcgetattr(self.stdin_fd)
|
|
tty.setraw(self.stdin_fd)
|
|
except (termios.error, OSError):
|
|
self.saved = None
|
|
os.write(sys.stdout.fileno(), b"\x1b[?1049h\x1b[0m\x1b[2J\x1b[H")
|
|
self.attached = True
|
|
|
|
def leave(self):
|
|
if not self.attached:
|
|
return
|
|
self.attached = False
|
|
try:
|
|
os.write(sys.stdout.fileno(), b"\x1b[0m\x1b[?25h\x1b[?1049l")
|
|
except OSError:
|
|
pass
|
|
if self.saved is not None:
|
|
try:
|
|
termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.saved)
|
|
except (termios.error, OSError):
|
|
pass
|
|
|
|
def write_out(self, data):
|
|
fd = sys.stdout.fileno()
|
|
view = memoryview(data)
|
|
while view:
|
|
try:
|
|
written = os.write(fd, view)
|
|
except BlockingIOError:
|
|
select.select([], [fd], [], 1.0)
|
|
continue
|
|
view = view[written:]
|
|
|
|
def on_winch(self, _signum, _frame):
|
|
try:
|
|
os.write(self.wake_w, b"w")
|
|
except OSError:
|
|
pass
|
|
|
|
def run(self):
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
sock.connect(self.sock_path)
|
|
except OSError:
|
|
sys.stderr.write("no server running on %s\n" % self.sock_path)
|
|
return 1
|
|
sock.sendall(MPLEX_MAGIC + self.hello())
|
|
self.wake_r, self.wake_w = os.pipe()
|
|
os.set_blocking(self.wake_w, False)
|
|
previous = signal.signal(signal.SIGWINCH, self.on_winch) if hasattr(signal, "SIGWINCH") else None
|
|
buf = bytearray()
|
|
code = 0
|
|
message = ""
|
|
try:
|
|
while True:
|
|
watch = [sock, self.wake_r] + ([self.stdin_fd] if self.attached else [])
|
|
try:
|
|
ready, _w, _x = select.select(watch, [], [])
|
|
except InterruptedError:
|
|
continue
|
|
if self.wake_r in ready:
|
|
os.read(self.wake_r, 4096)
|
|
rows, cols = self.size()
|
|
sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii")))
|
|
if self.attached and self.stdin_fd in ready:
|
|
data = os.read(self.stdin_fd, 4096)
|
|
if not data:
|
|
message = "[detached]"
|
|
break
|
|
sock.sendall(self.frame(b"I", data))
|
|
if sock in ready:
|
|
data = sock.recv(262144)
|
|
if not data:
|
|
code = 1
|
|
message = "[lost server]"
|
|
break
|
|
buf += data
|
|
done = self.handle_frames(sock, buf)
|
|
if done is not None:
|
|
code, message = done
|
|
break
|
|
finally:
|
|
self.leave()
|
|
if previous is not None:
|
|
signal.signal(signal.SIGWINCH, previous)
|
|
sock.close()
|
|
os.close(self.wake_r)
|
|
os.close(self.wake_w)
|
|
if message:
|
|
(sys.stderr if code else sys.stdout).write(message + "\n")
|
|
return code
|
|
|
|
def handle_frames(self, sock, buf):
|
|
while len(buf) >= 5:
|
|
length = struct.unpack(">I", bytes(buf[1:5]))[0]
|
|
if len(buf) < 5 + length:
|
|
return None
|
|
kind, payload = bytes(buf[0:1]), bytes(buf[5:5 + length])
|
|
del buf[:5 + length]
|
|
if kind == b"A":
|
|
self.enter()
|
|
elif kind == b"O":
|
|
self.write_out(payload)
|
|
elif kind == b"M":
|
|
sys.stdout.write(payload.decode("utf-8", "replace") + "\n")
|
|
sys.stdout.flush()
|
|
elif kind == b"E":
|
|
return 1, payload.decode("utf-8", "replace")
|
|
elif kind in (b"D", b"X"):
|
|
return 0, payload.decode("utf-8", "replace")
|
|
elif kind == b"Z":
|
|
self.suspend(sock)
|
|
return None
|
|
|
|
def suspend(self, sock):
|
|
self.leave()
|
|
os.kill(os.getpid(), signal.SIGTSTP)
|
|
self.enter()
|
|
rows, cols = self.size()
|
|
sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii")))
|
|
|
|
|
|
class MplexCli:
|
|
"""tai.py --mplex <command...>: any mplex command; new-session and attach-session attach this terminal."""
|
|
|
|
@staticmethod
|
|
def attaches(name, rest):
|
|
if name == "attach-session":
|
|
return True
|
|
if name != "new-session":
|
|
return False
|
|
try:
|
|
return not MplexArgs.parse(name, MPLEX_COMMAND_SPECS[name][1], rest, 0, None).has("d")
|
|
except MplexError:
|
|
return True
|
|
|
|
@staticmethod
|
|
def split_commands(words):
|
|
"""argv to commands: a lone ";" separates them, "\\;" is a literal semicolon argument."""
|
|
commands = [[]]
|
|
for word in words:
|
|
if word == ";":
|
|
commands.append([])
|
|
else:
|
|
commands[-1].append(";" if word == "\\;" else word)
|
|
return [item for item in commands if item]
|
|
|
|
@classmethod
|
|
def main(cls, home, words):
|
|
commands = cls.split_commands(words) or [["new-session"]]
|
|
try:
|
|
name = MplexCommands.resolve(commands[0][0])
|
|
except MplexError as exc:
|
|
sys.stderr.write("%s\n" % exc)
|
|
return 1
|
|
commands[0][0] = name
|
|
words = commands[0]
|
|
if cls.attaches(name, words[1:]):
|
|
if os.environ.get(PLEXER_ACTIVE_ENV):
|
|
sys.stderr.write("sessions should be nested with care, unset %s to force\n" % PLEXER_ACTIVE_ENV)
|
|
return 1
|
|
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
sys.stderr.write("open terminal failed: not a terminal\n")
|
|
return 1
|
|
if name in ("new-session", "start-server"):
|
|
if not MplexServer.ensure_running(home):
|
|
sys.stderr.write("could not start the mplex server, see %s\n" % os.path.join(plexer_dir(home), MPLEX_LOG_NAME))
|
|
return 1
|
|
return MplexClient(home, commands).run()
|
|
|
|
|
|
class NativePlexerBackend(PlexerBackend):
|
|
"""What the shell hook execs into: a new mplex session attached to this terminal."""
|
|
|
|
def __init__(self, home, profile, shell_cmd=None):
|
|
self.home = home
|
|
self.profile = profile
|
|
self.shell_cmd = shell_cmd
|
|
|
|
def run(self):
|
|
if pty is None or termios is None or select is None or fcntl is None:
|
|
return "error: pty/termios/select not available on this platform"
|
|
return MplexCli.main(self.home, ["new-session"] + (list(self.shell_cmd) if self.shell_cmd else []))
|