mplex: copy mode matches tmux 3.6 (cursor, scrolling, search, selections), window selection history, loop survives a failing iteration, spec mplex.md
Copy mode was compared with the installed tmux 3.6 by differential fuzzing and 842 recorded results instead of being written from memory: the cursor stands between characters and remembers its column (lastcx/lastsx), pages are rows - 2, searches land at the match end for emacs keys and the match start for vi keys and scroll by tmux's rule, a failed search is a status message, emacs selections are half open and vi selections include the cursor character and the line end, select-word on blanks takes the previous word, copy-line and copy-end-of-line start a fresh selection. Window selection keeps a history (last, then previous with wrap when the current window dies), swap-window keeps the current window current, new-session -d -x -y sizes the window as given. Server loop logs and survives a failing iteration (50 in a row end it). mplex.md is the specification (architecture, wire format, caps, every option, command and default key, what was checked against tmux, known differences); MplexSpecTests fails when it drifts from the code. New: MplexCopyModeVersusTmuxTests (tmux-recorded fixtures), MplexSessionTests, MplexServerLoopTests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,7 +109,7 @@ base-index = 1
|
||||
"C-b" = false
|
||||
```
|
||||
|
||||
A bad entry is skipped, counted, announced on the status line and listed by `C-b ~`; it never stops the multiplexer from starting. Inside tmux, both use `C-b`, so set a different prefix. The full specification (architecture, wire format, hard caps, option and command tables, every default key, known gaps) is [mplex.md](mplex.md); the default prefix in earlier versions of the wrapper was `Ctrl+A` with `Ctrl+A q` killing the shell, which is gone: use `C-b d` to detach and `exit` to end a shell.
|
||||
A bad entry is skipped, counted, announced on the status line and listed by `C-b ~`; it never stops the multiplexer from starting. Inside tmux, both use `C-b`, so set a different prefix. The full specification (architecture, wire format, hard caps, option and command tables, every default key, known gaps) is [mplex.md](mplex.md); the default prefix in earlier versions of the wrapper was `Ctrl+A` with `Ctrl+A q` killing the shell, which is gone: use `C-b d` to detach and `exit` to end a shell. Because the hook replaces the login shell, detaching also closes that terminal window; the session keeps running and `tai.py --mplex attach` from any terminal brings it back.
|
||||
|
||||
The auto-wrap checks three conditions, in the shell hook itself, before ever invoking `tai.py --plexer-wrap`: never wrap if a plexer is already active (`$TAI_PLEXER_ACTIVE`), never wrap if tmux already is (`$TMUX`), and never wrap if `~/.tai-no-plexer` exists — the hand brake. That file is the one thing to touch if the auto-wrap is ever doing the wrong thing on a given machine; it needs no flag or argument, just existing is enough, and it's rechecked on the Python side too (`plexer_should_wrap()`) as defense in depth, not trusted to the shell guard alone.
|
||||
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
# mplex: tai's terminal multiplexer
|
||||
|
||||
Specification of what the multiplexer does today. The code is the `Mplex*` classes in `tai.py`; the
|
||||
default key tables, command specs and option definitions in this file are checked against that code
|
||||
by `MplexSpecTests` in `test_tai.py`, so this page cannot silently drift. Gaps are listed in
|
||||
[Differences from tmux](#differences-from-tmux-and-gaps), next to the code that would close them.
|
||||
|
||||
## What it is
|
||||
|
||||
A tmux-shaped multiplexer inside the single `tai.py` file, stdlib only. Key bindings, command names,
|
||||
command flags and option names follow tmux 3.6; the default tables were dumped from the real binary
|
||||
(research: `research/shell-multiplexer-auto-attach/local-tmux-3-6-keybinding-and-option-dumps.md`).
|
||||
The default prefix is `C-b`, as in tmux. Everything is rebindable in `~/.tai/mplex.toml`.
|
||||
|
||||
```
|
||||
tai.py --mplex new session, attach this terminal (what the shell hook runs)
|
||||
tai.py --mplex attach attach to the most recently used session
|
||||
tai.py --mplex new -s work -d a detached session called work
|
||||
tai.py --mplex ls list sessions
|
||||
tai.py --mplex <any command> run a command against the running server (split-window -h, list-keys, ...)
|
||||
tai.py --plexer-wrap the same as bare --mplex; this is what the installed shell hooks exec
|
||||
```
|
||||
|
||||
Commands follow tmux's grammar: `;` as its own argument separates commands (`\;` is a literal
|
||||
semicolon argument), targets use `-t session:window.pane`, `%pane`, `@window`, `$session`, `+`, `-`, `!`.
|
||||
Commands print their output and exit 0, or print the error and exit 1.
|
||||
|
||||
Inside a pane `TAI_PLEXER_ACTIVE=1`, `TAI_PLEXER_SOCKET=<server socket>` and `TAI_PLEXER_PANE=<id>` are set,
|
||||
so the agent tools (`plexer_send`, `plexer_read`, `plexer_type`, `plexer_status`) reach the pane they run in.
|
||||
Attaching from inside a pane is refused ("sessions should be nested with care"), as in tmux.
|
||||
If you run mplex inside tmux, both use `C-b`: set `prefix = "C-a"` in `mplex.toml`, or send `C-b C-b`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Server**: one detached daemon per `TAI_HOME` (`~/.tai` by default), started by the first
|
||||
`new-session`/`start-server`. A single `select` loop, no threads. It owns the ptys, a `TuiVtScreen` per
|
||||
pane with scrollback, the layouts, sessions, paste buffers and the key state of every client. It
|
||||
exits when its last session ends (or, if it never had one, after `MPLEX_IDLE_EXIT` seconds). Its stdout and
|
||||
stderr go to `plexer/server.log`; tracebacks land there.
|
||||
- **Client**: `tai.py --mplex ...` connects to `plexer/server.sock`, sends one hello (size, `TERM`, cwd,
|
||||
environment, the command list), then forwards raw input bytes and resizes and writes the finished frames it
|
||||
receives. It never interprets a key. Key parsing, tables, prompts and drawing all happen in the server.
|
||||
- **Rendering**: per client the server composes a full grid (panes, borders, status row, prompt, overlays),
|
||||
diffs it against the previous frame by row and sends only changed rows, at most one frame per
|
||||
`MPLEX_RENDER_INTERVAL`.
|
||||
- **Sessions**: several clients may attach to one session; the window size is the smallest attached client.
|
||||
A detached session keeps running. `kill-server` ends everything.
|
||||
- **Sockets and files** (all under `<TAI_HOME>/plexer/`, directory `0700`, files `0600`):
|
||||
`server.sock`, `server.lock` (flock, one server per home), `server.log`, and one log per pane,
|
||||
`<server start>-pane<id>.log`, a verbatim byte log that rolls over to `.1` at `MPLEX_LOG_MAX`.
|
||||
|
||||
### Wire format
|
||||
|
||||
Both directions use frames: 1 byte kind, 4 bytes big-endian length, payload. A connection starts with the
|
||||
single byte `0x01`; anything else is treated as one line of the JSON control protocol below.
|
||||
|
||||
| Direction | Kind | Payload |
|
||||
|---|---|---|
|
||||
| client to server | `H` | hello JSON: `rows`, `cols`, `term`, `cwd`, `pid`, `tty`, `env`, `pane`, `commands` (list of word lists) |
|
||||
| client to server | `I` | raw input bytes |
|
||||
| client to server | `R` | resize JSON `{rows, cols}` |
|
||||
| server to client | `A` | attached: enter raw mode and the alternate screen |
|
||||
| server to client | `O` | terminal output |
|
||||
| server to client | `M` | text to print on stdout (command output) |
|
||||
| server to client | `E` | error text, client exits 1 |
|
||||
| server to client | `D` / `X` | detached / exited (and `[server exited]`): print the text, exit 0 |
|
||||
| server to client | `Z` | suspend the client (`C-z`) |
|
||||
|
||||
### Control socket (JSON lines)
|
||||
|
||||
The same socket serves `NativePlexerController` (the agent tools). One request line, one reply line:
|
||||
`{"cmd": "send", "text": "...", "enter": true, "pane": "3"}`, `{"cmd": "read", "lines": 50}`, `{"cmd": "status"}`.
|
||||
`pane` is optional and defaults to the active pane of the most recently used session; an unknown pane is an
|
||||
error. `read` returns the tail of the pane's raw byte log. A malformed request returns `{"ok": false, "error": ...}`
|
||||
and never disturbs the server.
|
||||
|
||||
### Hard caps
|
||||
|
||||
| Constant | Value | Limits |
|
||||
|---|---|---|
|
||||
| `MPLEX_MAX_SESSIONS` / `MPLEX_MAX_WINDOWS` / `MPLEX_MAX_PANES` / `MPLEX_MAX_CLIENTS` | 64 / 256 / 256 / 32 | objects the server will hold |
|
||||
| `MPLEX_FRAME_MAX` | 4 MiB | one frame; a larger length drops the client |
|
||||
| `MPLEX_CLIENT_OUT_MAX` | 4 MiB | unsent output per client; over it, frames are dropped and the next one repaints everything |
|
||||
| `MPLEX_PANE_IN_MAX` | 4 MiB | queued input per pane; over it, input is dropped and counted |
|
||||
| `MPLEX_HISTORY_CAP` | 200000 | the largest accepted `history-limit` |
|
||||
| `MPLEX_LOG_MAX` | 16 MiB | one pane log before it rolls over (one old generation kept) |
|
||||
| `MPLEX_BUFFERS_CAP` | 100 | paste buffers (`buffer-limit` default 50) |
|
||||
| `MPLEX_MESSAGES_MAX` / `MPLEX_PROMPT_HISTORY_CAP` | 200 / 100 | `show-messages` entries, entries per prompt history |
|
||||
| `MPLEX_COMMAND_DEPTH_MAX` | 16 | nesting of `if-shell`, `source-file`, `run-shell -C` |
|
||||
| `MPLEX_LOOP_ERRORS_MAX` | 50 | failed server loop iterations in a row before the server gives up (each failure is logged and survived) |
|
||||
| `MPLEX_SHELL_TIMEOUT` | 5 s | `#(...)`, `if-shell`, `run-shell`, copy commands (they block the server for that long at most) |
|
||||
| `MPLEX_CONFIG_MAX` | 1 MiB | size of a config or source file |
|
||||
| `MPLEX_PASTE_MAX` | 4 MiB | one paste buffer |
|
||||
| `MPLEX_RENDER_INTERVAL` | 12 ms | minimum time between frames for a client |
|
||||
| `MPLEX_REAP_KILL_AFTER` | 2 s | SIGHUP is escalated to SIGKILL for a pane process that ignored it |
|
||||
|
||||
## Keys
|
||||
|
||||
Key names use tmux notation: `a`, `C-a`, `M-a`, `C-M-a`, `S-Up`, `Space`, `Enter`, `Tab`, `BTab`, `BSpace`, `Escape`,
|
||||
`Up Down Left Right Home End`, `PPage` (alias `PageUp`), `NPage` (`PageDown`), `IC` (`Insert`), `DC` (`Delete`), `F1` to `F12`,
|
||||
`^a` for `C-a`. Modifier order is normalized to `C-`, `M-`, `S-`. `C-m`, `C-i`, `C-[` are `Enter`, `Tab`, `Escape`.
|
||||
|
||||
Lookup order for every key from an attached client:
|
||||
|
||||
1. an open prompt, then an open overlay (`display-panes`), take the key;
|
||||
2. in the `root` table: a binding runs;
|
||||
3. the prefix key or `prefix2` switches the client to the `prefix` table; the next key is looked up there (an unbound key is dropped);
|
||||
4. otherwise the top mode of the pane takes it (copy mode uses `copy-mode` or `copy-mode-vi` according to `mode-keys`; a
|
||||
key unbound there is ignored, not sent to the shell);
|
||||
5. otherwise the bytes go to the pane's shell (cursor keys become `ESC O x` when the application asked for it).
|
||||
|
||||
A binding flagged `repeat` keeps the client in its table for `repeat-time` ms; another repeatable key within that time runs
|
||||
without the prefix, anything else leaves the table and is handled normally. A lone `Escape` is held for `escape-time` ms to
|
||||
tell it from `M-key`. `switch-client -T <table>` enters a custom table.
|
||||
|
||||
## Configuration: ~/.tai/mplex.toml
|
||||
|
||||
Read when the server starts, and again by `source-file ~/.tai/mplex.toml` (applied on top of the running state).
|
||||
The file is `<TAI_HOME>/mplex.toml`. It needs Python 3.11 or newer (`tomllib`); on older Pythons the defaults apply and the
|
||||
status line says why. Two tables are accepted:
|
||||
|
||||
```toml
|
||||
[options] # any option from the table below
|
||||
prefix = "C-a" # main key; "None" disables
|
||||
prefix2 = "F12" # second prefix
|
||||
base-index = 1
|
||||
mode-keys = "vi"
|
||||
status-position = "top"
|
||||
status-left = "[#S] "
|
||||
status-right = "#H %H:%M"
|
||||
status-style = "bg=blue,fg=white"
|
||||
history-limit = 10000
|
||||
|
||||
[keys]
|
||||
clear = ["prefix"] # optional: empty these tables before applying the entries below
|
||||
|
||||
[keys.prefix] # key tables: prefix, root, copy-mode, copy-mode-vi, or your own (switch-client -T name)
|
||||
"|" = "split-window -h" # a command line: the same syntax as the command prompt, ; chains commands
|
||||
"-" = "split-window -v"
|
||||
"C-b" = false # false (or "") unbinds a default
|
||||
r = { command = "source-file ~/.tai/mplex.toml", note = "reload config", repeat = false }
|
||||
Up = { command = "select-pane -U", repeat = true }
|
||||
|
||||
[keys.root] # keys that need no prefix
|
||||
"M-Left" = "previous-window"
|
||||
"M-Right" = "next-window"
|
||||
|
||||
[keys.copy-mode-vi]
|
||||
y = "send-keys -X copy-selection-and-cancel"
|
||||
```
|
||||
|
||||
A key given as an inline table accepts exactly `command`, `repeat` and `note`. Keys are validated with the key grammar above;
|
||||
commands are checked for existence when loaded.
|
||||
|
||||
Errors never abort the load and are never silent: the offending entry is skipped, counted, the first client sees
|
||||
`mplex.toml: N problem(s) skipped, see show-messages` on the status row, and each problem is listed by `show-messages`
|
||||
(at most 50 are kept). Unknown options, unknown top-level tables, bad key names, unknown commands, wrong value types, out of
|
||||
range numbers, invalid TOML, a file over `MPLEX_CONFIG_MAX` and a missing `tomllib` are all reported this way.
|
||||
|
||||
Options set at runtime: `set-option [-g|-w] name value` (session scope without `-g`, window scope with `-w`),
|
||||
`set-option -u name` unsets, `-a` appends to strings, `-o` refuses to overwrite, `-q` ignores unknown names, a bare boolean name toggles.
|
||||
`bind-key`, `unbind-key` change tables at runtime but are not written back to the file.
|
||||
|
||||
### Style strings
|
||||
|
||||
`fg=colour`, `bg=colour`, attributes `bold dim italics underscore blink reverse hidden strikethrough` and their `no` forms,
|
||||
`default`, `none`. Colours: the eight names, `bright` + name, `colour0` to `colour255`, `#rrggbb`, `default`.
|
||||
Inside format strings `#[style]` switches style from that point on; `#[default]` returns to the base style.
|
||||
|
||||
### Formats
|
||||
|
||||
`#S #I #W #F #P #T #H #h #D`, `#{variable}`, `#{?cond,a,b}` (chains allowed), modifiers `=N: =-N: =/N/marker: E: T: q: n: b: d:
|
||||
t: ! !! l: s/re/with/:`, comparisons `==: !=: <: >: <=: >=: &&: ||:`, `m:` (glob) `m/r:` (regex), `e|+ - * / %:`
|
||||
arithmetic, `#(shell command)` (cached for `status-interval`), `##` for a literal `#`, `#,` and `#}` inside conditionals,
|
||||
and strftime codes (`%H:%M`). Variables include the session, window, pane, client, history and copy mode values used by the
|
||||
default bindings; every option name is also a variable (booleans give `1`/`0`).
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Type | Default |
|
||||
|---|---|---|
|
||||
| `allow-set-title` | bool | `on` |
|
||||
| `automatic-rename` | bool | `on` |
|
||||
| `automatic-rename-format` | str | `#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}` |
|
||||
| `base-index` | number 0..9999 | `0` |
|
||||
| `buffer-limit` | number 1..100 | `50` |
|
||||
| `clock-mode-colour` | colour | `blue` |
|
||||
| `clock-mode-style` | 12\|24 | `24` |
|
||||
| `copy-command` | str | `(empty)` |
|
||||
| `copy-mode-current-match-style` | style | `bg=magenta,fg=black` |
|
||||
| `copy-mode-mark-style` | style | `bg=red,fg=black` |
|
||||
| `copy-mode-match-style` | style | `bg=cyan,fg=black` |
|
||||
| `default-command` | str | `(empty)` |
|
||||
| `default-shell` | str | `(empty)` |
|
||||
| `default-terminal` | str | `tmux-256color` |
|
||||
| `detach-on-destroy` | bool | `on` |
|
||||
| `display-panes-active-colour` | colour | `red` |
|
||||
| `display-panes-colour` | colour | `blue` |
|
||||
| `display-panes-time` | number 1..600000 | `1000` |
|
||||
| `display-time` | number 0..600000 | `750` |
|
||||
| `escape-time` | number 0..10000 | `10` |
|
||||
| `history-limit` | number 0..200000 | `2000` |
|
||||
| `main-pane-height` | number 1..10000 | `24` |
|
||||
| `main-pane-width` | number 1..10000 | `80` |
|
||||
| `message-command-style` | style | `bg=black,fg=yellow` |
|
||||
| `message-style` | style | `bg=yellow,fg=black` |
|
||||
| `mode-keys` | vi\|emacs | `emacs (vi when $VISUAL or $EDITOR contains "vi")` |
|
||||
| `mode-style` | style | `noattr,bg=yellow,fg=black` |
|
||||
| `pane-active-border-style` | str | `#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}` |
|
||||
| `pane-base-index` | number 0..9999 | `0` |
|
||||
| `pane-border-lines` | single\|double\|heavy\|simple | `single` |
|
||||
| `pane-border-style` | style | `default` |
|
||||
| `prefix` | key | `C-b` |
|
||||
| `prefix2` | key | `None` |
|
||||
| `remain-on-exit` | bool | `off` |
|
||||
| `renumber-windows` | bool | `off` |
|
||||
| `repeat-time` | number 0..10000 | `500` |
|
||||
| `set-clipboard` | on\|external\|off | `off` |
|
||||
| `status` | bool | `on` |
|
||||
| `status-interval` | number 0..3600 | `15` |
|
||||
| `status-justify` | left\|centre\|center\|right | `left` |
|
||||
| `status-keys` | vi\|emacs | `emacs (vi when $VISUAL or $EDITOR contains "vi")` |
|
||||
| `status-left` | str | `[#{session_name}] ` |
|
||||
| `status-left-length` | number 0..1000 | `10` |
|
||||
| `status-left-style` | style | `default` |
|
||||
| `status-position` | top\|bottom | `bottom` |
|
||||
| `status-right` | str | `"#{=21:pane_title}" %H:%M %d-%b-%y` |
|
||||
| `status-right-length` | number 0..1000 | `40` |
|
||||
| `status-right-style` | style | `default` |
|
||||
| `status-style` | style | `bg=green,fg=black` |
|
||||
| `synchronize-panes` | bool | `off` |
|
||||
| `window-status-current-format` | str | `#I:#W#{?window_flags,#{window_flags}, }` |
|
||||
| `window-status-current-style` | style | `default` |
|
||||
| `window-status-format` | str | `#I:#W#{?window_flags,#{window_flags}, }` |
|
||||
| `window-status-last-style` | style | `default` |
|
||||
| `window-status-separator` | str | ` ` |
|
||||
| `window-status-style` | style | `default` |
|
||||
| `word-separators` | str | `!"#$%&'()*+,-./:;<=>?@[\]^`{\|}~` |
|
||||
| `wrap-search` | bool | `on` |
|
||||
|
||||
`mouse`, `set-titles`, `lock-*`, `monitor-*`, `visual-*`, `update-environment` and the rest of tmux's options are not
|
||||
implemented and are rejected with an error, not accepted and ignored.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Aliases | Flags | Arguments |
|
||||
|---|---|---|---|
|
||||
| `attach-session` | `attach`, `a` | `-d` `-E` `-r` `-x` `-c arg` `-f arg` `-t arg` | 0 |
|
||||
| `bind-key` | `bind` | `-n` `-r` `-T arg` `-N arg` | 1..n |
|
||||
| `break-pane` | `breakp` | `-a` `-b` `-d` `-P` `-F arg` `-n arg` `-s arg` `-t arg` | 0 |
|
||||
| `capture-pane` | `capturep` | `-a` `-C` `-e` `-J` `-M` `-N` `-p` `-P` `-q` `-T` `-b arg` `-E arg` `-S arg` `-t arg` | 0 |
|
||||
| `choose-buffer` | | `-N` `-r` `-Z` `-F arg` `-f arg` `-K arg` `-O arg` `-t arg` | 0..1 |
|
||||
| `choose-client` | | `-N` `-r` `-Z` `-F arg` `-f arg` `-K arg` `-O arg` `-t arg` | 0..1 |
|
||||
| `choose-tree` | | `-G` `-N` `-r` `-s` `-w` `-Z` `-F arg` `-f arg` `-K arg` `-O arg` `-t arg` | 0..1 |
|
||||
| `clear-history` | `clearhist` | `-H` `-t arg` | 0 |
|
||||
| `clock-mode` | | `-t arg` | 0 |
|
||||
| `command-prompt` | | `-1` `-b` `-F` `-k` `-l` `-i` `-N` `-I arg` `-p arg` `-t arg` `-T arg` | 0..1 |
|
||||
| `confirm-before` | `confirm` | `-b` `-y` `-c arg` `-p arg` `-t arg` | 1 |
|
||||
| `copy-mode` | | `-d` `-e` `-H` `-M` `-q` `-S` `-u` `-s arg` `-t arg` | 0 |
|
||||
| `delete-buffer` | `deleteb` | `-b arg` | 0 |
|
||||
| `detach-client` | `detach` | `-a` `-P` `-E arg` `-s arg` `-t arg` | 0 |
|
||||
| `display-message` | `display` | `-a` `-C` `-I` `-l` `-N` `-p` `-v` `-c arg` `-d arg` `-F arg` `-t arg` | 0..1 |
|
||||
| `display-panes` | `displayp` | `-b` `-N` `-d arg` `-t arg` | 0..1 |
|
||||
| `find-window` | `findw` | `-C` `-i` `-N` `-r` `-T` `-Z` `-t arg` | 1 |
|
||||
| `has-session` | `has` | `-t arg` | 0 |
|
||||
| `if-shell` | `if` | `-b` `-F` `-t arg` | 2..3 |
|
||||
| `kill-pane` | `killp` | `-a` `-t arg` | 0 |
|
||||
| `kill-server` | | | 0 |
|
||||
| `kill-session` | | `-a` `-C` `-t arg` | 0 |
|
||||
| `kill-window` | `killw` | `-a` `-t arg` | 0 |
|
||||
| `last-pane` | `lastp` | `-d` `-e` `-Z` `-t arg` | 0 |
|
||||
| `last-window` | `last` | `-t arg` | 0 |
|
||||
| `list-buffers` | `lsb` | `-F arg` `-f arg` | 0 |
|
||||
| `list-clients` | `lsc` | `-F arg` `-f arg` `-t arg` | 0 |
|
||||
| `list-commands` | `lscm` | `-F arg` | 0..1 |
|
||||
| `list-keys` | `lsk` | `-1` `-a` `-N` `-P arg` `-T arg` | 0..1 |
|
||||
| `list-panes` | `lsp` | `-a` `-s` `-F arg` `-f arg` `-t arg` | 0 |
|
||||
| `list-sessions` | `ls` | `-F arg` `-f arg` | 0 |
|
||||
| `list-windows` | `lsw` | `-a` `-F arg` `-f arg` `-t arg` | 0 |
|
||||
| `load-buffer` | `loadb` | `-b arg` `-t arg` | 1 |
|
||||
| `move-window` | `movew` | `-a` `-b` `-d` `-k` `-r` `-s arg` `-t arg` | 0 |
|
||||
| `new-session` | `new` | `-A` `-d` `-D` `-E` `-P` `-X` `-c arg` `-e arg` `-F arg` `-f arg` `-n arg` `-s arg` `-t arg` `-x arg` `-y arg` | 0..n |
|
||||
| `new-window` | `neww` | `-a` `-b` `-d` `-k` `-P` `-S` `-c arg` `-e arg` `-F arg` `-n arg` `-t arg` | 0..n |
|
||||
| `next-layout` | `nextl` | `-t arg` | 0 |
|
||||
| `next-window` | `next` | `-a` `-t arg` | 0 |
|
||||
| `paste-buffer` | `pasteb` | `-d` `-p` `-r` `-s arg` `-b arg` `-t arg` | 0 |
|
||||
| `previous-layout` | `prevl` | `-t arg` | 0 |
|
||||
| `previous-window` | `prev` | `-a` `-t arg` | 0 |
|
||||
| `refresh-client` | `refresh` | `-c` `-D` `-l` `-L` `-R` `-S` `-U` `-A arg` `-B arg` `-C arg` `-f arg` `-r arg` `-t arg` | 0..1 |
|
||||
| `rename-session` | `rename` | `-t arg` | 1 |
|
||||
| `rename-window` | `renamew` | `-t arg` | 1 |
|
||||
| `resize-pane` | `resizep` | `-D` `-L` `-M` `-R` `-T` `-U` `-Z` `-x arg` `-y arg` `-t arg` | 0..1 |
|
||||
| `rotate-window` | `rotatew` | `-D` `-U` `-Z` `-t arg` | 0 |
|
||||
| `run-shell` | `run` | `-b` `-C` `-E` `-c arg` `-d arg` `-t arg` | 0..1 |
|
||||
| `save-buffer` | `saveb` | `-a` `-b arg` | 1 |
|
||||
| `select-layout` | `selectl` | `-E` `-n` `-o` `-p` `-t arg` | 0..1 |
|
||||
| `select-pane` | `selectp` | `-D` `-d` `-e` `-L` `-l` `-M` `-m` `-R` `-U` `-Z` `-T arg` `-t arg` | 0 |
|
||||
| `select-window` | `selectw` | `-l` `-n` `-p` `-T` `-t arg` | 0 |
|
||||
| `send-keys` | `send` | `-F` `-H` `-K` `-l` `-M` `-R` `-X` `-c arg` `-N arg` `-t arg` | 0..n |
|
||||
| `send-prefix` | | `-2` `-t arg` | 0 |
|
||||
| `set-buffer` | `setb` | `-a` `-w` `-b arg` `-n arg` `-t arg` | 0..1 |
|
||||
| `set-option` | `set` | `-a` `-F` `-g` `-o` `-p` `-q` `-s` `-u` `-U` `-w` `-t arg` | 1..2 |
|
||||
| `set-window-option` | `setw` | `-a` `-F` `-g` `-o` `-q` `-u` `-t arg` | 1..2 |
|
||||
| `show-buffer` | `showb` | `-b arg` | 0 |
|
||||
| `show-messages` | `showmsgs` | `-J` `-T` `-t arg` | 0 |
|
||||
| `show-options` | `show` | `-A` `-g` `-H` `-p` `-q` `-s` `-v` `-w` `-t arg` | 0..1 |
|
||||
| `show-window-options` | `showw` | `-g` `-v` `-t arg` | 0..1 |
|
||||
| `source-file` | `source` | `-F` `-n` `-q` `-v` `-t arg` | 1..n |
|
||||
| `split-window` | `splitw` | `-b` `-d` `-e` `-f` `-h` `-I` `-P` `-v` `-Z` `-c arg` `-e arg` `-F arg` `-l arg` `-t arg` | 0..n |
|
||||
| `start-server` | `start` | | 0 |
|
||||
| `suspend-client` | `suspendc` | `-t arg` | 0 |
|
||||
| `swap-pane` | `swapp` | `-d` `-D` `-U` `-Z` `-s arg` `-t arg` | 0 |
|
||||
| `swap-window` | `swapw` | `-d` `-s arg` `-t arg` | 0 |
|
||||
| `switch-client` | `switchc` | `-E` `-l` `-n` `-p` `-r` `-Z` `-c arg` `-t arg` `-T arg` | 0 |
|
||||
| `unbind-key` | `unbind` | `-a` `-n` `-q` `-T arg` | 0..1 |
|
||||
|
||||
Commands that parse but are refused with "command not supported by mplex": `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`.
|
||||
Flags for mouse handling (`send-keys -M`, `copy-mode -M`, `resize-pane -M/-T`) and `select-pane -e/-d` are refused the same way.
|
||||
A unique prefix of a command name is accepted (`kill-ser`).
|
||||
|
||||
`send-keys -X <command>` drives copy mode. The commands are: `cancel`, `cursor-up`, `cursor-down`, `cursor-down-and-cancel`,
|
||||
`cursor-left`, `cursor-right`, `start-of-line`, `end-of-line`, `back-to-indentation`, `top-line`, `middle-line`, `bottom-line`,
|
||||
`history-top`, `history-bottom`, `page-up`, `page-down`, `page-down-and-cancel`, `halfpage-up`, `halfpage-down`,
|
||||
`halfpage-down-and-cancel`, `scroll-up`, `scroll-down`, `scroll-down-and-cancel`, `scroll-middle`, `scroll-top`, `scroll-bottom`,
|
||||
`cursor-centre-vertical`, `cursor-centre-horizontal`, `goto-line`, `refresh-from-pane`, `toggle-position`, `next-word`,
|
||||
`next-word-end`, `next-space`, `next-space-end`, `previous-word`, `previous-space`, `next-paragraph`, `previous-paragraph`,
|
||||
`next-matching-bracket`, `previous-matching-bracket`, `jump-forward`, `jump-backward`, `jump-to-forward`, `jump-to-backward`,
|
||||
`jump-again`, `jump-reverse`, `begin-selection`, `stop-selection`, `clear-selection`, `select-line`, `select-word`,
|
||||
`rectangle-toggle`, `rectangle-on`, `rectangle-off`, `other-end`, `set-mark`, `jump-to-mark`, `copy-selection`,
|
||||
`copy-selection-and-cancel`, `copy-pipe`, `copy-pipe-and-cancel`, `append-selection`, `append-selection-and-cancel`,
|
||||
`copy-end-of-line`, `copy-end-of-line-and-cancel`, `copy-pipe-end-of-line`, `copy-pipe-end-of-line-and-cancel`, `copy-line`,
|
||||
`copy-line-and-cancel`, `copy-pipe-line`, `copy-pipe-line-and-cancel`, `search-forward`, `search-backward`,
|
||||
`search-forward-text`, `search-backward-text`, `search-forward-incremental`, `search-backward-incremental`, `search-again`,
|
||||
`search-reverse`. A copy goes to the top paste buffer, to `copy-command` (or the command given to `copy-pipe*`) when set, and
|
||||
as an OSC 52 clipboard sequence when `set-clipboard` is `on` or `external`. Search is a regular expression with smart case
|
||||
(case-insensitive unless the text has an uppercase letter), falling back to a literal when the expression is invalid.
|
||||
|
||||
## Default key bindings
|
||||
|
||||
Copied from tmux 3.6 without mouse bindings. `prefix ?` shows the notes in the running multiplexer; `list-keys` shows the
|
||||
live tables, including your changes.
|
||||
|
||||
### prefix table (after `C-b`)
|
||||
|
||||
| Key | Command | Repeat | Note |
|
||||
|---|---|---|---|
|
||||
| `Space` | `next-layout` | | Select next layout |
|
||||
| `!` | `break-pane` | | Break pane to a new window |
|
||||
| `"` | `split-window` | | Split window vertically |
|
||||
| `#` | `list-buffers` | | List all paste buffers |
|
||||
| `$` | `command-prompt -I "#S" { rename-session "%%" }` | | Rename current session |
|
||||
| `%` | `split-window -h` | | Split window horizontally |
|
||||
| `&` | `confirm-before -p "kill-window #W? (y/n)" kill-window` | | Kill current window |
|
||||
| `'` | `command-prompt -T window-target -p index { select-window -t ":%%" }` | | Prompt for window index to select |
|
||||
| `(` | `switch-client -p` | | Switch to previous client |
|
||||
| `)` | `switch-client -n` | | Switch to next client |
|
||||
| `,` | `command-prompt -I "#W" { rename-window "%%" }` | | Rename current window |
|
||||
| `-` | `delete-buffer` | | Delete the most recent paste buffer |
|
||||
| `.` | `command-prompt -T target { move-window -t "%%" }` | | Move the current window |
|
||||
| `/` | `command-prompt -k -p key { list-keys -1N "%%" }` | | Describe key binding |
|
||||
| `0` | `select-window -t :=0` | | Select window 0 |
|
||||
| `1` | `select-window -t :=1` | | Select window 1 |
|
||||
| `2` | `select-window -t :=2` | | Select window 2 |
|
||||
| `3` | `select-window -t :=3` | | Select window 3 |
|
||||
| `4` | `select-window -t :=4` | | Select window 4 |
|
||||
| `5` | `select-window -t :=5` | | Select window 5 |
|
||||
| `6` | `select-window -t :=6` | | Select window 6 |
|
||||
| `7` | `select-window -t :=7` | | Select window 7 |
|
||||
| `8` | `select-window -t :=8` | | Select window 8 |
|
||||
| `9` | `select-window -t :=9` | | Select window 9 |
|
||||
| `:` | `command-prompt` | | Prompt for a command |
|
||||
| `;` | `last-pane` | | Move to the previously active pane |
|
||||
| `<` | `display-menu` | | Display window menu |
|
||||
| `=` | `choose-buffer -Z` | | Choose a paste buffer from a list |
|
||||
| `>` | `display-menu` | | Display pane menu |
|
||||
| `?` | `list-keys -N` | | List key bindings |
|
||||
| `C` | `customize-mode -Z` | | Customize options |
|
||||
| `D` | `choose-client -Z` | | Choose and detach a client from a list |
|
||||
| `E` | `select-layout -E` | | Spread panes out evenly |
|
||||
| `L` | `switch-client -l` | | Switch to the last client |
|
||||
| `M` | `select-pane -M` | | Clear the marked pane |
|
||||
| `[` | `copy-mode` | | Enter copy mode |
|
||||
| `]` | `paste-buffer -p` | | Paste the most recent paste buffer |
|
||||
| `c` | `new-window` | | Create a new window |
|
||||
| `d` | `detach-client` | | Detach the current client |
|
||||
| `f` | `command-prompt { find-window -Z "%%" }` | | Search for a pane |
|
||||
| `i` | `display-message` | | Display window information |
|
||||
| `l` | `last-window` | | Select the previously current window |
|
||||
| `m` | `select-pane -m` | | Toggle the marked pane |
|
||||
| `n` | `next-window` | | Select the next window |
|
||||
| `o` | `select-pane -t :.+` | | Select the next pane |
|
||||
| `p` | `previous-window` | | Select the previous window |
|
||||
| `q` | `display-panes` | | Display pane numbers |
|
||||
| `r` | `refresh-client` | | Redraw the current client |
|
||||
| `s` | `choose-tree -Zs` | | Choose a session from a list |
|
||||
| `t` | `clock-mode` | | Show a clock |
|
||||
| `w` | `choose-tree -Zw` | | Choose a window from a list |
|
||||
| `x` | `confirm-before -p "kill-pane #P? (y/n)" kill-pane` | | Kill the active pane |
|
||||
| `z` | `resize-pane -Z` | | Zoom the active pane |
|
||||
| `{` | `swap-pane -U` | | Swap the active pane with the pane above |
|
||||
| `}` | `swap-pane -D` | | Swap the active pane with the pane below |
|
||||
| `~` | `show-messages` | | Show messages |
|
||||
| `DC` | `refresh-client -c` | yes | Reset so the visible part of the window follows the cursor |
|
||||
| `PPage` | `copy-mode -u` | | Enter copy mode and scroll up |
|
||||
| `Up` | `select-pane -U` | yes | Select the pane above the active pane |
|
||||
| `Down` | `select-pane -D` | yes | Select the pane below the active pane |
|
||||
| `Left` | `select-pane -L` | yes | Select the pane to the left of the active pane |
|
||||
| `Right` | `select-pane -R` | yes | Select the pane to the right of the active pane |
|
||||
| `M-1` | `select-layout even-horizontal` | | Set the even-horizontal layout |
|
||||
| `M-2` | `select-layout even-vertical` | | Set the even-vertical layout |
|
||||
| `M-3` | `select-layout main-horizontal` | | Set the main-horizontal layout |
|
||||
| `M-4` | `select-layout main-vertical` | | Set the main-vertical layout |
|
||||
| `M-5` | `select-layout tiled` | | Select the tiled layout |
|
||||
| `M-6` | `select-layout main-horizontal-mirrored` | | Set the main-horizontal-mirrored layout |
|
||||
| `M-7` | `select-layout main-vertical-mirrored` | | Set the main-vertical-mirrored layout |
|
||||
| `M-n` | `next-window -a` | | Select the next window with an alert |
|
||||
| `M-o` | `rotate-window -D` | | Rotate through the panes in reverse |
|
||||
| `M-p` | `previous-window -a` | | Select the previous window with an alert |
|
||||
| `M-Up` | `resize-pane -U 5` | yes | Resize the pane up by 5 |
|
||||
| `M-Down` | `resize-pane -D 5` | yes | Resize the pane down by 5 |
|
||||
| `M-Left` | `resize-pane -L 5` | yes | Resize the pane left by 5 |
|
||||
| `M-Right` | `resize-pane -R 5` | yes | Resize the pane right by 5 |
|
||||
| `C-b` | `send-prefix` | | Send the prefix key |
|
||||
| `C-o` | `rotate-window` | | Rotate through the panes |
|
||||
| `C-z` | `suspend-client` | | Suspend the current client |
|
||||
| `C-Up` | `resize-pane -U` | yes | Resize the pane up |
|
||||
| `C-Down` | `resize-pane -D` | yes | Resize the pane down |
|
||||
| `C-Left` | `resize-pane -L` | yes | Resize the pane left |
|
||||
| `C-Right` | `resize-pane -R` | yes | Resize the pane right |
|
||||
| `S-Up` | `refresh-client -U 10` | yes | Move the visible part of the window up |
|
||||
| `S-Down` | `refresh-client -D 10` | yes | Move the visible part of the window down |
|
||||
| `S-Left` | `refresh-client -L 10` | yes | Move the visible part of the window left |
|
||||
| `S-Right` | `refresh-client -R 10` | yes | Move the visible part of the window right |
|
||||
|
||||
### copy-mode table (`mode-keys emacs`)
|
||||
|
||||
| Key | Command | Repeat | Note |
|
||||
|---|---|---|---|
|
||||
| `Escape` | `send-keys -X cancel` | | |
|
||||
| `Space` | `send-keys -X page-down` | | |
|
||||
| `,` | `send-keys -X jump-reverse` | | |
|
||||
| `;` | `send-keys -X jump-again` | | |
|
||||
| `F` | `command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }` | | |
|
||||
| `N` | `send-keys -X search-reverse` | | |
|
||||
| `P` | `send-keys -X toggle-position` | | |
|
||||
| `R` | `send-keys -X rectangle-toggle` | | |
|
||||
| `T` | `command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }` | | |
|
||||
| `X` | `send-keys -X set-mark` | | |
|
||||
| `f` | `command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }` | | |
|
||||
| `g` | `command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }` | | |
|
||||
| `n` | `send-keys -X search-again` | | |
|
||||
| `q` | `send-keys -X cancel` | | |
|
||||
| `r` | `send-keys -X refresh-from-pane` | | |
|
||||
| `t` | `command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }` | | |
|
||||
| `Home` | `send-keys -X start-of-line` | | |
|
||||
| `End` | `send-keys -X end-of-line` | | |
|
||||
| `NPage` | `send-keys -X page-down` | | |
|
||||
| `PPage` | `send-keys -X page-up` | | |
|
||||
| `Up` | `send-keys -X cursor-up` | | |
|
||||
| `Down` | `send-keys -X cursor-down` | | |
|
||||
| `Left` | `send-keys -X cursor-left` | | |
|
||||
| `Right` | `send-keys -X cursor-right` | | |
|
||||
| `M-1` | `command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-2` | `command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-3` | `command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-4` | `command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-5` | `command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-6` | `command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-7` | `command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-8` | `command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-9` | `command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `M-<` | `send-keys -X history-top` | | |
|
||||
| `M->` | `send-keys -X history-bottom` | | |
|
||||
| `M-R` | `send-keys -X top-line` | | |
|
||||
| `M-b` | `send-keys -X previous-word` | | |
|
||||
| `M-f` | `send-keys -X next-word-end` | | |
|
||||
| `M-l` | `send-keys -X cursor-centre-horizontal` | | |
|
||||
| `M-m` | `send-keys -X back-to-indentation` | | |
|
||||
| `M-r` | `send-keys -X middle-line` | | |
|
||||
| `M-v` | `send-keys -X page-up` | | |
|
||||
| `M-w` | `send-keys -X copy-pipe-and-cancel` | | |
|
||||
| `M-x` | `send-keys -X jump-to-mark` | | |
|
||||
| `M-{` | `send-keys -X previous-paragraph` | | |
|
||||
| `M-}` | `send-keys -X next-paragraph` | | |
|
||||
| `M-Up` | `send-keys -X halfpage-up` | | |
|
||||
| `M-Down` | `send-keys -X halfpage-down` | | |
|
||||
| `C-Space` | `send-keys -X begin-selection` | | |
|
||||
| `C-a` | `send-keys -X start-of-line` | | |
|
||||
| `C-b` | `send-keys -X cursor-left` | | |
|
||||
| `C-c` | `send-keys -X cancel` | | |
|
||||
| `C-e` | `send-keys -X end-of-line` | | |
|
||||
| `C-f` | `send-keys -X cursor-right` | | |
|
||||
| `C-g` | `send-keys -X clear-selection` | | |
|
||||
| `C-k` | `send-keys -X copy-pipe-end-of-line-and-cancel` | | |
|
||||
| `C-l` | `send-keys -X cursor-centre-vertical` | | |
|
||||
| `C-n` | `send-keys -X cursor-down` | | |
|
||||
| `C-p` | `send-keys -X cursor-up` | | |
|
||||
| `C-r` | `command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }` | | |
|
||||
| `C-s` | `command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }` | | |
|
||||
| `C-v` | `send-keys -X page-down` | | |
|
||||
| `C-w` | `send-keys -X copy-pipe-and-cancel` | | |
|
||||
| `C-Up` | `send-keys -X scroll-up` | | |
|
||||
| `C-Down` | `send-keys -X scroll-down` | | |
|
||||
| `C-M-b` | `send-keys -X previous-matching-bracket` | | |
|
||||
| `C-M-f` | `send-keys -X next-matching-bracket` | | |
|
||||
|
||||
### copy-mode-vi table (`mode-keys vi`)
|
||||
|
||||
| Key | Command | Repeat | Note |
|
||||
|---|---|---|---|
|
||||
| `Enter` | `send-keys -X copy-pipe-and-cancel` | | |
|
||||
| `Escape` | `send-keys -X clear-selection` | | |
|
||||
| `Space` | `send-keys -X begin-selection` | | |
|
||||
| `#` | `send-keys -FX search-backward -- "#{copy_cursor_word}"` | | |
|
||||
| `$` | `send-keys -X end-of-line` | | |
|
||||
| `%` | `send-keys -X next-matching-bracket` | | |
|
||||
| `*` | `send-keys -FX search-forward -- "#{copy_cursor_word}"` | | |
|
||||
| `,` | `send-keys -X jump-reverse` | | |
|
||||
| `/` | `command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }` | | |
|
||||
| `0` | `send-keys -X start-of-line` | | |
|
||||
| `1` | `command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `2` | `command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `3` | `command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `4` | `command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `5` | `command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `6` | `command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `7` | `command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `8` | `command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `9` | `command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }` | | |
|
||||
| `:` | `command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }` | | |
|
||||
| `;` | `send-keys -X jump-again` | | |
|
||||
| `?` | `command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }` | | |
|
||||
| `A` | `send-keys -X append-selection-and-cancel` | | |
|
||||
| `B` | `send-keys -X previous-space` | | |
|
||||
| `D` | `send-keys -X copy-pipe-end-of-line-and-cancel` | | |
|
||||
| `E` | `send-keys -X next-space-end` | | |
|
||||
| `F` | `command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }` | | |
|
||||
| `G` | `send-keys -X history-bottom` | | |
|
||||
| `H` | `send-keys -X top-line` | | |
|
||||
| `J` | `send-keys -X scroll-down` | | |
|
||||
| `K` | `send-keys -X scroll-up` | | |
|
||||
| `L` | `send-keys -X bottom-line` | | |
|
||||
| `M` | `send-keys -X middle-line` | | |
|
||||
| `N` | `send-keys -X search-reverse` | | |
|
||||
| `P` | `send-keys -X toggle-position` | | |
|
||||
| `T` | `command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }` | | |
|
||||
| `V` | `send-keys -X select-line` | | |
|
||||
| `W` | `send-keys -X next-space` | | |
|
||||
| `X` | `send-keys -X set-mark` | | |
|
||||
| `^` | `send-keys -X back-to-indentation` | | |
|
||||
| `b` | `send-keys -X previous-word` | | |
|
||||
| `e` | `send-keys -X next-word-end` | | |
|
||||
| `f` | `command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }` | | |
|
||||
| `g` | `send-keys -X history-top` | | |
|
||||
| `h` | `send-keys -X cursor-left` | | |
|
||||
| `j` | `send-keys -X cursor-down` | | |
|
||||
| `k` | `send-keys -X cursor-up` | | |
|
||||
| `l` | `send-keys -X cursor-right` | | |
|
||||
| `n` | `send-keys -X search-again` | | |
|
||||
| `o` | `send-keys -X other-end` | | |
|
||||
| `q` | `send-keys -X cancel` | | |
|
||||
| `r` | `send-keys -X refresh-from-pane` | | |
|
||||
| `t` | `command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }` | | |
|
||||
| `v` | `send-keys -X rectangle-toggle` | | |
|
||||
| `w` | `send-keys -X next-word` | | |
|
||||
| `z` | `send-keys -X scroll-middle` | | |
|
||||
| `{` | `send-keys -X previous-paragraph` | | |
|
||||
| `}` | `send-keys -X next-paragraph` | | |
|
||||
| `BSpace` | `send-keys -X cursor-left` | | |
|
||||
| `Home` | `send-keys -X start-of-line` | | |
|
||||
| `End` | `send-keys -X end-of-line` | | |
|
||||
| `NPage` | `send-keys -X page-down` | | |
|
||||
| `PPage` | `send-keys -X page-up` | | |
|
||||
| `Up` | `send-keys -X cursor-up` | | |
|
||||
| `Down` | `send-keys -X cursor-down` | | |
|
||||
| `Left` | `send-keys -X cursor-left` | | |
|
||||
| `Right` | `send-keys -X cursor-right` | | |
|
||||
| `M-x` | `send-keys -X jump-to-mark` | | |
|
||||
| `C-b` | `send-keys -X page-up` | | |
|
||||
| `C-c` | `send-keys -X cancel` | | |
|
||||
| `C-d` | `send-keys -X halfpage-down` | | |
|
||||
| `C-e` | `send-keys -X scroll-down` | | |
|
||||
| `C-f` | `send-keys -X page-down` | | |
|
||||
| `C-h` | `send-keys -X cursor-left` | | |
|
||||
| `C-j` | `send-keys -X copy-pipe-and-cancel` | | |
|
||||
| `C-u` | `send-keys -X halfpage-up` | | |
|
||||
| `C-v` | `send-keys -X rectangle-toggle` | | |
|
||||
| `C-y` | `send-keys -X scroll-up` | | |
|
||||
| `C-Up` | `send-keys -X scroll-up` | | |
|
||||
| `C-Down` | `send-keys -X scroll-down` | | |
|
||||
|
||||
`display-menu` (`<`, `>`) and `customize-mode` (`C`) are bound exactly as in tmux but report "not supported" when used.
|
||||
|
||||
## Pane modes
|
||||
|
||||
Copy mode (`copy-mode`, and the read-only view that `list-keys`, `show-messages` and other long output open: `view-mode`),
|
||||
`clock-mode`, and the choosers `choose-tree`, `choose-buffer`, `choose-client`. Chooser keys: `Up/k/C-p`, `Down/j/C-n`,
|
||||
`PPage/C-b`, `NPage/C-f`, `Home/g`, `End/G`, `Right/l/+` expand, `Left/h/-` collapse, `t` tag, `v` preview, `x` kill (asks
|
||||
`y`), `Enter` choose, `0`-`9` choose the numbered entry, `q/Escape/C-c` leave. The chooser keys are fixed, not rebindable.
|
||||
`display-panes` is an overlay on the client (digit selects, any other key dismisses).
|
||||
|
||||
The command prompt edits like tmux's emacs mode: `C-a C-e C-b C-f M-b M-f BSpace C-h DC C-d C-k C-u C-w C-y C-t`, `Up/Down`
|
||||
history (per prompt type, capped), `Enter`, `Escape/C-c/C-g`. The single key (`-1`), key name (`-k`), numeric (`-N`) and
|
||||
incremental (`-i`, `C-s`/`C-r` step) variants are implemented.
|
||||
|
||||
## Measurements
|
||||
|
||||
Taken on the development machine (Linux, Python 3.14), one pane at 200x50 in an attached client:
|
||||
an idle server used 0.000 s of CPU over 5 s; `seq 1 300000` reached the attached terminal in 1.95 s wall with 1.72 s of
|
||||
server CPU. vim and htop render correctly inside a pane. No numbers exist for many panes, many clients, slow terminals or
|
||||
other machines.
|
||||
|
||||
## Checked against tmux 3.6
|
||||
|
||||
Behavior was compared with the tmux 3.6 installed on the development machine, command by command, instead of being written
|
||||
from memory. What exists as evidence:
|
||||
|
||||
- Default key tables, notes, options and command flags: dumped from tmux (research `local-tmux-3-6-keybinding-and-option-dumps.md`).
|
||||
- Pane geometry: 69 scenarios (all seven layouts for 2 to 7 panes, splits with `-b` `-l` and percentages, resize in all
|
||||
directions and to an absolute size, kill, rotate, swap, directional select, last-pane, zoom, break-pane, kill-pane -a) give
|
||||
identical pane sizes, positions and active pane in tmux and in mplex. Unit fixtures in `MplexLayoutTests` carry the values.
|
||||
- Windows: 26 scenarios (new-window `-a` `-b` `-k` and explicit indexes, gap filling, kill and what becomes current, select, last,
|
||||
next, previous, `-T`, move, swap, renumber, base-index, break-pane) agree on indexes, names, flags and errors, except one flag
|
||||
after `swap-window -d` (tmux marks a different window as last).
|
||||
- Copy mode movement: a differential fuzz of random key sequences against tmux (12 steps per sequence; content with blanks,
|
||||
indentation, punctuation and history; the search string reset before each sequence) ran clean over 360 emacs-key sequences
|
||||
and over about 240 of 300 vi-key sequences; the remaining vi-key difference is the line-crossing bookkeeping described below.
|
||||
842 recorded results (782 cursor and scroll states, 60 selections and copies, both key modes) are replayed by
|
||||
`MplexCopyModeVersusTmuxTests`. Rules learned that way and implemented: the cursor stands between characters and may sit one
|
||||
past the line end; vertical moves, pages and scrolls remember a column (`lastcx`/`lastsx`) and snap to the line end the way
|
||||
tmux does; a page is `rows - 2`; searches put the cursor at the match end for emacs keys and the match start for vi keys, and
|
||||
scroll the match into view by tmux's rule; a search string outlives copy mode and restarts upward; a failed search is a status
|
||||
message, not a command error; selections are half open for emacs keys and include the cursor character (and the newline at a
|
||||
line end) for vi keys; `select-word` on blanks takes the previous word; `copy-line` and `copy-end-of-line` start a fresh
|
||||
selection.
|
||||
- Copy mode selections: the same fuzz with selections (begin, select-word, select-line, rectangle, other-end, then copies)
|
||||
did not converge: roughly one random sequence in five still copies a different range, typically when `select-word` or
|
||||
`select-line` is followed by more cursor moves (tmux then extends by words or lines, mplex by characters).
|
||||
|
||||
## Differences from tmux and gaps
|
||||
|
||||
Each item names the code that would have to change.
|
||||
|
||||
- **No mouse.** No mouse reporting, selection, border dragging or wheel scroll. `MplexKeys.split` drops mouse sequences;
|
||||
a client enabling mouse mode and an `MplexClientConn` routing table would be needed.
|
||||
- **No reflow on resize, no wrapped-line flag.** `TuiVtScreen.resize` truncates rows when the pane gets narrower and cannot get
|
||||
the text back; tmux reflows. Copy mode treats each screen row as a line, so word motions, `end-of-line` and copies do not
|
||||
join a long line that the terminal wrapped, where tmux does. Fix: store a wrapped flag per row in `TuiVtScreen` and re-wrap
|
||||
in `resize`; `MplexCopyMode.llen`, `next_word` and `selection_text` would then follow it.
|
||||
- **Wide characters count as one column** (the existing `TuiVtScreen` gap in `tui.md`); CJK and emoji misalign.
|
||||
- **Copy mode corners that differ from tmux:** the non-bracket fallback of `next-matching-bracket` and
|
||||
`previous-matching-bracket` (tmux behaves like several different word motions depending on the position, mplex uses
|
||||
next-space-end and previous-space); a failed `jump-*` followed by `jump-reverse`; word- and line-granular selection after
|
||||
`select-word` or `select-line` plus cursor moves (mplex stays character based); the `lastcx`/`lastsx` bookkeeping when a
|
||||
backward word motion crosses a line (tmux records in some situations, mplex never does); a cursor placed beyond the line
|
||||
end by `goto-line`; the `swap-window -d` last-window flag. `append-selection` was not compared.
|
||||
- **Window flags** show `*`, `-`, `Z`, `M`; there is no bell, activity or silence tracking, so `next-window -a` finds no
|
||||
window and says so. Needs per-pane output/bell counters in `MplexServer.pane_read`.
|
||||
- **`status-keys vi`** is treated as emacs. The command prompt has no tab completion.
|
||||
- **Status line**: no multi-line status, `status-format`, ranges or `pane-border-status`.
|
||||
- **Layouts** are the seven named presets and the splits you make; layout strings from `list-windows -F '#{window_layout}'`
|
||||
are not parsed or produced.
|
||||
- **Windows are sized to the smallest attached client** (`window-size smallest`); `latest` is not offered.
|
||||
- **Environment**: new panes get the environment of the client that created the session, there is no `update-environment`
|
||||
or `set-environment`.
|
||||
- **Choosers** do not have tmux's sort order, filter, `-K`, `-O` and format flags (accepted by the parser, not used).
|
||||
- **Refresh panning** (`S-Up` ...) is accepted and does nothing, since a window is never larger than its client.
|
||||
- **Set-clipboard defaults to `off`** (tmux: `external`), because mplex cannot know whether the terminal understands OSC 52.
|
||||
- `default-terminal` falls back to `screen-256color`, `xterm-256color`, `xterm`, then `vt100` when its terminfo entry is missing.
|
||||
- Shortcuts that need a client, mouse or menu in tmux and are refused here are listed under Commands.
|
||||
|
||||
## Tests
|
||||
|
||||
- `test_tai.py`: `Mplex*Tests`, unit level (keys, parser, defaults, config, formats, layout, canvas, VT screen extensions,
|
||||
copy mode and its tmux-recorded fixtures, sessions, prompt, templates, framing) and `MplexSpecTests`, which checks this
|
||||
file against the code.
|
||||
- `tests/mplex.py`: real client under a pty, real server, real bash: lifecycle, every keyboard shortcut with an effect,
|
||||
copy mode round trip, `mplex.toml`, two clients, the command line.
|
||||
- `tests/plexer.py`: control socket, per-pane targeting, permissions, large and malformed requests.
|
||||
@@ -6118,6 +6118,7 @@ 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")
|
||||
@@ -8591,6 +8592,14 @@ class MplexCopyMode(MplexMode):
|
||||
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)
|
||||
@@ -8609,16 +8618,16 @@ class MplexCopyMode(MplexMode):
|
||||
if x < xx:
|
||||
x += 1
|
||||
elif ay < self.total() - 1:
|
||||
self.goto(0, ay + 1)
|
||||
x, ay = 0, ay + 1
|
||||
xx = self.llen(ay)
|
||||
else:
|
||||
break
|
||||
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
|
||||
x = self.run_end(x, ay, xx, separators)
|
||||
if vi:
|
||||
x -= 1
|
||||
self.goto(x, ay)
|
||||
|
||||
def c_next_space_end(self, words, client):
|
||||
@@ -9022,38 +9031,42 @@ class MplexCopyMode(MplexMode):
|
||||
self.scroll_to(end if forward and not vi else start, line)
|
||||
return True
|
||||
|
||||
def search_command(self, words, forward, literal=False):
|
||||
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):
|
||||
raise MplexError("Search failed")
|
||||
self.search_failed(client)
|
||||
|
||||
def c_search_forward(self, words, client):
|
||||
self.search_command(words, True)
|
||||
self.search_command(words, True, False, client)
|
||||
|
||||
def c_search_backward(self, words, client):
|
||||
self.search_command(words, False)
|
||||
self.search_command(words, False, False, client)
|
||||
|
||||
def c_search_forward_text(self, words, client):
|
||||
self.search_command(words, True, True)
|
||||
self.search_command(words, True, True, client)
|
||||
|
||||
def c_search_backward_text(self, words, client):
|
||||
self.search_command(words, False, True)
|
||||
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):
|
||||
raise MplexError("Search failed")
|
||||
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):
|
||||
raise MplexError("Search failed")
|
||||
self.search_failed(client)
|
||||
self.search = (text, forward, literal)
|
||||
|
||||
def incremental(self, words, forward):
|
||||
@@ -10853,53 +10866,70 @@ class MplexServer:
|
||||
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:
|
||||
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:
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
self.sweep_bad_fds()
|
||||
continue
|
||||
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
|
||||
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)
|
||||
|
||||
+848
-5
@@ -5147,6 +5147,807 @@ class TuiLazyTests(unittest.TestCase):
|
||||
self.assertIn("under", app.render_text())
|
||||
|
||||
|
||||
class TuiObservedTests(unittest.TestCase):
|
||||
"""State a panel keeps in a list, dict, set or TuiStyle is watched: an
|
||||
in-place mutation repaints the panel with no touch() call."""
|
||||
|
||||
class Board(tai.TuiPanel):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.items = ["a", "b"]
|
||||
self.meta = {"rows": [[1]], "k": 1}
|
||||
self.tags = {1, 2}
|
||||
|
||||
def content_rows(self):
|
||||
text = ",".join(str(item) for item in self.items) + str(self.meta["rows"][0]) + str(sorted(self.tags))
|
||||
return [[tai.TuiSpan(text, self.style)]]
|
||||
|
||||
def mount(self):
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = 6, 40
|
||||
app.verify = True
|
||||
root = tai.TuiPanel(flow=None)
|
||||
board = self.Board(x=0, y=0, w=30, h=2)
|
||||
root.add(board)
|
||||
app.mount(root)
|
||||
app.frame_bytes()
|
||||
return app, board
|
||||
|
||||
def assertTouches(self, app, board, action, label):
|
||||
app.frame_bytes()
|
||||
self.assertNotIn(board, app._damage)
|
||||
action(board)
|
||||
self.assertIn(board, app._damage, "%s did not touch the panel" % label)
|
||||
app.frame_bytes()
|
||||
|
||||
def test_every_list_mutator_touches(self):
|
||||
import operator
|
||||
cases = {
|
||||
"append": lambda b: b.items.append("x"), "extend": lambda b: b.items.extend(["x", "y"]),
|
||||
"insert": lambda b: b.items.insert(0, "x"), "remove": lambda b: b.items.remove("a"),
|
||||
"pop": lambda b: b.items.pop(), "clear": lambda b: b.items.clear(),
|
||||
"sort": lambda b: b.items.sort(), "reverse": lambda b: b.items.reverse(),
|
||||
"iadd": lambda b: operator.iadd(b.items, ["x"]), "imul": lambda b: operator.imul(b.items, 2),
|
||||
"setitem": lambda b: b.items.__setitem__(0, "z"), "slice": lambda b: b.items.__setitem__(slice(0, 1), ["p", "q"]),
|
||||
"delitem": lambda b: b.items.__delitem__(0),
|
||||
}
|
||||
for label, action in cases.items():
|
||||
app, board = self.mount()
|
||||
self.assertTouches(app, board, action, "list." + label)
|
||||
|
||||
def test_every_dict_mutator_touches(self):
|
||||
import operator
|
||||
cases = {
|
||||
"setitem": lambda b: b.meta.__setitem__("n", 2), "update": lambda b: b.meta.update({"n": 2}),
|
||||
"setdefault": lambda b: b.meta.setdefault("n", 3), "pop": lambda b: b.meta.pop("k"),
|
||||
"popitem": lambda b: b.meta.popitem(), "clear": lambda b: b.meta.clear(),
|
||||
"delitem": lambda b: b.meta.__delitem__("k"), "ior": lambda b: operator.ior(b.meta, {"n": 1}),
|
||||
}
|
||||
for label, action in cases.items():
|
||||
app, board = self.mount()
|
||||
self.assertTouches(app, board, action, "dict." + label)
|
||||
|
||||
def test_every_set_mutator_touches(self):
|
||||
import operator
|
||||
cases = {
|
||||
"add": lambda b: b.tags.add(9), "discard": lambda b: b.tags.discard(1), "remove": lambda b: b.tags.remove(1),
|
||||
"pop": lambda b: b.tags.pop(), "clear": lambda b: b.tags.clear(), "update": lambda b: b.tags.update({7}),
|
||||
"difference_update": lambda b: b.tags.difference_update({1}),
|
||||
"intersection_update": lambda b: b.tags.intersection_update({1}),
|
||||
"symmetric_difference_update": lambda b: b.tags.symmetric_difference_update({1, 8}),
|
||||
"ior": lambda b: operator.ior(b.tags, {5}), "iand": lambda b: operator.iand(b.tags, {1}),
|
||||
"isub": lambda b: operator.isub(b.tags, {1}), "ixor": lambda b: operator.ixor(b.tags, {1, 6}),
|
||||
}
|
||||
for label, action in cases.items():
|
||||
app, board = self.mount()
|
||||
self.assertTouches(app, board, action, "set." + label)
|
||||
|
||||
def test_a_mutation_shows_on_screen_with_no_touch_call(self):
|
||||
app, board = self.mount()
|
||||
board.items.append("zed")
|
||||
self.assertIn("zed", app.frame_bytes())
|
||||
board.meta["rows"][0].append(77)
|
||||
self.assertIn("77", app.frame_bytes())
|
||||
board.tags.add(4242)
|
||||
self.assertIn("4242", app.frame_bytes())
|
||||
|
||||
def test_nested_containers_are_watched_to_the_depth_cap(self):
|
||||
app, board = self.mount()
|
||||
board.deep = {"a": [{"b": [1]}]}
|
||||
app.frame_bytes()
|
||||
self.assertTouches(app, board, lambda b: b.deep["a"][0]["b"].append(2), "nested")
|
||||
board.items.append([1])
|
||||
self.assertTouches(app, board, lambda b: b.items[-1].append(2), "inserted")
|
||||
nest = []
|
||||
node = nest
|
||||
for _depth in range(tai.TUI_OBSERVE_DEPTH + 3):
|
||||
node.append([])
|
||||
node = node[0]
|
||||
board.nest = nest
|
||||
node = board.nest
|
||||
for _depth in range(tai.TUI_OBSERVE_DEPTH + 1):
|
||||
node = node[0]
|
||||
self.assertIs(type(node), list)
|
||||
|
||||
def test_the_panel_keeps_a_copy_and_copies_travel_plain(self):
|
||||
import copy
|
||||
import json
|
||||
import pickle
|
||||
app, board = self.mount()
|
||||
source = [1, 2]
|
||||
board.items = source
|
||||
self.assertIsNot(board.items, source)
|
||||
self.assertEqual(board.items, [1, 2])
|
||||
app.frame_bytes()
|
||||
source.append(3)
|
||||
self.assertNotIn(board, app._damage)
|
||||
self.assertIs(type(copy.deepcopy(board.items)), list)
|
||||
self.assertIs(type(copy.copy(board.items)), list)
|
||||
self.assertIs(type(pickle.loads(pickle.dumps(board.meta))), dict)
|
||||
self.assertEqual(json.dumps(board.meta), '{"rows": [[1]], "k": 1}')
|
||||
self.assertEqual(board.tags, {1, 2})
|
||||
|
||||
def test_a_container_moved_to_another_panel_belongs_to_the_new_one(self):
|
||||
app, first = self.mount()
|
||||
second = self.Board(x=0, y=3, w=30, h=2)
|
||||
first.parent.add(second)
|
||||
app.frame_bytes()
|
||||
second.items = first.items
|
||||
app.frame_bytes()
|
||||
second.items.append("q")
|
||||
self.assertIn(second, app._damage)
|
||||
self.assertNotIn(first, app._damage)
|
||||
self.assertNotIn("q", first.items)
|
||||
|
||||
def test_private_attributes_and_unmounted_panels_are_left_alone(self):
|
||||
board = self.Board()
|
||||
board.items.append("x")
|
||||
board._cache = []
|
||||
self.assertIs(type(board._cache), list)
|
||||
self.assertIsNone(board.app)
|
||||
|
||||
def test_a_style_written_in_place_repaints_every_panel_using_it(self):
|
||||
app, first = self.mount()
|
||||
second = self.Board(x=0, y=3, w=30, h=2)
|
||||
first.parent.add(second)
|
||||
shared = tai.TuiStyle(fg="red")
|
||||
first.style = shared
|
||||
second.style = shared
|
||||
app.frame_bytes()
|
||||
shared.fg = "green"
|
||||
self.assertEqual({first, second}, {node for node in (first, second) if node in app._damage})
|
||||
self.assertIn("32m", app.frame_bytes())
|
||||
shared.fg = "green"
|
||||
self.assertNotIn(first, app._damage)
|
||||
|
||||
def test_the_whole_app_repaints_correctly_after_container_mutations(self):
|
||||
"""Random in-place mutations, never a touch(): every frame equals the
|
||||
reference compositor (verify is on)."""
|
||||
import random
|
||||
rng = random.Random(5)
|
||||
app, board = self.mount()
|
||||
for step in range(200):
|
||||
op = rng.choice(("append", "pop", "set", "tag", "meta", "sort"))
|
||||
if op == "append":
|
||||
board.items.append("i%d" % step)
|
||||
elif op == "pop" and board.items:
|
||||
board.items.pop()
|
||||
elif op == "set" and board.items:
|
||||
board.items[rng.randrange(len(board.items))] = "s%d" % step
|
||||
elif op == "tag":
|
||||
board.tags.symmetric_difference_update({rng.randrange(20)})
|
||||
elif op == "meta":
|
||||
board.meta["rows"][0].append(step)
|
||||
elif op == "sort":
|
||||
board.items.sort()
|
||||
app.frame_bytes()
|
||||
self.assertGreater(app.verified, 150)
|
||||
|
||||
|
||||
class TuiLayoutTests(unittest.TestCase):
|
||||
"""A layout pass arranges only the subtrees that asked, each from the
|
||||
nearest ancestor whose size does not follow its content, and the result
|
||||
is exactly what arranging the whole tree would give (app.verify checks
|
||||
that on every partial pass)."""
|
||||
|
||||
class Probe(tai.TuiPanel):
|
||||
log = None
|
||||
|
||||
def arrange(self, x, y, w, h, clip):
|
||||
if TuiLayoutTests.Probe.log is not None:
|
||||
TuiLayoutTests.Probe.log.append(self.id)
|
||||
super().arrange(x, y, w, h, clip)
|
||||
|
||||
def setUp(self):
|
||||
Probe = self.Probe
|
||||
self.app = tai.TuiApp()
|
||||
self.app.rows, self.app.cols = 30, 90
|
||||
self.app.verify = True
|
||||
self.root = Probe(id="root", flow=None)
|
||||
self.page = Probe(id="page", x=0, y=0, w="100%", h="100%", flow="cols")
|
||||
self.left = Probe(id="left", w=20, h="100%", flow="rows")
|
||||
self.right = Probe(id="right", w="1fr", h="100%", flow="rows")
|
||||
self.page.add(self.left, self.right)
|
||||
for n in range(3):
|
||||
self.left.add(tai.TuiText("l%d" % n, id="l%d" % n))
|
||||
self.right.add(tai.TuiText("r%d" % n, id="r%d" % n))
|
||||
self.root.add(self.page)
|
||||
self.app.mount(self.root)
|
||||
self.app.frame_bytes()
|
||||
|
||||
def tearDown(self):
|
||||
self.Probe.log = None
|
||||
|
||||
def arranged(self, action):
|
||||
self.app.frame_bytes()
|
||||
self.app.verify = False
|
||||
self.Probe.log = []
|
||||
action()
|
||||
self.app.frame_bytes()
|
||||
seen, self.Probe.log = self.Probe.log, None
|
||||
return seen
|
||||
|
||||
def test_a_child_added_inside_a_fixed_size_panel_arranges_only_that_panel(self):
|
||||
seen = self.arranged(lambda: self.left.add(tai.TuiText("more")))
|
||||
self.assertIn("left", seen)
|
||||
self.assertNotIn("right", seen)
|
||||
self.assertNotIn("page", seen)
|
||||
self.assertNotIn("root", seen)
|
||||
|
||||
def test_a_panels_own_size_change_arranges_its_parent(self):
|
||||
seen = self.arranged(lambda: setattr(self.left, "w", 30))
|
||||
self.assertIn("page", seen)
|
||||
self.assertNotIn("root", seen)
|
||||
self.assertEqual(self.right.ax, 30)
|
||||
|
||||
def test_growth_of_a_content_sized_panel_climbs_to_the_first_fixed_one(self):
|
||||
self.left.w = "auto"
|
||||
self.app.frame_bytes()
|
||||
seen = self.arranged(lambda: setattr(self.root.find("l0"), "text", "a much longer label than before"))
|
||||
self.assertIn("page", seen)
|
||||
self.assertNotIn("root", seen)
|
||||
self.assertEqual(self.right.ax, len("a much longer label than before"))
|
||||
|
||||
def test_text_of_a_content_sized_panel_that_changes_size_relays_out(self):
|
||||
self.left.w = "auto"
|
||||
self.app.frame_bytes()
|
||||
before = self.right.ax
|
||||
self.root.find("l1").text = "x" * 40
|
||||
self.app.frame_bytes()
|
||||
self.assertEqual(self.right.ax, 40)
|
||||
self.assertNotEqual(before, 40)
|
||||
|
||||
def test_z_and_layer_changes_arrange_nothing(self):
|
||||
seen = self.arranged(lambda: setattr(self.left, "z", 4))
|
||||
self.assertEqual(seen, [])
|
||||
seen = self.arranged(lambda: setattr(self.left, "layer", "overlay"))
|
||||
self.assertEqual(seen, [])
|
||||
|
||||
def test_quiet_writes_arrange_nothing(self):
|
||||
seen = self.arranged(lambda: (setattr(self.left, "w", 20), setattr(self.right, "visible", True)))
|
||||
self.assertEqual(seen, [])
|
||||
|
||||
def test_hiding_and_showing_a_panel_moves_its_neighbours(self):
|
||||
self.left.visible = False
|
||||
self.app.frame_bytes()
|
||||
self.assertEqual(self.right.ax, 0)
|
||||
self.left.visible = True
|
||||
self.app.frame_bytes()
|
||||
self.assertEqual(self.right.ax, 20)
|
||||
|
||||
def test_removed_panels_leave_no_stale_cells(self):
|
||||
self.right.remove(self.root.find("r1"))
|
||||
self.app.frame_bytes()
|
||||
self.assertNotIn("r1", self.app.render_text())
|
||||
|
||||
def test_an_anonymous_dirty_layout_arranges_everything(self):
|
||||
seen = self.arranged(self.app.dirty_layout)
|
||||
self.assertIn("root", seen)
|
||||
|
||||
def test_too_many_requests_fall_back_to_a_full_pass(self):
|
||||
def spam():
|
||||
for n in range(tai.TuiApp.LAYOUT_STARTS + 5):
|
||||
self.left.add(tai.TuiText("n%d" % n))
|
||||
seen = self.arranged(spam)
|
||||
self.assertIn("root", seen)
|
||||
|
||||
def test_random_trees_partial_equals_full(self):
|
||||
"""A seeded storm over random trees (every size spec, docks, floats,
|
||||
borders, padding, visibility, adds, removes, text growth). Every
|
||||
partial pass is checked against a full arrange and every frame
|
||||
against the reference compositor."""
|
||||
import random
|
||||
for seed in range(6):
|
||||
rng = random.Random(seed)
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = 30, 90
|
||||
app.verify = True
|
||||
root = tai.TuiPanel(flow=None, id="root")
|
||||
containers = [root]
|
||||
nodes = []
|
||||
|
||||
def spec():
|
||||
return rng.choice(["auto", "auto", 3, 8, 14, "25%", "50%", "100%", "1fr", "2fr"])
|
||||
|
||||
def make():
|
||||
kw = dict(w=spec(), h=spec())
|
||||
if rng.random() < .25:
|
||||
kw["border"] = True
|
||||
if rng.random() < .15:
|
||||
kw["padding"] = rng.choice([1, (1, 2), 0])
|
||||
if rng.random() < .15:
|
||||
kw.update(x=rng.choice([0, 3, "center", -2]), y=rng.choice([0, 2, "center"]))
|
||||
elif rng.random() < .12:
|
||||
kw["dock"] = rng.choice(["top", "bottom", "left", "right"])
|
||||
if rng.random() < .5:
|
||||
return tai.TuiText("t" * rng.randrange(1, 30), **kw)
|
||||
return tai.TuiPanel(flow=rng.choice(["rows", "cols", None]), gap=rng.choice([0, 0, 1]), **kw)
|
||||
|
||||
def adopt(node):
|
||||
rng.choice(containers).add(node)
|
||||
nodes.append(node)
|
||||
if not isinstance(node, tai.TuiText):
|
||||
containers.append(node)
|
||||
|
||||
for _n in range(12):
|
||||
adopt(make())
|
||||
app.mount(root)
|
||||
app.frame_bytes()
|
||||
values = {
|
||||
"w": spec, "h": spec, "padding": lambda: tai.TuiPanel.norm_padding(rng.choice([0, 1, (1, 2)])),
|
||||
"gap": lambda: rng.choice([0, 1, 2]), "border": lambda: rng.random() < .5,
|
||||
"x": lambda: rng.choice([None, 0, 5, "center"]), "y": lambda: rng.choice([None, 0, 3]),
|
||||
"flow": lambda: rng.choice(["rows", "cols", None]), "align": lambda: rng.choice(["left", "center", "right"]),
|
||||
"valign": lambda: rng.choice(["top", "center", "bottom"]), "dock": lambda: rng.choice([None, "top", "left"]),
|
||||
"offset": lambda: rng.choice([(0, 0), (1, 1), (-1, 2)])}
|
||||
for _step in range(150):
|
||||
if len(nodes) < 3:
|
||||
adopt(make())
|
||||
op = rng.choice(["set", "set", "set", "text", "add", "remove", "visible", "z"])
|
||||
node = rng.choice(nodes)
|
||||
if op == "set":
|
||||
name = rng.choice(sorted(values))
|
||||
setattr(node, name, values[name]())
|
||||
elif op == "text" and isinstance(node, tai.TuiText):
|
||||
node.text = "t" * rng.randrange(1, 40) + "\n" * rng.randrange(0, 3)
|
||||
elif op == "add":
|
||||
adopt(make())
|
||||
elif op == "remove" and node.parent is not None:
|
||||
node.parent.remove(node)
|
||||
gone = {id(item) for item in node.walk()}
|
||||
nodes[:] = [item for item in nodes if id(item) not in gone]
|
||||
containers[:] = [item for item in containers if id(item) not in gone]
|
||||
elif op == "visible":
|
||||
node.visible = not node.visible
|
||||
elif op == "z":
|
||||
node.z = rng.randrange(0, 4)
|
||||
app.frame_bytes()
|
||||
self.assertGreater(app.layouts["partial"], 100)
|
||||
|
||||
def test_a_diverging_partial_layout_is_caught(self):
|
||||
"""The oracle works: a partial pass that skips a panel it should have
|
||||
arranged raises."""
|
||||
original = self.app._layout_roots
|
||||
self.app._layout_roots = lambda starts: []
|
||||
self.right.w = "3fr"
|
||||
self.left.w = 33
|
||||
with self.assertRaises(tai.TuiError):
|
||||
self.app.frame_bytes()
|
||||
self.app._layout_roots = original
|
||||
|
||||
|
||||
class TuiScrollTests(unittest.TestCase):
|
||||
"""Scrolling a log is a terminal scroll plus the rows that are new, and a
|
||||
log reuses the converted rows of lines it has already painted. The bytes
|
||||
are replayed through the VT emulator, so a wrong scroll cannot hide."""
|
||||
|
||||
@staticmethod
|
||||
def chars(grid):
|
||||
return [("".join(cell[0] if cell else " " for cell in row)).rstrip() for row in grid]
|
||||
|
||||
def build(self, rows=24, cols=80, lines=120, border=False, padding=0, cls=None):
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = rows, cols
|
||||
app.verify = True
|
||||
root = tai.TuiPanel(flow=None, style=tai.TuiStyle(bg="black"))
|
||||
side = tai.TuiRows(dock="left", w=14)
|
||||
side.add(*[tai.TuiText("side %d" % n) for n in range(5)])
|
||||
log = (cls or tai.TuiLog)(w="100%", h="100%", border=border, padding=padding)
|
||||
for n in range(lines):
|
||||
log.append("line %03d %s" % (n, "abcdefghij" * (n % 5)), tai.TuiStyle(fg="red" if n % 3 else "green"))
|
||||
root.add(side, log)
|
||||
app.mount(root)
|
||||
vt = tai.TuiVtScreen(rows, cols)
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
return app, log, vt
|
||||
|
||||
def same(self, app, vt):
|
||||
want = app.compositor.compose_reference(app.root, app.rows, app.cols)
|
||||
self.assertEqual(self.chars(vt.grid), tai.TuiCompositor.render_text(want).split("\n"))
|
||||
|
||||
def test_wheel_scroll_is_a_terminal_scroll_and_stays_exact(self):
|
||||
import random
|
||||
rng = random.Random(11)
|
||||
for cls in (tai.TuiLog, tai.TuiChatLog):
|
||||
app, log, vt = self.build(cls=cls)
|
||||
for _step in range(120):
|
||||
app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction=rng.choice(("up", "down", "up"))))
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
self.same(app, vt)
|
||||
self.assertGreater(app.compositor.stats["scrolled"], 500, cls.__name__)
|
||||
|
||||
def test_a_scroll_frame_writes_far_less_than_a_cell_rewrite(self):
|
||||
app, log, vt = self.build(rows=40, cols=120, lines=300)
|
||||
log.scroll_by(0, -3)
|
||||
app.frame_bytes()
|
||||
app.verify = False
|
||||
stats = dict(app.compositor.stats)
|
||||
log.scroll_by(0, -3)
|
||||
out = app.frame_bytes()
|
||||
emitted = app.compositor.stats["emitted"] - stats["emitted"]
|
||||
self.assertTrue("\x1bM" in out or "\x1bD" in out, "no terminal scroll was emitted")
|
||||
self.assertLess(emitted, 40 * 106 // 4, "a 3 line scroll rewrote %d cells" % emitted)
|
||||
|
||||
def test_full_width_log_scrolls_without_repairing_strips(self):
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = 20, 60
|
||||
app.verify = True
|
||||
root = tai.TuiPanel(flow=None)
|
||||
log = tai.TuiLog(x=0, y=0, w="100%", h="100%")
|
||||
for n in range(100):
|
||||
log.append("row %03d" % n)
|
||||
root.add(log)
|
||||
app.mount(root)
|
||||
vt = tai.TuiVtScreen(20, 60)
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
for direction in ("up", "up", "down", "down", "up"):
|
||||
app.dispatch(tai.TuiScroll(x=1, y=1, direction=direction))
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
self.same(app, vt)
|
||||
self.assertEqual(app.compositor.stats["scrolled"], 5 * 20)
|
||||
|
||||
def test_bordered_and_padded_logs_scroll_exactly(self):
|
||||
for border, padding in ((True, 0), (False, 1), (True, (1, 2))):
|
||||
app, log, vt = self.build(border=border, padding=padding)
|
||||
for direction in ("up", "up", "down", "up", "down", "down"):
|
||||
app.dispatch(tai.TuiScroll(x=log.ax + 3, y=log.ay + 3, direction=direction))
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
self.same(app, vt)
|
||||
|
||||
def test_appending_while_pinned_scrolls(self):
|
||||
app, log, vt = self.build()
|
||||
app.verify = True
|
||||
for n in range(30):
|
||||
log.append("fresh %d" % n)
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
self.same(app, vt)
|
||||
self.assertGreater(app.compositor.stats["scrolled"], 0)
|
||||
|
||||
def test_a_wrong_hint_is_ignored_and_a_broken_scroll_is_caught(self):
|
||||
app, log, vt = self.build()
|
||||
log.scroll_y = 10
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
before = app.compositor.stats["scrolled"]
|
||||
log.scroll_y = 10
|
||||
log._shift = 7
|
||||
log.touch()
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
self.same(app, vt)
|
||||
self.assertEqual(app.compositor.stats["scrolled"], before)
|
||||
compositor_scroll = tai.TuiCompositor.scroll
|
||||
|
||||
def broken(self, shifts, fresh, cols, rows, out):
|
||||
believed = [list(row) for row in self.last]
|
||||
strips = compositor_scroll(self, shifts, fresh, cols, rows, out)
|
||||
if strips:
|
||||
self.last[:] = believed
|
||||
return strips
|
||||
|
||||
tai.TuiCompositor.scroll = broken
|
||||
try:
|
||||
app.verify = False
|
||||
with self.assertRaises(AssertionError):
|
||||
for _n in range(10):
|
||||
app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction="up"))
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
self.same(app, vt)
|
||||
finally:
|
||||
tai.TuiCompositor.scroll = compositor_scroll
|
||||
|
||||
def test_random_scroll_storm_with_overlays_and_resizes(self):
|
||||
import random
|
||||
for seed in range(8):
|
||||
rng = random.Random(seed)
|
||||
app, log, vt = self.build(rows=rng.randrange(12, 40), cols=rng.randrange(40, 110), lines=rng.randrange(40, 200),
|
||||
border=rng.random() < .4, padding=rng.choice([0, 0, 1]), cls=rng.choice([tai.TuiLog, tai.TuiChatLog]))
|
||||
dialogs = []
|
||||
for step in range(120):
|
||||
op = rng.choice(("wheel", "wheel", "wheel", "append", "page", "dialog", "close", "resize", "scrollx"))
|
||||
if op == "wheel":
|
||||
app.dispatch(tai.TuiScroll(x=log.ax + 1, y=log.ay + 1, direction=rng.choice(("up", "down"))))
|
||||
elif op == "append":
|
||||
log.append("new %d %s" % (step, "y" * rng.randrange(0, 60)))
|
||||
elif op == "page":
|
||||
log.scroll_by(0, rng.choice((-30, -7, -1, 1, 5, 40)))
|
||||
elif op == "scrollx":
|
||||
log.scroll_x = rng.choice((0, 0, 2, 5))
|
||||
elif op == "dialog" and len(dialogs) < 2:
|
||||
dialog = tai.TuiQuestion("Pick", ["one", "two"], on_done=lambda value: None, title="Q", w=rng.randrange(20, 36))
|
||||
app.root.add(dialog)
|
||||
dialogs.append(dialog)
|
||||
elif op == "close" and dialogs:
|
||||
dialogs.pop().close()
|
||||
elif op == "resize":
|
||||
app.rows, app.cols = rng.randrange(12, 40), rng.randrange(40, 110)
|
||||
app.compositor.invalidate()
|
||||
app.dirty_layout()
|
||||
vt = tai.TuiVtScreen(app.rows, app.cols)
|
||||
vt.feed(app.frame_bytes().encode("utf-8"))
|
||||
if step % 5 == 0:
|
||||
self.same(app, vt)
|
||||
|
||||
def test_log_cells_equal_the_span_paint(self):
|
||||
"""The reused-row path must give the cells the plain paint gives, for
|
||||
every border, padding, scroll and width, memo cold and warm."""
|
||||
import random
|
||||
rng = random.Random(3)
|
||||
for cls in (tai.TuiLog, tai.TuiChatLog):
|
||||
for _case in range(40):
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = 20, 70
|
||||
root = tai.TuiPanel(flow=None)
|
||||
log = cls(x=rng.randrange(0, 5), y=rng.randrange(0, 3), w=rng.randrange(1, 60), h=rng.randrange(1, 18),
|
||||
border=rng.random() < .4, padding=rng.choice([0, 1, (1, 2), (0, 1, 2, 0)]),
|
||||
style=tai.TuiStyle(bg=rng.choice([None, "blue"])))
|
||||
for n in range(rng.randrange(0, 60)):
|
||||
log.append("line %d %s" % (n, "é" * rng.randrange(0, 40)), tai.TuiStyle(fg=rng.choice(["red", "green"])))
|
||||
root.add(log)
|
||||
app.mount(root)
|
||||
app.frame_bytes()
|
||||
log.scroll_x = rng.choice((0, 0, 3))
|
||||
log.scroll_y = rng.randrange(0, 30)
|
||||
log.scroll_y = log.scroll_y
|
||||
compositor = app.compositor
|
||||
for _pass in range(2):
|
||||
want = compositor.cells_from(log.paint(), log.aw)
|
||||
self.assertEqual(log.paint_cells(compositor), want, "%s border=%s padding=%s" % (cls.__name__, log.border, log.padding))
|
||||
|
||||
def test_a_scroll_converts_only_new_lines(self):
|
||||
app, log, vt = self.build(lines=400)
|
||||
app.verify = False
|
||||
log.scroll_by(0, -2)
|
||||
app.frame_bytes()
|
||||
compositor = app.compositor
|
||||
converted = []
|
||||
original = compositor.cells_from
|
||||
|
||||
def counting(painted, width):
|
||||
converted.append(len(painted))
|
||||
return original(painted, width)
|
||||
|
||||
compositor.cells_from = counting
|
||||
log.scroll_by(0, -3)
|
||||
app.frame_bytes()
|
||||
self.assertEqual(sum(converted), 3)
|
||||
|
||||
def test_the_memo_is_capped(self):
|
||||
app, log, vt = self.build(lines=tai.TUI_ROW_MEMO + 300, rows=30)
|
||||
app.verify = False
|
||||
for pos in range(0, tai.TUI_ROW_MEMO + 300, 20):
|
||||
log.scroll_y = pos
|
||||
app.frame_bytes()
|
||||
self.assertLessEqual(len(log._row_memo), tai.TUI_ROW_MEMO)
|
||||
|
||||
|
||||
class TuiLoopTests(unittest.TestCase):
|
||||
"""The main loop sleeps until something is due. These run the real _main
|
||||
on pipes (a stand-in for the terminal) and measure wakeups and latency."""
|
||||
|
||||
def drive(self, app, driver, hold=0.6, finish=True):
|
||||
"""Run app._main with stdin and stdout on pipes; driver(write_fd,
|
||||
frames) runs on a thread, then the app quits (unless finish is False:
|
||||
the driver must end the loop itself). Returns the list of (time,
|
||||
bytes) frames."""
|
||||
import asyncio
|
||||
import threading
|
||||
stdin_r, stdin_w = os.pipe()
|
||||
out_r, out_w = os.pipe()
|
||||
os.set_blocking(out_r, False)
|
||||
app._stdin_fd = stdin_r
|
||||
app._stdout_fd = out_w
|
||||
frames = []
|
||||
original = app.frame_bytes
|
||||
|
||||
def counted():
|
||||
data = original()
|
||||
if data:
|
||||
frames.append((time.time(), len(data)))
|
||||
return data
|
||||
|
||||
app.frame_bytes = counted
|
||||
|
||||
def run_driver():
|
||||
time.sleep(0.15)
|
||||
driver(stdin_w, frames)
|
||||
time.sleep(hold)
|
||||
if finish:
|
||||
app.quit()
|
||||
app.wake()
|
||||
|
||||
thread = threading.Thread(target=run_driver, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
asyncio.run(app._main())
|
||||
finally:
|
||||
thread.join(timeout=5)
|
||||
for fd in (stdin_r, stdin_w, out_r, out_w):
|
||||
os.close(fd)
|
||||
return frames
|
||||
|
||||
def scene(self):
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = 10, 40
|
||||
app.verify = True
|
||||
root = tai.TuiRows()
|
||||
label = tai.TuiText("-", id="label")
|
||||
box = tai.TuiTextBox(id="box", focusable=True)
|
||||
root.add(label, box)
|
||||
app.mount(root)
|
||||
return app, label, box
|
||||
|
||||
def test_an_idle_screen_wakes_almost_never(self):
|
||||
app, label, box = self.scene()
|
||||
frames = self.drive(app, lambda w, f: time.sleep(1.2), hold=0.0)
|
||||
self.assertLessEqual(app.iterations, 4, "an idle loop ran %d times in 1.3 s" % app.iterations)
|
||||
self.assertEqual(len(frames), 1)
|
||||
|
||||
def test_a_key_reaches_the_screen_in_milliseconds(self):
|
||||
app, label, box = self.scene()
|
||||
latency = []
|
||||
|
||||
def driver(write_fd, frames):
|
||||
for char in b"abc":
|
||||
before = len(frames)
|
||||
started = time.time()
|
||||
os.write(write_fd, bytes([char]))
|
||||
while len(frames) == before and time.time() - started < 1.0:
|
||||
time.sleep(0.0005)
|
||||
latency.append(frames[-1][0] - started if len(frames) > before else 9.9)
|
||||
time.sleep(0.1)
|
||||
|
||||
self.drive(app, driver, hold=0.1)
|
||||
self.assertEqual(box.value, "abc")
|
||||
self.assertLess(max(latency), 0.05, latency)
|
||||
|
||||
def test_a_touch_from_another_thread_wakes_the_loop(self):
|
||||
app, label, box = self.scene()
|
||||
latency = []
|
||||
|
||||
def driver(write_fd, frames):
|
||||
for n in range(3):
|
||||
before = len(frames)
|
||||
started = time.time()
|
||||
label.text = "thread %d" % n
|
||||
while len(frames) == before and time.time() - started < 1.0:
|
||||
time.sleep(0.0005)
|
||||
latency.append(frames[-1][0] - started if len(frames) > before else 9.9)
|
||||
time.sleep(0.2)
|
||||
|
||||
self.drive(app, driver, hold=0.1)
|
||||
self.assertLess(max(latency), 0.08, latency)
|
||||
|
||||
def test_timers_fire_on_time_without_polling(self):
|
||||
app, label, box = self.scene()
|
||||
fired = []
|
||||
started = []
|
||||
|
||||
def driver(write_fd, frames):
|
||||
started.append(time.time())
|
||||
app.after(0.3, lambda: fired.append(time.time()))
|
||||
app.after(0.45, lambda: setattr(label, "text", "timer"))
|
||||
time.sleep(0.7)
|
||||
|
||||
frames = self.drive(app, driver, hold=0.05)
|
||||
self.assertEqual(len(fired), 1)
|
||||
self.assertLess(abs(fired[0] - started[0] - 0.3), 0.06)
|
||||
self.assertEqual(label.text, "timer")
|
||||
self.assertLessEqual(app.iterations, 8)
|
||||
|
||||
def test_animation_runs_at_the_frame_rate_then_stops(self):
|
||||
app, label, box = self.scene()
|
||||
mover = tai.TuiProgress(0.0, w=20)
|
||||
app.root.add(mover)
|
||||
|
||||
def driver(write_fd, frames):
|
||||
mover.animate("value", 1.0, duration=0.4)
|
||||
time.sleep(0.5)
|
||||
|
||||
frames = self.drive(app, driver, hold=0.5)
|
||||
self.assertEqual(mover.value, 1.0)
|
||||
self.assertGreaterEqual(len(frames), 8)
|
||||
self.assertLessEqual(app.iterations, 8 + int(0.4 * app.fps) + 6)
|
||||
|
||||
def test_half_a_key_sequence_is_flushed_as_escape(self):
|
||||
app, label, box = self.scene()
|
||||
seen = []
|
||||
app.root.on_key = lambda event: seen.append(event.key)
|
||||
|
||||
def driver(write_fd, frames):
|
||||
os.write(write_fd, b"\x1b")
|
||||
time.sleep(0.2)
|
||||
|
||||
self.drive(app, driver, hold=0.0)
|
||||
self.assertIn("escape", seen)
|
||||
|
||||
def test_a_resize_signal_wakes_the_loop(self):
|
||||
app, label, box = self.scene()
|
||||
app.rows = 5
|
||||
app.cols = 20
|
||||
|
||||
def driver(write_fd, frames):
|
||||
app._on_winch(None, None)
|
||||
time.sleep(0.2)
|
||||
|
||||
self.drive(app, driver, hold=0.0)
|
||||
self.assertEqual((app.rows, app.cols), (24, 80))
|
||||
|
||||
def test_ticking_panels_run_every_frame_and_only_then(self):
|
||||
app, label, box = self.scene()
|
||||
ticks = []
|
||||
|
||||
class Ticker(tai.TuiPanel):
|
||||
def on_tick(self, event):
|
||||
ticks.append(event.now)
|
||||
|
||||
self.drive(app, lambda w, f: time.sleep(0.4), hold=0.0)
|
||||
self.assertEqual(ticks, [])
|
||||
app2, label2, box2 = self.scene()
|
||||
app2.root.add(Ticker(w=1, h=1))
|
||||
self.drive(app2, lambda w, f: time.sleep(0.4), hold=0.0)
|
||||
self.assertGreaterEqual(len(ticks), 8)
|
||||
|
||||
def test_ctrl_c_and_end_of_input_end_the_loop_at_once(self):
|
||||
for ending in (b"\x03", None):
|
||||
app, label, box = self.scene()
|
||||
marks = []
|
||||
|
||||
def driver(write_fd, frames):
|
||||
marks.append(time.time())
|
||||
if ending is None:
|
||||
os.close(write_fd)
|
||||
else:
|
||||
os.write(write_fd, ending)
|
||||
time.sleep(2.0)
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
stdin_r, stdin_w = os.pipe()
|
||||
out_r, out_w = os.pipe()
|
||||
app._stdin_fd = stdin_r
|
||||
app._stdout_fd = out_w
|
||||
|
||||
def run_driver():
|
||||
time.sleep(0.15)
|
||||
driver(stdin_w, [])
|
||||
|
||||
thread = threading.Thread(target=run_driver, daemon=True)
|
||||
thread.start()
|
||||
asyncio.run(app._main())
|
||||
ended = time.time()
|
||||
for fd in (stdin_r, out_r, out_w) + ((stdin_w,) if ending is not None else ()):
|
||||
os.close(fd)
|
||||
self.assertLess(ended - marks[0], 0.3, "the loop kept sleeping after %r" % (ending,))
|
||||
|
||||
def test_wake_is_safe_without_a_loop(self):
|
||||
app = tai.TuiApp()
|
||||
app.wake()
|
||||
app.dirty_paint()
|
||||
|
||||
def test_chat_has_no_polling_timers_when_idle(self):
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = 30, 100
|
||||
root, refs = tai.tui_build_chat(app, FakeChatAgent())
|
||||
app.mount(root)
|
||||
repeating = [handle for handle in app.timers if handle[1] is not None]
|
||||
self.assertEqual(len(repeating), 1, "idle chat timers: %r" % [handle[1] for handle in repeating])
|
||||
self.assertIsNone(refs.get("spin_timer"))
|
||||
|
||||
def test_composer_resizes_from_an_event_not_a_timer(self):
|
||||
app = tai.TuiApp()
|
||||
app.rows, app.cols = 30, 100
|
||||
root, refs = tai.tui_build_chat(app, FakeChatAgent())
|
||||
app.mount(root)
|
||||
box = refs["box"]
|
||||
app.frame_bytes()
|
||||
height = box.h
|
||||
box.lines = ["a", "b", "c"]
|
||||
self.assertEqual(box.h, 3)
|
||||
box.lines.append("d")
|
||||
self.assertEqual(box.h, 4)
|
||||
box.lines = ["a"]
|
||||
self.assertEqual(box.h, height)
|
||||
|
||||
|
||||
class TuiEventTests(unittest.TestCase):
|
||||
def test_bubble_order_and_stop(self):
|
||||
order = []
|
||||
@@ -7631,8 +8432,12 @@ class MplexStubServer:
|
||||
self.config = tai.MplexConfig()
|
||||
self.options = tai.MplexOptions()
|
||||
self.copied = []
|
||||
self.messages = []
|
||||
self.dirty = 0
|
||||
|
||||
def message(self, client, text, milliseconds=None):
|
||||
self.messages.append(text)
|
||||
|
||||
def pane_options(self, pane):
|
||||
return self.options
|
||||
|
||||
@@ -8566,10 +9371,10 @@ class MplexCopyModeTests(unittest.TestCase):
|
||||
self.run_cmd(mode, "history-top")
|
||||
self.run_cmd(mode, "search-forward", "apple")
|
||||
self.assertEqual(mode.abs_y(), 0)
|
||||
with self.assertRaises(tai.MplexError):
|
||||
self.run_cmd(mode, "search-forward", "zebra")
|
||||
with self.assertRaises(tai.MplexError):
|
||||
self.run_cmd(mode, "search-forward", "(unbalanced")
|
||||
self.run_cmd(mode, "search-forward", "zebra")
|
||||
self.run_cmd(mode, "search-forward", "(unbalanced")
|
||||
self.assertEqual(server.messages, ["Search failed", "Search failed"])
|
||||
self.assertEqual(mode.abs_y(), 0)
|
||||
|
||||
def test_select_word_on_blanks_takes_the_previous_word(self):
|
||||
server, pane, mode = self.make("alpha beta", rows=4, cols=30)
|
||||
@@ -8791,7 +9596,7 @@ class MplexSpecTests(unittest.TestCase):
|
||||
def test_every_cap_is_documented(self):
|
||||
for name in ("MPLEX_MAX_SESSIONS", "MPLEX_MAX_WINDOWS", "MPLEX_MAX_PANES", "MPLEX_MAX_CLIENTS", "MPLEX_FRAME_MAX", "MPLEX_CLIENT_OUT_MAX",
|
||||
"MPLEX_PANE_IN_MAX", "MPLEX_HISTORY_CAP", "MPLEX_LOG_MAX", "MPLEX_BUFFERS_CAP", "MPLEX_MESSAGES_MAX", "MPLEX_PROMPT_HISTORY_CAP",
|
||||
"MPLEX_COMMAND_DEPTH_MAX", "MPLEX_SHELL_TIMEOUT", "MPLEX_CONFIG_MAX", "MPLEX_PASTE_MAX", "MPLEX_RENDER_INTERVAL", "MPLEX_REAP_KILL_AFTER"):
|
||||
"MPLEX_COMMAND_DEPTH_MAX", "MPLEX_LOOP_ERRORS_MAX", "MPLEX_SHELL_TIMEOUT", "MPLEX_CONFIG_MAX", "MPLEX_PASTE_MAX", "MPLEX_RENDER_INTERVAL", "MPLEX_REAP_KILL_AFTER"):
|
||||
self.assertIn("`%s`" % name, self.doc, name)
|
||||
self.assertTrue(hasattr(tai, name), name)
|
||||
|
||||
@@ -9013,6 +9818,44 @@ class MplexFramingTests(unittest.TestCase):
|
||||
self.assertIsNone(client.prev)
|
||||
|
||||
|
||||
class MplexServerLoopTests(unittest.TestCase):
|
||||
def make(self, behaviour):
|
||||
server = tai.MplexServer.__new__(tai.MplexServer)
|
||||
server.running = True
|
||||
server.calls = 0
|
||||
|
||||
def iterate():
|
||||
server.calls += 1
|
||||
behaviour(server)
|
||||
|
||||
server.iterate = iterate
|
||||
return server
|
||||
|
||||
def test_a_failing_iteration_is_survived_and_logged(self):
|
||||
def behaviour(server):
|
||||
if server.calls <= 2:
|
||||
raise RuntimeError("boom %d" % server.calls)
|
||||
server.running = False
|
||||
|
||||
server = self.make(behaviour)
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
tai.MplexServer.loop(server)
|
||||
self.assertEqual(server.calls, 3)
|
||||
self.assertIn("boom 1", err.getvalue())
|
||||
self.assertIn("boom 2", err.getvalue())
|
||||
|
||||
def test_endless_failures_end_the_loop(self):
|
||||
def behaviour(server):
|
||||
raise RuntimeError("always")
|
||||
|
||||
server = self.make(behaviour)
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
tai.MplexServer.loop(server)
|
||||
self.assertEqual(server.calls, tai.MPLEX_LOOP_ERRORS_MAX)
|
||||
self.assertFalse(server.running)
|
||||
|
||||
|
||||
class MplexPaneEnvTests(unittest.TestCase):
|
||||
def test_controller_targets_its_pane_from_the_environment(self):
|
||||
with mock.patch.dict(os.environ, {tai.PLEXER_SOCKET_ENV: "/tmp/x.sock", tai.PLEXER_PANE_ENV: "7"}, clear=False):
|
||||
|
||||
Reference in New Issue
Block a user